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.
| Stars | min_pct (inclusive) | max_pct | Example AOP raw / 32 | Example COP raw / 49 |
|---|---|---|---|---|
| 5 | 90.00 | 100.00 | 28.80 – 32.00 | 44.10 – 49.00 |
| 4 | 70.00 | < 90.00 | 22.40 – 28.79 | 34.30 – 44.09 |
| 3 | 50.00 | < 70.00 | 16.00 – 22.39 | 24.50 – 34.29 |
| 2 | 30.00 | < 50.00 | 9.60 – 15.99 | 14.70 – 24.49 |
| 1 | 0.00 | < 30.00 | 0.00 – 9.59 | 0.00 – 14.69 |
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.
| Category | Raw score | Rounded pct | Expected stars | What it proves |
|---|---|---|---|---|
| AOP | 28.80 | 90.00 | 5 | Lower edge of 5-star is inclusive |
| AOP | 28.79 | 89.97 | 4 | Just below the 5-star edge drops a star |
| COP | 44.10 | 90.00 | 5 | Same percentage band applies to COP |
| AOP | 22.40 | 70.00 | 4 | 4-star lower edge inclusive |
| COP | 34.30 | 70.00 | 4 | COP 4-star edge |
| AOP | 16.00 | 50.00 | 3 | 3-star lower edge inclusive |
| AOP | 9.60 | 30.00 | 2 | 2-star lower edge inclusive |
| COP | 14.70 | 30.00 | 2 | COP 2-star edge |
| AOP | 0.00 | 0.00 | 1 | Floor band always matches |
| AOP | 32.00 | 100.00 | 5 | Maximum 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.
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_UPand that quantization happens after the ×100 scaling.
Failure Symptoms and Fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| Boundary score yields one star too low | Float division before quantization | Use Decimal throughout; quantize after scaling |
| Boundary score yields one star too high | Comparison uses > instead of >= | Make lower bound inclusive in the band table |
| COP ratings systematically low | Dividing by 32 instead of 49 | Look up the maximum from CATEGORY_MAX, never hard-code |
| Score of 0 raises an error | Band table missing the 0.00 floor row | Always include the 0.00 band as the terminal match |
| Score above maximum accepted silently | Missing range validation | Reject 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.