Skip to content

Commit 169e57b

Browse files
FelixBCclaude
andcommitted
fix(simulator,ui): correct rest-priority, float break counter, and midnight pill
Engine (engine.py / models.py / rules.py): - Mandatory rest now unconditionally wins over a coincident 30-min break in the post-split event-insertion chain. Previously, when both break_hit and rest_hit fired simultaneously the engine inserted an unnecessary 30-min break before the 10h rest; the 10h rest resets driving_minutes_since_break anyway, so the break was redundant and illegal (on-duty work performed after the driving cap was reached). This also eliminated the spurious "10h 30m" merged rest run that made the boundary pill and DOT 11h check appear to disagree. - driving_minutes_since_break changed from int to float (models.py) and all int() truncation casts removed from the two arithmetic sites in engine.py, so sub-minute segments accumulate without rounding loss. - Corresponding 0 → 0.0 literals updated in rules.py for consistency. Frontend (TimelineView.tsx): - Replaced the per-day, minute-of-day–scoped compliance logic with a single computeCompliance pass that walks all events as one continuous timestamp stream. The DOT 11h check now accumulates driving since the last true ≥10h rest run, never resetting at calendar midnight. Both the DOT 11h check and the between-day boundary pill read from the same detectRestRuns output, so they cannot disagree. - Between-day rest pill is suppressed when detected boundary rest is 0 min (driver legally crossing midnight within one duty window); green ≥10h and 34h restart pills still render normally. Tests: - Added test_fmcsa_09_chicago_mustang_norwalk_cycle69_no_midnight_chain to test_fmcsa_scenarios.py: exact bug-report input (Chicago → Mustang OK → Norwalk CA, cycle 69h) with three invariant checks — 11h cap per window, every mandatory rest ≥10h, per-day peak matches cross-day accumulation. - Added test_continuous_rest_11h.py: two tests that mirror frontend semantics (detectRestRuns + drivingMaxByDay) and assert pill/check agreement for the Chicago → Denver → LA cycle-65 scenario. - All 37 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent bebac36 commit 169e57b

6 files changed

Lines changed: 798 additions & 67 deletions

File tree

