"""Normalize structured JSON into the checker's flat schema. Real systems export noon data with their own field names, often wrapped inside a query result like {"select * from ...": [ { ...record... } ]}. This module: 1. unwraps nested wrappers down to the actual record, and 2. maps known source field names (case-insensitive) onto our schema keys, including summing grade-level fuel ROB into total FO / DO ROB. Already-canonical dicts (from the LLM/regex parsers) pass through unchanged. """ # canonical schema key -> accepted source keys (lower-cased on lookup) ALIASES = { "vessel_name": ["vessel_name"], "report_date": ["report_date", "utc_date_time"], "position": ["position"], "course_deg": ["course_deg", "course", "heading", "course_made_good"], "steaming_hours": ["steaming_hours", "steaming_time_hrs"], "distance_run": ["distance_run", "observerd_distance", "observed_distance", "distancetravelled", "distance_travelled_actual"], "avg_speed": ["avg_speed", "speed", "avg_speed_kn"], "rpm_avg": ["rpm_avg", "merpm", "me_rpm"], "slip_pct": ["slip_pct", "slip"], "me_power_kw": ["me_power_kw", "me_shaft_power_kw"], "me_load_pct": ["me_load_pct", "me_load"], "me_fo_cons": ["me_fo_cons", "me_consumption"], "ae_fo_cons": ["ae_fo_cons", "ae_consumption"], "boiler_fo_cons": ["boiler_fo_cons", "boiler_consumption"], "cyl_oil_cons": ["cyl_oil_cons"], "fo_rob": ["fo_rob"], "fo_rob_prev": ["fo_rob_prev"], "fo_bunkered": ["fo_bunkered"], "do_rob": ["do_rob"], "fw_rob": ["fw_rob"], "fw_produced": ["fw_produced"], "draft_fwd": ["draft_fwd", "draftfwd"], "draft_aft": ["draft_aft", "draftaft"], "wind_force": ["wind_force", "windforce"], "sea_state": ["sea_state"], "swell_m": ["swell_m"], "air_temp": ["air_temp", "ambient_air_temperature"], "sea_temp": ["sea_temp"], } # When only per-grade ROB is present, total FO = residual grades, DO = distillates. FO_ROB_PARTS = ["rob_vlsfo", "rob_hsfo", "rob_ulsfo", "rob_lfo"] DO_ROB_PARTS = ["rob_lsmgo", "rob_mdo", "rob_go_do"] # every source key we recognise (used to spot the real record while unwrapping) _KNOWN = set() for _v in ALIASES.values(): _KNOWN.update(_v) _KNOWN.update(FO_ROB_PARTS) _KNOWN.update(DO_ROB_PARTS) def _looks_like_record(d) -> bool: return isinstance(d, dict) and bool({k.lower() for k in d} & _KNOWN) def _unwrap(obj, depth=0): """Descend through wrapper dicts/lists to the first object that looks like an actual noon record.""" if depth > 6: return obj if isinstance(obj, list): return _unwrap(obj[0], depth + 1) if obj else {} if isinstance(obj, dict): if _looks_like_record(obj): return obj for v in obj.values(): if isinstance(v, (dict, list)): inner = _unwrap(v, depth + 1) if _looks_like_record(inner): return inner return obj def _num_or_none(v): try: return float(v) except (TypeError, ValueError): return None def normalize_report(obj) -> dict: """Return a dict using the checker's schema keys. Safe on already-canonical input (it simply passes the recognised keys through).""" rec = _unwrap(obj) if not isinstance(rec, dict): return {} low = {k.lower(): v for k, v in rec.items()} out = {} for canon, sources in ALIASES.items(): for s in sources: if s in low and low[s] not in (None, ""): out[canon] = low[s] break # Derive total FO / DO ROB from per-grade fields if not already mapped. if "fo_rob" not in out: parts = [_num_or_none(low.get(p)) for p in FO_ROB_PARTS] parts = [p for p in parts if p is not None] if parts: out["fo_rob"] = round(sum(parts), 2) if "do_rob" not in out: parts = [_num_or_none(low.get(p)) for p in DO_ROB_PARTS] parts = [p for p in parts if p is not None] if parts: out["do_rob"] = round(sum(parts), 2) return out