4. Compute Weighted Overall Star Rating from Category Scores
Why a Weighted Rating Layer Is Needed
Bharat NCAP publishes three category scores for each assessed vehicle: Adult Occupant Protection (AOP), Child Occupant Protection (COP), and Safety Assist (SA). Each category is expressed on a 0–100 scale after normalization (the previous case handled that mapping). The overall star rating, however, is not a simple average. It is a weighted combination of the three categories, and the result must be rounded to an integer star value using an explicit, auditable rule. If the dashboard computes this ad hoc in the UI layer, two components can disagree, and a reviewer cannot reproduce a published rating.
This case builds a dedicated scoring module that takes the three normalized category scores plus a weight configuration, produces a weighted composite score, and maps it to a 1–5 star rating with a documented rounding rule. The module must be deterministic, configurable, and testable in isolation from the rendering layer.
Defining the Weight Contract
The weights are configuration, not hard-coded constants, because Bharat NCAP protocols and internal dashboard policies can change. A weight set is valid only if the three weights are non-negative and sum to exactly 1.0 within a small tolerance. Store weights as decimals, not percentages, to avoid a second conversion step.
{
"aop": 0.50,
"cop": 0.30,
"sa": 0.20
}
The composite score is the dot product of category scores and weights:
composite = aop * w_aop + cop * w_cop + sa * w_sa
With AOP = 82, COP = 74, SA = 61 and the weights above, the composite is 82*0.50 + 74*0.30 + 61*0.20 = 41.0 + 22.2 + 12.2 = 75.4. That number is the input to the star mapping, not the output.
The Rounding Rule and Star Mapping
The critical design decision is how a composite score becomes a star. Two rules are common and they produce different results near boundaries:
- Floor-to-band: the star is the highest band whose lower threshold the composite meets or exceeds. A composite of 75.4 with bands at 80/70/60/50 yields 3 stars.
- Round-then-band: round the composite to the nearest integer first, then apply bands. 75.4 rounds to 75, still 3 stars here, but 79.6 rounds to 80 and jumps to 4 stars.
For an auditable dashboard, use floor-to-band on the unrounded composite and expose the band thresholds as configuration. This avoids the discontinuity where a 0.1-point change flips a star. The mapping table below is the default policy; treat it as data, not code.
| Composite score (lower bound, inclusive) | Star rating |
|---|---|
| 80.0 | 5 |
| 70.0 | 4 |
| 60.0 | 3 |
| 50.0 | 2 |
| 0.0 | 1 |
Reference Implementation
The module below is self-contained: it validates weights, computes the composite, applies floor-to-band mapping, and returns both the star and the intermediate values so the UI can display an audit trail. It uses only the Python standard library.
from dataclasses import dataclass
DEFAULT_WEIGHTS = {"aop": 0.50, "cop": 0.30, "sa": 0.20}
DEFAULT_BANDS = [(80.0, 5), (70.0, 4), (60.0, 3), (50.0, 2), (0.0, 1)]
WEIGHT_TOLERANCE = 1e-6
@dataclass
class RatingResult:
composite: float
stars: int
band_lower: float
weights: dict
def validate_weights(weights: dict) -> None:
for key in ("aop", "cop", "sa"):
if key not in weights:
raise ValueError(f"missing weight: {key}")
if weights[key] < 0:
raise ValueError(f"negative weight: {key}")
total = sum(weights.values())
if abs(total - 1.0) > WEIGHT_TOLERANCE:
raise ValueError(f"weights must sum to 1.0, got {total:.6f}")
def compute_rating(aop: float, cop: float, sa: float,
weights: dict = DEFAULT_WEIGHTS,
bands: list = DEFAULT_BANDS) -> RatingResult:
validate_weights(weights)
for name, val in (("aop", aop), ("cop", cop), ("sa", sa)):
if not (0.0 <= val <= 100.0):
raise ValueError(f"{name} out of range: {val}")
composite = aop * weights["aop"] + cop * weights["cop"] + sa * weights["sa"]
for lower, stars in bands: # bands sorted high to low
if composite >= lower:
return RatingResult(round(composite, 2), stars, lower, dict(weights))
return RatingResult(round(composite, 2), 1, 0.0, dict(weights))
Two behaviors are worth calling out. First, validate_weights rejects any set that does not sum to 1.0 within tolerance, so a typo like 0.5 / 0.3 / 0.3 fails loudly instead of silently inflating scores. Second, the band loop assumes bands are ordered from highest lower bound to lowest; the default list satisfies this, and callers passing custom bands must preserve the order.
Verifying the Logic
Run the module against boundary cases before wiring it into the dashboard. The assertions below are executable checks, not illustrative claims:
r = compute_rating(82, 74, 61)
assert r.composite == 75.4 and r.stars == 3
# boundary: exactly on a band lower bound
assert compute_rating(80, 80, 80).stars == 5
assert compute_rating(70, 70, 70).stars == 4
# just below a boundary stays in the lower band
assert compute_rating(79.9, 79.9, 79.9).stars == 4
# weight validation rejects bad sums
try:
compute_rating(80, 80, 80, {"aop": 0.5, "cop": 0.3, "sa": 0.3})
raise AssertionError("expected ValueError")
except ValueError:
pass
If a boundary assertion fails, check band ordering first, then confirm the composite is computed on unrounded inputs. A common symptom is a vehicle scoring 79.95 landing on 5 stars because someone rounded to 80.0 before banding.
Wiring Into the Dashboard
Keep the scoring module pure and call it from the data layer, not from the component. The dashboard should receive a RatingResult alongside the raw category scores so the scorecard (next case) can render both the stars and the audit trail. When weights change, recompute all rows rather than patching individual vehicles, so the whole table stays consistent with one weight set.
With the module in place, the remaining work is presentation: the scorecard and comparison table consume RatingResult directly and never re-derive stars from category scores.