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
« 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.
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.
9Pure calculation functions, kept independent of Flask routing (mirrors the
10style of reports/utilization.py) so they can be unit-tested deterministically.
11"""
13from datetime import date as _date
14from datetime import timedelta
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)
23__all__ = ["project_due_date", "weekly_utilization_rate"]
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
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)
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))