3. Star Rating Computation from Category Scores

Why Star Rating Is a Separate Computation

Bharat NCAP publishes a star rating per assessment category — Adult Occupant Protection (AOP) and Child Occupant Protection (COP) — and the dashboard must reproduce those ratings exactly. The rating is not a linear rescale of the category score. It is a step function: a category score of 27.99 out of 32 and a score of 24.00 out of 32 can land on different stars, while 24.00 and 26.50 can land on the same star. Getting the boundaries and the rounding order right is the whole task, and it is where most dashboard implementations drift from the published report.

Two rules dominate the implementation:

  • Normalize first, then threshold. Category scores are published as raw points against a category maximum (32 for AOP, 49 for COP in the Bharat NCAP protocol). The star thresholds are defined on the percentage of the category maximum, so the raw score must be converted to a percentage before any comparison.
  • Round once, at a defined point. Published ratings are computed from the percentage rounded to two decimal places, and the threshold comparison uses >= on that rounded value. Rounding after the comparison, or comparing unrounded floats, produces off-by-one-star results exactly at the boundary.

Threshold Table and Boundary Semantics

The table below encodes the star bands as percentage intervals. Each band is half-open on the lower side and closed on the upper side, except the top band, which is closed at 100%. The min_pct column is the value the comparison uses.

Starsmin_pct (inclusive)max_pctExample AOP raw / 32Example COP raw / 49
590.00100.0028.80 – 32.0044.10 – 49.00
470.00< 90.0022.40 – 28.7934.30 – 44.09
350.00< 70.0016.00 – 22.3924.50 – 34.29
230.00< 50.009.60 – 15.9914.70 – 24.49
10.00< 30.000.00 – 9.590.00 – 14.69
The raw-point ranges in the last two columns are derived from the percentage bands and are shown for sanity-checking only. Always compute from the percentage; do not hard-code raw-point cutoffs, because category maxima differ between AOP and COP and may change with protocol revisions.

Reference Implementation

The function below is self-contained: it carries its own threshold table, validates inputs, and returns both the star rating and the intermediate percentage so the dashboard can display the derivation. It uses Decimal with ROUND_HALF_UP to match the published rounding convention rather than Python's default banker's rounding.

from decimal import Decimal, ROUND_HALF_UP

# (min_pct inclusive, stars) — ordered high to low
STAR_BANDS = [
    (Decimal("90.00"), 5),
    (Decimal("70.00"), 4),
    (Decimal("50.00"), 3),
    (Decimal("30.00"), 2),
    (Decimal("0.00"),  1),
]

CATEGORY_MAX = {"AOP": Decimal("32"), "COP": Decimal("49")}

def category_percentage(raw_score, category):
    maximum = CATEGORY_MAX[category]
    if raw_score < 0 or raw_score > maximum:
        raise ValueError(f"{category} score {raw_score} outside 0..{maximum}")
    pct = (Decimal(str(raw_score)) / maximum) * Decimal("100")
    return pct.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)

def star_rating(raw_score, category):
    pct = category_percentage(raw_score, category)
    for min_pct, stars in STAR_BANDS:
        if pct >= min_pct:
            return stars, pct
    raise AssertionError("unreachable: 0.00 band always matches")

Note the ordering of operations inside category_percentage: divide, multiply by 100, then quantize to two decimals. Quantizing earlier (for example rounding the raw ratio to two decimals before scaling) shifts boundary cases by up to 0.01 percentage points, which is enough to flip a star at 90.00%.

Boundary Verification

The cases below are the ones that actually break naive implementations. Run them as a table-driven test; each row states the expected star and the expected rounded percentage.

CategoryRaw scoreRounded pctExpected starsWhat it proves
AOP28.8090.005Lower edge of 5-star is inclusive
AOP28.7989.974Just below the 5-star edge drops a star
COP44.1090.005Same percentage band applies to COP
AOP22.4070.0044-star lower edge inclusive
COP34.3070.004COP 4-star edge
AOP16.0050.0033-star lower edge inclusive
AOP9.6030.0022-star lower edge inclusive
COP14.7030.002COP 2-star edge
AOP0.000.001Floor band always matches
AOP32.00100.005Maximum score does not overflow the table

A quick executable check that exercises the table:

