File size: 7,072 Bytes
0846808
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""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