Skip to content

Commit 43f9c83

Browse files
committed
fix(simulator): three post-submission bug fixes
- Split multi-day events at midnight in log sheet grouping - Skip pre-trip inspection when cycle already exhausted (cycle=70) - Raise ValueError on geocoding failure instead of silent fallback - All 26 tests passing in mock mode
1 parent 54beaf6 commit 43f9c83

2 files changed

Lines changed: 53 additions & 20 deletions

File tree

backend/simulator/engine.py

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Core HOS trip simulation engine."""
22

3+
from dataclasses import replace
34
from datetime import datetime, timedelta, date
45
from collections import defaultdict
56

@@ -307,12 +308,42 @@ def get_log_sheet_date(utc_time: datetime) -> date:
307308
return utc_time.astimezone(HOME_TERMINAL_TZ).date()
308309

309310

311+
def split_event_at_midnight(event: TimelineEvent) -> list[TimelineEvent]:
312+
"""
313+
Split a TimelineEvent into one segment per calendar day it spans.
314+
Example: 34h OFF_DUTY starting Apr 30 06:15 AM becomes:
315+
- Apr 30: 06:15 AM -> midnight (17.75h)
316+
- May 1: midnight -> midnight (24.00h)
317+
- May 2: midnight -> 08:15 AM ( 8.25h)
318+
Each segment inherits all fields from parent. duration_hours recalculated.
319+
"""
320+
segments: list[TimelineEvent] = []
321+
current_start = event.start_time
322+
323+
while current_start < event.end_time:
324+
next_midnight = (current_start + timedelta(days=1)).replace(
325+
hour=0, minute=0, second=0, microsecond=0
326+
)
327+
segment_end = min(next_midnight, event.end_time)
328+
duration = (segment_end - current_start).total_seconds() / 3600.0
329+
segments.append(replace(
330+
event,
331+
start_time=current_start,
332+
end_time=segment_end,
333+
duration_hours=duration,
334+
))
335+
current_start = segment_end
336+
337+
return segments if segments else [event]
338+
339+
310340
def _build_log_sheets(timeline: list[TimelineEvent]) -> list[LogSheet]:
311341
"""Group timeline events into per-day LogSheet objects."""
312342
by_date: dict[date, list[TimelineEvent]] = defaultdict(list)
313343
for event in timeline:
314-
day = get_log_sheet_date(event.start_time)
315-
by_date[day].append(event)
344+
for sub_event in split_event_at_midnight(event):
345+
day = get_log_sheet_date(sub_event.start_time)
346+
by_date[day].append(sub_event)
316347

317348
sheets: list[LogSheet] = []
318349
for day in sorted(by_date):
@@ -389,13 +420,15 @@ def simulate_trip(trip_input: TripInput) -> TripPlanResult:
389420
current_time = now
390421

391422
# 4. Pre-trip inspection (15 min ON_DUTY)
392-
pre_trip_hours = PRE_TRIP_MINUTES / 60.0
393-
current_time = _add_event(
394-
timeline, DutyStatus.ON_DUTY, current_time,
395-
pre_trip_hours, trip_input.current_location, "Pre-trip inspection",
396-
coords=current_coords,
397-
)
398-
state.cycle_hours_used += pre_trip_hours
423+
# Skip if cycle already exhausted — restart must happen first
424+
if minutes_until_cycle_limit(state) > 0:
425+
pre_trip_hours = PRE_TRIP_MINUTES / 60.0
426+
current_time = _add_event(
427+
timeline, DutyStatus.ON_DUTY, current_time,
428+
pre_trip_hours, trip_input.current_location, "Pre-trip inspection",
429+
coords=current_coords,
430+
)
431+
state.cycle_hours_used += pre_trip_hours
399432

400433
# 5. Simulate leg 1 (current → pickup)
401434
current_time, state = _simulate_leg(leg1, timeline, state, current_time, violations)

backend/simulator/geocoding.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ def _api_key() -> str | None:
2121

2222

2323
def geocode_address(address: str) -> tuple[float, float]:
24-
"""Return (lat, lng). Falls back to mock if ORS_API_KEY is not set."""
24+
"""Return (lat, lng). Falls back to mock only if ORS_API_KEY is not set."""
2525
key = _api_key()
2626
if not key:
2727
return _MOCK_COORD
@@ -39,8 +39,11 @@ def geocode_address(address: str) -> tuple[float, float]:
3939
# ORS returns [lng, lat]
4040
return (coords[1], coords[0])
4141
except Exception as exc:
42-
print(f"ORS geocoding failed for '{address}': {exc}. Falling back to mock.")
43-
return _MOCK_COORD
42+
print(f"ORS geocoding error for '{address}': {exc}")
43+
raise ValueError(
44+
f"Could not geocode location '{address}'. "
45+
f"Use format 'City, ST' e.g. 'Chicago, IL'"
46+
)
4447

4548

4649
def get_route(
@@ -49,7 +52,7 @@ def get_route(
4952
) -> dict:
5053
"""
5154
Return routing dict with distance_miles, duration_hours, coordinates.
52-
Falls back to mock if ORS_API_KEY is not set.
55+
Falls back to mock only if ORS_API_KEY is not set.
5356
"""
5457
key = _api_key()
5558
if not key:
@@ -91,11 +94,8 @@ def get_route(
9194
"coordinates": coords,
9295
}
9396
except Exception as exc:
94-
print(
95-
f"ORS routing failed from {origin} to {destination}: {exc}. Falling back to mock."
97+
print(f"ORS routing error from {origin} to {destination}: {exc}")
98+
raise ValueError(
99+
f"Could not compute route from {origin} to {destination}. "
100+
f"Verify locations and try again."
96101
)
97-
return {
98-
"distance_miles": _MOCK_ROUTE["distance_miles"],
99-
"duration_hours": _MOCK_ROUTE["duration_hours"],
100-
"coordinates": [origin, destination],
101-
}

0 commit comments

Comments
 (0)