"""Deterministic noon-report checks. No LLM — fast, certain, never hallucinates. Findings use the FuelSense-style taxonomy: severity: CRITICAL | ERROR | WARNING category: MISSING_DATA | OUT_OF_RANGE | INCONSISTENT_DATA """ from dataclasses import dataclass, asdict import schema from schema import FIELDS, REQUIRED, BY_KEY @dataclass class Finding: severity: str # CRITICAL | ERROR | WARNING category: str # MISSING_DATA | OUT_OF_RANGE | INCONSISTENT_DATA field: str message: str def as_row(self): return [self.severity, self.category, self.field, self.message] def _num(v): """Coerce to float, return None if not numeric.""" try: return float(v) except (TypeError, ValueError): return None def check_missing(report: dict) -> list[Finding]: out = [] for key in REQUIRED: val = report.get(key) if val is None or str(val).strip() == "": out.append(Finding( "CRITICAL", "MISSING_DATA", BY_KEY[key].label, f"Required field '{BY_KEY[key].label}' is missing.", )) return out def check_ranges(report: dict) -> list[Finding]: out = [] for f in FIELDS: if f.lo is None and f.hi is None: continue v = _num(report.get(f.key)) if v is None: continue if (f.lo is not None and v < f.lo) or (f.hi is not None and v > f.hi): out.append(Finding( "ERROR", "OUT_OF_RANGE", f.label, f"{f.label} = {v}{f.unit} is outside expected range " f"[{f.lo}, {f.hi}]{f.unit}.", )) return out def check_consistency(report: dict) -> list[Finding]: """Cross-field arithmetic. The high-value checks officers miss.""" out = [] hours = _num(report.get("steaming_hours")) dist = _num(report.get("distance_run")) speed = _num(report.get("avg_speed")) # 1. distance / hours should match reported avg speed (within 5%) if hours and dist is not None and speed is not None and hours > 0: implied = dist / hours if speed > 0 and abs(implied - speed) / speed > 0.05: out.append(Finding( "ERROR", "INCONSISTENT_DATA", "Avg speed", f"Reported avg speed {speed}kn disagrees with " f"distance/hours = {implied:.2f}kn (>5% off).", )) # 2. FO ROB carry-over: prev_rob - total_cons should ≈ reported ROB prev_rob = _num(report.get("fo_rob_prev")) me = _num(report.get("me_fo_cons")) or 0 ae = _num(report.get("ae_fo_cons")) or 0 rob = _num(report.get("fo_rob")) bunkered = _num(report.get("fo_bunkered")) or 0 if prev_rob is not None and rob is not None: expected = prev_rob - (me + ae) + bunkered if abs(expected - rob) > 1.0: # >1 mt drift out.append(Finding( "CRITICAL", "INCONSISTENT_DATA", "FO ROB", f"FO ROB {rob}mt doesn't reconcile: " f"prev {prev_rob} - cons {me+ae} + bunkered {bunkered} " f"= {expected:.1f}mt (drift {expected-rob:+.1f}mt).", )) # 3. draft sanity: aft usually >= fwd when laden/trimmed by stern fwd = _num(report.get("draft_fwd")) aft = _num(report.get("draft_aft")) if fwd is not None and aft is not None and fwd - aft > 0.5: out.append(Finding( "WARNING", "INCONSISTENT_DATA", "Draft", f"Trimmed by head: fwd {fwd}m > aft {aft}m by " f"{fwd-aft:.1f}m. Confirm intentional.", )) return out def check_advanced(report: dict) -> list[Finding]: """Higher-order reconciliations. Still pure arithmetic + explicit rules — no LLM. Thresholds live in schema.py and are read at call time so they can be tuned (or patched in tests) without touching this file.""" out = [] hours = _num(report.get("steaming_hours")) dist = _num(report.get("distance_run")) speed = _num(report.get("avg_speed")) rpm = _num(report.get("rpm_avg")) slip = _num(report.get("slip_pct")) me = _num(report.get("me_fo_cons")) wind = _num(report.get("wind_force")) # 1. Slip reconciliation. Engine distance = RPM * pitch * minutes, in nm. # Computed slip = (engine_dist - observed_dist) / engine_dist. # DISABLED unless a real propeller pitch is configured — we never invent # the constant we'd be checking against. pitch = schema.PROP_PITCH_M if (pitch and rpm and hours and hours > 0 and dist is not None and slip is not None): engine_dist = rpm * pitch * (hours * 60.0) / 1852.0 # revs*m -> nm if engine_dist > 0: computed_slip = (engine_dist - dist) / engine_dist * 100.0 if abs(computed_slip - slip) > schema.SLIP_TOLERANCE_PCT: out.append(Finding( "ERROR", "INCONSISTENT_DATA", "Slip", f"Reported slip {slip}% disagrees with slip computed from " f"RPM x pitch = {computed_slip:.1f}% " f"(>{schema.SLIP_TOLERANCE_PCT:g} pts off).", )) # 2. ME fuel-oil burn rate outside the plausible band (mt per steaming hour). if me is not None and hours and hours > 0: rate = me / hours if (rate < schema.ME_FO_RATE_LO_MT_PER_H or rate > schema.ME_FO_RATE_HI_MT_PER_H): out.append(Finding( "WARNING", "INCONSISTENT_DATA", "ME FO consumption", f"ME burn rate {rate:.2f} mt/h is outside the typical band " f"[{schema.ME_FO_RATE_LO_MT_PER_H}, " f"{schema.ME_FO_RATE_HI_MT_PER_H}] mt/h. Confirm cons vs. hours.", )) # 3. High slip in calm water — usually hull fouling or a data error, # not weather. if (slip is not None and wind is not None and slip > schema.HIGH_SLIP_PCT and wind <= schema.CALM_WIND_BF): out.append(Finding( "WARNING", "INCONSISTENT_DATA", "Slip", f"High slip {slip}% in calm conditions (BF{wind:g}). " f"Check hull fouling or a distance/RPM entry error.", )) # 4. High speed sustained in heavy weather — verify speed/distance entry. if (speed is not None and wind is not None and wind >= schema.ROUGH_WIND_BF and speed >= schema.HIGH_SPEED_KN): out.append(Finding( "WARNING", "INCONSISTENT_DATA", "Average speed", f"Avg speed {speed}kn sustained in heavy weather (BF{wind:g}) " f"looks high. Verify distance and steaming hours.", )) return out def run_all(report: dict) -> list[Finding]: findings = [] findings += check_missing(report) findings += check_ranges(report) findings += check_consistency(report) findings += check_advanced(report) # severity sort: CRITICAL first order = {"CRITICAL": 0, "ERROR": 1, "WARNING": 2} findings.sort(key=lambda f: order.get(f.severity, 9)) return findings