|
1 | 1 | """Core HOS trip simulation engine.""" |
2 | 2 |
|
| 3 | +from dataclasses import replace |
3 | 4 | from datetime import datetime, timedelta, date |
4 | 5 | from collections import defaultdict |
5 | 6 |
|
@@ -307,12 +308,42 @@ def get_log_sheet_date(utc_time: datetime) -> date: |
307 | 308 | return utc_time.astimezone(HOME_TERMINAL_TZ).date() |
308 | 309 |
|
309 | 310 |
|
| 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 | + |
310 | 340 | def _build_log_sheets(timeline: list[TimelineEvent]) -> list[LogSheet]: |
311 | 341 | """Group timeline events into per-day LogSheet objects.""" |
312 | 342 | by_date: dict[date, list[TimelineEvent]] = defaultdict(list) |
313 | 343 | 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) |
316 | 347 |
|
317 | 348 | sheets: list[LogSheet] = [] |
318 | 349 | for day in sorted(by_date): |
@@ -389,13 +420,15 @@ def simulate_trip(trip_input: TripInput) -> TripPlanResult: |
389 | 420 | current_time = now |
390 | 421 |
|
391 | 422 | # 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 |
399 | 432 |
|
400 | 433 | # 5. Simulate leg 1 (current → pickup) |
401 | 434 | current_time, state = _simulate_leg(leg1, timeline, state, current_time, violations) |
|
0 commit comments