3. Normalize Adult Occupant Protection Scores Across Test Variants

Why normalization is required

Bharat NCAP adult occupant protection (AOP) results arrive from several test variants: a full-width frontal offset at 64 km/h, a side movable deformable barrier impact, and a pole side impact. Each variant reports its own sub-scores with different maximums and different internal weighting. A frontal offset record may expose frontal_offset_score out of 16, a side barrier record may expose side_barrier_score out of 16, and a pole record may expose pole_score out of 2. If the dashboard plots these raw values side by side, a vehicle that scores 14/16 in frontal offset looks identical to one scoring 14/16 in side barrier, even though the two variants carry different weight in the final AOP total. The normalization layer solves this by mapping every variant sub-score onto a single 0–100 scale, while keeping the original star rating and the test configuration untouched.

The key design constraint is preservation: normalization must never overwrite the source values. The output record keeps the raw sub-score, the variant identifier, the original star rating, and the test configuration, and adds a normalized field alongside them. Downstream cases (weighted overall rating, scorecard rendering) consume the normalized field, but any audit or re-derivation can still reach the raw data.

Defining the variant map

Each test variant has a known maximum and a known contribution to the AOP total. We encode this as a declarative map so that adding a new variant is a data change, not a code change. The example below uses representative maxima consistent with a Bharat NCAP-style AOP breakdown; treat the numbers as teaching parameters and confirm them against the official protocol before production use.

# variant_map.py
# Maps each AOP test variant to its raw maximum and its share of the AOP total.
# Values are teaching examples; verify against the current Bharat NCAP protocol.

VARIANT_MAP = {
    "frontal_offset_64": {
        "raw_max": 16.0,
        "aop_weight": 0.50,   # share of the 0-100 AOP scale
        "label": "Full-width frontal offset, 64 km/h",
    },
    "side_mdb": {
        "raw_max": 16.0,
        "aop_weight": 0.30,
        "label": "Side movable deformable barrier",
    },
    "side_pole": {
        "raw_max": 2.0,
        "aop_weight": 0.20,
        "label": "Side pole impact",
    },
}

# Sanity check: weights must sum to 1.0 so the normalized AOP total is 0-100.
assert abs(sum(v["aop_weight"] for v in VARIANT_MAP.values()) - 1.0) < 1e-9

The assertion is deliberate. If a future protocol revision changes the weights and they no longer sum to 1.0, the module fails at import time rather than silently producing an AOP total that exceeds 100 or falls short of it.

The normalization function

Normalization happens in two stages. First, each raw sub-score is rescaled to its own 0–100 range using its variant maximum. Second, the rescaled values are combined using the variant weights to produce a single normalized AOP score. Keeping the two stages separate matters: the per-variant rescaled value is useful for variant-level comparison charts, while the combined value feeds the overall rating.

# normalize.py
from variant_map import VARIANT_MAP

def rescale_variant(raw_score: float, variant: str) -> float:
    """Map a raw sub-score onto 0-100 for a single variant."""
    spec = VARIANT_MAP[variant]
    if raw_score < 0 or raw_score > spec["raw_max"]:
        raise ValueError(
            f"{variant}: raw_score {raw_score} outside [0, {spec['raw_max']}]"
        )
    return round(raw_score / spec["raw_max"] * 100.0, 2)

def normalize_aop(record: dict) -> dict:
    """Return a new record with normalized AOP fields added; source untouched."""
    variant = record["test_variant"]
    raw = record["aop_sub_score"]

    per_variant = rescale_variant(raw, variant)
    weighted = per_variant * VARIANT_MAP[variant]["aop_weight"]

    out = dict(record)  # shallow copy preserves original fields
    out["aop_normalized_variant"] = per_variant
    out["aop_normalized_weighted"] = round(weighted, 2)
    # star rating and test configuration are carried through unchanged
    return out

Two behaviors are worth calling out. The range check raises on out-of-range input instead of clamping, because a raw score above the variant maximum indicates a data problem upstream (a mis-parsed column or a wrong variant tag), and silently clamping would hide it. The shallow copy means the caller's original record is never mutated, so the raw sub-score, star rating, and configuration remain available for audit.

