File size: 6,658 Bytes
4ce7a7e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
"""Canonical reference table: (make, model, year) -> body_type, segment, avg cost.

Built from the cost-bearing datasets (iaai + ganeshsura) during Phase 1 EDA.
Persisted as Parquet (with CSV mirror for inspection) at:

    data/processed/reference_table.parquet
    data/processed/reference_table.csv

The table supports a graceful-degradation `nearest()` lookup used by the
fallback estimator and the Tier-2 cost path.
"""

from __future__ import annotations

import json
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Optional

DEFAULT_PATH = Path("data/processed/reference_table.parquet")


@dataclass
class ReferenceRow:
    make: Optional[str]
    model: Optional[str]
    year: Optional[int]
    body_type: str
    segment: str
    avg_cost_usd: float
    n_samples: int
    datasets: str   # comma-joined list of source dataset names

    def to_dict(self) -> dict:
        return self.__dict__.copy()


# --- build --------------------------------------------------------------


def build(
    rows: Iterable[dict],
    out_path: Path = DEFAULT_PATH,
) -> Path:
    """Build reference table from per-record dicts.

    `rows` must contain (at minimum) `make`, `model`, `year`, `body_type`,
    `segment`, `cost_usd`, `dataset`. Missing fields are normalized to None.
    """
    import pandas as pd  # local import — pandas is in [ml] extras

    df = pd.DataFrame(list(rows))
    if df.empty:
        out_path.parent.mkdir(parents=True, exist_ok=True)
        empty = pd.DataFrame(
            columns=["make", "model", "year", "body_type", "segment",
                     "avg_cost_usd", "n_samples", "datasets"]
        )
        empty.to_parquet(out_path, index=False)
        empty.to_csv(out_path.with_suffix(".csv"), index=False)
        return out_path

    for col in ("make", "model", "body_type", "segment"):
        if col in df:
            df[col] = df[col].fillna("unknown").astype(str).str.lower()
        else:
            df[col] = "unknown"
    if "year" not in df:
        df["year"] = None

    grouped = (
        df.groupby(["make", "model", "year", "body_type", "segment"], dropna=False)
          .agg(
              avg_cost_usd=("cost_usd", "mean"),
              n_samples=("cost_usd", "size"),
              datasets=("dataset", lambda s: ",".join(sorted(set(map(str, s))))),
          )
          .reset_index()
    )
    grouped = grouped.sort_values(["make", "model", "year"]).reset_index(drop=True)

    out_path.parent.mkdir(parents=True, exist_ok=True)
    grouped.to_parquet(out_path, index=False)
    grouped.to_csv(out_path.with_suffix(".csv"), index=False)
    return out_path


def load(path: Path | None = None):
    import pandas as pd
    if path is None:
        path = DEFAULT_PATH
    if not path.exists():
        raise FileNotFoundError(
            f"Reference table not built yet at {path}. Run Phase 1 EDA notebook "
            f"or `ccdp data build-reference-table`."
        )
    return pd.read_parquet(path)


# --- lookup -------------------------------------------------------------


def nearest(
    make: Optional[str] = None,
    model: Optional[str] = None,
    year: Optional[int] = None,
    body_type: Optional[str] = None,
    segment: Optional[str] = None,
    path: Path | None = None,
) -> Optional[dict]:
    """Graceful-degradation lookup. Returns the matched aggregate row + 'how'.

    Chain:
        1. exact (make, model, year)
        2. (make, model) any year — most recent first
        3. (segment, body_type)
        4. (body_type,) only
        5. (segment,) only
    """
    df = load(path)
    if df.empty:
        return None

    def _result(sub, how: str) -> Optional[dict]:
        if sub.empty:
            return None
        # weighted average by sample count
        if "n_samples" in sub and sub["n_samples"].sum() > 0:
            cost = (sub["avg_cost_usd"] * sub["n_samples"]).sum() / sub["n_samples"].sum()
        else:
            cost = sub["avg_cost_usd"].mean()
        return {
            "match_how": how,
            "n_samples": int(sub["n_samples"].sum()) if "n_samples" in sub else len(sub),
            "avg_cost_usd": float(cost),
            "body_type": _mode(sub.get("body_type")),
            "segment": _mode(sub.get("segment")),
            "example_model": _example_model(sub),
        }

    if make and model and year is not None:
        sub = df[(df["make"] == make.lower()) & (df["model"].astype(str).str.startswith(model.lower())) & (df["year"] == year)]
        r = _result(sub, "exact")
        if r:
            return r

    if make and model:
        sub = df[(df["make"] == make.lower()) & (df["model"].astype(str).str.startswith(model.lower()))]
        r = _result(sub, "make_model_any_year")
        if r:
            return r

    if segment and body_type:
        sub = df[(df["segment"] == segment.lower()) & (df["body_type"] == body_type.lower())]
        r = _result(sub, "segment_body_type")
        if r:
            return r

    if body_type:
        sub = df[df["body_type"] == body_type.lower()]
        r = _result(sub, "body_type")
        if r:
            return r

    if segment:
        sub = df[df["segment"] == segment.lower()]
        r = _result(sub, "segment")
        if r:
            return r

    return None


def _mode(series) -> str:
    if series is None or len(series) == 0:
        return "unknown"
    m = series.mode()
    return str(m.iloc[0]) if not m.empty else "unknown"


def _example_model(sub) -> str:
    if sub.empty:
        return "unknown"
    top = sub.sort_values("n_samples", ascending=False).iloc[0]
    parts = [str(top.get("make", "")), str(top.get("model", ""))]
    return " ".join(p for p in parts if p and p != "unknown").strip() or "unknown"


# --- coverage report ----------------------------------------------------


def coverage_report(path: Path | None = None) -> dict:
    """Quick health summary for the report generator."""
    df = load(path)
    if df.empty:
        return {"rows": 0}
    return {
        "rows": len(df),
        "unique_makes": int(df["make"].nunique()),
        "unique_models": int(df["model"].nunique()),
        "year_range": [int(df["year"].dropna().min()) if df["year"].notna().any() else None,
                       int(df["year"].dropna().max()) if df["year"].notna().any() else None],
        "body_type_counts": json.loads(df["body_type"].value_counts().to_json()),
        "segment_counts": json.loads(df["segment"].value_counts().to_json()),
        "total_samples": int(df["n_samples"].sum()),
        "median_avg_cost_usd": float(df["avg_cost_usd"].median()),
    }