Enhance app functionality with JSON input handling and improved findings rendering. Added new sample reports and updated existing ones for better testing coverage. Refactored findings display to an HTML panel for clarity.
2b7e0fd | """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 | |