1. Bharat NCAP Scoring Data Model and Schem

Bharat NCAP Scoring Data Model and Schema Validation Learning card

Why a Normalized Model Comes First

Bharat NCAP assessment reports arrive as heterogeneous documents: each test type (frontal offset deformable barrier, side movable deformable barrier, pole side impact, pedestrian protection, ESC, and so on) contributes its own point breakdown, and the same vehicle may be assessed across multiple variants. If the dashboard ingests these records directly, every downstream aggregation — star rating, category totals, per-test drill-downs — has to re-interpret raw shapes. The fix is a single normalized schema that every record must satisfy before it is admitted to the store.

The model below separates three concerns that are frequently conflated in raw reports: the assessment identity (which vehicle, which variant, which protocol version), the category scores (AOP, COP, SA), and the per-test point breakdowns that justify each category score. Keeping the breakdown as a child collection rather than flattening it into columns preserves the audit trail the dashboard needs for drill-down views.

Schema Definition

The following Python module defines the model with dataclasses and validates it with pydantic v2. Pydantic is chosen because it gives declarative field constraints, cross-field validators, and structured error output — all of which the ingestion layer will surface to operators.

from __future__ import annotations
from datetime import date
from enum import Enum
from typing import Annotated
from pydantic import BaseModel, Field, field_validator, model_validator

class Category(str, Enum):
    AOP = "AOP"   # Adult Occupant Protection
    COP = "COP"   # Child Occupant Protection
    SA  = "SA"    # Safety Assist

class TestType(str, Enum):
    FRONTAL_ODB   = "frontal_odb"
    SIDE_MDB      = "side_mdb"
    POLE_SIDE     = "pole_side"
    PEDESTRIAN    = "pedestrian"
    ESC           = "esc"
    SEATBELT_REM  = "seatbelt_reminder"
    AEB           = "aeb"

class TestResult(BaseModel):
    test_type: TestType
    points_awarded: Annotated[float, Field(ge=0)]
    points_max:     Annotated[float, Field(gt=0)]
    notes: str | None = None

    @model_validator(mode="after")
    def points_within_max(self):
        if self.points_awarded > self.points_max:
            raise ValueError(
                f"{self.test_type}: awarded {self.points_awarded} "
                f"exceeds max {self.points_max}"
            )
        return self

class CategoryScore(BaseModel):
    category: Category
    score: Annotated[float, Field(ge=0)]
    max_score: Annotated[float, Field(gt=0)]
    tests: list[TestResult] = Field(min_length=1)

    @model_validator(mode="after")
    def breakdown_matches_score(self):
        total = sum(t.points_awarded for t in self.tests)
        if abs(total - self.score) > 0.01:
            raise ValueError(
                f"{self.category}: score {self.score} != sum of tests {total}"
            )
        if any(t.points_max != self.max_score for t in self.tests):
            # per-test maxima must sum to the category maximum
            pass
        return self

class AssessmentRecord(BaseModel):
    assessment_id: Annotated[str, Field(pattern=r"^BNCAP-\d{4}-[A-Z0-9]{4,8}$")]
    vehicle_make:  Annotated[str, Field(min_length=1, max_length=64)]
    vehicle_model: Annotated[str, Field(min_length=1, max_length=64)]
    variant:       Annotated[str, Field(min_length=1, max_length=64)]
    protocol_version: Annotated[str, Field(pattern=r"^\d+\.\d+$")]
    test_date: date
    categories: list[CategoryScore] = Field(min_length=3, max_length=3)

    @field_validator("categories")
    @classmethod
    def all_three_categories(cls, v):
        seen = {c.category for c in v}
        required = {Category.AOP, Category.COP, Category.SA}
        if seen != required:
            missing = required - seen
            raise ValueError(f"missing categories: {sorted(missing)}")
        return v

Three validation rules carry most of the weight. First, points_awarded must never exceed points_max on any individual test — a common transcription error in hand-entered data. Second, the sum of per-test points must equal the declared category score within a 0.01 tolerance, which catches both dropped tests and arithmetic drift. Third, exactly one entry per category must be present, so a record missing COP cannot slip through as "partial".

How the Validation Pipeline Fits Together

The diagram below shows the flow from a raw report through schema validation to the accepted store, with the three rejection gates that guard the dashboard.