CASES = [
    ("AOP", "28.80", "90.00", 5),
    ("AOP", "28.79", "89.97", 4),
    ("COP", "44.10", "90.00", 5),
    ("AOP", "22.40", "70.00", 4),
    ("COP", "34.30", "70.00", 4),
    ("AOP", "16.00", "50.00", 3),
    ("AOP", "9.60",  "30.00", 2),
    ("COP", "14.70", "30.00", 2),
    ("AOP", "0.00",  "0.00",  1),
    ("AOP", "32.00", "100.00", 5),
]

for category, raw, expected_pct, expected_stars in CASES:
    stars, pct = star_rating(Decimal(raw), category)
    assert pct == Decimal(expected_pct), (category, raw, pct)
    assert stars == expected_stars, (category, raw, stars)
print("all boundary cases passed")

Expected output on a correct implementation is the single line all boundary cases passed. If an assertion fires on the 28.80 row with 89.99 instead of 90.00, the cause is almost always float division before quantization — switch to Decimal as shown. If it fires with the correct percentage but the wrong star, the band table is being compared with > instead of >=.

How the Rating Flows Through the Dashboard

The SVG below shows the computation as a signal chain: raw category points enter, are normalized against the category maximum, rounded once, then compared against the ordered band table. The two branches (AOP and COP) are independent and produce two separate star ratings, which is exactly how Bharat NCAP reports them — there is no single combined star value at this stage.

Star Rating Computation Chain AOP branch raw AOP points 0 … 32 ÷ 32 × 100 normalize quantize 0.01 ROUND_HALF_UP band lookup pct ≥ min_pct AOP stars 1 … 5 COP branch raw COP points 0 … 49 ÷ 49 × 100 normalize quantize 0.01 ROUND_HALF_UP band lookup pct ≥ min_pct COP stars 1 … 5 Ordered band table (evaluated top-down, first match wins) 5★ pct ≥ 90.00 4★ pct ≥ 70.00 3★ pct ≥ 50.00 2★ pct ≥ 30.00 1★ pct ≥ 0.00 Comparison uses the rounded percentage; lower bound is inclusive, so 90.00 → 5★ and 89.99 → 4★.

Verifying Against Published Outcomes

Once the function passes the boundary table, the remaining verification is against real assessment records already in the store. The check is a join, not a re-derivation: for each vehicle variant, compare the computed AOP and COP stars with the stars recorded in the ingested report. A mismatch is a defect in either the ingestion mapping or the threshold table, and the two are distinguishable by inspecting the intermediate percentage.

def verify_against_report(record, star_rating):
    """record: dict with raw scores and published stars from the report."""
    mismatches = []
    for category in ("AOP", "COP"):
        stars, pct = star_rating(record[f"{category.lower()}_raw"], category)
        published = record[f"{category.lower()}_stars"]
        if stars != published:
            mismatches.append({
                "category": category,
                "raw": record[f"{category.lower()}_raw"],
                "pct": str(pct),
                "computed": stars,
                "published": published,
            })
    return mismatches

Interpretation of the mismatch payload:

  • Percentage matches the report but stars differ — the band table is wrong or the comparison operator is wrong. Fix the table, not the ingestion.
  • Percentage differs from the report's stated percentage — the raw score or the category maximum is wrong. Check the ingestion mapping for that test type; a common cause is summing only the frontal test instead of all tests contributing to the category.
  • Percentage is off by exactly 0.01 at a boundary — rounding mode or rounding order. Confirm ROUND_HALF_UP and that quantization happens after the ×100 scaling.
Keep the computed percentage in the dashboard payload alongside the star rating. When a reviewer questions a rating, the percentage is the single number that explains it, and it makes boundary disputes resolvable without re-running the pipeline.

Failure Symptoms and Fixes

SymptomLikely causeFix
Boundary score yields one star too lowFloat division before quantizationUse Decimal throughout; quantize after scaling
Boundary score yields one star too highComparison uses > instead of >=Make lower bound inclusive in the band table
COP ratings systematically lowDividing by 32 instead of 49Look up the maximum from CATEGORY_MAX, never hard-code
Score of 0 raises an errorBand table missing the 0.00 floor rowAlways include the 0.00 band as the terminal match
Score above maximum accepted silentlyMissing range validationReject raw scores outside 0..maximum before computing

With the function, the boundary table, and the report-comparison check in place, the dashboard has a rating computation that is reproducible from raw points and auditable against published outcomes — which is the prerequisite for the weighted aggregation and API layers that follow.