2. Ingesting Bharat NCAP Assessment Reports into a Structured Store

Why ingestion is a separate concern from scoring

The scoring data model from the previous step defines what a valid Bharat NCAP assessment record looks like. Ingestion answers a different question: given the raw assessment reports that testing agencies publish — typically PDFs or semi-structured exports containing per-test point tables — how do we reliably produce records that satisfy that model? The two concerns must stay separate. If parsing logic leaks into the scoring layer, every new report format forces changes to star-rating computation. If validation is skipped during ingestion, malformed rows silently poison downstream aggregation and produce plausible-looking but wrong star ratings.

This case builds a pipeline with three explicit stages: extract (pull tabular test data out of a report), normalize (map heterogeneous test types onto the canonical model), and validate-and-store (reject incomplete records, persist the rest with their per-test point breakdowns intact). The critical requirement is that per-test point breakdowns survive ingestion. A vehicle's Adult Occupant Protection score is not a single number in the source report — it is the sum of points earned across frontal offset, side impact, and pole tests, each with its own sub-items. The dashboard later needs those sub-items to explain why a score is what it is, so flattening them at ingestion time is a defect, not a simplification.

The canonical record shape

Assume the scoring model exposes a record with a vehicle identity block, a category block, and a list of test results. Each test result carries a test type, the maximum available points, the points awarded, and a breakdown of sub-items. The ingestion pipeline's job is to emit exactly this shape. A minimal but complete representation in Python:

from dataclasses import dataclass, field
from typing import Literal

TestType = Literal["frontal_offset", "side_impact", "side_pole", "pedestrian", "esc", "seatbelt_reminder"]

@dataclass
class SubItem:
    name: str
    max_points: float
    awarded: float

@dataclass
class TestResult:
    test_type: TestType
    max_points: float
    awarded: float
    sub_items: list[SubItem] = field(default_factory=list)

@dataclass
class AssessmentRecord:
    vehicle_id: str
    make: str
    model: str
    variant: str
    category: Literal["AOP", "COP", "SA"]
    tests: list[TestResult] = field(default_factory=list)

    def category_score(self) -> float:
        return sum(t.awarded for t in self.tests)

Note that category_score() is derived, never stored. Storing a computed total alongside its components invites drift: a corrected sub-item updates the breakdown but not the cached total. Derive at read time.

Extraction: handling multiple test types

Real assessment reports mix test types within one category. An AOP section might contain a frontal offset test scored out of 16, a side impact test out of 16, and a pole test out of 2, each with sub-items like "head", "chest", "legs". The extractor must not assume a fixed column layout. A robust approach is to parse each report into a list of raw rows keyed by a detected test-type header, then hand those rows to a per-type normalizer.

import re

TEST_HEADERS = {
    "frontal offset": "frontal_offset",
    "side impact": "side_impact",
    "side pole": "side_pole",
    "pedestrian protection": "pedestrian",
    "electronic stability control": "esc",
    "seat belt reminder": "seatbelt_reminder",
}

def detect_test_type(line: str) -> str | None:
    low = line.lower()
    for phrase, canonical in TEST_HEADERS.items():
        if phrase in low:
            return canonical
    return None

def split_into_sections(raw_lines: list[str]) -> dict[str, list[str]]:
    sections: dict[str, list[str]] = {}
    current = None
    for line in raw_lines:
        detected = detect_test_type(line)
        if detected:
            current = detected
            sections.setdefault(current, [])
        elif current and line.strip():
            sections[current].append(line.strip())
    return sections

The detect_test_type function is deliberately tolerant: it matches on phrases rather than exact strings, because report headings vary ("Frontal Offset Deformable Barrier" vs "Frontal Offset Test"). Unknown headings are skipped rather than guessed — an unrecognized section is a signal to extend the header map, not to coerce it into the nearest known type.

Normalization and the point-breakdown contract