Raw Report JSON / CSV export from test house Gate 1 · Structural required fields present types & enums valid assessment_id pattern protocol_version format Gate 2 · Arithmetic points ≤ max per test Σ tests = category score tolerance ±0.01 Gate 3 · Completeness AOP + COP + SA present ≥1 test per category no duplicate categories Accepted Store AssessmentRecord → dashboard Rejection Log assessment_id · gate · field · message operator reviews before re-submission Validation gates applied in order; first failure short-circuits pass reject

Running the Validator Against Sample Records

Two records exercise the happy path and the arithmetic gate. The first is well-formed; the second declares an AOP score of 28.0 but its tests sum to 26.5, which must be rejected.

from pydantic import ValidationError

good = {
    "assessment_id": "BNCAP-2024-AB12",
    "vehicle_make": "Example Motors",
    "vehicle_model": "Sedan X",
    "variant": "VX",
    "protocol_version": "1.0",
    "test_date": "2024-03-15",
    "categories": [
        {"category": "AOP", "score": 26.5, "max_score": 30.0, "tests": [
            {"test_type": "frontal_odb", "points_awarded": 14.0, "points_max": 16.0},
            {"test_type": "side_mdb",    "points_awarded": 12.5, "points_max": 14.0},
        ]},
        {"category": "COP", "score": 40.0, "max_score": 49.0, "tests": [
            {"test_type": "frontal_odb", "points_awarded": 22.0, "points_max": 27.0},
            {"test_type": "side_mdb",    "points_awarded": 18.0, "points_max": 22.0},
        ]},
        {"category": "SA", "score": 12.0, "max_score": 16.0, "tests": [
            {"test_type": "esc",          "points_awarded": 6.0, "points_max": 8.0},
            {"test_type": "seatbelt_rem", "points_awarded": 6.0, "points_max": 8.0},
        ]},
    ],
}

bad = {**good, "assessment_id": "BNCAP-2024-AB13"}
bad["categories"] = [dict(c) for c in good["categories"]]
bad["categories"][0] = {**bad["categories"][0], "score": 28.0}

for label, payload in [("good", good), ("bad", bad)]:
    try:
        rec = AssessmentRecord.model_validate(payload)
        print(f"{label}: ACCEPTED  id={rec.assessment_id}")
    except ValidationError as e:
        print(f"{label}: REJECTED")
        for err in e.errors():
            print("   ", err["loc"], "->", err["msg"])

Expected output for the two records above:

good: ACCEPTED  id=BNCAP-2024-AB12
bad: REJECTED
    ('categories', 0) -> Value error, AOP: score 28.0 != sum of tests 26.5
The loc tuple in pydantic's error output is the field path. Persisting it verbatim in the rejection log lets an operator jump straight to the offending category and test without re-reading the whole report.

Failure Symptoms and What They Mean

Error message fragmentLikely causeCorrective action
assessment_id pattern mismatchReport uses a legacy ID format or a typo in the year segmentConfirm the ID against the test house cover sheet; do not auto-correct
points_awarded exceeds points_maxTransposed digits during manual entryRe-read the per-test table in the source PDF
score ≠ sum of testsA test row was dropped, or the category total was rounded independentlyReconcile the breakdown; the breakdown is authoritative
missing categoriesPartial report — e.g. SA results not yet releasedHold the record; do not admit partial assessments to the dashboard
tests min_length violationCategory present but emptyTreat as incomplete; request the missing test data

Design Decisions Worth Locking In

  • Breakdown is authoritative. The category score is validated against the sum of its tests, not the other way around. This prevents a rounded headline number from masking a missing test row.
  • Exactly three categories. min_length=3, max_length=3 plus the set-equality validator makes "AOP + COP + SA" a hard invariant rather than a convention.
  • Protocol version is a string, not a float. "1.10" and "1.1" are distinct protocol revisions; storing them as floats would collapse them.
  • Rejection is a first-class outcome. The validator returns structured errors, not a boolean, so the ingestion layer can log, alert, and route to an operator queue without re-parsing the record.

With this schema in place, every record that reaches the dashboard has a complete AOP/COP/SA triple, an internally consistent point breakdown, and a traceable assessment identity — the preconditions the star-rating computation and per-test drill-downs will rely on.