backend/simulator/engine.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ def _simulate_leg(
246246
coords=remaining_leg.end_coords,
247247
)
248248
state.driving_hours_today += drive_hours
249-
state.driving_minutes_since_break += int(drive_minutes)
249+
state.driving_minutes_since_break += drive_minutes
250250
state.cycle_hours_used += drive_hours
251251
# Scale miles to the actual driven minutes when clamped.
252252
if remaining_leg.duration_minutes > 0:
@@ -274,7 +274,7 @@ def _simulate_leg(
274274
coords=partial.end_coords,
275275
)
276276
state.driving_hours_today += drive_hours
277-
state.driving_minutes_since_break += int(partial.duration_minutes)
277+
state.driving_minutes_since_break += partial.duration_minutes
278278
state.cycle_hours_used += drive_hours
279279
state.miles_since_fuel += partial.distance_miles
280280
current_time = end_time
@@ -294,26 +294,29 @@ def _simulate_leg(
294294
rest_hit = avail_11h <= 0.001 or avail_window <= 0.001 or avail_cycle <= 0.001
295295
stop_coords = remaining_leg.start_coords
296296

297-
if fuel_hit and break_hit:
298-
# Coincident: break then fuel
299-
current_time, state = _insert_break(timeline, state, current_time, remaining_leg.start_location, coords=stop_coords)
300-
current_time, state = _insert_fuel_stop(timeline, state, current_time, remaining_leg.start_location, coords=stop_coords)
301-
elif fuel_hit and rest_hit:
302-
# Rest first, then fuel
297+
# Rest wins over break: a 10h rest resets the break counter, so
298+
# inserting a break before a mandatory rest is both unnecessary and
299+
# illegal (on-duty activity after the 11h/14h cap was reached).
300+
if rest_hit and fuel_hit:
301+
# Rest first (also resets break counter), then fuel
303302
current_time, state = _insert_mandatory_rest(
304303
timeline, state, current_time, remaining_leg.start_location, violations, coords=stop_coords,
305304
)
306305
if "hos_infeasible" in " ".join(violations):
307306
return current_time, state
308307
current_time, state = _insert_fuel_stop(timeline, state, current_time, remaining_leg.start_location, coords=stop_coords)
309-
elif break_hit:
310-
current_time, state = _insert_break(timeline, state, current_time, remaining_leg.start_location, coords=stop_coords)
311308
elif rest_hit:
312309
current_time, state = _insert_mandatory_rest(
313310
timeline, state, current_time, remaining_leg.start_location, violations, coords=stop_coords,
314311
)
315312
if "hos_infeasible" in " ".join(violations):
316313
return current_time, state
314+
elif fuel_hit and break_hit:
315+
# Coincident break+fuel (rest not needed)
316+
current_time, state = _insert_break(timeline, state, current_time, remaining_leg.start_location, coords=stop_coords)
317+
current_time, state = _insert_fuel_stop(timeline, state, current_time, remaining_leg.start_location, coords=stop_coords)
318+
elif break_hit:
319+
current_time, state = _insert_break(timeline, state, current_time, remaining_leg.start_location, coords=stop_coords)
317320
elif fuel_hit:
318321
current_time, state = _insert_fuel_stop(timeline, state, current_time, remaining_leg.start_location, coords=stop_coords)
319322

@@ -439,7 +442,7 @@ def simulate_trip(
439442
window_start=now,
440443
window_end=now + timedelta(hours=14),
441444
driving_hours_today=0.0,
442-
driving_minutes_since_break=0,
445+
driving_minutes_since_break=0.0,
443446
cycle_hours_used=trip_input.cycle_hours_used,
444447
miles_since_fuel=0.0,
445448
restart_used=False,
@@ -512,4 +515,4 @@ def simulate_trip(
512515
total_duration_hours=total_hours,
513516
cycle_hours_remaining=cycle_remaining,
514517
violations=violations,
515-
)
518+
)

backend/simulator/models.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,10 @@ class SimulationState:
6767
window_start: datetime
6868
window_end: datetime
6969
driving_hours_today: float = 0.0
70-
driving_minutes_since_break: int = 0
70+
# Minutes driven since the last qualifying break. Kept as float so small
71+
# clamped segments (e.g. after an 11h or 14h cap split) do not lose
72+
# sub-minute time and silently delay the mandatory 30-min break.
73+
driving_minutes_since_break: float = 0.0
7174
cycle_hours_used: float = 0.0
7275
miles_since_fuel: float = 0.0
7376
restart_used: bool = False

backend/simulator/rules.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,14 @@ def needs_driving_reset(state: SimulationState, current_time: datetime) -> bool:
6767
def apply_qualifying_break(state: SimulationState, consecutive_non_driving_minutes: float) -> SimulationState:
6868
"""Reset the 30-min break counter if break is long enough (Rule 3)."""
6969
if consecutive_non_driving_minutes >= QUALIFYING_BREAK_MINUTES:
70-
state.driving_minutes_since_break = 0
70+
state.driving_minutes_since_break = 0.0
7171
return state
7272

7373

7474
def apply_10h_reset(state: SimulationState, reset_end_time: datetime) -> SimulationState:
7575
"""Rule 5: after 10 consecutive hours off, reset daily limits."""
7676
state.driving_hours_today = 0.0
77-
state.driving_minutes_since_break = 0
77+
state.driving_minutes_since_break = 0.0
7878
state.window_start = reset_end_time
7979
state.window_end = reset_end_time + timedelta(hours=DRIVING_WINDOW_HOURS)
8080
return state
@@ -84,7 +84,7 @@ def apply_34h_restart(state: SimulationState, restart_end_time: datetime) -> Sim
8484
"""Rule 6: 34-hour restart resets cycle hours and daily limits."""
8585
state.cycle_hours_used = 0.0
8686
state.driving_hours_today = 0.0
87-
state.driving_minutes_since_break = 0
87+
state.driving_minutes_since_break = 0.0
8888
state.window_start = restart_end_time
8989
state.window_end = restart_end_time + timedelta(hours=DRIVING_WINDOW_HOURS)
9090
state.restart_used = True
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"""End-to-end validation of the two cross-day HOS invariants that the
2+
TimelineView UI shows:
3+
4+
1. For every day, max(driving minutes accumulated since the last contiguous
5+
>= 10h off-duty/sleeper run) must be <= 660 (FMCSA 11h cap).
6+
2. For every pair of adjacent days, the boundary-pill "continuous rest"
7+
calculation must AGREE with the DOT 11h check — both derived from the
8+
same single-pass rest-run analysis.
9+
10+
Scenario: Chicago, IL -> Denver, CO -> Los Angeles, CA, cycle hours used 65.
11+
This is the exact input that produced the contradictory UI in the bug
12+
report.
13+
"""
14+
15+
from unittest.mock import patch
16+
17+
from simulator.models import DutyStatus, TripInput
18+
from simulator.engine import simulate_trip
19+
20+
21+
CHICAGO_DENVER_LA = TripInput(
22+
current_location="Chicago, IL",
23+
pickup_location="Denver, CO",
24+
dropoff_location="Los Angeles, CA",
25+
cycle_hours_used=65.0,
26+
)
27+
28+
REST_STATUSES = {DutyStatus.OFF_DUTY, DutyStatus.SLEEPER_BERTH}
29+
RESET_10H_MIN = 10 * 60
30+
DOT_11H_MIN = 11 * 60
31+
32+
33+
def _detect_rest_runs(events):
34+
"""Mirror of the frontend detectRestRuns: maximal contiguous rest runs
35+
where each event's end_time exactly equals the next event's start_time."""
36+
runs = []
37+
run_start = None
38+
run_end = None
39+
run_minutes = 0.0
40+
41+
def flush():
42+
nonlocal run_start, run_end, run_minutes
43+
if run_start is not None:
44+
runs.append((run_start, run_end, run_minutes))
45+
run_start = None
46+
run_end = None
47+
run_minutes = 0.0
48+
49+
for event in events:
50+
if event.status not in REST_STATUSES:
51+
flush()
52+
continue
53+
duration = (event.end_time - event.start_time).total_seconds() / 60.0
54+
if run_start is not None and event.start_time == run_end:
55+
run_end = event.end_time
56+
run_minutes += duration
57+
else:
58+
flush()
59+
run_start = event.start_time
60+
run_end = event.end_time
61+
run_minutes = duration
62+
flush()
63+
return runs
64+
65+
66+
def _compute_driving_max_by_day(events, runs):
67+
"""Mirror of the frontend driving-max-by-day walk."""
68+
reset_moments = sorted(
69+
run_start.timestamp() + RESET_10H_MIN * 60
70+
for run_start, _, total in runs
71+
if total >= RESET_10H_MIN
72+
)
73+
74+
driving_max = {}
75+
driving_since_reset = 0.0
76+
next_reset = 0
77+
78+
for event in events:
79+
start_ts = event.start_time.timestamp()
80+
while next_reset < len(reset_moments) and reset_moments[next_reset] <= start_ts:
81+
driving_since_reset = 0.0
82+
next_reset += 1
83+
84+
if event.status == DutyStatus.DRIVING:
85+
duration = (event.end_time - event.start_time).total_seconds() / 60.0
86+
driving_since_reset += duration
87+
88+
day = event.start_time.astimezone().date().isoformat()
89+
driving_max[day] = max(driving_max.get(day, 0.0), driving_since_reset)
90+
91+
return driving_max
92+
93+
94+
@patch("simulator.engine.get_route")
95+
@patch("simulator.engine.geocode_address")
96+
def test_chicago_denver_la_cycle65_no_11h_violation(mock_geocode, mock_route):
97+
mock_geocode.side_effect = [
98+
(41.8781, -87.6298),
99+
(39.7392, -104.9903),
100+
(34.0522, -118.2437),
101+
]
102+
mock_route.side_effect = [
103+
{"distance_miles": 1000.0, "duration_hours": 15.0},
104+
{"distance_miles": 1000.0, "duration_hours": 17.0},
105+
]
106+
result = simulate_trip(CHICAGO_DENVER_LA)
107+
108+
runs = _detect_rest_runs(result.timeline)
109+
driving_max = _compute_driving_max_by_day(result.timeline, runs)
110+
111+
offenders = {day: mins for day, mins in driving_max.items() if mins > DOT_11H_MIN}
112+
assert not offenders, (
113+
"Simulator produced driving runs exceeding 11h between 10h resets: "
114+
f"{offenders}. Full per-day max: {driving_max}"
115+
)
116+
117+
118+
@patch("simulator.engine.get_route")
119+
@patch("simulator.engine.geocode_address")
120+
def test_chicago_denver_la_cycle65_pill_and_check_agree(mock_geocode, mock_route):
121+
"""For every adjacent day pair where a >=10h rest run spans the boundary,
122+
the next day's DOT 11h check MUST show 'since last reset' below the
123+
cumulative that led to the reset. Specifically: the run's start-of-day
124+
carry-over must be 0 immediately after the reset (<= DOT_11H_MIN trivially)
125+
and the next-day max must not exceed DOT_11H_MIN."""
126+
mock_geocode.side_effect = [
127+
(41.8781, -87.6298),
128+
(39.7392, -104.9903),
129+
(34.0522, -118.2437),
130+
]
131+
mock_route.side_effect = [
132+
{"distance_miles": 1000.0, "duration_hours": 15.0},
133+
{"distance_miles": 1000.0, "duration_hours": 17.0},
134+
]
135+
result = simulate_trip(CHICAGO_DENVER_LA)
136+
137+
runs = _detect_rest_runs(result.timeline)
138+
driving_max = _compute_driving_max_by_day(result.timeline, runs)
139+
140+
# Boundary rest per day, same rule as the frontend pill.
141+
sheets = result.log_sheets
142+
for i in range(len(sheets) - 1):
143+
curr, nxt = sheets[i], sheets[i + 1]
144+
if not curr.events or not nxt.events:
145+
continue
146+
last, first = curr.events[-1], nxt.events[0]
147+
if (
148+
last.end_time != first.start_time
149+
or last.status not in REST_STATUSES
150+
or first.status not in REST_STATUSES
151+
):
152+
continue
153+
boundary_ts = last.end_time.timestamp()
154+
containing = next(
155+
(
156+
total
157+
for run_start, run_end, total in runs
158+
if run_start.timestamp() < boundary_ts < run_end.timestamp()
159+
),
160+
None,
161+
)
162+
assert containing is not None, (
163+
f"Boundary between {curr.date} and {nxt.date} has contiguous rest "
164+
"on both sides but no rest run contains it — pill/check will disagree."
165+
)
166+
if containing >= RESET_10H_MIN:
167+
next_day_max = driving_max.get(nxt.date.isoformat(), 0.0)
168+
assert next_day_max <= DOT_11H_MIN, (
169+
f"Pill says 10h reset ✓ for {curr.date}->{nxt.date} but DOT 11h "
170+
f"check on {nxt.date} reports {next_day_max} min > {DOT_11H_MIN}."
171+
)

0 commit comments

Comments
 (0)