Each section's rows are parsed into SubItem objects. The contract is strict: the sum of sub-item max_points must equal the test's declared maximum, and the sum of awarded must equal the test's awarded total. If either invariant fails, the record is rejected with a diagnostic naming the offending test. This catches the most common extraction failure — a sub-item row silently dropped because its label wrapped onto two lines.

def build_test_result(test_type: str, rows: list[str]) -> TestResult:
    sub_items = []
    for row in rows:
        m = re.match(r"(.+?)\s+([\d.]+)\s*/\s*([\d.]+)", row)
        if not m:
            continue
        name, awarded, max_pts = m.group(1).strip(), float(m.group(2)), float(m.group(3))
        sub_items.append(SubItem(name=name, max_points=max_pts, awarded=awarded))
    if not sub_items:
        raise ValueError(f"no sub-items parsed for {test_type}")
    total_max = sum(s.max_points for s in sub_items)
    total_awarded = sum(s.awarded for s in sub_items)
    return TestResult(test_type=test_type, max_points=total_max,
                      awarded=total_awarded, sub_items=sub_items)

The regex expects the common "label 12.5 / 16" layout. Reports that use separate columns require a different row parser, but the invariant check downstream is format-agnostic — that is the point of separating extraction from validation.

Bharat NCAP report ingestion — per-test breakdown preservation Raw report AOP section COP section SA section mixed test types per-test point tables Extract detect_test_type() split_into_sections() unknown heading → skip, flag for review Normalize build_test_result() SubItem list test_type → canonical max/awarded totals Validate Σ sub.max == test.max Σ sub.awarded == test.awarded schema check on record Structured store AssessmentRecord breakdowns intact Rejected diagnostics log names failing test pass invariant fail Downstream consumers star rating · weighted aggregation read category_score() — never cached

Validation and storage

Validation runs after normalization and before persistence. It checks two things the extractor cannot: that every test's sub-item totals reconcile, and that the assembled AssessmentRecord satisfies the schema from the scoring model (required fields present, category in the allowed set, no duplicate test types within a category). Records that fail are written to a diagnostics log with the vehicle ID, the failing test type, and the observed versus expected totals. They are not partially stored — a half-ingested vehicle is worse than an absent one, because the dashboard would render it with a misleadingly low score.

def ingest(raw_lines: list[str], identity: dict) -> AssessmentRecord:
    sections = split_into_sections(raw_lines)
    tests = []
    for test_type, rows in sections.items():
        result = build_test_result(test_type, rows)
        if abs(result.max_points - result.awarded) > 0 and result.awarded > result.max_points:
            raise ValueError(f"{test_type}: awarded {result.awarded} exceeds max {result.max_points}")
        tests.append(result)
    record = AssessmentRecord(**identity, tests=tests)
    seen = set()
    for t in record.tests:
        if t.test_type in seen:
            raise ValueError(f"duplicate test type {t.test_type}")
        seen.add(t.test_type)
    return record

The identity dict supplies vehicle_id, make, model, variant, and category from the report's cover metadata, which is parsed separately from the test tables. Keeping identity extraction out of the section parser avoids coupling the two when report layouts change independently.

Verifying the pipeline

A reproducible check uses a small synthetic report with two AOP tests: a frontal offset with sub-items 8/8, 4/4, 2/4 (total 14/16) and a side impact with 10/10, 4/6 (total 14/16). After ingestion, assert that record.category_score() == 28.0, that len(record.tests) == 2, and that the frontal offset test retains three sub-items. Then corrupt one row by deleting the "2 / 4" line and confirm ingestion raises with a message naming frontal_offset — the invariant catches the dropped row because the sub-item sum no longer matches the declared maximum. These assertions are the acceptance criteria: a pipeline that passes them preserves breakdowns and rejects incomplete data, which is exactly what downstream aggregation depends on.

When a new report format appears, extend TEST_HEADERS and add a row parser variant; leave the invariant checks untouched. If validation starts rejecting previously accepted reports, the cause is almost always a changed sub-item label layout, not a scoring-model change.