Coverage for app/maintenance/due_date_projection.py: 100%

29 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-17 15:29 +0000

1"""Backlog: maintenance due-date projection from utilization trend. 

2 

3Hours-based triggers show "due at X h", but an owner plans on a calendar. 

4This computes a rolling utilization rate (hours/week) from an aircraft's 

5recent flight history and projects the calendar date at which an 

6hours-based trigger will reach its due value — always an estimate, never 

7a substitute for the real hours-based due figure. 

8 

9Pure calculation functions, kept independent of Flask routing (mirrors the 

10style of reports/utilization.py) so they can be unit-tested deterministically. 

11""" 

12 

13from datetime import date as _date 

14from datetime import timedelta 

15 

16from models import HoursBasis # pyright: ignore[reportMissingImports] 

17from reports.utilization import ( # pyright: ignore[reportMissingImports] 

18 engine_hours_flown, 

19 flight_hours_flown, 

20 flights_in_period, 

21) 

22 

23__all__ = ["project_due_date", "weekly_utilization_rate"] 

24 

25# Trailing window the rate is computed over. 

26WINDOW_DAYS = 90 

27# Minimum-data guard (see backlog item): an aircraft flown only once or 

28# twice in the window produces a meaningless trend — require at least this 

29# many flights, spanning at least MIN_SPAN_DAYS, before trusting the rate. 

30MIN_FLIGHTS = 3 

31MIN_SPAN_DAYS = 14 

32 

33 

34def weekly_utilization_rate( 

35 aircraft_id: int, hours_basis: str, today: "_date | None" = None 

36) -> "float | None": 

37 """Average hours/week over the trailing WINDOW_DAYS, or None if there 

38 isn't enough recent flight history to trust a trend.""" 

39 today = today or _date.today() 

40 period_start = today - timedelta(days=WINDOW_DAYS) 

41 flights = flights_in_period(aircraft_id, period_start, today) 

42 if len(flights) < MIN_FLIGHTS: 

43 return None 

44 flight_dates = [f.date for f in flights] 

45 if (max(flight_dates) - min(flight_dates)).days < MIN_SPAN_DAYS: 

46 return None 

47 hours = ( 

48 flight_hours_flown(aircraft_id, period_start, today) 

49 if hours_basis == HoursBasis.FLIGHT 

50 else engine_hours_flown(aircraft_id, period_start, today) 

51 ) 

52 if hours <= 0: 

53 return None 

54 return hours / (WINDOW_DAYS / 7.0) 

55 

56 

57def project_due_date( 

58 current_hours: "float | None", due_hours: float, weekly_rate: "float | None" 

59) -> "_date | None": 

60 """Calendar date at which current_hours reaches due_hours, projecting 

61 forward at weekly_rate hours/week. None if there's no rate to project 

62 from, or no current-hours reading to project from.""" 

63 if weekly_rate is None or weekly_rate <= 0 or current_hours is None: 

64 return None 

65 remaining = due_hours - current_hours 

66 if remaining <= 0: 

67 return _date.today() 

68 weeks = remaining / weekly_rate 

69 return _date.today() + timedelta(days=round(weeks * 7))