How the pieces connect

The diagram below shows the data path for a single record: the raw sub-score enters the variant-specific rescale, the rescaled value is multiplied by the variant weight, and the weighted contribution is written back alongside the preserved original fields. The star rating and test configuration bypass normalization entirely and are carried through unchanged.

AOP normalization path — one record Source record test_variant aop_sub_score star_rating test_config e.g. side_mdb, 13.5 Rescale to 0–100 raw / raw_max × 100 Apply variant weight rescaled × aop_weight Normalized record aop_normalized_variant aop_normalized_weighted star_rating (preserved) test_config (preserved) aop_sub_score (preserved) star_rating + test_config bypass normalization Range guard raw_score outside [0, raw_max] → ValueError, no silent clamp

Worked example

Consider a vehicle tested in the side movable deformable barrier variant with a raw sub-score of 13.5 out of 16 and a 5-star rating. The rescale gives 13.5 / 16 × 100 = 84.38. Applying the 0.30 weight gives a weighted contribution of 84.38 × 0.30 = 25.31. The output record carries aop_normalized_variant = 84.38, aop_normalized_weighted = 25.31, and the original aop_sub_score = 13.5, star_rating = 5, and test_config = "side_mdb" unchanged.

A second vehicle in the frontal offset variant scores 14.0 out of 16. Its rescaled value is 87.50 and its weighted contribution is 87.50 × 0.50 = 43.75. Comparing the two weighted contributions (25.31 vs 43.75) is meaningful because both are on the same 0–100 AOP scale; comparing the raw 13.5 and 14.0 directly would not be, since they come from variants with different maxima and different weights.

VariantRaw sub-scoreRaw maxRescaled (0–100)WeightWeighted contribution
frontal_offset_6414.016.087.500.5043.75
side_mdb13.516.084.380.3025.31
side_pole1.82.090.000.2018.00

For a single vehicle that has all three variants, the normalized AOP total is the sum of the weighted contributions: 43.75 + 25.31 + 18.00 = 87.06. That total is the value the next case's weighted overall rating consumes. The per-variant rescaled values remain available for variant-level breakdowns.

Verification and failure symptoms

Run the module against a small fixture and check three things: the weights sum to 1.0 (the import-time assertion), each rescaled value lies in [0, 100], and the preserved fields are byte-identical to the source. A quick check:

from normalize import normalize_aop

rec = {"test_variant": "side_mdb", "aop_sub_score": 13.5,
       "star_rating": 5, "test_config": "side_mdb"}
out = normalize_aop(rec)

assert out["aop_normalized_variant"] == 84.38
assert out["aop_normalized_weighted"] == 25.31
assert out["star_rating"] == rec["star_rating"]
assert out["test_config"] == rec["test_config"]
assert rec["aop_sub_score"] == 13.5  # source not mutated

Typical failure symptoms and their causes:

  • ValueError on a valid-looking row — the raw sub-score exceeds the variant maximum, usually because the CSV column was mapped to the wrong variant or a percentage was stored where a raw score was expected. Check the variant tag against the source export.
  • Normalized total above 100 — the variant weights no longer sum to 1.0 after a protocol edit. The import-time assertion should have caught this; if it was removed, restore it.
  • Star rating changes after normalization — the function is mutating the input record instead of copying it. Confirm the shallow copy is present and that no downstream code writes back into the source dict.
  • Rescaled value of 0 for a non-zero raw score — the variant key is missing from VARIANT_MAP, so the lookup fails or falls through to a default. Add the variant to the map rather than defaulting.
Keep the variant map in one place and load it from configuration if the protocol is expected to change between assessment cycles. The normalization function should never hard-code a maximum or a weight; it should read them from the map so that a protocol revision is a data update, not a code change.
Do not normalize the star rating. The star rating is an official outcome of the assessment and must be carried through exactly as reported. Normalization applies only to the sub-scores that feed the AOP total; the star rating and test configuration are preserved fields, not inputs to the rescale.