1. Bharat NCAP Scoring Data Model and Schem
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.
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
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 fragment | Likely cause | Corrective action |
|---|---|---|
assessment_id pattern mismatch | Report uses a legacy ID format or a typo in the year segment | Confirm the ID against the test house cover sheet; do not auto-correct |
points_awarded exceeds points_max | Transposed digits during manual entry | Re-read the per-test table in the source PDF |
| score ≠ sum of tests | A test row was dropped, or the category total was rounded independently | Reconcile the breakdown; the breakdown is authoritative |
| missing categories | Partial report — e.g. SA results not yet released | Hold the record; do not admit partial assessments to the dashboard |
tests min_length violation | Category present but empty | Treat as incomplete; request the missing test data |
Design Decisions Worth Locking In
- Breakdown is authoritative. The category
scoreis 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=3plus 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.