diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64df5a3..357a25a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: test: runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 40 strategy: matrix: python: ["3.11", "3.12"] diff --git a/docs/v4/CREW.md b/docs/v4/CREW.md new file mode 100644 index 0000000..82d249d --- /dev/null +++ b/docs/v4/CREW.md @@ -0,0 +1,61 @@ +# AirOpsEnv v4's crew: harder days, still no stricter than reality + +Status: design, 2026-10-17; calm guard at JetBlue's fair weather, 2026-10-22. Part of v4 (docs/v4/DESIGN.md): the +layers and this crew ship as one version. Results: docs/v4/REPORT.md. + +## §0 What done means + +The layers alone made the days more realistic but barely harder (do-nothing's median excess over the anchor 9.2% -> 9.6%). +Edgar's crew model made them much harder (20.6%) by being stricter than reality: on schedule alone, with no delay and +no programs, the calm day cancelled flights on every date, the controls' recorded days cost less than the anchor, and +the anchor cancelled 129% of what JetBlue did. + +The crew is done when, on the pack's dates, measured by `scripts/v4_check.py` against v2 and against the same +dates without crews (`--nocrew`): +1. **Harder:** do-nothing's median excess over the anchor >= 1.4x v2's, and the follower's >= 1.5x v2's. +2. **No stricter than reality (guards):** world_check's controls gate passes (each control's recorded day >= its + anchor); the calm day is held to JetBlue's fair weather (docs/v4/DESIGN.md §6.2): on every date whose calm day + without crews is within that bar, the crew's is too, so crews push no fair day past the cancellations JetBlue's own + fair days have (DECISIONS 2026-10-22; until then the guard was relative, no more cancellations and ferries than + without crews on every date, which counted against the crew the round trip it cancels where the base world is an + aircraft short and the layers alone fly it up to 200 minutes late); the recorded day's excess over the anchor is + >= 0 on >= 95% of dates; the anchor cancels no more than JetBlue did (<= 100%); the replay flies >= 97% of + JetBlue's flown flights and its departures within 15 min stay within 1 point of v2's. +3. **Sound and playable:** as the layers (every anchor proven or flagged and replaying exactly; agreement tests; the live + server test). + +## §1 Lever 1: a crew layer legal on schedule + +Edgar's proxy pairings (`repairs.pairings`) chain each tail's legs from a base departure to the next base arrival +with no duty limit, so a long chain through outstations exceeds FAR 117 on schedule and its later legs can never fly. +v4 keeps Edgar's crew API (`model.Crew`, `Flight.crew`, `Flight.legal`, `Task.bases`, `Task.auto_at`, the sim and MIP +enforcement, the reserve pools of docs/v4/DESIGN.md §3.4) and replaces only how pairings are cut, in `programs/layers/crew.py`: +- a duty starts at a tail's first leg, at a base departure, or after a rest (a ground gap >= REST, 10 h); +- it takes the tail's next leg while the duty would still end, on schedule, `buffer` minutes inside its Table B limit + (report REPORT minutes before its first departure, 117 Table B by the report's local hour and the duty's segments) + and holds at most `max_segments` legs; otherwise a new duty starts there (a crew change at a base, or an + overnight crew at an outstation); +- each leg's crew deadline is report + limit + EXTENSION (2 h, 117.19) - its scheduled block, as Edgar's; a base + departure past it flies only on a reserve (the pools). + +So every duty is legal on schedule and the crew binds only when delays push a duty past its limit, as real crews time +out. `max_segments` is kept but has no effect in practice: every base departure starts a duty, so duties rarely +exceed 3 legs (V4-1's fit on hub windows: the same scores at 3 to 6 for every buffer from 30 min up; only at 0 does a cap of 3 +keep one sample date's calm day at the layers' own). +`buffer` and `max_segments` are fitted on train dates only: the values that maximise do-nothing's excess while +every §0 guard holds on the train dates (defaults 60 min and 4, the spike's), on whole days since V4-3. Reserve +pools are then fitted as for the layers. + +## §2 Further levers (only if §1 alone misses §0) + +Candidates, each with the same guards: fitting the departure-cap rule (SHORT) on train dates to the realism guard; +closure rates per hub and month. The loop pauses for Alec before any of these (LOOP.md pause rules). + +## §3 Units + +| Unit | What | Done when | +|---|---|---| +| V4-0 | This doc | merged | +| V4-1 | `layers/crew.py` pairings legal on schedule, `network.build(crew=...)`, `v4_pack --crew v4`, train-date fit of buffer and max_segments under the guards, tests (sim/MIP agreement on a v4-crewed day; the calm day of a v4-crewed date cancels nothing for crews) | gates green; a sample of real dates passes the calm and controls guards | +| V4-2 | The v4 pack and check against v2 and v3 on the same dates; docs/v4/REPORT.md | §0 met, or the loop pauses with the numbers | +| V4-3 | One v4 on Edgar's v0.5.0: the layers and the crew as one version (pack b6-v4, bundle airops-v4), the calm day at JetBlue's fair-weather bar, the crew fitted on whole days under it; the pack, the check against v2 and the no-crew build, docs/v4/REPORT.md | the check run, its numbers in REPORT.md | diff --git a/docs/v4/DESIGN.md b/docs/v4/DESIGN.md new file mode 100644 index 0000000..f4250d2 --- /dev/null +++ b/docs/v4/DESIGN.md @@ -0,0 +1,247 @@ +# AirOpsEnv v4: realistic, playable storm days on the programs world + +Status: design, 2026-10-16; one version with the crew, 2026-10-22. Base: `earakely-scale/agentenv-airops-plugin` +v0.5.0 (`b6b71d8`). Home: the fork `agutmanstein-scale/agentenv-airops-plugin`. Evidence behind every choice: the v2 +probe (`~/handoff/out/report.md` on the devbox) and the layer spike (`~/handoff/spike/README.md`); results: +docs/v4/REPORT.md. + +"v4" here names the environment: v2's programs world with realism layers and a crew legal on schedule +(docs/v4/CREW.md), the desk's view of them, and a playable day. It sits beside Edgar's v3 (`airops-occ3`: passengers +and the customer desk on the b6-v3 pack) and is built on v2's world, not v3's: it has none of v3's passengers or +desks. To keep it apart from the data model's own version, a task that carries any layer is written as model version +4; a task without one is v2/main's task, byte for byte, and every reference on it is unchanged. + +## §0 Why, and what done means + +The probe found the programs world too easy and too loose for the task to test a desk: +- 64% of JetBlue's 2,260 cancellations on the 149 candidate days could have flown in hindsight (v1: 40%); JetBlue's + GDP slot pool sits 41-69% idle in its replay; 1,078 of the cancellations are on legs no v2 rule can hold. +- Doing nothing costs only +8.9% over the hindsight optimum at the median, and the optimum cancels 26% of what + JetBlue cancelled. + +The spike showed MIP-expressible layers fix much of that: on all 149 days, do-nothing +13.9% median, the optimum +cancels 37% of JetBlue's, the forecast desk's gap +4.4% -> +8.3% on 6 days, every anchor reproduced exactly. + +**Status (2026-10-22): one v4, the layers and the crew together.** The layers (§2-§4) and the crew +(docs/v4/CREW.md) were built and checked as two steps (on 129 hub-window dates on 2026-10-17, then on v2 0.3.2's whole +days on 2026-10-20) and ship as one. The calm day is now held to JetBlue's own fair weather (§6.2). The check's former +bar, <= 1 forced cancellation and <= 2 ferries on >= 90% of dates, was set for hub windows of 60 to 190 flights; on +whole days of about 690 neither v2, the layers nor the crew met it (0.81, 0.81, 0.79), while JetBlue itself has <= 1 +carrier cancellation on only 69% of its fair days. Its failures were the base world's aircraft shortages: the layers +alone fly a short aircraft's round trip up to 200 minutes late on the calm day, and the crew, which can't legally wait +that long, cancels it, as a fair-weather desk would. The pack's results are in docs/v4/REPORT.md. + +v4 is done when, on the v4 pack's days (§6), measured by `scripts/v4_check.py` (§6.2), with docs/v4/CREW.md §0's +guards: +1. **Harder:** do-nothing's median excess over the hindsight optimum is at least 1.4x v2's on the same days, and the + follower's median gap at least 1.5x v2's. +2. **More realistic:** the optimum's cancellations reach at least 35% of JetBlue's (v2: 26%), the share of JetBlue's + cancellations that could have flown for free drops by at least 10 points, and the recorded day's excess over the + optimum falls; the replay still flies >= 97% of the flights JetBlue flew and its departures within 15 min do not + fall below v2's. +3. **Sound:** every anchor is proven to GAP or flagged; every MIP solution replays exactly in the sim and every sim day + is a MIP solution, layers on, in the test suite and on the pack. +4. **Playable:** an LLM plays a v4 day end to end through the AgentEnv server (§5), graded on the truth. + +Non-goals: new FAA program logic (the programs model and `flow/` are Edgar's and stay as they are), v3's passengers +and desks, next-day schedules. + +## §1 Principles + +- **One rule, two enforcers.** Every layer is a rule `sim.execute` applies and a set of columns or rows the hindsight + MIP (`programs/mip.py`) carries, so the anchor stays a proven optimum. The suite's agreement tests + (`tests/programs/test_hindsight_mip.py`: `test_every_optimum_replays_exactly_in_the_sim`, + `test_every_day_the_sim_runs_is_a_solution_of_the_model_at_its_cost`) gain a layered day per layer. +- **Layers are data.** A task carries `layers` (§2); builders in `programs/layers/` fill it from pinned public + inputs. Absent layers mean v2 behaviour. Calibration constants are fitted on train days only and written to the + pack's manifest. +- **Reuse upstream.** Edgar's crew model (`model.Crew`, `repairs.crews`, `Task.bases`), whole-network storm dates + (`programs/network.py`), the world check (`scripts/world_check.py`), SPECIs (`flow/sources.py`) and the weather + timeline (`flow/weather.py`) are used as they are; v4 adds to them and never changes their semantics silently. + +## §2 Task and data + +- **Unit:** a whole-network storm date (`network.build`), Edgar's current direction, with crew bases on + (`network.build` gains `bases`, as `build_candidate` has). Per-hub windows (`candidates.build_candidate`) take the + same layers, for comparison with v2. +- **Schema:** `Task.layers: Layers | None` (model version 4 when set), a frozen dataclass with `from_dict`/`to_dict`: + `dep_caps: {station: {hour: cap}}`, `arr_caps: {station: {hour: cap}}` (outstations only), `closures: {station: + ((start, end, dep_cap, arr_cap), ...)}` (JetBlue's departure and arrival caps over [start, end)), `reserves: {base: + pool}`, and for the desk's view (§4) `weather: ((minute, kind, station, text), ...)`, `lags: {kind: + minutes}` and `rates: {hub: rate}` (§3.2's `rate`, which the builder also writes here so the day as known can price + a closure under way; set by whoever builds the closures). The last three are written only when set. Hours and + minutes from the task's midnight, as everywhere. +- **Inputs:** the 19 months' pinned BTS on-time (every carrier), IEM METARs (routine, `report_type=3`) and SPECIs + (`report_type=4`, via `flow/sources.py`'s pins), and nothing new from the FAA. Every new input is sha256-pinned in + the pack manifest. + +## §3 World layers (the truth) + +Each section: what it is, how the sim applies it, how the MIP carries it, how it is calibrated. + +### §3.1 Hub departure caps +- **What:** in an hour when every carrier together pushed back (BTS gate departure) less than `SHORT` of its schedule + at JFK or BOS (and at least `MIN_SCHED` were scheduled), JetBlue's cap is its in-scope scheduled share of the hour's + schedule times the hour's actual pushbacks, never below what its in-scope flights actually pushed back. Other hours + stay uncapped. (Spike: the optimum's cancellations 87 -> 112 of 296 on 16 days, the largest single lever.) +- **Sim:** `Rules.dep_hour(f, t)`; `_leave` checks it right after the stop holds, before the GDP logic, and moves t + to the next hour when full; a pinned (`left`) flight counts in its hour. +- **MIP:** each copy leaving a capped hub hour joins row `("dep", (hub, hour))` with room = cap less pinned flights. +- **Calibration:** `SHORT` and `MIN_SCHED` stay at the spike's 0.9 and 5, not fitted: the caps are floored at + JetBlue's recorded pushbacks per hour, so the recorded day fits at any value and the replay gives no target to fit + them to (DECISIONS 2026-10-17, U6 Q1). The outstation arrival caps (§3.3) share them. + +### §3.2 Ramp closures as a departure- and gate-in-rate cut +- **What:** lightning within 5 nm closes the ramp. Truth intervals come from METARs: TS in present weather (not VCTS), + begin and end minutes from the TSB/TSE remarks, SPECIs for exact onset. The data reject a full stop: during such + intervals all carriers still pushed back at ~75% of the rate just before (JFK 411 vs 563, BOS 449 vs 584 over the + same minutes), and still gated in at 70-74% of it (BTS over the 19 months: JFK 565 vs 807 on 75 closures, BOS 732 + vs 989 on 64). So a closure cuts the hub's departure cap to `rate` x JetBlue's normal hourly departures for its + duration, never below what JetBlue's in-scope flights pushed back inside it, and caps its gate-ins there the same + way: `rate` x its normal hourly arrivals into the hub for the duration, never below the in-scope flights scheduled + into the hub that arrive inside it as the sim times the recorded day (recorded pushback plus truth block, diversions + included: the replay flies them to the hub), so the recorded day's arrivals fit the caps. There is no gate hold (an aircraft landing inside a closure reaching its gate only at its end): it + contradicts the gate-ins above, and it made the recorded day infeasible (a recorded turn inside a closure, landed and + pushed back again before its end, took a probe day's airline replay from 670 to 19740; DECISIONS 2026-10-17, U3 + Q1). +- **Sim:** each closure is two cap rows over its interval, departures counted by pushback minute and arrivals by + arrival minute (the departure plus the world's block, as the nominal cap counts them), pinned flights in both. A + departure into a full closure's departures waits for its end; one whose arrival would fall inside a full closure's + arrivals leaves so as to arrive at its end. Aircraft reach the gate on landing, as in v2. +- **MIP:** a row per closure and direction, `("closure_dep", (hub, start))` over the copies leaving inside it and + `("closure_arr", (hub, start))` over those arriving inside it, room = the cap less the pinned flights; a closure's + end, and its end less the block, join the copy minutes. The model stays linear. +- **Calibration:** `rate` per hub fitted on train days from BTS pushbacks inside vs just before closures, used for + both caps. + +### §3.3 Outstation arrival caps +- **What:** the §3.1 rule on arrivals at every non-hub destination, from BTS arrivals of every carrier. It stands in + for the destinations' own GDPs, ground stops and weather, which v2 doesn't model (44 of 125 out-of-hub residual + flights on the shipped days had one listed). +- **Sim / MIP:** the caps join the nominal arrival caps (`World.nominal`), which `Rules.hour` and `_place` already + read for any station: no new mechanism. + +### §3.4 A finite reserve pool at crew bases +- **What:** Edgar's crew model gives base departures unlimited reserves (no crew deadline). v4 gives each base a + pool of `reserves[base]` crews per day: a base departure past its pairing's crew deadline flies only by calling one. +- **Sim:** plan key `reserve: True`; `granted(task, plan)` grants in flight-id order while the base's pool lasts. +- **MIP:** a 0/1 reserve column per eligible flight bounding its copies past the crew deadline, and a pool row per + base. The airline replay calls a reserve for each base departure its replay with unlimited reserves (the v2 replay) + flies past its crew deadline: one JetBlue flew late, or one its aircraft or the programs make leave late (deciding + from JetBlue's recorded minute alone would auto-cancel the second kind with the pool to spare). When the pool binds, + the calls are granted in flight-id order, as any plan's are. +- **Desk:** `call_reserve` and `release_reserve`; a release is refused once the flight's crew deadline has passed + (without the reserve it would auto-cancel back then, a decision before the clock). +- **Calibration:** none is public, so the pool per base is fitted to G1's thresholds on the train dates: the + smallest pool at which, on every train date, the airline replay auto-cancels at most 1% and at most 2 of the + flights JetBlue flew by their deadline (a date already over that with unlimited reserves: no more than with them). + Per base with the others unlimited, the largest over the dates, then rechecked with every pool on + (`layers.calibrate`; DECISIONS 2026-10-17, U6 Q2). + +### §3.5 Slot release (experiment) +- **What:** a cancelled flight's RBS slot is released to compression (other carriers) at the cancellation's decision + time, unless the airline substitutes a flight into it first. +- **Sim / MIP:** slot rows gain `<= 1 - c_holder` for slots past the holder's decision; the sim drops a released slot + from the pool. +- **Kept only if** it moves §0's metrics without breaking the replay; otherwise recorded as tried and dropped. +- **Tried and dropped (U8, 2026-10-17).** The strict variant (a slot RBS gave to a flight the plan cancels leaves + JetBlue's pool and no JetBlue flight may take it; MIP `room <= 1 - c_holder`, the holder's auto-cancel its own + column) passed the sim/MIP agreement tests, but on the 16 sample day-hubs (`~/handoff/spike/sample.txt`, rebuilt + with this branch's `build_candidate` at v2's windows and scales; every anchor proven at 600 s) it moved §0's + metrics the wrong way or not at all, off -> on: the probe's feasible share of JetBlue's 296 cancellations + 72.3% -> 72.0% (§0 wants -10 points); do-nothing's median excess over the optimum +13.1% -> +12.1% (do-nothing + cancels nothing, so release only constrains the optimum); the optimum's cancellations 89 -> 87 (cancelling a + holder no longer frees its slot for a fuller flight); the replay's auto-cancels 2 -> 2, flown flights unchanged. + JetBlue's replay releases 136 of the 485 pool slots, but a restored cancellation takes back its own slot, so the + probe's idle slots are not what release removes. Release is not part of v4: `Layers.release`, which no enforcer read, + was removed from the schema in U11. Patch and numbers: `~/handoff/v3-loop/units/U8/`. + +## §4 What the desk knows, and when (the day as known) + +`sim.known(task, t)` carries the layers as a desk sees them; the follower and the playable day both read it. +- **Caps** (departure, outstation arrival, closure rates): known `CAP_LOOK` (240 min) ahead, as v1's slot caps were. + The watches run until every cap is known (as v1's `core.known.watch_times`; a closure's caps once it has ended), + while some flight can leave unfrozen. +- **Closures:** known from their start minute (the onset the METAR or SPECI remarks give, TSBmm, with no report lag), + expected to end `GUESS` minutes after the watch (placeholder 30; the train-day median of remaining duration, fitted + with the pack) until its end comes. A closure under way is priced over that expected window at its hub's rate + (`Layers.rates`, `closures.fit_rate`'s), floored only at the pushbacks and arrivals before the watch + (`closures.caps`), so neither its true length nor what flew inside it later shows; an ended one has its true caps. +- **News and its delivery lag:** advisories at signature + `lag.advisory`, METARs at observation + `lag.metar`, + SPECIs at observation + `lag.speci`, TAFs at issue + `lag.taf`. Lags are per product type (default 0, 5, 5, 10 min) + and part of the task (`Layers.lags`, over the defaults; whole minutes >= 0). The advisory lag stays 0 until the day + as known reads the programs as delivered rather than as signed (`advisories.issued_by`). News reaches the desk at the + first watch at or after it is due (watches are the playable day's only delivery points). +- **Watches wake on events:** the hourly watches plus an event watch when an advisory or a SPECI reaches the desk and + when a closure starts or ends; one closer than `WATCH_MIN` (15) to the watch before moves to 15 after it (to the + last minute, deadline - FREEZE, if that is sooner, so every event by then reaches the desk), and a gap over 60 is + filled every 60, so watches are 15 to 60 min apart but for such a last one. Routine METARs and TAFs don't wake the desk (an hourly METAR + would double the watches with nothing new), and freeze boundaries aren't events. Without layers the watches are v2's. + The follower uses the same watch times (`references.watch_times`), so its gap measures the same desk. + (Amended in U7 from "the earliest of a news item, a freeze boundary and 60 min after the last one" and "known once + the METAR or SPECI reports TS".) + +## §5 The playable day + +A v4 port of v1's day (`day.py`, `live.py`, `tasks.py`, `schedule.py`) onto the programs world, in +`src/agentenv_airops/v4day/` (v1's stay untouched). +- **Clock and watches:** event-driven watches (§4). Each tool call costs `CALL_MIN` minutes of virtual time + (default 1), so thinking costs time without tying the score to provider speed. +- **Situation:** the time, the freeze line, the programs as issued (GDPs, stops, proposed), departure and outstation + caps known so far, closures under way, the latest METAR/SPECI/TAF, every flight (status, known delay, the plan's + departure and arrival on the day as known, its slot or hold, its crew deadline and rest), aircraft positions and + the reserve pool left at each base. +- **Actions:** `cancel`, `hold` (not_before), `restore`, `call_reserve`, confirmed per watch and frozen as v2's + follower freezes (FREEZE before departure; cancellations final). +- **Grade:** the day's cost on the truth (`sim.execute`), against the anchor and do-nothing: + `reward = (naive - cost) / (naive - anchor)`, clipped as v1's `core.reward`, with the follower and the rule as + reference scores. +- **Server:** an AgentEnv live env as `live.py` serves v1 (`urn:airops:live-load` pattern, virtual clock extension), + with v4 prompts in `tasks.py`'s style. + +## §6 The v4 pack and its check + +### §6.1 Pack (`scripts/v4_pack.py`) +Whole-network storm dates over the 19 months, the crew on at JetBlue's bases (JFK, BOS, FLL, MCO) with v4's cut +fitted on train dates (docs/v4/CREW.md §1), every layer fitted on train dates, the draw as `network.split`. Manifest: +inputs and their pins, layer constants and the dates they were fitted on, the crew cut and its fit, and `calm`, the +fair-weather bar of §6.2. `--bases none` builds the same dates with the layers alone: the no-crew pack the check reads +the crew's guards against. + +### §6.2 Check (`scripts/v4_check.py`) +`world_check.py`'s gates with layers on, plus §0's metrics against the same dates without layers: do-nothing gap, +follower gap, optimum's cancellations against JetBlue's, the could-fly-for-free share (the probe, per restore so +pairs count once), replay flown share and departures within 15 min, proofs and exact replays. A v1/v2/v4 report. + +The calm day (world_check's `calm_day`, and the crew's guard) is held to JetBlue's fair weather (`layers.fairday`). A +fair day is a date on which JetBlue cancelled none of a whole-network task's flights (every leg of a tail that touched +a hub, every hub flight) for weather (BTS code B) or the NAS (C); the cancellations it still has are the carrier's own +(A: mechanical, crew), the kind a calm day forces where an aircraft is short. The bar is their share of the day's +flights at the 90th percentile of the pack's months (0.92% over the 19 months, 315 fair days of 578), and a calm day +is within it when it forces at most max(1, floor(rate x flights)) cancellations and at most 2 ferries; JetBlue's own +fair days are within it on 90% of them. `calm_day` passes when >= 90% of the kept storm dates are within it, and the +crew's guard when every date whose calm day without crews is within it stays within it with the crew. + +## §7 Milestones (the loop's units; one linear stack, since they share sim.py and mip.py) + +| Unit | What | Done when | +|---|---|---| +| U1 | This doc | merged in the fork | +| U2 | `Layers` schema (model v4 when set), `programs/layers/` package, hub departure caps (§3.1) | sim and MIP enforce them; agreement tests pass on a capped day; tasks without layers byte-identical; ruff, pytest green | +| U3 | Ramp closures as a departure and gate-in rate cut (§3.2), METAR/SPECI parsing, calibration | as U2 for a closure day; `fit_rate` (the train-split fit and the manifest rates are U6's: DECISIONS 2026-10-17, U3 Q2) | +| U4 | Outstation arrival caps (§3.3) | as U2 for an outstation-capped day | +| U5 | Finite reserve pool at bases (§3.4), replay reserve calls | as U2 for a crewed day with a binding pool | +| U6 | Layers on whole-network dates: `network.build(bases=, layers=)`, the layer builders, train-date calibration | a layered network date builds, replays and solves exactly | +| U7 | The day as known (§4): layered `sim.known`, delivery lags, event-driven watch times for the follower | follower runs on a layered date; its plans replay; watch times include events | +| U8 | Slot release experiment (§3.5) | measured on the sample; kept or recorded as dropped | +| U9 | Playable day core (§5): `v4day` clock, situation, actions, grading | a scripted policy plays a day to the same cost as the sim | +| U10 | Live server, tools, prompts (§5) | an LLM (fake LiteLLM in tests) plays a day through the server | +| U11 | v3 pack and `v4_check.py` (§6), report | §0's criteria checked on the pack, with numbers | + +## §8 Risks + +- Upstream main moves daily; the stack rebases onto it before each push, and Edgar's semantics win. +- Share-of-actual caps are tight in calm hours by construction; only hours below `SHORT` of schedule are capped. +- Synthetic crews and pools are stand-ins (no crew data is public); they are labelled as such everywhere. +- Event-driven watches multiply follower solves; watch solves keep Edgar's 2,400 s limit and the check reports any + unproven watch. diff --git a/scripts/v4_check.py b/scripts/v4_check.py new file mode 100644 index 0000000..7f2b37a --- /dev/null +++ b/scripts/v4_check.py @@ -0,0 +1,410 @@ +"""Is v4 done? docs/v4/DESIGN.md §0's criteria measured on the v4 pack against v2 on the same dates, and world_check's +gates on the v4 pack (§6.2). No LLM is called; only the reference solvers run (HiGHS), and the suite's agreement and +live-play tests as evidence. + + python scripts/v4_check.py PACK --v2 DIR [--nocrew DIR] [--workers 14] [--prove-s 1800] [--watch-s 900] + [--no-tests] + +1. the v4 pack's tasks (scripts/v4_pack.py) and their references under PACK/refs/ (v4_pack.refs: those there whose + signature matches are kept, the others run, so a pack built with `--refs` runs nothing here); +2. v2's tasks of the same dates in DIR: read from DIR/tasks.jsonl where it has them, the others built (network.build + without crew bases or layers, at scale 1 and the 03:00 cutoff as the pack builds them, the controls at the pack's + control windows, then rescaled to the pack's scale) and written there, and their references under DIR/refs/ the + same way; +3. PACK/check.json and PACK/check.md: every criterion with its values, its threshold and pass/fail, then world_check's + gates on the v4 pack. + +The criteria, on the storm dates both packs have the numbers for (the controls only in `sound`): +- harder: do-nothing's median excess over the anchor at least DO_NOTHING x v2's; the follower's median excess + over the anchor (its gap) at least FOLLOWER x v2's; +- realistic: the anchors' cancellations (auto-cancels included) at least OPTIMUM_SHARE of JetBlue's, pooled; the + could-fly-for-free share (programs.probe: the feasible restores, a pair once, over JetBlue's cancellations, pooled) + at least FREE_DROP below v2's; the recorded day's median excess over the anchor below v2's; the airline replay flies + at least FLOWN_SHARE of the flights JetBlue flew, pooled; and of the flights it flies, the share leaving within 15 + min of JetBlue's minute is not below v2's; +- sound: every anchor of the pack proven to its gap, or flagged (listed) where it isn't, and none failed; every anchor + replays exactly (its plan's cost on the sim is its objective, every departure as the MIP has it); the suite's + sim/MIP agreement tests (tests/programs/test_hindsight_mip.py, layered days included) pass; +- playable: the live-server test in which an LLM (a fake LiteLLM) plays a v4 day end to end, graded on the truth + (tests/v4day/test_v4_agent.py), passes. +On a pack with crews (v4's, or Edgar's: its manifest names crew bases), docs/v4/CREW.md §0's guards are criteria +too, on the same storm dates: the recorded day costs no less than the anchor on at least RECORDED_SHARE of them; the +anchors cancel (auto-cancels included) no more than JetBlue did, pooled; on every date whose calm day in the no-crew +pack (`--nocrew`, built with --bases none, its references read under DIR/refs/, never run) is within the fair-weather +bar below, the crew pack's is too, so its crews push no fair day past JetBlue's own; and of the replay's flown flights +the share leaving within 15 min of JetBlue's is within WITHIN_SLACK of v2's. +The gates, as world_check registers them (airline_replay, fidelity, calm_day, controls, references) on the pack's kept +tasks, a task being kept when every MIP it ran proved and no reference failed; `days` reports the pack's dates. +`calm_day` holds each date to JetBlue's fair-weather bar (layers.fairday: the share of a fair day's flights JetBlue +itself cancels, at its 90th percentile, as the pack's manifest records it under `calm`, at least 1, and FERRIES +ferries) rather than world_check's <= 1 and <= 2, which a hub window of 60 to 190 flights was set for and a whole day +of several hundred is not: JetBlue meets <= 1 on only 69% of its own fair days. +""" + +import argparse +import json +import statistics +import subprocess +import sys +import time +from collections.abc import Callable, Iterable, Mapping +from dataclasses import replace +from datetime import UTC, datetime +from pathlib import Path + +import v4_pack +import world_check + +from agentenv_airops.programs import candidates, network, rbs, unseen +from agentenv_airops.programs.advisories import HUBS +from agentenv_airops.programs.layers import fairday +from agentenv_airops.programs.model import Task, load_tasks, write_tasks +from agentenv_airops.programs.repairs import CUTOFF + +ROOT = Path(__file__).resolve().parents[1] +INPUTS, DERIVED = ROOT / "cache" / "inputs", ROOT / "cache" / "derived" +DO_NOTHING, FOLLOWER = 1.4, 1.5 +OPTIMUM_SHARE, FREE_DROP, FLOWN_SHARE = 0.35, 0.10, 0.97 +RECORDED_SHARE, WITHIN_SLACK = 0.95, 0.01 +AGREEMENT = ("tests/programs/test_hindsight_mip.py",) +PLAYABLE = ("tests/v4day/test_v4_agent.py",) + + +def main(argv: list[str] | None = None) -> None: + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("pack", type=Path) + ap.add_argument("--v2", type=Path, required=True, help="v2's folder: its tasks.jsonl and refs/, built if missing") + ap.add_argument("--nocrew", type=Path, + help="the pack built with --bases none, whose calm days a crew pack's are read against") + ap.add_argument("--workers", type=int, default=14) + ap.add_argument("--prove-s", type=float, default=v4_pack.PROVE_S, help="time limit of each anchor, flat or calm") + ap.add_argument("--watch-s", type=float, default=v4_pack.WATCH_S, help="time limit of each follower watch solve") + ap.add_argument("--no-tests", action="store_true", help="skip the agreement and live-play tests") + args = ap.parse_args(argv) + limits = (args.prove_s, args.watch_s) + clock = v4_pack.Clock() + manifest = json.loads((args.pack / "manifest.json").read_text()) + bar = manifest.get("calm") + if not bar: + sys.exit("ERROR: the pack's manifest has no calm-day bar (scripts/v4_pack.py writes it as `calm`)") + v4 = load_tasks(args.pack) + v4_refs = v4_pack.refs(args.pack, v4, args.workers, limits, clock) + v2 = v2_tasks(args.v2, v4, manifest, args.workers) + clock.lap("v2 tasks", f"{len(v2)} in {args.v2}") + v2_refs = v4_pack.refs(args.v2, v2, args.workers, limits, clock) + tests = {} if args.no_tests else {"agreement": pytest(AGREEMENT), "playable": pytest(PLAYABLE)} + clock.lap("tests", json.dumps({k: v["pass"] for k, v in tests.items()})) + crewed = bool(manifest.get("bases")) + plain = read_refs(args.nocrew, [t.task_id for t in v4]) if crewed and args.nocrew else None + out = {"criteria": criteria(v4_refs, v2_refs, tests, crewed, plain, bar["rate"]), + "gates": gates(v4, v4_refs, manifest), + "parameters": {"pack": str(args.pack), "v2": str(args.v2), + "nocrew": str(args.nocrew) if args.nocrew else None, "calm": bar, + "crewed": crewed, "time_limits_s": limits, + "scale": manifest["scale"], "code": v4_pack.revision(), "pack_code": manifest.get("code")}, + "tasks": [row(t, v4_refs.get(t.task_id, {}), v2_refs.get(t.task_id, {})) for t in v4], + "wall_s": clock.laps} + out["done"] = all(c["pass"] for c in out["criteria"].values()) + (args.pack / "check.json").write_text(json.dumps(out, indent=1, default=str) + "\n") + (args.pack / "check.md").write_text(markdown(out)) + print(json.dumps({k: {"pass": c["pass"], "value": c.get("value")} for k, c in out["criteria"].items()} + | {"gates": {k: g["pass"] for k, g in out["gates"].items()}, "done": out["done"]}, indent=1), + flush=True) + + +def v2_tasks(folder: Path, v4: Iterable[Task], manifest: Mapping, workers: int) -> list[Task]: + """v2's task of each v4 task's date, in the v4 pack's order: read from folder/tasks.jsonl, else built without crew + or layers at scale 1 and the cutoff (a control at the pack's control windows), rescaled to the pack's scale with + the v4 task's split, and folder/tasks.jsonl written with every one.""" + v4 = list(v4) + path = folder / "tasks.jsonl" + have = {t.task_id: t for t in load_tasks(path)} if path.exists() else {} + todo = [t for t in v4 if t.task_id not in have] + if todo: + fits = candidates.e_table(INPUTS, DERIVED, workers=workers) + e_fit, e_median = unseen.table(fits) + scale = manifest["scale"] + controls = manifest.get("controls", {}) + jobs = [(t.date, t.storms, None, "") if t.split != "control" else + (t.date, tuple(controls[t.date]["hubs"]), {h: tuple(w) for h, w in controls[t.date]["windows"].items()}, + "control") for t in todo] + built = network.build_all(jobs, INPUTS, DERIVED, e_fit, e_median, dict.fromkeys(HUBS, 1.0), CUTOFF, + workers=max(1, workers)) + split = {(t.date, t.split == "control"): t.split for t in todo} + shipped = unseen.table(fits, scale) + for b in built: + if b.task is None: + print(f" v2 {b.row['date']} not built: {b.row['reason']}", flush=True) + continue + s = split[(b.task.date, b.task.split == "control")] + have[b.task.task_id] = rbs.rescale(replace(b.task, split=s), scale, *shipped) + out = [have[t.task_id] for t in v4 if t.task_id in have] + if todo: + write_tasks(out, path) + return out + + +def pytest(paths: Iterable[str]) -> dict: + """The suite's tests at `paths`, run as the gates run them: pass/fail and pytest's last line.""" + t0 = time.monotonic() + run = subprocess.run([sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider", *paths], cwd=ROOT, + capture_output=True, text=True) + lines = [x for x in run.stdout.splitlines() if x.strip()] + return {"tests": list(paths), "pass": run.returncode == 0, "summary": lines[-1] if lines else run.stderr[-500:], + "wall_s": round(time.monotonic() - t0, 1)} + + +def read_refs(folder: Path, ids: Iterable[str]) -> dict[str, dict]: + """The references a pack holds under folder/refs/ for each of `ids` it has.""" + out = {} + for i in ids: + try: + out[i] = json.loads((folder / "refs" / f"{i}.json").read_text()) + except (OSError, ValueError): + continue + return out + + +def criteria(v4: Mapping[str, dict], v2: Mapping[str, dict], tests: Mapping[str, dict], crewed: bool = False, + plain: Mapping[str, dict] | None = None, rate: float | None = None) -> dict: + """§0's criteria from the two packs' references by task id (refs/.json each) and the tests' outcomes; on a + `crewed` pack the crew's guards too, the calm day against the no-crew pack's references `plain` at the + fair-weather bar `rate`.""" + storm = {i: (r, v2[i]) for i, r in v4.items() if r.get("split") != "control" and i in v2} + out = { + "harder_do_nothing": ratio_of(storm, lambda r: excess(r, "naive"), DO_NOTHING, + "do-nothing's median excess over the anchor, v4 >= 1.4 x v2"), + "harder_follower": ratio_of(storm, lambda r: excess(r, "follower"), FOLLOWER, + "the follower's median excess over the anchor, v4 >= 1.5 x v2"), + "realistic_optimum_cancels": pooled_of(storm, anchor_cancels, lambda r: r["jetblue"]["cancelled"], + lambda a, b: a >= OPTIMUM_SHARE, + "the anchors' cancellations over JetBlue's, v4 >= 35%"), + "realistic_free_share": pooled_of(storm, lambda r: r["probe"]["feasible"], lambda r: r["probe"]["cancelled"], + lambda a, b: b is not None and a <= b - FREE_DROP, + "JetBlue's cancellations that could fly for free (a pair once), v4 at least " + "10 points below v2"), + "realistic_recorded_excess": median_of(storm, lambda r: excess(r, "recorded"), lambda a, b: a < b, + "the recorded day's median excess over the anchor, v4 < v2"), + "realistic_replay_flown": pooled_of(storm, lambda r: r["fidelity"]["flown"], + lambda r: r["fidelity"]["jetblue_flown"], lambda a, b: a >= FLOWN_SHARE, + "the replay flies >= 97% of the flights JetBlue flew"), + "realistic_replay_within_15": pooled_of(storm, lambda r: r["fidelity"]["within"], + lambda r: r["fidelity"]["flown"], lambda a, b: b is not None and a >= b, + "of the replay's flown flights, the share leaving within 15 min of " + "JetBlue's minute, v4 >= v2"), + "sound_anchors_proven": proven(v4), + "sound_anchors_exact": exact(v4), + "sound_agreement_tests": tested(tests, "agreement", "the suite's sim/MIP agreement tests pass"), + "playable": tested(tests, "playable", "an LLM (fake LiteLLM) plays a v4 day end to end through the server"), + } + if crewed: + out |= guards(storm, plain, rate) + return out + + +def guards(storm: Mapping[str, tuple[dict, dict]], plain: Mapping[str, dict] | None, rate: float | None) -> dict: + """docs/v4/CREW.md §0's guards on a crew pack's storm dates (each its references and v2's), the calm day at the + fair-weather bar `rate`.""" + rec = [x >= 0 for r, _ in storm.values() if (x := safe(lambda r: excess(r, "recorded"), r)) is not None] + rec_share = round(sum(rec) / len(rec), 4) if rec else None + calm = {} + for i, (r, _) in storm.items(): + b = (plain or {}).get(i, {}) + if rate is not None and "cancelled" in r.get("calm", {}) and "cancelled" in b.get("calm", {}): + calm[i] = within(r, rate) or not within(b, rate) + worse = sorted(i for i, ok in calm.items() if not ok) + return { + "guard_recorded": {"rule": "the recorded day costs >= the anchor on >= 95% of dates", "n": len(rec), + "value": rec_share, "threshold": RECORDED_SHARE, + "pass": rec_share is not None and rec_share >= RECORDED_SHARE}, + "guard_anchor_cancels": pooled_of(storm, anchor_cancels, lambda r: r["jetblue"]["cancelled"], + lambda a, b: a <= 1, "the anchors' cancellations over JetBlue's, <= 100%"), + "guard_calm_vs_nocrew": {"rule": "on every date whose no-crew calm day (--nocrew) is within JetBlue's " + "fair-weather bar, the crew's is too", "n": len(calm), + "value": len(calm) - len(worse), "worse": worse, + "pass": plain is not None and bool(calm) and not worse}, + "guard_within_15": pooled_of(storm, lambda r: r["fidelity"]["within"], lambda r: r["fidelity"]["flown"], + lambda a, b: b is not None and a >= b - WITHIN_SLACK, + "of the replay's flown flights, the share leaving within 15 min of JetBlue's " + "minute, within 1 point of v2's"), + } + + +def within(r: dict, rate: float) -> bool: + """Whether the references `r`'s calm day is within JetBlue's fair-weather bar at `rate` (layers.fairday).""" + return fairday.within(r["calm"]["cancelled"], r["calm"]["ferries"], r["flights"], rate) + + +def excess(r: dict, k: str) -> float | None: + """r[k]'s cost over the proven anchor's, less 1; None where either is missing.""" + a = r.get("anchor", {}) + if not a.get("proven") or "total" not in r.get(k, {}) or not a.get("objective"): + return None + return r[k]["total"] / a["objective"] - 1 + + +def anchor_cancels(r: dict) -> int | None: + a = r.get("anchor", {}) + return a["cancelled"] + a["auto_cancelled"] if a.get("proven") and "cancelled" in a else None + + +def values(pairs: Mapping[str, tuple[dict, dict]], f: Callable[[dict], float | None]) -> tuple[list, list, int]: + """f on both packs' references of every task where it is defined on both, and the tasks it is defined on.""" + a, b = [], [] + for r4, r2 in pairs.values(): + x, y = safe(f, r4), safe(f, r2) + if x is not None and y is not None: + a.append(x) + b.append(y) + return a, b, len(a) + + +def safe(f: Callable[[dict], float | None], r: dict) -> float | None: + try: + return f(r) + except (KeyError, TypeError): + return None + + +def ratio_of(pairs: Mapping[str, tuple[dict, dict]], f: Callable, times: float, rule: str) -> dict: + a, b, n = values(pairs, f) + m4, m2 = med(a), med(b) + ratio = round(m4 / m2, 3) if m4 is not None and m2 else None + return {"rule": rule, "n": n, "v4": m4, "v2": m2, "value": ratio, "threshold": times, + "pass": ratio is not None and m2 > 0 and ratio >= times} + + +def median_of(pairs: Mapping[str, tuple[dict, dict]], f: Callable, ok: Callable[[float, float], bool], + rule: str) -> dict: + a, b, n = values(pairs, f) + m4, m2 = med(a), med(b) + return {"rule": rule, "n": n, "v4": m4, "v2": m2, "value": m4, + "pass": m4 is not None and m2 is not None and ok(m4, m2)} + + +def pooled_of(pairs: Mapping[str, tuple[dict, dict]], num: Callable, den: Callable, + ok: Callable[[float, float | None], bool], rule: str) -> dict: + """sum(num) / sum(den) on each pack over the tasks where both are defined on both.""" + rows = [(safe(num, r4), safe(den, r4), safe(num, r2), safe(den, r2)) for r4, r2 in pairs.values()] + rows = [x for x in rows if None not in x] + n4, d4, n2, d2 = (sum(x[k] for x in rows) for k in range(4)) + s4 = round(n4 / d4, 4) if d4 else None + s2 = round(n2 / d2, 4) if d2 else None + return {"rule": rule, "n": len(rows), "v4": s4, "v2": s2, "value": s4, "v4_counts": [n4, d4], + "v2_counts": [n2, d2], "pass": s4 is not None and ok(s4, s2)} + + +def proven(v4: Mapping[str, dict]) -> dict: + """Every anchor of the pack proven, or flagged where it isn't; it fails only on an anchor that failed or is + missing.""" + failed = sorted(i for i, r in v4.items() if "proven" not in r.get("anchor", {})) + flagged = {i: {k: r["anchor"].get(k) for k in ("status", "gap_pct")} for i, r in sorted(v4.items()) + if i not in failed and not r["anchor"]["proven"]} + return {"rule": "every anchor proven to its gap, or flagged", "n": len(v4), "value": len(v4) - len(failed) - + len(flagged), "flagged": flagged, "failed": failed, + "max_gap_pct": max((r["anchor"].get("gap_pct") or 0 for i, r in v4.items() if i not in failed), + default=None), "pass": bool(v4) and not failed} + + +def exact(v4: Mapping[str, dict]) -> dict: + ran = {i: r["anchor"] for i, r in v4.items() if "exact" in r.get("anchor", {})} + off = sorted(i for i, a in ran.items() if not a["exact"]) + return {"rule": "every anchor's plan replays exactly in the sim", "n": len(ran), "value": len(ran) - len(off), + "not_exact": off, "pass": bool(ran) and not off and len(ran) == len(v4)} + + +def tested(tests: Mapping[str, dict], name: str, rule: str) -> dict: + t = tests.get(name) + return {"rule": rule, "value": t["summary"] if t else "not run", **(t or {}), "pass": bool(t and t["pass"])} + + +def gates(tasks: Iterable[Task], results: Mapping[str, dict], manifest: Mapping) -> dict: + """world_check's gates (world_check.summary's) on the pack: its kept storm tasks, its controls.""" + tasks = {t.task_id: t for t in tasks} + storms = {i: t for i, t in tasks.items() if t.split != "control"} + held = [i for i, t in tasks.items() if t.split == "control"] + kept = {i for i, r in results.items() if not world_check.unproven(r)} + pool = {i: r for i, r in results.items() if i in storms and i in kept} + autos = [(r["airline"]["auto"], r["airline"]["flew"]) for r in pool.values() if "auto" in r.get("airline", {})] + bar = manifest["calm"] + calm = [within(r, bar["rate"]) for r in pool.values() if "cancelled" in r.get("calm", {})] + ctl = {i: world_check.control_checks(results.get(i, {}), i in kept) for i in held} + fid = {(i, h): unseen.program_delay(t, hub=h) for i, t in storms.items() if i in pool for h in t.storms} + left = {i: world_check.unproven(r) for i, r in results.items() if i not in kept} + return { + "airline_replay": world_check.gate(world_check.share([world_check.replays(a, n) for a, n in autos]), + len(autos), f"auto-cancels <= {world_check.AUTO_SHARE:.0%} of the flights " + f"JetBlue flew by their deadline and <= {world_check.AUTO_MAX}, per kept " + "storm date", {"auto_cancels": sum(a for a, _ in autos), + "flew": sum(n for _, n in autos)}), + "fidelity": world_check.fidelity_gate(fid, {i: t.split for i, t in storms.items()}, True), + "calm_day": world_check.gate(world_check.share(calm), len(calm), f"forced cancellations <= {bar['rate']:.2%} " + f"of the day's flights (JetBlue's {bar['quantile']:.0%} fair-day share, at least " + f"1) and <= {fairday.FERRIES} ferries, per kept storm date"), + "controls": {"controls": ctl, "n": len(ctl), "threshold": "both control dates: recorded >= anchor and naive " + f"within {world_check.CONTROL_MARGIN:.0%} of the flat anchor", + "pass": len(ctl) == world_check.CONTROL_DATES and all(c["ok"] for c in ctl.values())}, + "references": world_check.references_gate({i: r for i, r in results.items() if i in kept}, left), + "days": {"storm_dates": manifest.get("storm_dates"), "built": manifest.get("built"), + "split": {s: sum(t.split == s for t in storms.values()) for s in ("eval", "train")}, + "in_pack": len(storms), "controls": len(held), "kept": len(kept & set(storms)), + "by": {k: sum(network.label(t) == k for t in storms.values()) + for k in sorted({network.label(t) for t in storms.values()})}, + "unbuilt": len(manifest.get("unbuilt", {})), "pass": True}, + } + + +def row(t: Task, r4: dict, r2: dict) -> dict: + """One task's numbers on both packs.""" + def nums(r): + return {"naive_x": rnd(excess(r, "naive")), "follower_x": rnd(excess(r, "follower")), + "recorded_x": rnd(excess(r, "recorded")), "anchor": r.get("anchor", {}).get("objective"), + "proven": r.get("anchor", {}).get("proven"), "exact": r.get("anchor", {}).get("exact"), + "anchor_cancels": safe(anchor_cancels, r), "jetblue_cancelled": r.get("jetblue", {}).get("cancelled"), + "free": r.get("probe", {}).get("feasible"), "flown": r.get("fidelity", {}).get("flown"), + "jetblue_flown": r.get("fidelity", {}).get("jetblue_flown"), + "within_15": r.get("fidelity", {}).get("within"), "watches": r.get("watches"), + "left_out": world_check.unproven(r) if r else ["missing"], "seconds": r.get("seconds")} + return {"task_id": t.task_id, "split": t.split, "hubs": "+".join(t.storms), "v4": nums(r4), "v2": nums(r2)} + + +def med(v: list[float]) -> float | None: + return round(statistics.median(v), 4) if v else None + + +def rnd(x: float | None) -> float | None: + return None if x is None else round(x, 4) + + +def markdown(out: dict) -> str: + lines = [f"# v4 check ({datetime.now(UTC).date().isoformat()})", "", + f"Done (every §0 criterion passes): **{out['done']}**", "", "## §0 criteria", "", + "| Criterion | Rule | n | v4 | v2 | Value | Pass |", "|---|---|---|---|---|---|---|"] + for k, c in out["criteria"].items(): + lines.append(f"| {k} | {c['rule']} | {c.get('n', '')} | {c.get('v4', '')} | {c.get('v2', '')} | " + f"{c.get('value')} | {c['pass']} |") + flagged = out["criteria"]["sound_anchors_proven"] + lines += ["", f"Anchors flagged (not proven): {json.dumps(flagged['flagged'])}; failed: {flagged['failed']}", "", + "## world_check's gates on the v4 pack", "", "| Gate | Value | Threshold | Pass |", "|---|---|---|---|"] + for k, g in out["gates"].items(): + value = {x: v for x, v in g.items() if x not in ("threshold", "pass", "rule", "controls", "left_out_tasks")} + lines.append(f"| {k} | {json.dumps(value, default=str)} | {g.get('threshold', '')} | {g['pass']} |") + lines += ["", "## Controls", "", f"```\n{json.dumps(out['gates']['controls']['controls'], indent=1)}\n```", "", + "## Per task (v4 / v2)", "", + "| task | split | hubs | naive x | follower x | recorded x | anchor cancels / JetBlue | free | " + "flown / JetBlue | within 15 | proven | exact |", "|" + "---|" * 12] + for r in out["tasks"]: + a, b = r["v4"], r["v2"] + lines.append(f"| {r['task_id']} | {r['split']} | {r['hubs']} | {a['naive_x']} / {b['naive_x']} | " + f"{a['follower_x']} / {b['follower_x']} | {a['recorded_x']} / {b['recorded_x']} | " + f"{a['anchor_cancels']} / {b['anchor_cancels']} of {a['jetblue_cancelled']} | " + f"{a['free']} / {b['free']} | {a['flown']} / {b['flown']} of {a['jetblue_flown']} | " + f"{a['within_15']} / {b['within_15']} | {a['proven']} / {b['proven']} | " + f"{a['exact']} / {b['exact']} |") + lines += ["", "## Parameters and wall clock", "", + f"```\n{json.dumps({**out['parameters'], 'wall_s': out['wall_s']}, indent=1, default=str)}\n```"] + return "\n".join(lines) + "\n" + + +if __name__ == "__main__": + main() diff --git a/scripts/v4_pack.py b/scripts/v4_pack.py new file mode 100644 index 0000000..153ef58 --- /dev/null +++ b/scripts/v4_pack.py @@ -0,0 +1,446 @@ +"""Build v4's pack: every whole-network storm date with crew and every v4 layer, its constants fitted on the train +dates alone, and the control dates beside them (docs/v4/DESIGN.md §2, §6.1); with `--refs`, the references +scripts/v4_check.py reads, and each task's anchor and follower costs for the playable day's load. + + python scripts/v4_pack.py --out DIR [--only DATE,...] [--workers 14] [--seed 0] [--resume] + [--refs] [--prove-s 1800] [--watch-s 900] [--bases none|JFK,BOS,...] + [--crew edgar|v4] [--buffer MIN] [--max-segments N] + +1. every storm date over the 19 cached months (network.storms) built as a whole-network task with the crew model at + JetBlue's crew bases by default (`--bases JFK,BOS,FLL,MCO`; `none` builds the layers alone, the no-crew pack + scripts/v4_check.py --nocrew reads), its pairings cut by `--crew`: v4's legal on schedule (layers.crew), the + default, at `--buffer` and `--max-segments`, or Edgar's proxy; at scale 1 and the 03:00 cutoff, as world_check + builds them, and so are the control + dates (network.control_dates, each with its hubs' control windows, split 'control'); each date's build is written + to DIR/built/ (v4's to DIR/built-v4-BUFFER-SEGMENTS/) as it finishes (a control's as control-DATE.json), and + `--resume` keeps those already built there (a date left unbuilt, or a file cut short, is built again). A date an + input is missing for (say the FAA registry, for a tail B-43 doesn't type) is unbuilt, its error the reason; any + other error stops the pack; +2. the seeded whole-event split over the built storm dates, as world_check draws it (network.split, `--seed`, default + 0), and the unseen-traffic scale fitted on the train dates (unseen.fit_scale), every task (the controls too) + rescaled to it; +3. the calm day's bar, JetBlue's fair-weather cancellations over the pack's months (layers.fairday.rate, written to + the manifest as `calm`); the layer constants fitted on the train dates only (layers.calibrate.fit): the ramp + closures' rate per hub, then, with `--crew v4` and `--buffer` or `--max-segments` left out, v4's crew cut over the + grid of what is left out (layers.calibrate.crew_fit at that bar, on its seeded sample of the train dates, each + built at every cut of the grid and once without crews, to DIR/built-nocrew/, its scores kept under DIR/crew-fit/; + no cut holding the guards stops the pack, the fit written to the manifest), every date then built again at the + cut, then + the reserve pool per base, the cap clauses kept at 0.9 and 5; +4. every date of the split (eval and train) and every control date (`--only` a few of them, storm or control) given + every layer at those constants (network.layered, as network.build(layers=) does), written to DIR/tasks.jsonl, the + storm dates first, and DIR/manifest.json: the inputs the pack reads beyond each task's own pins (`sources`) with + their sha256 (BTS on-time per month, the hubs' IEM METARs, SPECIs and TAFs per month), the seed, the split, the + controls and the scale, the crew mode with its buffer, max segments and fit, the constants with the dates they were + fitted on, the counts per task and per layer, and the wall clock per phase; +5. with `--refs`, the references on every task written (`refs`, below): one JSON per task under DIR/refs/ as it + finishes, in a pool of `--workers` processes, heaviest first, a result kept when its task's signature (the task, the + references and the time limits) matches; then DIR/refs.json, each task's anchor and follower costs on the truth + with the task's digest (references.digest), which the playable day's load reads (v4day.live) so it never solves a + MIP. + +The references per task (world_check's, so its gates read them as they are): naive; the airline replay (cost, +auto-cancels over the flights JetBlue flew by their deadline, reserves called and granted) and its fidelity (of the +flights JetBlue flew, how many the replay flies and how many of those leave within FIDELITY_MIN of JetBlue's minute); +the recorded day; the anchor (references.anchor at `--prove-s`: objective, status, gap, whether its plan replays +exactly); the follower (references.follow at `--watch-s`: cost and watches; a watch solve that does not prove stops +it, captured as its error and flagged `unproven_watch`); the calm day (forced cancellations and ferries); the flat +anchor on the control dates (world_check's controls gate); and the probe's verdict counts (programs.probe, a restored +pair once). A reference that fails is captured as its error, never raised. + +`--only` still builds and splits every storm date: the split and the constants are the whole pack's, so its tasks are +the ones the full pack writes for those dates. +""" + +import argparse +import hashlib +import json +import os +import subprocess +import sys +import time +from collections import Counter +from collections.abc import Iterable, Mapping, Sequence +from concurrent.futures import ProcessPoolExecutor, as_completed +from dataclasses import replace +from pathlib import Path + +import pandas as pd +import world_check + +from agentenv_airops.build import bts, pack, weather +from agentenv_airops.core.sim import FLOWN +from agentenv_airops.flow import sources +from agentenv_airops.programs import candidates, network, probe, rbs, references, repairs, sim, unseen +from agentenv_airops.programs.advisories import HUBS +from agentenv_airops.programs.candidates import Built +from agentenv_airops.programs.layers import calibrate, config, crew, fairday +from agentenv_airops.programs.model import Task, write_tasks + +ROOT = Path(__file__).resolve().parents[1] +INPUTS, DERIVED = ROOT / "cache" / "inputs", ROOT / "cache" / "derived" +CONTROL = "control-" +REFS = ("naive", "airline", "recorded", "anchor", "follower", "calm") +CONTROL_REFS = ("flat",) +PROVE_S, WATCH_S = 1800.0, 900.0 +FIDELITY_MIN = 15 +BASES = "JFK,BOS,FLL,MCO" +REFS_VERSION = 1 +"""Bumped when a reference's definition here changes, so a resumed --refs runs it again.""" + + +def main(argv: list[str] | None = None) -> None: + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("--out", type=Path, required=True) + ap.add_argument("--only", default="", help="comma-separated storm or control dates to write (default: every one)") + ap.add_argument("--workers", type=int, default=14) + ap.add_argument("--seed", type=int, default=0) + ap.add_argument("--resume", action="store_true", help="keep the builds already under DIR/built/") + ap.add_argument("--refs", action="store_true", help="run the references on every task written") + ap.add_argument("--bases", default=BASES, + help="the crew model's bases, comma-separated (default JetBlue's); 'none' builds the layers alone, " + "the no-crew pack scripts/v4_check.py --nocrew reads") + ap.add_argument("--crew", choices=crew.MODES, default="v4", + help="how the crew model cuts pairings at --bases: v4's legal on schedule or Edgar's proxy") + ap.add_argument("--buffer", type=int, help="v4's duty buffer in minutes (default: fitted on the train dates)") + ap.add_argument("--max-segments", type=int, help="v4's legs per duty at most (default: fitted on the train dates)") + ap.add_argument("--prove-s", type=float, default=PROVE_S, help="time limit of each anchor, flat or calm solve") + ap.add_argument("--watch-s", type=float, default=WATCH_S, help="time limit of each follower watch solve") + args = ap.parse_args(argv) + out = args.out + bases = () if args.bases.lower() == "none" else tuple(b for b in args.bases.split(",") if b) + fitting = bool(bases) and args.crew == "v4" and (args.buffer is None or args.max_segments is None) + rule = crew.Rule(args.crew, crew.BUFFER if args.buffer is None else args.buffer, + crew.MAX_SEGMENTS if args.max_segments is None else args.max_segments) if bases else crew.EDGAR + out.mkdir(parents=True, exist_ok=True) + clock = Clock() + + fits = candidates.e_table(INPUTS, DERIVED, workers=args.workers) + e_fit, e_median = unseen.table(fits) + storms, counts = network.storms(INPUTS) + controls = network.control_dates(INPUTS, DERIVED) + clock.lap("storms", f"{len(storms)} storm dates, {len(controls)} control dates, {len(fits)} advisories fitted") + jobs = {d: (d, hubs, None, "") for d, hubs in storms.items()} + jobs |= {CONTROL + d: (d, tuple(h for h in HUBS if h in w), w, "control") for d, w in controls.items()} + built = build(folder_of(out, rule), jobs, e_fit, e_median, args.workers, args.resume, bases, rule) + tasks, held = storm_and_control(built) + clock.lap("build", f"{len(tasks)} of {len(storms)} storm dates built, {len(held)} of {len(controls)} controls") + + event = candidates.events(storms) + drawn = network.split({d: t.storms for d, t in tasks.items()}, event, args.seed) + train = sorted(d for d, v in drawn.items() if v == "train") + scale = unseen.fit_scale([tasks[d] for d in train], fits) + if set(scale) != set(HUBS): + sys.exit(f"ERROR: no train dates to fit the scale at {sorted(set(HUBS) - set(scale))}") + shipped_e = unseen.table(fits, scale) + clock.lap("split", f"eval {len(drawn) - len(train)}, train {len(train)}, scale {scale}") + + spans = config.spans(INPUTS, candidates.MONTHS) + pushed = pd.concat([candidates.month_frame(INPUTS, DERIVED, m)[["Origin", "act_dep"]] for m in candidates.MONTHS], + ignore_index=True) + rated = calibrate.rates(train, pushed, spans) + bar = fairday.rate((bts.read_ontime(bts.ontime_path(INPUTS, m), DERIVED) for m in candidates.MONTHS), HUBS) + clock.lap("calm bar", json.dumps(bar)) + crewing = {**rule.to_dict(), "fitted": fitting} + if fitting: + sample = calibrate.sample(train) + rules = calibrate.grid((args.buffer,) if args.buffer is not None else calibrate.BUFFERS, + (args.max_segments,) if args.max_segments is not None else calibrate.SEGMENTS) + grid = {} + for r in [*rules, None]: + at = build(out / "built-nocrew" if r is None else folder_of(out, r), {d: jobs[d] for d in sample}, e_fit, + e_median, args.workers, args.resume, () if r is None else bases, r or crew.EDGAR) + grid[r] = [network.layered(rbs.rescale(replace(t, split="train"), scale, *shipped_e), rated, INPUTS, + DERIVED) for t in storm_and_control(at)[0].values()] + plain = grid.pop(None) + chosen, rows = calibrate.crew_fit(grid, plain, bar["rate"], args.workers, cache=out / "crew-fit") + crewing["fit"] = {"sample": list(sample), "seed": calibrate.SAMPLE_SEED, "time_limit_s": calibrate.CREW_S, + "rules": rows, "chosen": chosen.to_dict() if chosen else None} + clock.lap("crew fit", json.dumps(chosen.to_dict() if chosen else None)) + if chosen is None: + (out / "manifest.json").write_text(json.dumps({"code": revision(), "crew": crewing, "calm": bar}, + indent=1) + "\n") + sys.exit("ERROR: no crew cut of the grid holds the guards on the train sample (manifest.json: crew.fit)") + if chosen != rule: + rule = chosen + built = build(folder_of(out, rule), jobs, e_fit, e_median, args.workers, args.resume, bases, rule) + again = storm_and_control(built) + if set(again[0]) != set(tasks) or set(again[1]) != set(held): + sys.exit("ERROR: the dates built at the fitted crew cut are not those the split drew from") + tasks, held = again + clock.lap("build", f"again at {rule}") + crewing |= rule.to_dict() + shipped = {d: rbs.rescale(replace(tasks[d], split=v), scale, *shipped_e) for d, v in drawn.items()} + calm = {d: rbs.rescale(replace(t, split="control"), scale, *shipped_e) for d, t in held.items()} + constants = calibrate.pooled_fit(rated, [shipped[d] for d in train], frame_of, spans, args.workers) + clock.lap("calibrate", json.dumps({k: v for k, v in constants.to_dict().items() if k != "dates"})) + + only = dates_of(args.only) + if missing := [d for d in only if d not in shipped and d not in calm]: + sys.exit(f"ERROR: not in the split or the controls: " + f"{', '.join(f'{d} ({why(d, storms, controls, built)})' for d in missing)}") + chosen = [shipped[d] for d in sorted(shipped) if not only or d in only] + chosen += [calm[d] for d in sorted(calm) if not only or d in only] + layered = [network.layered(t, constants, INPUTS, DERIVED) for t in chosen] + write_tasks(layered, out / "tasks.jsonl") + clock.lap("layers", f"{len(layered)} tasks written") + + manifest = {"code": revision(), "seed": args.seed, "months": list(candidates.MONTHS), "bases": list(bases), + "crew": crewing if bases else None, "only": only, "storm_dates": len(storms), "built": len(tasks), + "candidates": counts, + "unbuilt": {d: b.row["reason"] for d, b in built.items() if not b.task}, + "split": dict(sorted(drawn.items())), "unsplit": sorted(set(tasks) - set(drawn)), + "controls": {d: {"hubs": list(jobs[CONTROL + d][1]), "windows": {h: list(x) for h, x in w.items()}, + "task_id": calm[d].task_id if d in calm else None} for d, w in controls.items()}, + "scale": scale, "constants": constants.to_dict(), "calm": bar, + "inputs": inputs_of(INPUTS, candidates.MONTHS), + "counts": {"total": total(layered), "tasks": {t.task_id: tally(t) for t in layered}}, + "wall_s": clock.laps} + (out / "manifest.json").write_text(json.dumps(manifest, indent=1) + "\n") + if args.refs: + results = refs(out, layered, args.workers, (args.prove_s, args.watch_s), clock) + manifest["refs"] = {"tasks": len(results), "time_limits_s": [args.prove_s, args.watch_s], + "failed": failed(results)} + manifest["wall_s"] = clock.laps + (out / "manifest.json").write_text(json.dumps(manifest, indent=1) + "\n") + print(json.dumps({"constants": manifest["constants"] | {"dates": len(constants.dates)}, + "counts": manifest["counts"]["total"], "wall_s": clock.laps}, indent=1), flush=True) + + +class Clock: + def __init__(self): + self.t0 = self.t = time.monotonic() + self.laps: dict[str, float] = {} + + def lap(self, name: str, note: str = "") -> None: + now = time.monotonic() + self.laps[name] = round(now - self.t, 1) + self.laps["total"] = round(now - self.t0, 1) + self.t = now + print(f"[{self.laps['total']:7.1f}s] {name}: {note}", flush=True) + + +def build(folder: Path, jobs: Mapping[str, tuple[str, tuple[str, ...], network.Windows | None, str]], + e_fit: Mapping[str, float], e_median: Mapping[str, float], workers: int, resume: bool, + bases: Sequence[str] = repairs.BASES, rule: crew.Rule = crew.EDGAR) -> dict[str, Built]: + """Every job's whole-network task with crew at `bases` cut by `rule`, at scale 1, one JSON per job under `folder` as + it finishes (a job keyed by its date, a control's by CONTROL + its date; each a (date, storm hubs, windows or None, + split)); with `resume` the jobs built there already are read back (kept), the others built again.""" + folder.mkdir(parents=True, exist_ok=True) + out = kept(folder, jobs) if resume else {} + ones = dict.fromkeys(HUBS, 1.0) + args = {k: (d, hubs, INPUTS, DERIVED, dict(e_fit), dict(e_median), ones, repairs.CUTOFF, w, split, tuple(bases), + None, rule) for k, (d, hubs, w, split) in jobs.items() if k not in out} + with ProcessPoolExecutor(max(1, workers)) as pool: + futures = {pool.submit(build_one, a): k for k, a in args.items()} + for job in as_completed(futures): + b = job.result() + save(folder / f"{futures[job]}.json", b) + out[futures[job]] = b + return dict(sorted(out.items())) + + +def folder_of(out: Path, rule: crew.Rule) -> Path: + """Where a pack's builds at `rule` go: DIR/built/ for Edgar's cut (or none), DIR/built-v4-BUFFER-SEGMENTS/ for + v4's.""" + return out / ("built" if rule.mode == "edgar" else f"built-v4-{rule.buffer}-{rule.max_segments}") + + +def storm_and_control(built: Mapping[str, Built]) -> tuple[dict[str, Task], dict[str, Task]]: + """The built storm tasks by date, and the built controls by date.""" + return ({d: b.task for d, b in built.items() if b.task and not d.startswith(CONTROL)}, + {d.removeprefix(CONTROL): b.task for d, b in built.items() if b.task and d.startswith(CONTROL)}) + + +def kept(folder: Path, keys: Iterable[str]) -> dict[str, Built]: + """The jobs whose build an earlier run wrote whole under `folder`, with a task or left out by the build's rules + (network.build's reason, say too few flights controlled): one it left unbuilt on an error (an input missing then + may be there now, `build_one`) or whose file it was killed writing is built again.""" + out = {} + for d in keys: + try: + x = json.loads((folder / f"{d}.json").read_text()) + except (OSError, ValueError): + continue + if x.get("task"): + out[d] = Built(Task.from_dict(x["task"]), x["row"]) + elif not str(x["row"].get("reason", "")).startswith("error:"): + out[d] = Built(None, x["row"]) + return out + + +def save(path: Path, b: Built) -> None: + """Write a date's build whole or not at all (a run killed mid-write leaves the old file, or none).""" + part = path.with_suffix(".part") + part.write_text(json.dumps({"row": b.row, "task": b.task.to_dict() if b.task else None}, default=str)) + os.replace(part, path) + + +def build_one(job: tuple) -> Built: + """network.build_one, a date an input is missing for (say the FAA registry, for a tail B-43 doesn't type) unbuilt + with the error as its reason. Any other error (a bug, or a fault such as too many open files that would drop a + date and so reshuffle the split) stops the pack.""" + try: + return network.build_one(job) + except FileNotFoundError as e: + return Built(None, {"date": job[0], "hubs": list(job[1]), "task_id": None, + "reason": f"error: {type(e).__name__}: {e}"}) + + +def dates_of(only: str) -> list[str]: + """`--only`'s dates, each once, in order.""" + return sorted({x.strip() for x in only.split(",") if x.strip()}) + + +def frame_of(d: str) -> pd.DataFrame: + return candidates.frame(INPUTS, DERIVED, d) + + +def why(d: str, storms: Mapping[str, tuple[str, ...]], controls: Mapping[str, network.Windows], + built: Mapping[str, Built]) -> str: + key = CONTROL + d if d in controls else d + if d not in storms and d not in controls: + return "neither a storm nor a control date" + if not built[key].task: + return built[key].row["reason"] + return "an event with an eval date elsewhere" + + +def inputs_of(inputs: Path, months: Iterable[str]) -> dict[str, str]: + """The sha256 of each input the pack reads beyond the tasks' own pins: every month's BTS on-time (the closure + rates' pushbacks) and each hub's routine METARs, SPECIs and TAFs (the closures and the desk's weather news).""" + src = pack.Sources(inputs) + paths = [bts.ontime_path(inputs, m) for m in months] + paths += [p for m in months for h in HUBS + for p in (weather.metar_path(inputs, h, m), sources.speci_path(inputs, h, m), + weather.afos_path(inputs, f"TAF{h}", m))] + return {str(p.relative_to(inputs)): src.file(p) for p in paths} + + +def tally(task: Task) -> dict: + """A task's flights and its layers' counts: crew deadlines (outstation departures', and base departures' under a + pool), departure-capped hours, closures, outstation-capped hours and stations, the pools, and the weather news + per kind.""" + lay = task.layers + return {"flights": len(task.flights), "tails": len(task.tails), + "crew_deadlines": sum(f.crew is not None and f.crew.deadline is not None for f in task.flights), + "pooled_departures": sum(task.reservable(f) for f in task.flights), + "dep_capped_hours": {h: len(by) for h, by in lay.dep_caps.items()}, + "closures": {h: len(x) for h, x in lay.closures.items()}, + "outstation_capped_hours": sum(len(by) for by in lay.arr_caps.values()), + "outstations_capped": len(lay.arr_caps), "pools": dict(lay.reserves), + "weather": dict(sorted(Counter(kind for _, kind, _, _ in lay.weather).items()))} + + +def total(tasks: Iterable[Task]) -> dict: + rows = [tally(t) for t in tasks] + return {"tasks": len(rows), **{k: sum(r[k] for r in rows) for k in ("flights", "tails", "crew_deadlines", + "pooled_departures", + "outstation_capped_hours", + "outstations_capped")}, + "dep_capped_hours": sum(sum(r["dep_capped_hours"].values()) for r in rows), + "closures": sum(sum(r["closures"].values()) for r in rows), + "weather": dict(sorted(sum((Counter(r["weather"]) for r in rows), Counter()).items())), + "tasks_with": {k: sum(bool(r[k]) for r in rows) for k in ("dep_capped_hours", "closures", + "outstation_capped_hours", "pools")}} + + +def refs(folder: Path, tasks: Sequence[Task], workers: int, limits: tuple[float, float], + clock: "Clock | None" = None) -> dict[str, dict]: + """The references on every task in a process pool, heaviest first, each result written to folder/refs/.json as it finishes; a result already there whose signature matches is kept. Then folder/refs.json: each task's + anchor and follower costs, where they ran, with the task's digest. Results are returned as written (JSON).""" + (folder / "refs").mkdir(parents=True, exist_ok=True) + results, jobs = {}, [] + for t in tasks: + names = REFS + (CONTROL_REFS if t.split == "control" else ()) + sig = signature(t, names, limits) + path = folder / "refs" / f"{t.task_id}.json" + try: + old = json.loads(path.read_text()) + except (OSError, ValueError): + old = {} + if old.get("signature") == sig: + results[t.task_id] = old + else: + jobs.append((t.task_id, t.to_json(), sig, names, limits)) + weights = {t.task_id: world_check.weight(t) for t in tasks} + jobs.sort(key=lambda j: weights[j[0]], reverse=True) + t0 = time.monotonic() + if jobs: + with ProcessPoolExecutor(max(1, min(workers, len(jobs)))) as pool: + for fut in as_completed([pool.submit(refs_job, j) for j in jobs]): + r = json.loads(json.dumps(fut.result(), default=str)) + part = folder / "refs" / f"{r['task_id']}.part" + part.write_text(json.dumps(r)) + os.replace(part, folder / "refs" / f"{r['task_id']}.json") + results[r["task_id"]] = r + print(f" [{time.monotonic() - t0:8.1f}s] {len(results)}/{len(tasks)} {r['task_id']} " + f"{world_check.brief(r)}", flush=True) + (folder / "refs.json").write_text(json.dumps(stored(tasks, results), indent=1) + "\n") + if clock: + clock.lap("refs", f"{len(jobs)} run, {len(tasks) - len(jobs)} kept, failed {failed(results)}") + return {t.task_id: results[t.task_id] for t in tasks} + + +def signature(task: Task, names: tuple[str, ...], limits: tuple[float, float]) -> str: + return hashlib.sha256(f"{task.to_json()}|{names}|{limits}|{REFS_VERSION}".encode()).hexdigest()[:16] + + +def refs_job(j: tuple) -> dict: + """world_check's references of one task (its job), with the replay's fidelity and reserve calls, the probe's + verdicts and JetBlue's own counts; a follower stopped by a watch solve that did not prove is flagged.""" + tid, line, sig, names, limits = j + out = world_check.job((tid, line, sig, names, limits)) + task = Task.from_dict(json.loads(line)) + out["jetblue"] = {"cancelled": sum(f.actual.dep is None for f in task.flights), + "flown": sum(f.actual.dep is not None for f in task.flights)} + for name, play in (("fidelity", lambda: fidelity(task)), ("probe", lambda: probe.probe(task).to_dict())): + world_check.attempt(out, name, play) + error = out.get("follower", {}).get("error", "") + if error: + out["follower"]["unproven_watch"] = error.startswith("RuntimeError") and " watch " in error + return out + + +def fidelity(task: Task) -> dict: + """Of the flights JetBlue flew, how many the airline replay flies and how many of those leave within FIDELITY_MIN + of JetBlue's departure (exactly on it too), and the replay's reserve calls: asked for and granted.""" + plan = references.airline_plan(task) + run = sim.execute(task, plan) + errs = [run.flights[f.id].dep - f.actual.dep for f in task.flights + if f.actual.dep is not None and run.flights[f.id].status == FLOWN] + return {"jetblue_flown": sum(f.actual.dep is not None for f in task.flights), "flown": len(errs), + "within": sum(abs(e) <= FIDELITY_MIN for e in errs), "exact": sum(e == 0 for e in errs), + "within_min": FIDELITY_MIN, "reserves_called": sum(bool(e.get("reserve")) for e in plan.values()), + "reserves_granted": len(run.reserved)} + + +def stored(tasks: Iterable[Task], results: Mapping[str, dict]) -> dict[str, dict]: + """Per task, the costs on the truth of its anchor's plan and its follower's, where they ran without error, with the + task's digest: what the playable day's load takes (v4day.live), so it solves no MIP.""" + out = {} + for t in tasks: + r = results.get(t.task_id, {}) + costs = {k: r[k]["total"] for k in ("anchor", "follower") if "total" in r.get(k, {})} + if costs: + out[t.task_id] = {**costs, "task": references.digest(t)} + return out + + +def failed(results: Mapping[str, dict]) -> dict[str, list[str]]: + """Per task, the references that failed or whose MIP did not prove (world_check.unproven), and the fidelity or the + probe if they failed.""" + return {i: bad for i, r in sorted(results.items()) + if (bad := world_check.unproven(r) + [k for k in ("fidelity", "probe") if "error" in r.get(k, {})])} + + +def revision() -> str: + try: + return subprocess.check_output(["git", "-C", str(ROOT), "rev-parse", "HEAD"], text=True).strip() + except (OSError, subprocess.CalledProcessError): + return "" + + +if __name__ == "__main__": + main() diff --git a/src/agentenv_airops/flow/sources.py b/src/agentenv_airops/flow/sources.py index 447d26f..4935941 100644 --- a/src/agentenv_airops/flow/sources.py +++ b/src/agentenv_airops/flow/sources.py @@ -30,15 +30,12 @@ from pathlib import Path import numpy as np -from PIL import Image from ..build import bts, fetch from ..build import weather as v1 from ..programs import candidates, unseen from ..programs.advisories import HUBS, nm -Image.MAX_IMAGE_PIXELS = None - ATR = "https://www.panynj.gov/content/dam/airports/statistics/statistics-general-info/annual-atr/ATR_2024.pdf" ATR_SHA256 = "0f28d133faa2b4347e4bbc98d1e2eff6b1ea065ca4d97e8a9773319e4b493b59" ATR_2024 = {1: (35_850, -4.8), 2: (34_228, -2.5), 3: (37_951, -7.6), 4: (38_661, -4.0), 5: (40_254, -4.5), @@ -175,6 +172,9 @@ def discs() -> dict[str, tuple[slice, slice, np.ndarray]]: def shares(body: bytes, masks: dict) -> dict[str, dict[str, float]]: + from PIL import Image # an extra's: the path helpers above serve installs without it (programs.layers) + + Image.MAX_IMAGE_PIXELS = None im = Image.open(io.BytesIO(body)) if im.mode != "P": raise ValueError(f"expected a palette PNG, got {im.mode}") diff --git a/src/agentenv_airops/programs/candidates.py b/src/agentenv_airops/programs/candidates.py index c312578..28ac53c 100644 --- a/src/agentenv_airops/programs/candidates.py +++ b/src/agentenv_airops/programs/candidates.py @@ -52,6 +52,7 @@ from ..core.model import Airport from . import advisories, rbs, repairs, unseen from .advisories import HUBS, Run, Stop +from .layers.crew import EDGAR, Rule from .model import Arrival, Flight, Programs, Task, coverage from .rbs import Covers @@ -309,9 +310,11 @@ def airports_of(codes: Iterable[str]) -> dict[str, Airport]: def build_candidate(d: str, hub: str, inputs: Path, derived: Path, e_fit: Mapping[str, float], e_median: Mapping[str, float], scale: Mapping[str, float], cutoff: int = repairs.CUTOFF, - window: tuple[int, int] | None = None, split: str = "", bases: Sequence[str] = ()) -> Built: + window: tuple[int, int] | None = None, split: str = "", bases: Sequence[str] = (), + crew: Rule = EDGAR) -> Built: """The task of (d, hub) at its rule window (or at `window`, as for a control), or None and the reasons; `e_fit` - and `e_median` are unseen.table's at `scale`; crew `bases` turn the crew model on (repairs.crews).""" + and `e_median` are unseen.table's at `scale`; crew `bases` turn the crew model on, its pairings cut by `crew` + (Edgar's, repairs.crews, or v4's, layers.crew).""" ctx = context(inputs, derived, d, hub) row = {"date": d, "hub": hub, "p0": None, "window": None, "slide": None, "task_id": None, "reason": "", "tried": []} @@ -331,7 +334,7 @@ def build_candidate(d: str, hub: str, inputs: Path, derived: Path, e_fit: Mappin if not sc.ok: row["tried"].append({"slide": slide, "window": list(w), "clauses": sc.clauses}) continue - task = assemble(ctx, sc, d, hub, e_fit, e_median, scale, cutoff, slide, split, bases) + task = assemble(ctx, sc, d, hub, e_fit, e_median, scale, cutoff, slide, split, bases, crew) if window or task.clauses["program_in_window"]: return Built(task, {**row, "window": list(w), "slide": slide, "task_id": task.task_id, "clauses": task.clauses}) @@ -349,9 +352,9 @@ def span(day: pack.Day, h: int) -> tuple[int, int] | None: def assemble(ctx: Context, sc: pack.Scope, d: str, hub: str, e_fit: Mapping[str, float], e_median: Mapping[str, float], scale: Mapping[str, float], cutoff: int, slide: int, split: str, - bases: Sequence[str] = ()) -> Task: + bases: Sequence[str] = (), crew: Rule = EDGAR) -> Task: day = ctx.day - flights = repairs.flights(day, sc, ctx.x, ctx.families, tests(ctx), cutoff=cutoff, bases=bases) + flights = repairs.flights(day, sc, ctx.x, ctx.families, tests(ctx), cutoff=cutoff, bases=bases, cut=crew.crews) tails, closure = repairs.tails(day, sc, flights, ctx.families, day.turn) programs = hub_programs(ctx, flights, sc.start, e_fit, e_median, scale) codes = ({f.orig for f in flights} | {f.dest for f in flights} | set(HUBS) diff --git a/src/agentenv_airops/programs/layers/__init__.py b/src/agentenv_airops/programs/layers/__init__.py new file mode 100644 index 0000000..8ccc7e6 --- /dev/null +++ b/src/agentenv_airops/programs/layers/__init__.py @@ -0,0 +1,12 @@ +"""v4's realism layers (model.Layers), each built for a task from pinned public inputs. + +A layer is a rule sim.execute applies and the hindsight MIP carries alike (docs/v4/DESIGN.md §1), so the anchor stays +a proven optimum. A builder returns its field of Layers; a task without layers is v2's world. +- depcaps: JetBlue's departure cap per hour at a hub, from every carrier's BTS pushbacks (§3.1). +- arrcaps: JetBlue's arrival cap per hour at an outstation, from every carrier's BTS arrivals (§3.3). +- closures: ramp closures at a hub from its METARs and SPECIs, a departure and an arrival cap over each (§3.2). +- weather: the hubs' METARs, SPECIs and TAFs as the desk's news (§4), each delivered after its kind's lag. +- config: the constants a pack fits, and every layer of a task at them (network.build's `layers`). +- calibrate: the constants fitted on a pack's train dates: closure rates and reserve pools (§3.4, §6.1). +- crew: v4's pairings legal on schedule, a build's crew Rule (docs/v4/CREW.md §1). +""" diff --git a/src/agentenv_airops/programs/layers/arrcaps.py b/src/agentenv_airops/programs/layers/arrcaps.py new file mode 100644 index 0000000..05e918f --- /dev/null +++ b/src/agentenv_airops/programs/layers/arrcaps.py @@ -0,0 +1,47 @@ +"""JetBlue's arrival cap per hour at its outstations, from what actually arrived there (docs/v4/DESIGN.md §3.3). + +v2 models the FAA's programs at JFK and BOS only, so a leg into an outstation under its own GDP, ground stop or weather +flies free. This is §3.1's rule on arrivals at every non-hub destination of the task's flights: an hour is constrained +when every carrier together arrived there (BTS gate arrival) less than `short` of what it had scheduled to arrive in it, +with at least `min_sched` scheduled. JetBlue's cap there is its in-scope share of the hour's schedule (the task's +flights scheduled to arrive at the station in it) times the hour's actual arrivals, rounded to the nearest flight, and +never below the task's flights into the station the airline replay lands in it. The replay lands a flight that pushed +back at its pushback plus its truth block, whatever its gate-in: a diversion flies to its scheduled destination, and a +gate-in held long after landing counts in the hour it landed. The floor keeps the recorded day's arrivals within the +caps; a replayed flight that a GDP slot or a late aircraft moves into a capped hour can still be held there, so the +recorded day is close to feasible, not feasible by construction (as with closures, §3.2). Other hours, and hours JetBlue +scheduled nothing in, stay uncapped. Hours are counted from the task's midnight, over the span of the hubs' nominal caps +(rbs.nominal): from the first watch's hour through the hour of the latest arrival a deadline allows. The caps join the +world's nominal arrival caps (sim.truth), so the sim and the MIP enforce them as they do a hub's. +""" + +import math +from collections import Counter + +import pandas as pd + +from ..advisories import HUBS +from ..model import Task + +SHORT = 0.9 +MIN_SCHED = 5 + + +def build(task: Task, frame: pd.DataFrame, short: float = SHORT, + min_sched: int = MIN_SCHED) -> dict[str, dict[int, int]]: + """The task's arrival caps per outstation and hour. `frame` holds every carrier's BTS flights of the task's dates + (candidates.frame: `Dest`, and `sched_arr` and `act_arr` in UTC epoch minutes, `act_arr` NaN when none).""" + lo, hi = task.start // 60, max(f.deadline + f.block for f in task.flights) // 60 + out: dict[str, dict[int, int]] = {} + for station in sorted({f.dest for f in task.flights} - set(HUBS)): + x = frame[frame.Dest == station] + sched = Counter(((x.sched_arr - task.base) // 60).astype(int)) + arrived = Counter(((x.act_arr.dropna() - task.base) // 60).astype(int)) + mine = Counter(f.sched_arr // 60 for f in task.flights if f.dest == station) + landed = Counter((f.actual.dep + f.block) // 60 for f in task.flights + if f.dest == station and f.actual.dep is not None) + caps = {h: max(landed[h], math.floor(mine[h] / sched[h] * arrived[h] + 0.5)) for h in range(lo, hi + 1) + if mine[h] and sched[h] >= min_sched and arrived[h] < short * sched[h]} + if caps: + out[station] = caps + return out diff --git a/src/agentenv_airops/programs/layers/calibrate.py b/src/agentenv_airops/programs/layers/calibrate.py new file mode 100644 index 0000000..45a0151 --- /dev/null +++ b/src/agentenv_airops/programs/layers/calibrate.py @@ -0,0 +1,210 @@ +"""v4's layer constants, fitted on a pack's train dates only (docs/v4/DESIGN.md §1, §6.1) and written to its manifest. + +- The ramp closures' rate per hub is closures.fit_rate over the train dates: every carrier's pushbacks inside the + closures that start on them over those in the same minutes just before (DECISIONS 2026-10-17, U3 Q2: refitted on + the pack's own split, not U3's scratch rates). +- The reserve pool per crew base (§3.4), as no crew data is public, is the smallest that keeps JetBlue's airline + replay within the airline-replay gate (world_check's and step 0's G1) on every train date: its auto-cancels among + the flights it flew by their deadline (references.counted) at most AUTO_SHARE of them and at most AUTO_MAX + (DECISIONS 2026-10-17, U6 Q2). A date whose replay is out of G1 even with unlimited reserves (the other layers' + doing) holds the pool to no more auto-cancels than that. On each date, with the other layers on, the replay calls a + reserve for each base departure its unlimited-reserve replay flies past its crew deadline + (references.airline_plan); per base, the smallest pool in 0 up to that date's calls there at which the replay, that + base pooled and the others unlimited, is kept so, and the base's pool is the largest of those over the dates. The + pools together are then replayed on every date; a date they don't keep (the bases' auto-cancels adding up past G1, + or one base's denial moving another's aircraft) lifts every base to its calls there, at which the replay is the + unlimited one, until every date is kept. +- The departure and outstation caps' clauses stay at depcaps' and arrcaps' SHORT and MIN_SCHED (0.9 and 5), not + fitted: the caps are floored at JetBlue's recorded pushbacks and arrivals, so any value fits the recorded day + (DECISIONS 2026-10-17, U6 Q1). +- v4's crew cut (layers.crew, docs/v4/CREW.md §1), when a pack fits it (`crew_fit`), comes before the pools: on a + seeded `sample` of at most SAMPLE train dates (SAMPLE_SEED), each date built at every Rule of the grid (BUFFERS x + SEGMENTS) with the other layers on at the fitted rates and unlimited reserves, and scored (`score`) by its naive day, + its recorded day, its anchor and its calm day at CREW_S, and so is each date built without crews, once (a score is + kept under the pack's `cache` by its task's signature, so a rerun solves only what changed). The cut is the one that + maximises do-nothing's median excess over the anchor on the sample while docs/v4/CREW.md §0's guards hold there + (`summary`): on every date whose calm day without crews is within JetBlue's fair-weather bar (layers.fairday, at the + pack's `rate`), the crew's calm day is within it too, so crews push no fair day past the cancellations JetBlue's own + fair days have (DECISIONS 2026-10-22); the recorded day costs no less than the anchor on at least RECORDED_SHARE of + the dates, and the anchors cancel (auto-cancels included) no more than JetBlue did, pooled; ties go to the larger + buffer, then the fewer segments (the laxer crew). A date counts where its MIPs proved (for the calm guard, its no-crew + build's too). None holds: no cut, and the pack stops. The pools are then fitted at that cut. +""" + +import hashlib +import json +import random +import statistics +from collections import Counter +from collections.abc import Callable, Iterable, Mapping, Sequence +from concurrent.futures import ProcessPoolExecutor +from contextlib import nullcontext +from dataclasses import replace +from itertools import product +from pathlib import Path + +import pandas as pd + +from .. import references +from ..model import Layers, Task +from . import closures, fairday +from .config import Config, build +from .crew import Rule + +AUTO_SHARE, AUTO_MAX = 0.01, 2 +BUFFERS, SEGMENTS = (0, 30, 60, 90, 120), (3, 4, 5, 6) +SAMPLE, SAMPLE_SEED, CREW_S = 12, 0, 600.0 +RECORDED_SHARE = 0.95 + + +def fit(dates: Iterable[str], tasks: Iterable[Task], frame_of: Callable[[str], pd.DataFrame], + spans: Mapping[str, Sequence[closures.Interval]], pushed: pd.DataFrame, workers: int = 1) -> Config: + """The constants fitted on the train `dates`: `tasks` their built tasks (crew on, no layers; others are ignored), + `frame_of` a date's every-carrier BTS flights (candidates.frame), `spans` each hub's closures, `pushed` every + carrier's BTS flights once each (`Origin`, `act_dep`) over the months the dates fall in.""" + return pooled_fit(rates(dates, pushed, spans), tasks, frame_of, spans, workers) + + +def rates(dates: Iterable[str], pushed: pd.DataFrame, spans: Mapping[str, Sequence[closures.Interval]]) -> Config: + """The closure rates fitted on the train `dates`, no pools.""" + dates = tuple(sorted(set(dates))) + return Config(rate=closures.fit_rate(dates, pushed, spans), dates=dates) + + +def pooled_fit(rated: Config, tasks: Iterable[Task], frame_of: Callable[[str], pd.DataFrame], + spans: Mapping[str, Sequence[closures.Interval]], workers: int = 1) -> Config: + """`rated` with the reserve pools fitted on its train dates' `tasks`, layered at its rates.""" + train = [replace(t, layers=build(t, rated, frame_of(t.date), spans)) for t in tasks if t.date in rated.dates] + return replace(rated, reserves=pools(train, workers)) + + +def sample(dates: Iterable[str], n: int = SAMPLE, seed: int = SAMPLE_SEED) -> tuple[str, ...]: + """At most n of the train `dates`, drawn with `seed`, in date order.""" + dates = sorted(set(dates)) + return tuple(sorted(random.Random(seed).sample(dates, min(n, len(dates))))) + + +def grid(buffers: Sequence[int] = BUFFERS, segments: Sequence[int] = SEGMENTS) -> list[Rule]: + return [Rule("v4", b, s) for b, s in product(buffers, segments)] + + +def crew_fit(tasks: Mapping[Rule, Sequence[Task]], plain: Sequence[Task], rate: float, workers: int = 1, + time_limit: float = CREW_S, cache: Path | None = None) -> tuple[Rule | None, list[dict]]: + """The cut of `tasks` (the sample's tasks built at each Rule, layered at the rates, unlimited reserves) that + maximises do-nothing's median excess over the anchor while the guards hold against `plain` (the sample built + without crews, alike), the calm days at the fair-weather bar `rate`, or None; and each Rule's row, then the + no-crew row.""" + jobs = [(rule, t) for rule, ts in tasks.items() for t in ts] + [(None, t) for t in plain] + args = [(t, time_limit, cache) for _, t in jobs] + with ProcessPoolExecutor(workers) if workers > 1 and len(jobs) > 1 else nullcontext() as pool: + scores = list(pool.map(kept_score, args) if pool else map(kept_score, args)) + by: dict[Rule | None, list[dict]] = {rule: [] for rule in tasks} | {None: []} + for (rule, _), s in zip(jobs, scores, strict=True): + by[rule].append(s) + base = {s["date"]: s for s in by.pop(None)} + rows = [summary(rule, s, base, rate) for rule, s in by.items()] + held = [(r, rule) for r, rule in zip(rows, by, strict=True) if r["guards"]] + rows.append(summary(None, list(base.values()), base, rate)) + if not held: + return None, rows + return max(held, key=lambda x: (x[0]["do_nothing"], x[1].buffer, -x[1].max_segments))[1], rows + + +def kept_score(job: tuple[Task, float, Path | None]) -> dict: + """`score`, read from the cache where the same task at the same time limit was scored, else written there.""" + task, time_limit, cache = job + if cache is None: + return score(task, time_limit) + sig = hashlib.sha256(f"{task.to_json()}|{time_limit}".encode()).hexdigest()[:16] + path = cache / f"{task.task_id}-{sig}.json" + try: + return json.loads(path.read_text()) + except (OSError, ValueError): + pass + out = score(task, time_limit) + cache.mkdir(parents=True, exist_ok=True) + path.with_suffix(".part").write_text(json.dumps(out)) + path.with_suffix(".part").replace(path) + return out + + +def score(task: Task, time_limit: float = CREW_S) -> dict: + """What the guards and the objective read of one task: the naive, recorded and anchor costs, the anchor's and + JetBlue's cancellations, the calm day's forced cancellations and ferries, the day's flights, and whether both + MIPs proved.""" + sol, run = references.anchor(task, time_limit) + calm = references.calm(task, time_limit) + return {"date": task.date, "naive": references.naive(task).cost.total, + "recorded": references.recorded(task)[0].total, "anchor": sol.objective, + "proven": sol.proven and calm.solution.proven, + "anchor_cancels": run.counts["cancelled"] + run.counts["auto_cancelled"], + "jetblue_cancels": sum(f.actual.dep is None for f in task.flights), "calm_cancels": calm.cancelled, + "calm_ferries": calm.ferries, "flights": len(task.flights)} + + +def summary(rule: Rule | None, scores: Sequence[dict], base: Mapping[str, dict], rate: float) -> dict: + """A Rule's row (None: the no-crew build's): do-nothing's median excess over the anchor and each guard on the + dates whose MIPs proved, the calm day against `base` (the no-crew scores by date) at the fair-weather bar + `rate`.""" + ok = [s for s in scores if s["proven"]] + paired = [(s, base[s["date"]]) for s in ok if s["date"] in base and base[s["date"]]["proven"]] + calm = share(calm_within(s, rate) or not calm_within(b, rate) for s, b in paired) + recorded = share(s["recorded"] >= s["anchor"] for s in ok) + cancels = sum(s["anchor_cancels"] for s in ok), sum(s["jetblue_cancels"] for s in ok) + checks = {"calm": calm == 1, "recorded": recorded is not None and recorded >= RECORDED_SHARE, + "cancels": cancels[0] <= cancels[1]} + head = rule.to_dict() if rule else {"crew": None, "buffer": None, "max_segments": None} + return {**head, "dates": len(scores), "proven": len(ok), + "do_nothing": round(statistics.median(s["naive"] / s["anchor"] - 1 for s in ok), 4) if ok else None, + "calm_share": calm, "recorded_share": recorded, "anchor_cancels": cancels[0], "jetblue_cancels": cancels[1], + "checks": checks, "guards": bool(ok) and all(checks.values()), "scores": list(scores)} + + +def calm_within(s: dict, rate: float) -> bool: + return fairday.within(s["calm_cancels"], s["calm_ferries"], s["flights"], rate) + + +def share(flags: Iterable[bool]) -> float | None: + flags = list(flags) + return round(sum(flags) / len(flags), 4) if flags else None + + +def pools(tasks: Sequence[Task], workers: int = 1) -> dict[str, int]: + """The smallest reserve pool per crew base of `tasks` (layered, crew on) that keeps every task's airline replay + within G1, or no worse than with unlimited reserves where that already isn't.""" + with ProcessPoolExecutor(workers) if workers > 1 and len(tasks) > 1 else nullcontext() as pool: + found = list(pool.map(least, tasks) if pool else map(least, tasks)) + free = [auto(t, {}) for t in tasks] + out = {b: max((k.get(b, 0) for k, _ in found), default=0) for b in sorted({b for t in tasks for b in t.bases})} + while True: + short = [calls for t, (_, calls), n in zip(tasks, found, free, strict=True) if not kept(t, out, n)] + if not short: + return out + for calls in short: + out |= {b: max(out[b], n) for b, n in calls.items()} + + +def least(task: Task) -> tuple[dict[str, int], dict[str, int]]: + """Per crew base of the task, the smallest pool (the others unlimited) that keeps its airline replay (`kept`), and + the replay's reserve calls there.""" + plan = references.airline_plan(pooled(task, dict.fromkeys(task.bases, len(task.flights)))) + calls = Counter(task.flight(i).orig for i, e in plan.items() if e.get("reserve")) + free = auto(task, {}) + return {b: next(k for k in range(calls[b] + 1) if k == calls[b] or kept(task, {b: k}, free)) + for b in task.bases}, {b: calls[b] for b in task.bases} + + +def kept(task: Task, pool: Mapping[str, int], free: int) -> bool: + """Whether the airline replay with `pool` is within G1 (auto-cancels at most AUTO_SHARE of its counted flights and + at most AUTO_MAX), or auto-cancels no more than the `free` it does with unlimited reserves.""" + replay = references.airline(pooled(task, {b: n for b, n in pool.items() if b in task.bases})) + return replay.auto <= free or (replay.auto <= AUTO_SHARE * replay.flew and replay.auto <= AUTO_MAX) + + +def auto(task: Task, pool: Mapping[str, int]) -> int: + """The airline replay's auto-cancels with `pool` at the task's crew bases it names, the others unlimited.""" + return references.airline(pooled(task, {b: n for b, n in pool.items() if b in task.bases})).auto + + +def pooled(task: Task, pool: Mapping[str, int]) -> Task: + return replace(task, layers=replace(task.layers or Layers(), reserves=dict(pool))) diff --git a/src/agentenv_airops/programs/layers/closures.py b/src/agentenv_airops/programs/layers/closures.py new file mode 100644 index 0000000..246be7a --- /dev/null +++ b/src/agentenv_airops/programs/layers/closures.py @@ -0,0 +1,152 @@ +"""Ramp closures at JFK and BOS from the METARs, as a cut in JetBlue's departure and gate-in rates (docs/v4/DESIGN.md +§3.2). + +An ASOS reports TS in present weather when it detects lightning within 5 nm of the field (VCTS is 5 to 10 nm, LTG DSNT +beyond: neither closes the ramp), and the remarks carry the thunderstorm's begin and end minutes (TSBmm or TSBhhmm, +TSEmm or TSEhhmm). Read oldest first, the routine METARs and the SPECIs give each closure: from a begin to its end; a +report with TS in present weather and none open opens one at the report; a report without TS closes one still open at +the report, unless its remarks end it first; the last report closes one still open. An end remark older than the open +closure's start belongs to an earlier storm (a SPECI's "TSE13B24" while a closure is open from :22) and is ignored. +Overlapping or touching intervals merge. The SPECIs, when the cache holds them, give the onset and end to the minute; +without them the routine reports alone, whose remarks carry the same minutes an hour later at most. + +Lightning doesn't stop the ramp: during such intervals every carrier together still pushed back at about 75% of its +rate in the same minutes just before (the spike: JFK 411 vs 563, BOS 449 vs 584), and gated in at 70 to 74% (BTS over +the 19 months: JFK 565 vs 807, BOS 732 vs 989). `fit_rate` measures the departure ratio per hub on train dates, leaving +out a closure whose minutes just before reach into another closure, as they hold no normal rate. `build` turns each +closure over a task into JetBlue's two caps there (§3.2), at the same rate: `rate` x JetBlue's normal hourly departures +from the hub (the task's flights scheduled to leave it in the clock hours the closure touches, per hour) x the +closure's length in hours, rounded to the nearest flight and never below what those flights actually pushed back inside +it; and the same of its flights scheduled to arrive at the hub, never below those that arrive inside it as the sim +times the recorded day: at the recorded pushback plus the truth block, a few minutes before the gate-in when the block +leaves out a held taxi-out, and at the hub even for a flight that diverted elsewhere (the airline replay flies it +there), and one diverted into the hub at the station it was scheduled into. The floors keep the recorded day's +pushbacks and arrivals inside a closure within its caps, so the airline replay leaves and lands there as on the open +day unless something else (a slot, a cap) moves a flight into a closure. `intervals` are in UTC epoch minutes, closures +in minutes from the task's midnight. +""" + +import csv +import io +import math +import re +from collections.abc import Iterable, Mapping, Sequence +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pandas as pd + +from ...build import bts +from ..candidates import midnight +from ..model import Task + +EVENT = re.compile(r"\bTS((?:[BE]\d{2,4})+)\b") +PART = re.compile(r"([BE])(\d{2,4})") +Interval = tuple[int, int] + + +def intervals(hub: str, metars: Iterable[Path], specis: Iterable[Path] = ()) -> list[Interval]: + """The hub's closures, [start, end) in UTC epoch minutes, from its routine METAR and SPECI CSVs (IEM ASOS); a + path that doesn't exist is skipped.""" + rows = [r for p in (*metars, *specis) if p.exists() for r in csv.DictReader(io.StringIO(p.read_text())) + if r["station"] == hub and (r["metar"] or "").strip()] + rows.sort(key=lambda r: r["valid"]) + out: list[Interval] = [] + start, now = None, None + for r in rows: + report = datetime.strptime(r["valid"], "%Y-%m-%d %H:%M").replace(tzinfo=UTC) + now = bts.epoch_minutes(report) + _, _, remarks = r["metar"].partition(" RMK ") + for m in EVENT.finditer(remarks): + for kind, digits in PART.findall(m.group(1)): + when = bts.epoch_minutes(_at(report, digits)) + if kind == "B" and start is None: + start = when + elif kind == "E" and start is not None and when >= start: + out.append((start, max(when, start + 1))) + start = None + here = any("TS" in c and not c.startswith("VC") for c in (r["wxcodes"] or "").split()) + if here and start is None: + start = now + elif not here and start is not None: + out.append((start, max(now, start + 1))) + start = None + if start is not None: + out.append((start, max(now, start + 1))) + merged: list[Interval] = [] + for a, b in sorted(out): + if merged and a <= merged[-1][1]: + merged[-1] = (merged[-1][0], max(merged[-1][1], b)) + else: + merged.append((a, b)) + return merged + + +def _at(report: datetime, digits: str) -> datetime: + """A remark's minute (mm, or hhmm), the latest such time at or before its report.""" + if len(digits) == 4: + t = report.replace(hour=int(digits[:2]), minute=int(digits[2:])) + return t - timedelta(days=1) if t > report else t + t = report.replace(minute=int(digits[-2:])) + return t - timedelta(hours=1) if t > report else t + + +def build(task: Task, spans: Mapping[str, Sequence[Interval]], + rate: Mapping[str, float]) -> dict[str, tuple[tuple[int, int, int, int], ...]]: + """The task's ramp closures per hub, (start, end, dep_cap, arr_cap) from its midnight: each of `spans` (UTC epoch + minutes, per hub) that overlaps the task's span, from its first watch to the end of the hour after its last + deadline. A hub closed over the task without a `rate` (`fit_rate` gives none to a hub with nothing pushed back + before its closures) is a ValueError: its caps can't be priced, and dropping the closure would hide the gap.""" + hi = 60 * (max(f.deadline for f in task.flights) // 60 + 2) + out = {} + for hub, xs in spans.items(): + shut = [] + for a, b in ((a - task.base, b - task.base) for a, b in xs): + if b <= task.start or a >= hi: + continue + if hub not in rate: + raise ValueError(f"{hub} closes {a}-{b} on {task.date} but has no fitted rate") + shut.append((a, b, *caps(task, hub, rate[hub], a, b))) + if shut: + out[hub] = tuple(shut) + return out + + +def caps(task: Task, hub: str, rate: float, a: int, b: int, by: int | None = None) -> tuple[int, int]: + """JetBlue's departure and arrival caps over a closure of `hub` from a to b at `rate`, as `build` prices them; `by` + floors them only at the pushbacks and arrivals before it, which is all the desk has seen at watch `by` + (sim.known prices a closure under way over its expected end so).""" + seen = (lambda m: True) if by is None else (lambda m: m < by) + leave = [f.sched_dep for f in task.flights if f.orig == hub] + land = [f.sched_arr for f in task.flights if f.dest == hub] + pushed = [f.actual.dep for f in task.flights if f.orig == hub and f.actual.dep is not None and seen(f.actual.dep)] + landed = [m for m in (f.actual.dep + f.block for f in task.flights if f.dest == hub and f.actual.dep is not None) + if seen(m)] + return _cap(rate, leave, pushed, a, b), _cap(rate, land, landed, a, b) + + +def _cap(rate: float, scheduled: Sequence[int], actual: Sequence[int], a: int, b: int) -> int: + """`rate` x the normal hourly count of `scheduled` minutes (per hour over the clock hours [a, b) touches) x the + hours in [a, b), to the nearest flight, and never below the `actual` minutes inside [a, b).""" + lo, top = 60 * (a // 60), 60 * -(-b // 60) + hourly = sum(lo <= t < top for t in scheduled) * 60 / (top - lo) + return max(sum(a <= t < b for t in actual), math.floor(rate * hourly * (b - a) / 60 + 0.5)) + + +def fit_rate(dates: Iterable[str], frame: pd.DataFrame, spans: Mapping[str, Sequence[Interval]]) -> dict[str, float]: + """Per hub, every carrier's pushbacks inside its closures that start on `dates` (local days) over its pushbacks in + the same number of minutes just before each, leaving out a closure whose minutes before reach into another of the + hub's closures. `frame` holds every carrier's BTS flights once each (`Origin`, and `act_dep` in UTC epoch minutes, + NaN when none); a hub with no pushback before any of its closures has no rate.""" + days = [(midnight(d), midnight(d) + 1440) for d in dates] + out = {} + for hub, xs in spans.items(): + pushed = frame.act_dep[frame.Origin == hub].dropna() + inside = before = 0 + for a, b in xs: + if any(lo <= a < hi for lo, hi in days) and not any(c < a and d > 2 * a - b for c, d in xs): + inside += int(((pushed >= a) & (pushed < b)).sum()) + before += int(((pushed >= 2 * a - b) & (pushed < a)).sum()) + if before: + out[hub] = inside / before + return out diff --git a/src/agentenv_airops/programs/layers/config.py b/src/agentenv_airops/programs/layers/config.py new file mode 100644 index 0000000..f1b52f3 --- /dev/null +++ b/src/agentenv_airops/programs/layers/config.py @@ -0,0 +1,62 @@ +"""v4's layer config: the constants fitted on a pack's train dates (layers.calibrate), and the builder that turns them +into a task's Layers (docs/v4/DESIGN.md §2, §6.1). + +The constants are the ramp closures' departure-rate cut per hub (`rate`, closures.fit_rate), the reserve crews per +crew base (`reserves`), and the departure and outstation caps' clauses (`short`, `min_sched`: depcaps' and arrcaps' +0.9 and 5, kept as set, not fitted), with the train `dates` they were fitted on. `build` gives a task every layer at +them: JetBlue's departure caps at the hubs (depcaps) and arrival caps at the outstations (arrcaps) from every +carrier's BTS flights of its dates, the hubs' ramp closures over its span (closures, from `spans`: the IEM routine +METARs and SPECIs), and the pool at each of the task's crew bases (none without the crew model). A base the config +leaves out keeps unlimited reserves. The desk's view goes with them (docs/v4/DESIGN.md §4): the closure rates +(Layers.rates), at which the day as known prices a closure under way over its expected end (sim.known), the hubs' +weather news (layers.weather) and the default delivery lags (model.LAGS). +""" + +import functools +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from pathlib import Path + +import pandas as pd + +from ...build import weather +from ...flow import sources +from ..advisories import HUBS +from ..model import Layers, Task +from . import arrcaps, closures, depcaps +from .weather import Item + + +@dataclass(frozen=True) +class Config: + rate: dict[str, float] = field(default_factory=dict) + reserves: dict[str, int] = field(default_factory=dict) + short: float = depcaps.SHORT + min_sched: int = depcaps.MIN_SCHED + dates: tuple[str, ...] = () + + @classmethod + def from_dict(cls, d: Mapping) -> "Config": + return cls(dict(d["rate"]), dict(d["reserves"]), d["short"], d["min_sched"], tuple(d["dates"])) + + def to_dict(self) -> dict: + return {"rate": dict(sorted(self.rate.items())), "reserves": dict(sorted(self.reserves.items())), + "short": self.short, "min_sched": self.min_sched, "dates": list(self.dates)} + + +def build(task: Task, cfg: Config, frame: pd.DataFrame, spans: Mapping[str, Sequence[closures.Interval]], + news: Sequence[Item] = ()) -> Layers: + """The task's layers at `cfg`. `frame` holds every carrier's BTS flights of its dates (candidates.frame), `spans` + each hub's closures in UTC epoch minutes (closures.intervals), `news` the hubs' weather (layers.weather.build).""" + return Layers(dep_caps=depcaps.build(task, frame, cfg.short, cfg.min_sched), + arr_caps=arrcaps.build(task, frame, cfg.short, cfg.min_sched), + closures=closures.build(task, spans, cfg.rate), + reserves={b: n for b, n in sorted(cfg.reserves.items()) if b in task.bases}, + weather=tuple(news), rates=dict(sorted(cfg.rate.items()))) + + +@functools.lru_cache(maxsize=8) +def spans(inputs: Path, months: tuple[str, ...]) -> dict[str, list[closures.Interval]]: + """Each hub's closures over `months`, from the cached routine METARs and SPECIs (IEM ASOS).""" + return {h: closures.intervals(h, [weather.metar_path(inputs, h, m) for m in months], + [sources.speci_path(inputs, h, m) for m in months]) for h in HUBS} diff --git a/src/agentenv_airops/programs/layers/crew.py b/src/agentenv_airops/programs/layers/crew.py new file mode 100644 index 0000000..d21679e --- /dev/null +++ b/src/agentenv_airops/programs/layers/crew.py @@ -0,0 +1,107 @@ +"""v4's crew layer (docs/v4/CREW.md §1): pairings legal on schedule, cut in place of Edgar's proxy (repairs.pairings). + +Edgar's pairings chain each tail's legs from a base departure to the next base arrival with no duty limit, so a long +chain through outstations exceeds 14 CFR 117 on schedule and its later legs can never fly. Here each tail's scheduled +legs of the day (cancelled ones and those before the window included, as repairs.crews takes them), in order, are cut +into duties: +- a duty starts at the tail's first leg, at every departure from a crew base, and after a ground gap of REST or more + (the 10 h rest of 117.25(e), an overnight); +- it takes the tail's next leg while the duty, that leg its last, would still end on schedule `buffer` minutes inside + its Table B limit (repairs.fdp, for the report REPORT minutes before its first departure, at the report's local hour + at its first origin, and the duty's segments with that leg) and hold at most `max_segments` legs; otherwise a new + duty starts at that leg (a crew change at a base, an overnight crew at an outstation). A first leg longer than its + limit alone is still a duty of its own. +A duty is a pairing, and its legs get Edgar's records (model.Crew): its report, station, segments and limit; an +outstation departure must block in by the report + the limit + EXTENSION (117.19(a)(1)), so its crew deadline is that +less its scheduled block, its rest REST after it; a departure from a base has none in the record (reserves crew it), +and under a finite reserve pool there (Layers.reserves) Task.crew gives it the same deadline and rest. So every +duty is legal on schedule with `buffer` and the extension to spare, and the crew binds only when delays push a duty +past its limit, as real crews time out; the sim and the hindsight MIP enforce it as they do Edgar's. + +A build's crew mode is a Rule: Edgar's ('edgar', repairs.crews) or v4's ('v4', `crews` at its buffer and max_segments, +fitted on train dates by layers.calibrate.crew_fit; defaults BUFFER and MAX_SEGMENTS, the spike's). +""" + +from collections.abc import Collection, Hashable, Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime +from zoneinfo import ZoneInfo + +import pandas as pd + +from .. import repairs +from ..model import EXTENSION, REST, Crew + +MODES = ("edgar", "v4") +BUFFER, MAX_SEGMENTS = 60, 4 + + +@dataclass(frozen=True) +class Rule: + """How a build cuts pairings: `mode` 'edgar' (repairs.crews; `buffer` and `max_segments` unused) or 'v4'.""" + + mode: str = "edgar" + buffer: int = BUFFER + max_segments: int = MAX_SEGMENTS + + def __post_init__(self): + if self.mode not in MODES: + raise ValueError(f"crew mode {self.mode!r} is none of {MODES}") + + def crews(self, legs: pd.DataFrame, base: int, bases: Collection[str], + zones: Mapping[str, str]) -> dict[Hashable, Crew]: + if self.mode == "edgar": + return repairs.crews(legs, base, bases, zones) + return crews(legs, base, bases, zones, self.buffer, self.max_segments) + + def to_dict(self) -> dict: + v4 = self.mode == "v4" + return {"crew": self.mode, "buffer": self.buffer if v4 else None, + "max_segments": self.max_segments if v4 else None} + + +EDGAR = Rule() + + +def crews(legs: pd.DataFrame, base: int, bases: Collection[str], zones: Mapping[str, str], buffer: int = BUFFER, + max_segments: int = MAX_SEGMENTS) -> dict[Hashable, Crew]: + """The crew of each of `legs` by row label, as repairs.crews takes them (Tail_Number, Origin, Dest, local sd and + CRSElapsedTime; `base` their midnight's UTC epoch minute, `zones` each origin's time zone), on duties cut legal on + schedule.""" + out = {} + for tail, x in legs.sort_values(["Tail_Number", "sd"]).groupby("Tail_Number"): + for k, duty in enumerate(duties(list(x.itertuples()), base, bases, zones, buffer, max_segments)): + first = duty[0] + report = int(first.sd) - repairs.REPORT + limit = repairs.fdp(hour(base, report, zones[first.Origin]), len(duty)) + for r in duty: + d = None if r.Origin in bases else report + limit + EXTENSION - int(r.CRSElapsedTime) + out[r.Index] = Crew(f"{tail}/{k}", report, first.Origin, len(duty), limit, d, + None if d is None else d + REST) + return out + + +def duties(legs: Sequence, base: int, bases: Collection[str], zones: Mapping[str, str], buffer: int = BUFFER, + max_segments: int = MAX_SEGMENTS) -> list[list]: + """A tail's legs (rows with Origin, Dest, sd and CRSElapsedTime) in scheduled order cut into duties legal on + schedule.""" + out: list[list] = [] + for r in legs: + if out and not (r.Origin in bases or int(r.sd) - end(out[-1][-1]) >= REST or len(out[-1]) >= max_segments): + report = int(out[-1][0].sd) - repairs.REPORT + limit = repairs.fdp(hour(base, report, zones[out[-1][0].Origin]), len(out[-1]) + 1) + if end(r) + buffer <= report + limit: + out[-1].append(r) + continue + out.append([r]) + return out + + +def end(r) -> int: + """A leg's scheduled arrival, its departure plus its scheduled block.""" + return int(r.sd) + int(r.CRSElapsedTime) + + +def hour(base: int, minute: int, zone: str) -> int: + """The local hour in `zone` of `minute` past the UTC epoch minute `base`.""" + return datetime.fromtimestamp(60 * (base + minute), ZoneInfo(zone)).hour diff --git a/src/agentenv_airops/programs/layers/depcaps.py b/src/agentenv_airops/programs/layers/depcaps.py new file mode 100644 index 0000000..d3a7404 --- /dev/null +++ b/src/agentenv_airops/programs/layers/depcaps.py @@ -0,0 +1,41 @@ +"""JetBlue's departure cap per hour at JFK and BOS, from what the airport actually pushed back (docs/v4/DESIGN.md +§3.1). + +v2 caps nothing leaving a hub. An hour is constrained when every carrier together pushed back (BTS gate departure) +less than `short` of what it had scheduled to leave the hub in it, with at least `min_sched` scheduled. JetBlue's cap +there is its in-scope share of the hour's schedule (the task's flights scheduled to leave the hub in it) times the +hour's actual pushbacks, rounded to the nearest flight (so a cap of 0 means JetBlue's expected share was under +half a flight), and never below what those flights actually pushed back in it, so the recorded +day stays feasible. Other hours, and hours JetBlue scheduled nothing in, stay uncapped. Hours are counted from the +task's midnight, over the task's span: from its first watch to the hour after its last deadline. +""" + +import math +from collections import Counter + +import pandas as pd + +from ..advisories import HUBS +from ..model import Task + +SHORT = 0.9 +MIN_SCHED = 5 + + +def build(task: Task, frame: pd.DataFrame, short: float = SHORT, + min_sched: int = MIN_SCHED) -> dict[str, dict[int, int]]: + """The task's departure caps per hub and hour. `frame` holds every carrier's BTS flights of the task's dates + (candidates.frame: `Origin`, and `sched_dep` and `act_dep` in UTC epoch minutes, `act_dep` NaN when none).""" + lo, hi = task.start // 60, max(f.deadline for f in task.flights) // 60 + 1 + out: dict[str, dict[int, int]] = {} + for hub in HUBS: + x = frame[frame.Origin == hub] + sched = Counter(((x.sched_dep - task.base) // 60).astype(int)) + pushed = Counter(((x.act_dep.dropna() - task.base) // 60).astype(int)) + mine = Counter(f.sched_dep // 60 for f in task.flights if f.orig == hub) + flew = Counter(f.actual.dep // 60 for f in task.flights if f.orig == hub and f.actual.dep is not None) + caps = {h: max(flew[h], math.floor(mine[h] / sched[h] * pushed[h] + 0.5)) for h in range(lo, hi + 1) + if mine[h] and sched[h] >= min_sched and pushed[h] < short * sched[h]} + if caps: + out[hub] = caps + return out diff --git a/src/agentenv_airops/programs/layers/fairday.py b/src/agentenv_airops/programs/layers/fairday.py new file mode 100644 index 0000000..0ef2590 --- /dev/null +++ b/src/agentenv_airops/programs/layers/fairday.py @@ -0,0 +1,57 @@ +"""The calm day's bar: JetBlue's own fair-weather cancellations (docs/v4/DESIGN.md §6.2). + +A fair day is a date on which JetBlue cancelled none of the flights in a whole-network task's scope (every leg of a +tail that touched a hub that day, and every flight to or from a hub) for weather (code B) or the National Airspace +System (code C). The cancellations such a day still has are the carrier's own (code A: mechanical, crew), the kind a +calm day in the model forces where an aircraft is short. `rate` is the QUANTILE of their share of the scope's flights +over the fair days of the months given, and a calm day of n flights is within the bar when it forces at most +`allowance(rate, n)` cancellations, max(1, floor(rate x n)), and at most FERRIES ferries: JetBlue's own fair days +are within it on QUANTILE of them. +""" + +import math +from collections.abc import Iterable + +import pandas as pd + +QUANTILE, FERRIES = 0.9, 2 +CARRIER = "B6" +CODES = "ABCD" + + +def days(frame: pd.DataFrame, hubs: Iterable[str], carrier: str = CARRIER) -> pd.DataFrame: + """Per date of `frame` (BTS on-time rows, as bts.read_ontime reads them), the scope's flights and its + cancellations by code.""" + hubs = set(hubs) + x = frame[frame.Reporting_Airline == carrier] + at_hub = x.Origin.isin(hubs) | x.Dest.isin(hubs) + tails = x[at_hub].groupby("FlightDate").Tail_Number.agg(lambda s: set(s.dropna())) + rows = [] + for day, g in x.groupby("FlightDate"): + scope = g[g.Tail_Number.isin(tails.get(day, set())) | g.Origin.isin(hubs) | g.Dest.isin(hubs)] + codes = scope.loc[scope.Cancelled.astype(float) == 1, "CancellationCode"].value_counts() + rows.append({"date": str(day)[:10], "flights": len(scope), **{c: int(codes.get(c, 0)) for c in CODES}}) + return pd.DataFrame(rows, columns=["date", "flights", *CODES]) + + +def rate(frames: Iterable[pd.DataFrame], hubs: Iterable[str], q: float = QUANTILE) -> dict: + """The bar over the fair days of `frames` (a month's BTS rows each): the q-quantile of the carrier's + cancellations' share of the scope's flights, with the counts it was read from.""" + hubs = tuple(hubs) + d = pd.concat([days(f, hubs) for f in frames], ignore_index=True) + fair = d[(d.B == 0) & (d.C == 0) & (d.flights > 0)] + if fair.empty: + raise ValueError("no fair day in the months given: no calm-day bar") + share = fair.A / fair.flights + return {"rate": round(float(share.quantile(q)), 6), "quantile": q, "fair_days": len(fair), "days": len(d), + "median_cancels": float(fair.A.median()), "ferries": FERRIES, "hubs": list(hubs)} + + +def allowance(rate: float, flights: int) -> int: + """The forced cancellations a calm day of `flights` flights may have: JetBlue's fair-day share of them, at least + one.""" + return max(1, math.floor(rate * flights)) + + +def within(cancelled: int, ferries: int, flights: int, rate: float) -> bool: + return cancelled <= allowance(rate, flights) and ferries <= FERRIES diff --git a/src/agentenv_airops/programs/layers/weather.py b/src/agentenv_airops/programs/layers/weather.py new file mode 100644 index 0000000..87d2cdf --- /dev/null +++ b/src/agentenv_airops/programs/layers/weather.py @@ -0,0 +1,70 @@ +"""The hubs' weather as the desk reads it (docs/v4/DESIGN.md §4): every routine METAR, SPECI and TAF of the task's +hubs over its span, as news items (minute, kind, station, text) for Layers.weather. + +A METAR or SPECI is dated by its observation (IEM ASOS `valid`), a TAF by its AFOS issue time; minutes are from the +task's midnight. Each reaches the desk at that minute plus its kind's lag (Layers.lags: metar 5, speci 5, taf 10 by +default, advisories 0), the time the product takes to be filed and spread. The span starts LEAD_OBS before the first +watch for the observations and LEAD_TAF for the TAFs (the TAF cycle is six hours), so the first watch has the latest of +each, and ends at the last deadline. The SPECI archive holds some reports the routine one has too; a SPECI with the same +minute and text as a routine METAR is dropped. Only the observation's raw text is carried: the desk reads it as a +dispatcher reads the wire. +""" + +import csv +import io +from datetime import UTC, datetime +from pathlib import Path + +from ...build import bts +from ...build import weather as v1 +from ...flow import sources +from ..model import Task + +LEAD_OBS = 90 +LEAD_TAF = 360 +Item = tuple[int, str, str, str] + + +def build(task: Task, inputs: Path) -> tuple[Item, ...]: + """The task's hubs' METARs, SPECIs and TAFs over its span, oldest first (minute, kind, hub, station order); a + month file the cache doesn't hold is skipped.""" + hi = max(f.deadline for f in task.flights) + months = _months(task.base + task.start - LEAD_TAF, task.base + hi) + out: dict[tuple[int, str, str], Item] = {} + for hub in task.storms: + for kind, path in [(k, p) for m in months for k, p in (("metar", v1.metar_path(inputs, hub, m)), + ("speci", sources.speci_path(inputs, hub, m)))]: + for minute, text in _observations(path, hub, task.base): + if task.start - LEAD_OBS <= minute <= hi: + out.setdefault((minute, hub, text), (minute, kind, hub, text)) + for m in months: + path = v1.afos_path(inputs, f"TAF{hub}", m) + if not path.exists(): + continue + for issued, body in v1.products(path.read_text(encoding="latin-1"), m): + minute = bts.epoch_minutes(issued) - task.base + if task.start - LEAD_TAF <= minute <= hi: + out.setdefault((minute, hub, body), (minute, "taf", hub, body)) + return tuple(sorted(out.values(), key=lambda x: (x[0], x[1], x[2], x[3]))) + + +def _observations(path: Path, hub: str, base: int) -> list[tuple[int, str]]: + if not path.exists(): + return [] + out = [] + for r in csv.DictReader(io.StringIO(path.read_text())): + text = (r["metar"] or "").strip() + if r["station"] == hub and text: + observed = datetime.strptime(r["valid"], "%Y-%m-%d %H:%M").replace(tzinfo=UTC) + out.append((bts.epoch_minutes(observed) - base, text)) + return out + + +def _months(lo: int, hi: int) -> list[str]: + """The UTC months epoch minutes lo to hi touch, as YYYY-MM.""" + first, last = (datetime.fromtimestamp(60 * x, UTC) for x in (lo, hi)) + out, y, m = [], first.year, first.month + while (y, m) <= (last.year, last.month): + out.append(f"{y}-{m:02d}") + y, m = (y + 1, 1) if m == 12 else (y, m + 1) + return out diff --git a/src/agentenv_airops/programs/mip.py b/src/agentenv_airops/programs/mip.py index 29cae0a..f731fd3 100644 --- a/src/agentenv_airops/programs/mip.py +++ b/src/agentenv_airops/programs/mip.py @@ -1,17 +1,22 @@ """The hindsight MIP (HiGHS): v1's time-line network (refs.mip) by split family, inside the programs. Each flight gets a copy per departure minute (every `step` minutes, at the EDCT of every slot into its destination, the -slot less its scheduled block, and at the end of its crew's rest) from its earliest to its deadline, the hub cutoff +slot less its scheduled block, at the end of every ramp closure at its origin, at the end of every one at its +destination less its block, and at the end of its crew's rest) from its earliest to its deadline, the hub cutoff included, less the minutes the world forbids: those a ground stop holds it, those at which a segment governs it with its slot time (the minute plus the scheduled block) inside the period but on none of JetBlue's slots, and those after its crew deadline before its crew's rest ends. A copy on a slot counts in that slot's row, at most one flight per slot; -any other copy into a hub counts in the nominal row of its arrival hour (the minute plus the block). A copy costs its -pax x minutes late at arrival and its aircraft's minutes (cost.flown_price: the minute's gate delay, and the world's -block overrun, the same for every copy of a flight); a cancellation costs `price`, the notice price at the solve time, -which keeps the model linear, less the cancellation's saving, and a caller pins a cancellation the sim decides (an -auto-cancel, `lapsed`) at the price the sim gives it (Task.auto_at). With `flat` every cancellation, the desk's or an -auto-cancel, costs the flat price less the saving, as Cost.flat prices it. Pinned flights keep their minute, their -cancellation or their auto-cancel, and use up the slots and nominal room they take. +any other copy into a hub, or into an outstation in an hour it caps (Layers.arr_caps, which join the nominal caps), +counts in the nominal row of its arrival hour (the minute plus the block); and any copy, slotted or not, leaving in an +hour its origin caps (Layers.dep_caps) counts in that hour's departure row, one leaving inside a ramp closure at its +origin (Layers.closures) in that closure's departure row, and one arriving (the minute plus the block) inside a ramp +closure at its destination in that closure's arrival row. A copy costs its pax x minutes late at arrival and its +aircraft's minutes (cost.flown_price: the minute's gate delay, and the world's block overrun, the same for every copy +of a flight); a cancellation costs `price`, the notice price at the solve time, which keeps the model linear, less the +cancellation's saving, and a caller pins a cancellation the sim decides (an auto-cancel, `lapsed`) at the price the +sim gives it (Task.auto_at). With `flat` every cancellation, the desk's or an auto-cancel, costs the flat price less +the saving, as Cost.flat prices it. Pinned flights keep their minute, their cancellation or their auto-cancel, and use +up the slots, nominal, departure and closure room they take. Aircraft flow as in v1, over nodes per (family, station) at every event time; a copy of a leg with a busy block reaches its destination's node at the block's ready time when that is later than its turn. A leg JetBlue turned into @@ -30,6 +35,13 @@ sim.execute reproduces from its departure minutes as holds, at the same cost, and every day sim.execute runs is a solution (`Network.vector`), which also starts the search. +With a finite reserve pool at a crew base (Layers.reserves, docs/v4/DESIGN.md §3.4) each departure from it that is +not pinned and has a copy past its crew deadline (Task.crew) before its crew's rest ends, or that may auto-cancel on +a line, gets a 0/1 reserve column, which bounds those copies (and, on a line, a column auto-cancelling it on the +reserve crew, at its deadline) and joins its base's pool row: room = the pool less the pinned flights the caller says +hold a reserve (`reserved`, sim.granted's of them), whose own auto-cancel is then priced at its deadline. The copies of +a reservable flight are Edgar's base departure's, every minute to its deadline, plus one at its crew's rest end. + Proven to GAP (0.01%); `canonical` then re-solves among the optimal plans for the one with the earliest weighted departures (v1's second pass, started from the first), so the plan, not only its cost, is the same on every run. Within the gap the first plan need not be the cheapest, so the second may cost less: the objective is the cost of the plan @@ -89,6 +101,7 @@ class Solution: started: bool canonical: bool lapsed: dict[int, int] + reserve: frozenset[int] = frozenset() @property def proven(self) -> bool: @@ -96,9 +109,10 @@ def proven(self) -> bool: def plan(self, decided: int) -> dict[int, dict]: """The solution as holds to each departure minute, its cancellations decided at `decided`; a flight it lets - auto-cancel is held past its deadline.""" - return {i: {"not_before": self.lapsed[i]} if i in self.lapsed else {"cancel": True, "decided": decided} - if d is None else {"not_before": d} for i, d in self.deps.items()} + auto-cancel is held past its deadline; one it flies past its crew deadline on a reserve crew calls one.""" + out = {i: {"not_before": self.lapsed[i]} if i in self.lapsed else {"cancel": True, "decided": decided} + if d is None else {"not_before": d} for i, d in self.deps.items()} + return {i: e | {"reserve": True} if i in self.reserve else e for i, e in out.items()} def to_dict(self) -> dict: return {"status": self.status, "objective": self.objective, "bound": round(self.bound, 1), @@ -110,12 +124,14 @@ def solve(task: Task, world: sim.World | None = None, price: Mapping[int, int] | fixed: Mapping[int, int | None] | None = None, earliest: int | None = None, step: int = 1, time_limit: float = 600.0, gap: float = GAP, threads: int = 1, canonical: bool = True, start: sim.Execution | None = None, lapsed: Collection[int] = (), fix: bool = True, - flat: bool = False, pax: Proxy | None = None) -> Solution: + flat: bool = False, pax: Proxy | None = None, + reserved: Collection[int] = ()) -> Solution: """The optimum in `world` (the truth by default) with each cancel arc at `price` (default: notice-priced at the first watch, or flat with `flat`). `fixed` pins flights to a departure minute (None: cancelled, or auto-cancelled for those in `lapsed`); the others leave at or after `earliest`. `start` is a day sim.execute ran in `world` that - the model holds, to start the search from; `pax` prices the passengers by their proxy.""" - net = Network(task, world, price, fixed, earliest, step, lapsed, flat, pax) + the model holds, to start the search from; `pax` prices the passengers by their proxy; `reserved` are the pinned + flights holding a reserve crew.""" + net = Network(task, world, price, fixed, earliest, step, lapsed, flat, pax, reserved) return net.solve(time_limit, gap, threads, canonical, None if start is None else net.vector(start), fix) @@ -141,7 +157,8 @@ class Network: def __init__(self, task: Task, world: sim.World | None = None, price: Mapping[int, int] | None = None, fixed: Mapping[int, int | None] | None = None, earliest: int | None = None, step: int = 1, - lapsed: Collection[int] = (), flat: bool = False, pax: Proxy | None = None): + lapsed: Collection[int] = (), flat: bool = False, pax: Proxy | None = None, + reserved: Collection[int] = ()): self.task, self.pax = task, pax self.world = world = sim.truth(task) if world is None else world if price is None: @@ -160,8 +177,11 @@ def __init__(self, task: Task, world: sim.World | None = None, price: Mapping[in else: lo = max(task.earliest(f, world.lb[f.id]), earliest or 0) jumps = {s - f.sched_block for seg in world.segments.get(f.dest, ()) for s in seg.slots} - if f.crew and f.crew.rest is not None: - jumps.add(f.crew.rest) + jumps |= {b for _, b, _, _ in world.closures.get(f.orig, ())} + jumps |= {b - world.block[f.id] for _, b, _, _ in world.closures.get(f.dest, ())} + crew = task.crew(f) + if crew and crew.rest is not None: + jumps.add(crew.rest) minutes = set(range(lo, f.deadline + 1, step)) | {d for d in jumps if lo <= d <= f.deadline} copies[f.id] = [(d, at) for d in sorted(minutes) if rules.held(f, d) == d and f.legal(d) == d and (at := _place(f, d, rules, pools))] @@ -236,6 +256,10 @@ def arc(u: int | None, v: int | None, c: int) -> None: self.xs: dict[tuple[int, int], int] = {} self.cancel: dict[int, int] = {} self.lapsed: dict[int, int] = {} + self.reserve: dict[int, int] = {} + self.on_reserve: dict[int, int] = {} + self.reserved = frozenset(i for i in reserved if i in fixed and task.reservable(task.flight(i))) + calls: dict[str, dict[int, int]] = defaultdict(dict) rows: dict[tuple, dict[int, int]] = defaultdict(dict) pinned: Counter = Counter() cover = [] @@ -250,21 +274,34 @@ def arc(u: int | None, v: int | None, c: int) -> None: arc(gate.get((f.id, d), node[((f.family, f.orig), d)]), lane.get((f.id, d), node[((f.family, f.dest), task.back(f, arr))]), c) r[c] = 1 - if kind != "free": + for row in ((kind, key),) + rules.leaving(f, d): + if row[0] == "free": + continue if f.id in fixed: - pinned[(kind, key)] += 1 + pinned[row] += 1 else: - rows[(kind, key)][c] = 1 + rows[row][c] = 1 ub = 0 if f.id in fixed and fixed[f.id] is not None else 1 lapses = f.id in on_line and (f.id not in fixed or f.id in lapsed) self.cancel[f.id] = c = m.col(price[f.id], 0 if lapses and f.id in fixed else ub, True, (f.deadline - f.sched_dep + 2) * w) r[c] = 1 if f.id in on_line: - auto = pax.price(f, task.auto_at(f), flat) if pax else flat_price(f) if flat else \ - cancel_price(f, task.auto_at(f)) + at = task.auto_at(f, f.id in self.reserved) + auto = pax.price(f, at, flat) if pax else flat_price(f) if flat else cancel_price(f, at) self.lapsed[f.id] = c = m.col(auto, ub if lapses else 0, True, (f.deadline - f.sched_dep + 3) * w) r[c] = 1 + if task.reservable(f) and f.id not in fixed: + bound = {self.xs[(f.id, d)]: 1 for d, _ in copies[f.id] if task.late(f, d)} + if f.id in on_line: + at = task.auto_at(f, True) + lapse = pax.price(f, at, flat) if pax else flat_price(f) if flat else cancel_price(f, at) + self.on_reserve[f.id] = c = m.col(lapse, 1, True, (f.deadline - f.sched_dep + 4) * w) + r[c] = bound[c] = 1 + if bound: + self.reserve[f.id] = c = m.col(0, 1, True) + m.row({**bound, c: -1}, -INF, 0) + calls[f.orig][c] = 1 cover.append(r) self.landed: dict[tuple[int, int], int] = dict(self.xs) self.turn: dict[tuple[int, int], int] = {} @@ -376,9 +413,15 @@ def arc(u: int | None, v: int | None, c: int) -> None: m.row(flow[v], -supply[v], -supply[v]) for r in cover: m.row(r, 1, 1) + shut = {(kind, (s, a)): cap for s, spans in world.closures.items() for a, _, dep, arr in spans + for kind, cap in (("closure_dep", dep), ("closure_arr", arr))} for (kind, key), r in rows.items(): - room = 1 if kind == "slot" else world.nominal[key[0]][key[1]] + room = (1 if kind == "slot" else world.nominal[key[0]][key[1]] if kind == "hour" + else world.dep_caps[key[0]][key[1]] if kind == "dep" else shut[(kind, key)]) m.row(r, -INF, max(0, room - pinned[(kind, key)])) + held = Counter(task.flight(i).orig for i in self.reserved) + for base, r in calls.items(): + m.row(r, -INF, max(0, task.layers.reserves[base] - held[base])) self.copies = copies self.at: dict[int, int] = {} self.links: dict[tuple[int, int], int] = {} @@ -469,9 +512,13 @@ def solve(self, time_limit: float = 600.0, gap: float = GAP, threads: int = 1, c for f in self.task.flights: flown = [d for d, _ in self.copies[f.id] if x[self.xs[(f.id, d)]] > 0.5] deps[f.id] = flown[0] if flown else None - lapsed = {i: self.task.flight(i).deadline + 1 for i, c in self.lapsed.items() if x[c] > 0.5} + lapsed = {i: self.task.flight(i).deadline + 1 for i, c in [*self.lapsed.items(), *self.on_reserve.items()] + if x[c] > 0.5} + reserve = {i for i, c in self.on_reserve.items() if x[c] > 0.5} | { + i for i in self.reserve if deps[i] is not None and self.task.late(self.task.flight(i), deps[i])} return Solution(status, round(objective), bound, round(100 * mip_gap, 4), round(wall, 2), deps, len(m.cost), - len(m.rows), sum(len(v) for v in self.copies.values()), start is not None, settled, lapsed) + len(m.rows), sum(len(v) for v in self.copies.values()), start is not None, settled, lapsed, + frozenset(reserve) | self.reserved) def rounded(self, time_limit: float = 600.0, threads: int = 1) -> dict[int, dict] | None: """The LP relaxation's optimum rounded to a plan the sim can play, a start for the search: a flight more @@ -508,8 +555,11 @@ def vector(self, run: sim.Execution) -> np.ndarray | None: via, fast = {}, set() for f in task.flights: r = run.flights[f.id] + if f.id in self.reserve: + x[self.reserve[f.id]] = f.id in run.reserved if r.status != FLOWN: - x[self.lapsed[f.id] if r.status == AUTO_CANCELLED and f.id in self.lapsed else self.cancel[f.id]] = 1 + lapse = self.on_reserve if f.id in run.reserved and f.id in self.on_reserve else self.lapsed + x[lapse[f.id] if r.status == AUTO_CANCELLED and f.id in lapse else self.cancel[f.id]] = 1 continue k = (f.id, r.dep) if k not in self.xs: diff --git a/src/agentenv_airops/programs/model.py b/src/agentenv_airops/programs/model.py index aeb6324..193128b 100644 --- a/src/agentenv_airops/programs/model.py +++ b/src/agentenv_airops/programs/model.py @@ -15,20 +15,34 @@ pieces, the VMC called arrival rate, and the demand: every BTS-scheduled arrival at the hub over the task, so the forecast RBS can run at any watch and the other carriers' controlled arrivals carry their BTS NAS delay for the fidelity clause. + +A task may carry v4's realism `layers` (Layers, programs.layers builds them from pinned public inputs); a task that +sets any is written as model version LAYERED, one without (None, or Layers() with nothing set) is v2's task byte +for byte. `version` is the v2 model's: LAYERED is read back as VERSION and written from the layers alone. + +With a finite reserve pool at a crew base (`Layers.reserves`, docs/v4/DESIGN.md §3.4) a departure from that base gets +its pairing's crew deadline as an outstation departure has it (the report + the FAR 117 limit + EXTENSION, less its +scheduled block, its rest REST after it: `Task.crew`), and may fly past it only on a reserve crew the plan calls +(`reserve`, granted while the base's pool lasts: sim.granted). A base the pool leaves out keeps Edgar's unlimited +reserves. """ import json import math from collections import Counter from collections.abc import Callable, Mapping -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from functools import cached_property from pathlib import Path from ..core.model import Airport -from .advisories import Advisory, Scope, Stop, nm +from .advisories import HUBS, Advisory, Scope, Stop, nm VERSION = 3 +LAYERED = 4 +EXTENSION = 120 +REST = 600 +LAGS = {"advisory": 0, "metar": 5, "speci": 5, "taf": 10} @dataclass(frozen=True) @@ -324,10 +338,75 @@ def e_bar(self) -> float: return self.e_median +@dataclass(frozen=True) +class Layers: + """The world's realism layers beyond the FAA's programs, hours and minutes from the task's midnight: + JetBlue's departure cap per hour at a hub (`dep_caps`) and arrival cap per hour at an outstation (`arr_caps`), + ramp closures per station as (start, end, dep_cap, arr_cap): JetBlue's departures from the station leaving in + [start, end) total at most dep_cap, and its arrivals there inside it at most arr_cap; and the reserve crews per + crew base. A hub's arrivals answer to its programs and nominal cap, so an arrival cap at a hub is a ValueError. + + The desk's news beyond the advisories (docs/v4/DESIGN.md §4): the hubs' weather as (minute, kind, station, text), + kind "metar", "speci" or "taf", the minute its observation or issue (layers.weather), and the delivery lag per + product kind in minutes (`lags` over LAGS: advisory 0, metar 5, speci 5, taf 10, a kind it leaves out keeping + LAGS's; one LAGS doesn't have, a negative lag or an advisory lag other than 0 is a ValueError), each item + reaching the desk at its minute plus its kind's lag; and the closure rate per hub that priced its closures + (`rates`, closures.fit_rate's), with which the day as known prices a closure under way over its expected end, not + its true one (sim.known). All three are written only when set, so a task without them reads and writes as + before.""" + + dep_caps: dict[str, dict[int, int]] = field(default_factory=dict) + arr_caps: dict[str, dict[int, int]] = field(default_factory=dict) + closures: dict[str, tuple[tuple[int, int, int, int], ...]] = field(default_factory=dict) + reserves: dict[str, int] = field(default_factory=dict) + weather: tuple[tuple[int, str, str, str], ...] = () + lags: dict[str, int] = field(default_factory=lambda: dict(LAGS)) + rates: dict[str, float] = field(default_factory=dict) + + def __post_init__(self): + unknown = sorted(set(self.lags) - set(LAGS)) + if unknown: + raise ValueError(f"lags for unknown kind(s) {', '.join(unknown)}: the kinds are {', '.join(LAGS)}") + bad = sorted(k for k, v in self.lags.items() if not isinstance(v, int) or isinstance(v, bool) or v < 0) + if bad: + raise ValueError(f"lags for {', '.join(bad)} not a whole number of minutes >= 0: news can't reach the desk " + "before its minute") + if self.lags.get("advisory", 0): + raise ValueError("an advisory lag must be 0: the day as known reads the programs as signed by the watch " + "(advisories.issued_by), so a later delivery would act on news the desk hasn't had") + object.__setattr__(self, "lags", LAGS | dict(self.lags)) + hubs = sorted(set(self.arr_caps) & set(HUBS)) + if hubs: + raise ValueError(f"arrival caps at hub(s) {', '.join(hubs)}: a hub's arrivals answer to its programs " + "and nominal cap") + + @classmethod + def from_dict(cls, d: Mapping) -> "Layers": + return cls({s: {int(h): c for h, c in by.items()} for s, by in d["dep_caps"].items()}, + {s: {int(h): c for h, c in by.items()} for s, by in d["arr_caps"].items()}, + {s: tuple((a, b, c, e) for a, b, c, e in spans) for s, spans in d["closures"].items()}, + dict(d["reserves"]), tuple((m, k, s, x) for m, k, s, x in d.get("weather", ())), + dict(d.get("lags", {})), dict(d.get("rates", {}))) + + def to_dict(self) -> dict: + out = {"dep_caps": {s: {str(h): c for h, c in sorted(by.items())} for s, by in self.dep_caps.items()}, + "arr_caps": {s: {str(h): c for h, c in sorted(by.items())} for s, by in self.arr_caps.items()}, + "closures": {s: [list(x) for x in spans] for s, spans in self.closures.items()}, + "reserves": dict(self.reserves)} + if self.weather: + out["weather"] = [list(x) for x in self.weather] + if self.lags != LAGS: + out["lags"] = dict(self.lags) + if self.rates: + out["rates"] = dict(self.rates) + return out + + @dataclass(frozen=True) class Task: """`hubs` are a whole-network task's storm hubs (`hub` the one whose window starts it), `needs` tomorrow's - aircraft per family and station; a hub window has neither, its needs being its tails' `end_req`.""" + aircraft per family and station; a hub window has neither, its needs being its tails' `end_req`. `layers` None + (or Layers() with nothing set) is v2's world.""" task_id: str split: str @@ -353,6 +432,7 @@ class Task: bases: tuple[str, ...] = () hubs: tuple[str, ...] = () needs: dict[str, dict[str, int]] = field(default_factory=dict) + layers: Layers | None = None @classmethod def from_dict(cls, d: Mapping) -> "Task": @@ -361,19 +441,24 @@ def from_dict(cls, d: Mapping) -> "Task": {c: Airport.from_dict(c, a) for c, a in d["airports"].items()}, dict(d["artcc"]), tuple(Tail.from_dict(t) for t in d["tails"]), tuple(Flight.from_dict(f) for f in d["flights"]), {h: Programs.from_dict(p) for h, p in d["programs"].items()}, dict(d["clauses"]), - dict(d["sources"]), d["version"], tuple(d.get("bases", ())), tuple(d.get("hubs", ())), - {fam: dict(by) for fam, by in d.get("needs", {}).items()}) + dict(d["sources"]), VERSION if d["version"] == LAYERED else d["version"], + tuple(d.get("bases", ())), tuple(d.get("hubs", ())), + {fam: dict(by) for fam, by in d.get("needs", {}).items()}, + Layers.from_dict(d["layers"]) if "layers" in d else None) def to_dict(self) -> dict: - return {"version": self.version, "task_id": self.task_id, "split": self.split, "carrier": self.carrier, - "hub": self.hub, "date": self.date, "tz": self.tz, "base": self.base, "window": list(self.window), - "start": self.start, "slide": self.slide, "dmax": self.dmax, "cutoff": self.cutoff, - "turn": dict(self.turn), "airports": {c: a.to_dict() for c, a in self.airports.items()}, - "artcc": dict(self.artcc), "tails": [t.to_dict() for t in self.tails], - "flights": [f.to_dict() for f in self.flights], - "programs": {h: p.to_dict() for h, p in self.programs.items()}, "clauses": self.clauses, - "sources": self.sources, "bases": list(self.bases), "hubs": list(self.hubs), - "needs": {fam: dict(by) for fam, by in self.needs.items()}} + layered = self.layered + out = {"version": LAYERED if layered else self.version, "task_id": self.task_id, + "split": self.split, "carrier": self.carrier, + "hub": self.hub, "date": self.date, "tz": self.tz, "base": self.base, "window": list(self.window), + "start": self.start, "slide": self.slide, "dmax": self.dmax, "cutoff": self.cutoff, + "turn": dict(self.turn), "airports": {c: a.to_dict() for c, a in self.airports.items()}, + "artcc": dict(self.artcc), "tails": [t.to_dict() for t in self.tails], + "flights": [f.to_dict() for f in self.flights], + "programs": {h: p.to_dict() for h, p in self.programs.items()}, "clauses": self.clauses, + "sources": self.sources, "bases": list(self.bases), "hubs": list(self.hubs), + "needs": {fam: dict(by) for fam, by in self.needs.items()}} + return out | {"layers": self.layers.to_dict()} if layered else out def to_json(self) -> str: return json.dumps(self.to_dict(), separators=(",", ":")) @@ -416,14 +501,44 @@ def earliest(self, f: Flight, lb: int) -> int: """Nothing in scope leaves before the first watch; an early departure (lb < 0) leaves before its schedule.""" return max(f.sched_dep + lb, self.start) - def auto_at(self, f: Flight) -> int: + def auto_at(self, f: Flight, reserve: bool = False) -> int: """When f auto-cancels if it can't leave: at its crew deadline, never before the first watch, if its crew's - rest would end past its deadline, else at its deadline.""" - c = f.crew + rest would end past its deadline, else at its deadline; on a `reserve` crew (one granted) at its deadline.""" + c = f.crew if reserve else self.crew(f) if c and c.deadline is not None and c.deadline < f.deadline < c.rest: return max(c.deadline, self.start) return f.deadline + def reservable(self, f: Flight) -> bool: + """Whether f is a departure from a crew base with a finite reserve pool, past whose crew deadline it flies only + on a reserve crew.""" + return (self.layers is not None and f.crew is not None and f.crew.deadline is None + and f.orig in self.layers.reserves) + + def crew(self, f: Flight) -> Crew | None: + """f's crew as the world holds it without a reserve: a departure from a base with a finite pool gets its + pairing's crew deadline and rest as an outstation departure has them (repairs.crews).""" + return self._crews.get(f.id, f.crew) + + @cached_property + def _crews(self) -> dict[int, Crew]: + out = {} + for f in self.flights: + if self.reservable(f): + d = f.crew.report + f.crew.fdp + EXTENSION - f.sched_block + out[f.id] = replace(f.crew, deadline=d, rest=d + REST) + return out + + def legal(self, f: Flight, t: int, reserve: bool = False) -> int: + """Flight.legal with the crew the world holds without a reserve (Task.crew); on a `reserve` crew, f's own.""" + c = f.crew if reserve else self.crew(f) + return c.rest if c and c.deadline is not None and c.deadline < t < c.rest else t + + def late(self, f: Flight, t: int) -> bool: + """Whether f, leaving at t, flies on a reserve crew: a reservable flight past its crew deadline before its rest + ends.""" + return self.reservable(f) and self.legal(f, t) != t + @property def lb(self) -> dict[int, int]: return {f.id: f.lb for f in self.flights} @@ -435,6 +550,15 @@ def needed(self) -> Counter: return Counter({(fam, s): n for fam, by in self.needs.items() for s, n in by.items()}) return Counter((t.family, t.end_req) for t in self.tails) + @property + def layered(self) -> bool: + """Whether the task sets any of v4's layers (written as LAYERED).""" + return self.layers is not None and self.layers != Layers() + + def lag(self, kind: str) -> int: + """The minutes a news item of `kind` takes to reach the desk (Layers.lags; LAGS without layers).""" + return (self.layers.lags if self.layers else LAGS)[kind] + @property def storms(self) -> tuple[str, ...]: """The hubs whose programs the task is about: its storm hubs, or its hub.""" diff --git a/src/agentenv_airops/programs/network.py b/src/agentenv_airops/programs/network.py index f6f761c..b3c94e1 100644 --- a/src/agentenv_airops/programs/network.py +++ b/src/agentenv_airops/programs/network.py @@ -30,6 +30,15 @@ The calm day starts from the positions the schedule assumes (`scheduled`), the storm day from the record's; the record's morning against the schedule's is a finding (`shortfall`). + +With crew `bases` the build turns the crew model on, as candidates.build_candidate does (every leg's crew, Task.bases), +its pairings cut by the build's crew Rule: Edgar's proxy (repairs.crews) by default, or v4's, legal on schedule +(layers.crew). With a v4 layer config (layers.config.Config, the constants fitted on train dates) the task carries +v4's layers (docs/v4/DESIGN.md §2): the departure caps at the hubs and the arrival caps at the outstations from every +carrier's BTS flights of the date (candidates.frame), the hubs' ramp closures from their METARs and SPECIs at the +config's fitted rates, and the config's reserve pool at each of the task's crew bases, with the desk's view of them: +the closure rates, the storm hubs' METARs, SPECIs and TAFs as news (layers.weather) and the default lags (`layered`). +Without either the task is v2's, byte for byte. """ import functools @@ -50,6 +59,8 @@ from . import candidates, rbs, repairs from .advisories import HUBS from .candidates import CARRIER, EVAL_PER_HUB, EVAL_WINTER, EVAL_WINTER_SHORT, MIN_CONTROLLED, MONTHS, Built, season +from .layers import config, weather +from .layers.crew import EDGAR, Rule from .model import Busy, Flight, Move, Tail, Task Windows = dict[str, tuple[int, int]] @@ -77,9 +88,11 @@ def control_dates(inputs: Path, derived: Path, months: Sequence[str] = MONTHS) - def build(d: str, hubs: Sequence[str], inputs: Path, derived: Path, e_fit: Mapping[str, float], e_median: Mapping[str, float], scale: Mapping[str, float], cutoff: int = repairs.CUTOFF, - windows: Windows | None = None, split: str = "") -> Built: + windows: Windows | None = None, split: str = "", bases: Sequence[str] = (), + layers: config.Config | None = None, crew: Rule = EDGAR) -> Built: """The whole-network task of date d with storm `hubs`, from their rule windows or the given `windows` (a - control's), or None and the reason.""" + control's), or None and the reason; crew `bases` turn the crew model on, its pairings cut by `crew` (Edgar's or + v4's, layers.crew), a `layers` config v4's layers.""" row = {"date": d, "hubs": list(hubs), "windows": {}, "slides": {}, "first_hours": {}, "fallback": False, "task_id": None, "reason": ""} if windows: @@ -98,19 +111,31 @@ def build(d: str, hubs: Sequence[str], inputs: Path, derived: Path, e_fit: Mappi row["windows"] = {h: [60 * p0, 60 * (p0 + candidates.LENGTHS[-1])] for h, p0 in first.items()} hub = min(row["windows"], key=lambda h: (row["windows"][h][0], HUBS.index(h))) task = assemble(candidates.context(inputs, derived, d, hub), d, hub, tuple(hubs), row, e_fit, e_median, scale, - cutoff, split) + cutoff, split, bases, crew) held = task.clauses["controlled_or_held"] if not windows and held < MIN_CONTROLLED: return Built(None, {**row, "controlled_or_held": held, "reason": f"fewer than {MIN_CONTROLLED} JetBlue flights controlled or held"}) + if layers is not None: + task = layered(task, layers, inputs, derived) return Built(task, {**row, "task_id": task.task_id}) +def layered(task: Task, cfg: config.Config, inputs: Path, derived: Path) -> Task: + """The task with v4's layers at `cfg`, from the date's BTS flights of every carrier, the hubs' closures over the + months they span and the storm hubs' METARs, SPECIs and TAFs over the task's span as news.""" + spans = config.spans(inputs, tuple(candidates.months_of(task.date))) + return replace(task, layers=config.build(task, cfg, candidates.frame(inputs, derived, task.date), spans, + weather.build(task, inputs))) + + def assemble(ctx: candidates.Context, d: str, hub: str, hubs: tuple[str, ...], row: Mapping, e_fit: Mapping[str, float], - e_median: Mapping[str, float], scale: Mapping[str, float], cutoff: int, split: str) -> Task: + e_median: Mapping[str, float], scale: Mapping[str, float], cutoff: int, split: str, + bases: Sequence[str] = (), crew: Rule = EDGAR) -> Task: day = ctx.day sc = scope(day, (min(w[0] for w in row["windows"].values()), max(w[1] for w in row["windows"].values())), START) - flights = repairs.flights(day, sc, ctx.x, ctx.families, candidates.tests(ctx), cutoff=cutoff) + flights = repairs.flights(day, sc, ctx.x, ctx.families, candidates.tests(ctx), cutoff=cutoff, bases=bases, + cut=crew.crews) tails, counts = network_tails(day, sc, flights, ctx.families, day.turn) need, short = needs(tails, flights) programs = candidates.hub_programs(ctx, flights, sc.start, e_fit, e_median, scale) @@ -129,7 +154,7 @@ def assemble(ctx: candidates.Context, d: str, hub: str, hubs: tuple[str, ...], r airports=candidates.airports_of(codes), artcc={c: ctx.artcc[c] for c in sorted(codes) if c in ctx.artcc}, tails=tuple(tails), flights=tuple(flights), programs=programs, clauses=clauses, sources=candidates.pins(ctx, sc), - hubs=hubs, needs=need) + bases=tuple(bases), hubs=hubs, needs=need) return rbs.rescale(task, scale, e_fit, e_median) @@ -356,9 +381,11 @@ def shortfall(task: Task) -> dict: def build_all(jobs: Sequence[tuple[str, tuple[str, ...], Windows | None, str]], inputs: Path, derived: Path, e_fit: Mapping[str, float], e_median: Mapping[str, float], scale: Mapping[str, float], - cutoff: int = repairs.CUTOFF, workers: int = 1) -> list[Built]: + cutoff: int = repairs.CUTOFF, workers: int = 1, bases: Sequence[str] = (), + layers: config.Config | None = None, crew: Rule = EDGAR) -> list[Built]: """`build` for every (date, storm hubs, windows, split), in a pool of `workers` processes, in date order.""" - args = [(d, hubs, inputs, derived, dict(e_fit), dict(e_median), dict(scale), cutoff, windows, split) + args = [(d, hubs, inputs, derived, dict(e_fit), dict(e_median), dict(scale), cutoff, windows, split, tuple(bases), + layers, crew) for d, hubs, windows, split in sorted(jobs, key=lambda j: j[0])] with ProcessPoolExecutor(workers) if workers > 1 else nullcontext() as pool: if not pool: @@ -367,8 +394,8 @@ def build_all(jobs: Sequence[tuple[str, tuple[str, ...], Windows | None, str]], def build_one(args: tuple) -> Built: - d, hubs, inputs, derived, e_fit, e_median, scale, cutoff, windows, split = args - return build(d, hubs, inputs, derived, e_fit, e_median, scale, cutoff, windows, split) + d, hubs, inputs, derived, e_fit, e_median, scale, cutoff, windows, split, bases, layers, crew = args + return build(d, hubs, inputs, derived, e_fit, e_median, scale, cutoff, windows, split, bases, layers, crew) def split(hubs: Mapping[str, Sequence[str]], event: Mapping[str, int], seed: int) -> dict[str, str]: diff --git a/src/agentenv_airops/programs/probe.py b/src/agentenv_airops/programs/probe.py new file mode 100644 index 0000000..8d21417 --- /dev/null +++ b/src/agentenv_airops/programs/probe.py @@ -0,0 +1,125 @@ +"""Which of JetBlue's cancellations could have flown on the day as it happened (the v2 probe, ~/handoff/out/v2_probe.py, +and docs/v4/DESIGN.md §0's could-fly-for-free share). + +Each JetBlue cancellation is put back on the airline replay (references.airline_plan, played on the truth by +sim.execute): its plan entry becomes {}, so it flies as early as it can and takes whatever slot the sim gives it, and +the day is flown again. A restore that flies every restored leg is `clean` when no flight the replay flew is knocked out +and none moves more than SHIFT_OK minutes and the end-of-day ferries cost no more, `strands` when only the ferries cost +more, `displaces` otherwise; one that leaves a restored leg unflown is `forced`. A cancellation that is forced or +strands alone is tried again with its out-and-back partner (`partner`: the cancelled leg back the other way, same +family, within PAIR_WINDOW of it), and takes the pair's verdict as `pair_` unless the pair is forced too. + +A cancellation could have flown for free when its verdict is clean or pair_clean. A pair is one restore however many of +its legs reach it: `Probe.restores` keys each restore by the flights it put back, so the share over JetBlue's +cancellations counts a restored pair once (`Probe.feasible`), where the v2 probe's rows count it once per leg +(`Probe.feasible_rows`). +""" + +from collections import Counter +from collections.abc import Mapping, Sequence +from dataclasses import dataclass + +from ..core.sim import FLOWN +from . import references, sim +from .model import Flight, Task + +SHIFT_OK = 15 +PAIR_WINDOW = 360 +VERDICTS = ("clean", "strands", "displaces", "forced", "pair_clean", "pair_strands", "pair_displaces") +FEASIBLE = ("clean", "pair_clean") + + +@dataclass(frozen=True) +class Restore: + """What putting `restored` back on the replay did: whether each restored leg flew, the replay's flights it knocked + out, the most another flown flight moved, and the change in the day's cost and its ferries' cost.""" + + restored: tuple[int, ...] + flew: bool + knocked: tuple[int, ...] + max_shift: int + d_cost: int + d_ferry: int + + @property + def verdict(self) -> str: + if not self.flew: + return "forced" + if self.knocked or self.max_shift > SHIFT_OK: + return "displaces" + return "clean" if self.d_ferry <= 0 else "strands" + + +@dataclass(frozen=True) +class Probe: + """Each JetBlue cancellation's verdict (`rows`, flight id -> verdict) and the restore it took (`restores`, the + flights put back -> verdict, a pair once).""" + + rows: dict[int, str] + restores: dict[tuple[int, ...], str] + + @property + def cancelled(self) -> int: + return len(self.rows) + + @property + def feasible(self) -> int: + """The restores that fly for free, a pair once.""" + return sum(v in FEASIBLE for v in self.restores.values()) + + @property + def feasible_rows(self) -> int: + """The cancellations whose verdict flies for free, as the v2 probe counted them (a pair once per leg).""" + return sum(v in FEASIBLE for v in self.rows.values()) + + def to_dict(self) -> dict: + return {"cancelled": self.cancelled, "feasible": self.feasible, "feasible_rows": self.feasible_rows, + "restores": len(self.restores), "pairs": sum(len(k) > 1 for k in self.restores), + "verdicts": counted(self.rows.values()), "restore_verdicts": counted(self.restores.values())} + + +def probe(task: Task) -> Probe: + """Every JetBlue cancellation of `task` restored on the airline replay, alone and, when that is forced or strands, + with its partner.""" + plan = references.airline_plan(task) + base = sim.execute(task, plan) + cancelled = [f for f in task.flights if f.actual.dep is None] + rows, restores = {}, {} + for f in cancelled: + one = restore(task, plan, base, [f.id]) + use = one + g = partner(f, cancelled) if one.verdict in ("forced", "strands") else None + if g is not None: + two = restore(task, plan, base, [f.id, g.id]) + if two.verdict != "forced": + use = two + v = use.verdict if use is one else f"pair_{use.verdict}" + rows[f.id] = v + restores.setdefault(tuple(sorted(use.restored)), v) + return Probe(rows, restores) + + +def restore(task: Task, plan: Mapping[int, Mapping], base: sim.Execution, ids: Sequence[int]) -> Restore: + """The replay `plan` (run as `base`) with the flights `ids` put back ({}) and the day flown again.""" + run = sim.execute(task, {**plan, **{i: {} for i in ids}}) + others = [i for i in run.flights if i not in ids] + knocked = tuple(i for i in others if base.flights[i].status == FLOWN and run.flights[i].status != FLOWN) + shift = max((run.flights[i].dep - base.flights[i].dep for i in others + if base.flights[i].status == FLOWN and run.flights[i].status == FLOWN), default=0) + return Restore(tuple(ids), all(run.flights[i].status == FLOWN for i in ids), knocked, shift, + run.cost.total - base.cost.total, run.cost.ferry - base.cost.ferry) + + +def partner(f: Flight, cancelled: Sequence[Flight]) -> Flight | None: + """v1's rule: the cancelled leg back the other way, same family, within PAIR_WINDOW of this one's arrival or + departure; the return after it first, else the leg before it.""" + back = [g for g in cancelled if g.orig == f.dest and g.dest == f.orig and g.family == f.family] + after = [g for g in back if f.sched_arr <= g.sched_dep <= f.sched_arr + PAIR_WINDOW] + before = [g for g in back if g.sched_arr <= f.sched_dep <= g.sched_arr + PAIR_WINDOW] + pool = sorted(after, key=lambda g: g.sched_dep) + sorted(before, key=lambda g: -g.sched_arr) + return pool[0] if pool else None + + +def counted(verdicts) -> dict[str, int]: + c = Counter(verdicts) + return {v: c[v] for v in VERDICTS if c[v]} diff --git a/src/agentenv_airops/programs/references.py b/src/agentenv_airops/programs/references.py index f2386a4..02fa630 100644 --- a/src/agentenv_airops/programs/references.py +++ b/src/agentenv_airops/programs/references.py @@ -24,24 +24,33 @@ arrival order as slot claims (each claims the slot of its recorded departure plus its scheduled block); auto-cancels are counted over the flights it flew by their deadline, which covers dmax and the hub cutoff, less those whose aircraft a leg it flew past its deadline brought (`counted`); a flight it flew past its crew deadline still counts, - which is what tells a crew model too strict for JetBlue's day; + which is what tells a crew model too strict for JetBlue's day. With a finite reserve pool at a base (§3.4 of + docs/v4/DESIGN.md) it calls a reserve for each departure from that base its replay with unlimited reserves (the v2 + replay) flies past its crew deadline (Task.crew), JetBlue's own departure or a later one its aircraft or the + programs make, granted while the pool lasts (sim.granted), and one the pool can't crew auto-cancels; - recorded day: JetBlue's real outcome priced directly, its aircraft's minutes on its recorded departures and blocks, cancellations decided at sched - NOTICE, plus end-of-day ferries from its recorded end positions to tomorrow's needs. A diverted flight that never reached its destination counts as cancelled when it left, the moment the desk knew; - calm day: from the positions the schedule assumes, every aircraft ready at the start and tomorrow's needs following them (network.scheduled), no delay, scheduled blocks, no programs: the hindsight optimum's forced cancellations and ferries; with `record`, from the record's positions and the task's needs, as the storm day starts. +The watches are hourly (`watch_times`), and on a layered task they also wake on its events: an advisory or SPECI +reaching the desk, a ramp closure starting (docs/v4/DESIGN.md §4); the follower, the rule and the playable day share +them. Like for like, nothing is decided before the first watch: a JetBlue cancellation due earlier than the start counts as decided there, as the anchor's and every desk's do. At a watch the day so far is history, as the env shows it: a flight that has left on the truth leaves when it did in the follower's and the rule's projection and is pinned there in -the follower's MIP, and every other flight leaves no earlier than the watch. No reference may cost less than the +the follower's MIP, and every other flight leaves no earlier than the watch. With a reserve pool the follower's plan +calls the reserves its last solve flies past a crew deadline, a pinned flight keeps the reserve the plan gave it, and +the pinned flights the pool granted one hold it in every later solve. No reference may cost less than the anchor (`below_anchor`). """ +import hashlib from collections.abc import Mapping -from dataclasses import dataclass +from dataclasses import dataclass, replace -from ..core.known import FREEZE, LOOK, STEP +from ..core.known import CAP_LOOK, FREEZE, LOOK, STEP from ..core.sim import AUTO_CANCELLED, CANCELLED, FLOWN from . import mip, network, sim from .advisories import HUBS @@ -49,6 +58,7 @@ from .model import Flight, Task TIME_LIMIT = 2400.0 +WATCH_MIN = 15 @dataclass(frozen=True) @@ -108,17 +118,20 @@ def follow(task: Task, programs: bool = True, time_limit: float = TIME_LIMIT, ca pinned |= frozen(run, t) fixed = {i: run.flights[i].dep if run.flights[i].status == FLOWN else None for i in pinned} lapsed = {i for i in pinned if run.flights[i].status == AUTO_CANCELLED} - price = {f.id: _price(f, plan.get(f.id, {}), task.auto_at(f) if f.id in pinned else t) for f in task.flights} + reserved = run.reserved & pinned + price = {f.id: _price(f, plan.get(f.id, {}), task.auto_at(f, f.id in reserved) if f.id in pinned else t) + for f in task.flights} sol = mip.solve(task, world, price, fixed, t + FREEZE if t > task.start else None, time_limit=time_limit, - canonical=canonical, start=run, lapsed=lapsed) + canonical=canonical, start=run, lapsed=lapsed, reserved=reserved) if not sol.proven: raise RuntimeError(f"{task.task_id}: watch {t} solve {sol.status} at gap {sol.gap_pct}%") cancelled = sorted(i for i, d in sol.deps.items() if i not in pinned and d is None and i not in sol.lapsed) plan = {i: v for i, v in plan.items() if i in pinned} | {i: {"cancel": True, "decided": t} for i in cancelled} pinned |= set(cancelled) deps = {i: sol.lapsed.get(i, d) for i, d in sol.deps.items() if i not in pinned} + plan |= {i: {"reserve": True} for i in sorted(sol.reserve - pinned)} held = deliberate(task, plan, deps, world, t) - plan |= {i: {"not_before": d} for i, d in held.items()} + plan |= {i: {**plan.get(i, {}), "not_before": d} for i, d in held.items()} log.append({"t": t, "frozen": len(pinned), "cancelled": cancelled, "held": len(held), "planned": sol.objective, "status": sol.status, "gap_pct": sol.gap_pct, "canonical": sol.canonical, "wall_s": sol.wall_s, "cols": sol.cols, "rows": sol.rows}) @@ -154,8 +167,13 @@ def naive(task: Task) -> sim.Execution: def airline_plan(task: Task) -> dict[int, dict]: - return {f.id: {"cancel": True, "decided": max(f.sched_dep - NOTICE, task.start)} if f.actual.dep is None + plan = {f.id: {"cancel": True, "decided": max(f.sched_dep - NOTICE, task.start)} if f.actual.dep is None else {"not_before": f.actual.dep, "claim": f.actual.dep + f.sched_block} for f in task.flights} + if not any(task.reservable(f) for f in task.flights): + return plan + free = sim.execute(replace(task, layers=replace(task.layers, reserves={})), plan).flights + return {i: e | {"reserve": True} if free[i].dep is not None and task.late(task.flight(i), free[i].dep) else e + for i, e in plan.items()} def airline(task: Task) -> Replay: @@ -197,14 +215,42 @@ def calm(task: Task, time_limit: float = TIME_LIMIT, record: bool = False) -> Ca def watch_times(task: Task) -> list[int]: - """Every STEP minutes from the start, through the first watch that knows every delay, while some flight could - still leave unfrozen (t + FREEZE <= its deadline).""" - reveal = max(f.sched_dep - LOOK for f in task.flights) + """Every STEP minutes from the start, through the first watch that knows every delay (on a layered task every + departure and outstation arrival cap too, known CAP_LOOK ahead as v1's slot caps were), while some flight could + still leave unfrozen (t + FREEZE <= its deadline). A layered task's watches also wake on events (docs/v4/DESIGN.md + §4, `events`) up to that last minute: walking the hourly watches and the events in order, one closer than + WATCH_MIN to the watch before moves to WATCH_MIN after it (to the last minute if that is sooner), taking in any + others up to there, and a gap longer than STEP gets a watch every STEP, so consecutive watches are WATCH_MIN to + STEP apart but for a last one moved onto the last minute, and every event by the last minute reaches the desk.""" + hours = [h for caps in ((task.layers.dep_caps, task.layers.arr_caps) if task.layered else ()) + for by in caps.values() for h in by] + reveal = max([f.sched_dep - LOOK for f in task.flights] + [60 * h - CAP_LOOK for h in hours]) last = max(f.deadline for f in task.flights) - FREEZE ts = [task.start] while ts[-1] < reveal and ts[-1] + STEP <= last: ts.append(ts[-1] + STEP) - return ts + if not task.layered: + return ts + kept = [task.start] + for c in sorted(set(ts) | {e for e in events(task) if task.start < e <= last}): + if c <= kept[-1]: + continue + c = min(max(c, kept[-1] + WATCH_MIN), last) + while c - kept[-1] > STEP: + kept.append(kept[-1] + STEP) + kept.append(c) + return kept + + +def events(task: Task) -> list[int]: + """The minutes a desk learns something it should act on: each advisory and each SPECI as it reaches the desk + (its signature or observation plus its kind's lag, Task.lag) and each ramp closure's start and end (until its end + the day as known expects it GUESS after each watch, sim.known).""" + advs = [a.issued + task.lag("advisory") for p in task.programs.values() for a in p.advisories] + layers = task.layers + specis = [m + task.lag("speci") for m, kind, _, _ in layers.weather if kind == "speci"] + bounds = [m for spans in layers.closures.values() for a, b, _, _ in spans for m in (a, b)] + return sorted(set(advs + specis + bounds)) def frozen(run: sim.Execution, t: int) -> set[int]: @@ -223,7 +269,7 @@ def so_far(task: Task, plan: Mapping[int, Mapping], t: int, run: sim.Execution | for f in task.flights: entry, dep = plan.get(f.id, {}), run.flights[f.id].dep if dep is not None and dep < t: - out[f.id] = {"left": dep} + out[f.id] = {"left": dep} | ({"reserve": True} if entry.get("reserve") else {}) elif entry.get("cancel"): out[f.id] = dict(entry) else: @@ -251,5 +297,11 @@ def past_midnight(task: Task, run: sim.Execution | None = None) -> int: return out +def digest(task: Task) -> str: + """The task's sha256 (16 hex digits): what a pack's stored reference costs were run on (scripts/v4_pack.py + --refs), so a load can tell them from a stale file's.""" + return hashlib.sha256(task.to_json().encode()).hexdigest()[:16] + + def _price(f: Flight, entry: Mapping, at: int) -> int: return cancel_price(f, entry["decided"] if entry.get("cancel") else at) diff --git a/src/agentenv_airops/programs/repairs.py b/src/agentenv_airops/programs/repairs.py index 520042b..3d712f6 100644 --- a/src/agentenv_airops/programs/repairs.py +++ b/src/agentenv_airops/programs/repairs.py @@ -40,7 +40,8 @@ takeoff (117.19(a)(1)). A departure from a base has no crew deadline: reserves crew it. A departure from an outstation must block in by the report + the limit + EXTENSION, so its crew deadline is that less its scheduled block, static as the schedule is; after it the crew may fly again only after REST, the 10 consecutive hours of rest - 117.25(e) requires (`rest`). + 117.25(e) requires (`rest`). v4 cuts the pairings legal on schedule instead (layers.crew, a build's crew Rule), the + records alike. """ import math @@ -53,7 +54,7 @@ from ..build import fleet, pack from .advisories import HUBS -from .model import Busy, Connection, Crew, Flight, Move, Outcome, Tail +from .model import EXTENSION, REST, Busy, Connection, Crew, Flight, Move, Outcome, Tail FAMILIES = ("A320", "A321", "A321P", "A220", "E190") MIN_TURN = 25 @@ -69,8 +70,6 @@ CUTOFF = 1440 + 180 BASES = ("JFK", "BOS", "FLL", "MCO") REPORT = 45 -EXTENSION = 120 -REST = 600 ECFR = "https://www.ecfr.gov/current/title-14/chapter-I/subchapter-G/part-117" TABLE_B = {0: (9, 9, 9, 9, 9, 9, 9), 4: (10, 10, 10, 10, 9, 9, 9), 5: (12, 12, 12, 12, 11.5, 11, 10.5), 6: (13, 13, 12, 12, 11.5, 11, 10.5), 7: (14, 14, 13, 13, 12.5, 12, 11.5), @@ -107,14 +106,16 @@ def p5(values: Iterable[float]) -> int: def flights(day: pack.Day, sc: pack.Scope, x: pd.DataFrame, families: Mapping[str, str], tests: Callable[[str, str, int, int, int], tuple[str | None, str | None, bool]], dmax: int = DMAX, - cutoff: int = CUTOFF, bases: Collection[str] = ()) -> list[Flight]: + cutoff: int = CUTOFF, bases: Collection[str] = (), + cut: Callable[..., dict[Hashable, Crew]] | None = None) -> list[Flight]: """The scope's flights, ids in (scheduled departure, flight number, origin) order. `x` holds every carrier's rows around the date with local sd/sa/ad/aa and the repaired `lb`; `tests(orig, dest, sched_arr, departs, sched_dep)` - gives (controlled, gs_held, exempt); with crew `bases` each flight has its crew.""" + gives (controlled, gs_held, exempt); with crew `bases` each flight has its crew, from `crews` or from `cut`, a + function of the same arguments (layers.crew.Rule.crews).""" crew = {} if bases: legs = day.cur[day.cur.Tail_Number.isin(sc.tails)] - crew = crews(legs, day.base, bases, {c: a["tz"] for c, a in pack.airports(set(legs.Origin)).items()}) + crew = (cut or crews)(legs, day.base, bases, {c: a["tz"] for c, a in pack.airports(set(legs.Origin)).items()}) dec = sc.dec.assign(num=sc.dec.Flight_Number_Reporting_Airline.astype(int)).sort_values(["sd", "num", "Origin"]) seats = defaultdict(list) for t in sc.tails: diff --git a/src/agentenv_airops/programs/sim.py b/src/agentenv_airops/programs/sim.py index 111a49f..3059337 100644 --- a/src/agentenv_airops/programs/sim.py +++ b/src/agentenv_airops/programs/sim.py @@ -1,7 +1,8 @@ """The storm day: v1's dispatch inside the FAA's programs at JFK and BOS, with JetBlue's slots and EDCTs in place of hourly caps. -A plan maps flight ids to {"cancel", "not_before", "claim", "decided", "left", "holds", "claims"}, every key optional; +A plan maps flight ids to {"cancel", "not_before", "claim", "decided", "left", "holds", "claims", "reserve"}, every +key optional; a flight left out flies as early as it can. A cancelled flight is cancelled for good, decided at "decided" (the first watch when absent). "holds" and "claims" are decisions taken during the day, each [decided, minute or None] in time order: a hold acts from its minute (the flight tries again then, and from then on waits for it), a claim binds from its @@ -15,13 +16,23 @@ (Task.quick), through any Moves it passes on the way, and any other flight only after the turn. What else holds a flight ready at minute t, in this order: - a ground stop at its destination hub covering its origin holds it to the stop's end; airborne flights fly on; +- a departure in an hour its origin caps (Layers.dep_caps, docs/v4/DESIGN.md §3.1) needs room under the cap, else + it tries again at the next hour's start; every flight that left in the hour counts, a pinned one ("left") from the + start of the day, so the others share the cap less the pinned ones; a pinned flight whose aircraft is not ready by + its minute leaves when it is, and its count moves to the hour it leaves in, where the MIP pins it; +- a departure inside a ramp closure at its origin (Layers.closures, §3.2: lightning at the field cuts the ramp's + rate, it doesn't stop it) needs room under the closure's departure cap, counted the same way over the closure's + minutes, else it tries again at the closure's end; an arrival inside a ramp closure at its destination (the minute + plus the block, as the nominal cap counts it) needs room under the closure's arrival cap, the pinned flights' arrivals + counted from the start of the day too, else it leaves so as to arrive at the closure's end; - a flight governed at t by a GDP segment (the one in force when it departs, the departure-time lock; its origin in scope and its scheduled arrival in the period) needs one of JetBlue's slots in that segment's pool: its claimed slot if it can still make it, else the earliest free slot at or after its earliest arrival t + scheduled block. It leaves at the slot less its scheduled block (its EDCT) and lands after its own block. With no free slot left before the period ends it arrives after the period; - any other arrival at a hub (exempt, uncontrolled, or past its period) needs room under the nominal cap of its - arrival hour (rbs.nominal); a slot holder does not count against it. + arrival hour (rbs.nominal), and an arrival at an outstation in an hour the station caps (Layers.arr_caps, §3.3) + under that cap, which joins the nominal caps; a slot holder does not count against it. Claims standing at the start bind first, in (claim, flight id) order, each to the first unbound slot at or after it in the pool of the segment that would govern the flight leaving at its claim less its scheduled block. A flight that can't make its claimed slot loses it and the slot is wasted, as a missed EDCT is. Any other slot is taken when its flight @@ -31,6 +42,11 @@ crew deadline may leave only from the end of its crew's rest on, still by its deadline; one that can't, its rest ending past its deadline, auto-cancels at its crew deadline, decided there, never before the first watch (Task.auto_at). +With a finite reserve pool at a crew base (Layers.reserves, §3.4) a departure from it has its pairing's crew deadline +too (Task.crew) unless the plan calls a reserve crew for it ("reserve"), which the sim grants in flight-id order while +the base's pool lasts (`granted`); a granted flight has no crew deadline, as every base departure has without the +pool, and keeps its reserve whether it needs it or not. A pinned flight flies at its minute whatever its crew, as it +did. Moves are fixed and uncosted, and bound to an aircraft (model.Move, `routes`): the one that flies the leg before the Move's break, landed and turned, is ready at the Move's destination at the Move's `ready` or then, whichever is later, @@ -62,7 +78,15 @@ and the scheduled block, the programs as issued by then, proposed GDPs included, every stop to its as-issued end, every GDP too unless a base rate ends it earlier), or the calm day (no delay, scheduled blocks, no programs). Nominal caps, deadlines, the hub cutoff and the crew deadlines, which the schedule fixes and the desk knows from the start, are -the same in all of them. +the same in all of them. The calm day carries no layer. The day as known carries the layers as the desk sees them +(docs/v4/DESIGN.md §4): the departure caps and outstation arrival caps of the hours starting within CAP_LOOK of the +watch, as v1's slot caps were known, the later hours uncapped; the ramp closures begun by the watch, with their end +once it has come, else expected GUESS minutes after the watch (a placeholder for the train days' median remaining +duration, to be fitted with the pack's calibration), an ended one at its caps as given and one under way at the caps +its hub's rate (Layers.rates) gives that expected window, floored only at the pushbacks and arrivals before the watch +(closures.caps), so neither its true length nor what flew inside it later shows; and the reserve pools as they are +(Task.layers, which `granted` and the MIP read), whose sizes the desk knows. A task without layers knows the v2 day as +before. `switch` carries the layers' counts over as it does the slots and nominal hours. """ import bisect @@ -70,13 +94,14 @@ import math from collections import Counter, defaultdict from collections.abc import Collection, Mapping -from dataclasses import dataclass, replace +from dataclasses import dataclass, field, replace -from ..core.known import LOOK +from ..core.known import CAP_LOOK, LOOK from ..core.sim import AUTO_CANCELLED, CANCELLED, FLOWN, Ferry, _assign from . import pax as passengers from . import rbs, repairs from .cost import Cost, ferry_cost, priced +from .layers import closures from .model import Flight, Move, Segment, Task Plan = Mapping[int | str, Mapping] @@ -85,6 +110,7 @@ SlotKey = tuple[str, int] HourKey = tuple[str, int] Route = tuple[str, tuple[Move, ...]] +GUESS = 30 @dataclass(frozen=True) @@ -94,12 +120,17 @@ class World: segments: Mapping[str, tuple[Segment, ...]] stops: tuple nominal: Mapping[str, Mapping[int, int]] + dep_caps: Mapping[str, Mapping[int, int]] = field(default_factory=dict) + closures: Mapping[str, tuple[tuple[int, int, int, int], ...]] = field(default_factory=dict) def truth(task: Task) -> World: + layers = task.layers return World(task.lb, {f.id: f.block for f in task.flights}, {h: p.segments for h, p in task.programs.items()}, - tuple(s for p in task.programs.values() for s in p.stops), nominal_caps(task)) + tuple(s for p in task.programs.values() for s in p.stops), + nominal_caps(task) | (layers.arr_caps if layers else {}), + layers.dep_caps if layers else {}, layers.closures if layers else {}) def known(task: Task, t: int, programs: bool = True, early: int = 0) -> World: @@ -113,7 +144,39 @@ def known(task: Task, t: int, programs: bool = True, early: int = 0) -> World: issued = {h: rbs.forecast(task, h, t, early) for h in task.programs} segments = {h: s for h, (s, _) in issued.items()} stops = tuple(p for _, ps in issued.values() for p in ps) - return World(lb, block, segments, stops, nominal_caps(task)) + if not task.layered: + return World(lb, block, segments, stops, nominal_caps(task)) + layers = task.layers + return World(lb, block, segments, stops, nominal_caps(task) | _ahead(layers.arr_caps, t), + _ahead(layers.dep_caps, t), closures_known(task, t)) + + +def _ahead(caps: Mapping[str, Mapping[int, int]], t: int) -> dict[str, dict[int, int]]: + """The caps of the hours starting within CAP_LOOK of t, per station that has any.""" + out = {s: {h: c for h, c in by.items() if 60 * h <= t + CAP_LOOK} for s, by in caps.items()} + return {s: by for s, by in out.items() if by} + + +def closures_known(task: Task, t: int) -> dict[str, tuple[tuple[int, int, int, int], ...]]: + """The ramp closures begun by t: those ended by t as they ran, the others expected to end GUESS after t, priced + at their hub's rate over that window. A closure under way at a hub without a rate is a ValueError: the truth's caps + would show its true length.""" + layers, out = task.layers, {} + for s, spans in layers.closures.items(): + shut = [] + for a, b, dep, arr in spans: + if a > t: + continue + if b <= t: + shut.append((a, b, dep, arr)) + continue + if s not in layers.rates: + raise ValueError(f"{s}'s closure {a}-{b} is under way at {t} but {s} has no rate to price it as known") + end = max(t, a) + GUESS + shut.append((a, end, *closures.caps(task, s, layers.rates[s], a, end, by=t))) + if shut: + out[s] = tuple(shut) + return out def calm(task: Task) -> World: @@ -134,7 +197,8 @@ def with_cutoff(task: Task, cutoff: int) -> Task: class Rules: """What holds each flight at a minute in a world: the stops at its destination covering its origin, the segment - governing it (None when out of scope or outside the period), and the nominal cap of its arrival hour.""" + governing it (None when out of scope or outside the period), the nominal cap of its arrival hour, the departure + cap of its hour of leaving, and the ramp closures it leaves and arrives inside.""" def __init__(self, task: Task, world: World): self.world = world @@ -167,6 +231,37 @@ def hour(self, f: Flight, t: int) -> tuple[HourKey, int] | None: h = (t + self.world.block[f.id]) // 60 return ((f.dest, h), caps[h]) if caps is not None and h in caps else None + def dep_hour(self, f: Flight, t: int) -> tuple[HourKey, int] | None: + """The departure cap that f, leaving at t, counts in at its origin.""" + caps = self.world.dep_caps.get(f.orig) + h = t // 60 + return ((f.orig, h), caps[h]) if caps is not None and h in caps else None + + def closure_dep(self, f: Flight, t: int) -> tuple[HourKey, int, int] | None: + """The ramp closure f, leaving at t, leaves inside at its origin: (origin, its start), its departure cap, its + end.""" + for a, b, cap, _ in self.world.closures.get(f.orig, ()): + if a <= t < b: + return (f.orig, a), cap, b + return None + + def closure_arr(self, f: Flight, t: int) -> tuple[HourKey, int, int] | None: + """The ramp closure f, leaving at t, arrives inside at its destination (t plus the block): (destination, its + start), its arrival cap, its end.""" + arr = t + self.world.block[f.id] + for a, b, _, cap in self.world.closures.get(f.dest, ()): + if a <= arr < b: + return (f.dest, a), cap, b + return None + + def leaving(self, f: Flight, t: int) -> tuple[tuple[str, HourKey], ...]: + """The layers' rows f, leaving at t, counts in: ("dep", its capped hour), ("closure_dep", the closure it leaves + inside) and ("closure_arr", the closure it arrives inside).""" + rows = (("dep", self.dep_hour(f, t)), ("closure_dep", self.closure_dep(f, t)), + ("closure_arr", self.closure_arr(f, t))) + return tuple((kind, x[0]) for kind, x in rows if x is not None) + + @dataclass(frozen=True) class FlightRun: @@ -194,6 +289,7 @@ class Execution: moves: dict[int, int | None] cost: Cost pax: passengers.Day | None = None + reserved: frozenset[int] = frozenset() @property def counts(self) -> dict[str, int]: @@ -226,6 +322,7 @@ def __init__(self, task: Task, plan: Plan, world: World, crew=None, pax: passeng start = task.start self.cancelled = {i: start if d.get("decided") is None else d["decided"] for i, d in want.items() if d.get("cancel")} + self.reserved = granted(task, want) self.runs: dict[int, FlightRun] = {} self.when: dict[int, float] = {} self.first: dict[int, int] = {} @@ -259,10 +356,18 @@ def __init__(self, task: Task, plan: Plan, world: World, crew=None, pax: passeng self.bound, self.claimed = _claims(task, ((claim_at(d, start), i) for i, d in want.items() if i not in self.cancelled), self.rules, self.taken) self.used: Counter = Counter() + self.pin() self.legs: dict[str, list[int]] = {p: [] for p in self.ready} self.waiting: dict[tuple[str, str], set[int]] = defaultdict(set) self.quick: dict[str, tuple[int, int]] = {} + def pin(self) -> None: + """Count the pinned flights not gone yet ("left") in the layers' rows from now: the others share each cap less + them, and a pinned flight's count moves to the hour it leaves in when it goes.""" + for i, d in self.want.items(): + if d.get("left") is not None and i not in self.cancelled and i not in self.runs: + self.used.update(self.rules.leaving(self.task.flight(i), d["left"])) + def until(self, stop: float = math.inf) -> None: heap = self.heap while heap and heap[0][0] < stop: @@ -303,9 +408,10 @@ def step(self, t: int, kind: int, c: int, i: int) -> None: when[i] = held heapq.heappush(heap, (held, kind, c, i)) return - s = t if left else f.legal(t) + reserve = i in self.reserved + s = t if left else task.legal(f, t, reserve) if s > f.deadline: - runs[i] = FlightRun(i, AUTO_CANCELLED, decided=task.auto_at(f)) + runs[i] = FlightRun(i, AUTO_CANCELLED, decided=task.auto_at(f, reserve)) if crew is not None: crew.cancel(i, runs[i].decided) return @@ -321,9 +427,10 @@ def step(self, t: int, kind: int, c: int, i: int) -> None: return self.waiting[(f.family, f.orig)].discard(i) if left: - s, slot, hour = t, *_history(f, t, self.rules, self.taken) + self.used.subtract(self.rules.leaving(f, entry["left"])) + s, slot, counts = t, *_history(f, t, self.rules, self.taken) else: - s, slot, hour = _leave(f, t, self.rules, self.claimed.get(i), self.bound, self.taken, self.used) + s, slot, counts = _leave(f, t, self.rules, self.claimed.get(i), self.bound, self.taken, self.used) if s != t: when[i] = s heapq.heappush(heap, (s, kind, c, i)) @@ -354,8 +461,7 @@ def step(self, t: int, kind: int, c: int, i: int) -> None: quick[p] = (task.quick[i][0], max([turned, *(m.ready for m in carried[i][1])]) if i in carried else turned) if slot is not None: self.taken.add(slot) - if hour is not None: - self.used[hour] += 1 + self.used.update(counts) self.legs[p].append(i) runs[i] = FlightRun(i, FLOWN, p, t, arr, slot[1] if slot else None, slot[0] if slot else None) if crew is not None: @@ -385,21 +491,21 @@ def rebind(self, i: int, t: int) -> list[int]: return [i] def switch(self, world: World, t: int) -> None: - """The day from minute t on in `world`: the flights gone keep their minutes and count again on its slots and - nominal hours, the claims standing at t bind again in its pools, and every flight not gone tries again from - t on its delays, blocks and programs, never earlier, its place among the flights of a minute read from them - too.""" + """The day from minute t on in `world`: the flights gone keep their minutes and count again on its slots, + nominal hours and layers' rows (the pinned flights not gone too), the claims standing at t bind again in its + pools, and every flight not gone tries again from t on its delays, blocks and programs, never earlier, its + place among the flights of a minute read from them too.""" task = self.task self.world, self.rules = world, Rules(task, world) if self.crew is not None: self.crew.lb = world.lb self.taken, self.used = set(), Counter() for _, i in sorted((r.dep, i) for i, r in self.runs.items() if r.status == FLOWN): - slot, hour = _history(task.flight(i), self.runs[i].dep, self.rules, self.taken) + slot, counts = _history(task.flight(i), self.runs[i].dep, self.rules, self.taken) if slot is not None: self.taken.add(slot) - if hour is not None: - self.used[hour] += 1 + self.used.update(counts) + self.pin() self.bound, self.claimed = _claims(task, ((claim_at(d, t), i) for i, d in self.want.items() if i not in self.runs and i not in self.cancelled), self.rules, self.taken) @@ -414,7 +520,7 @@ def execution(self) -> Execution: self.until() task, runs = self.task, self.runs for f in task.flights: - runs.setdefault(f.id, FlightRun(f.id, AUTO_CANCELLED, decided=task.auto_at(f))) + runs.setdefault(f.id, FlightRun(f.id, AUTO_CANCELLED, decided=task.auto_at(f, f.id in self.reserved))) ends = {p: station for (_, station), ps in self.at.items() for p in ps} out = ferries(task, ends, self.family) wasted = tuple(sorted(k for k, i in self.bound.items() if runs[i].status != FLOWN or @@ -426,7 +532,21 @@ def execution(self) -> Execution: {i: r.decided for i, r in runs.items() if r.status != FLOWN}, [(x.family, x.orig, x.dest) for x in out], day) return Execution({i: runs[i] for i in sorted(runs)}, {p: tuple(v) for p, v in self.legs.items()}, - dict(sorted(ends.items())), out, wasted, self.moves, cost, day) + dict(sorted(ends.items())), out, wasted, self.moves, cost, day, self.reserved) + + +def granted(task: Task, plan: Plan) -> frozenset[int]: + """The flights whose reserve call (`reserve` in the plan) the pool grants: those not cancelled, reservable + (Task.reservable), in flight-id order while their base's pool lasts.""" + pool = Counter(task.layers.reserves if task.layers else {}) + want = {int(k): v for k, v in plan.items()} + out = set() + for f in sorted(task.flights, key=lambda f: f.id): + d = want.get(f.id, {}) + if d.get("reserve") and not d.get("cancel") and task.reservable(f) and pool[f.orig] > 0: + pool[f.orig] -= 1 + out.add(f.id) + return frozenset(out) def hold(entry: Mapping, t: int) -> int: @@ -513,13 +633,27 @@ def _claims(task: Task, claims, rules: Rules, taken: Collection[SlotKey], bound: def _leave(f: Flight, t: int, rules: Rules, claim: SlotKey | None, bound: Mapping[SlotKey, int], taken: set[SlotKey], - used: Counter) -> tuple[int, SlotKey | None, HourKey | None]: - """The first minute from t at which f may leave, with the slot it takes there or the nominal hour it counts in.""" + used: Counter) -> tuple[int, SlotKey | None, tuple]: + """The first minute from t at which f may leave, with the slot it takes there and the caps it counts in: the + nominal hour it lands in without a slot, and its layers' rows (Rules.leaving).""" while True: held = rules.held(f, t) if held != t: t = held continue + dep = rules.dep_hour(f, t) + if dep is not None and used[("dep", dep[0])] >= dep[1]: + t = 60 * (dep[0][1] + 1) + continue + shut = rules.closure_dep(f, t) + if shut is not None and used[("closure_dep", shut[0])] >= shut[1]: + t = shut[2] + continue + shut = rules.closure_arr(f, t) + if shut is not None and used[("closure_arr", shut[0])] >= shut[1]: + t = shut[2] - rules.world.block[f.id] + continue + out = rules.leaving(f, t) seg = rules.governs(f, t) if seg is not None: eta = t + f.sched_block @@ -532,7 +666,7 @@ def _leave(f: Flight, t: int, rules: Rules, claim: SlotKey | None, bound: Mappin if s - f.sched_block != t: t = s - f.sched_block continue - return t, (seg.id, s), None + return t, (seg.id, s), out if eta <= seg.end: t = seg.end + 1 - f.sched_block continue @@ -540,17 +674,19 @@ def _leave(f: Flight, t: int, rules: Rules, claim: SlotKey | None, bound: Mappin if cap is not None and used[cap[0]] >= cap[1]: t = 60 * (cap[0][1] + 1) - rules.world.block[f.id] continue - return t, None, cap[0] if cap else None + return t, None, out + ((cap[0],) if cap else ()) -def _history(f: Flight, t: int, rules: Rules, taken: set[SlotKey]) -> tuple[SlotKey | None, HourKey | None]: - """The slot or nominal hour a flight that left at t counts in (mip._place for a pinned copy).""" +def _history(f: Flight, t: int, rules: Rules, taken: set[SlotKey]) -> tuple[SlotKey | None, tuple]: + """The slot or nominal hour a flight that left at t counts in (mip._place for a pinned copy), and its layers' + rows.""" + out = rules.leaving(f, t) seg = rules.governs(f, t) if seg is not None and t + f.sched_block <= seg.end: key = (seg.id, t + f.sched_block) - return (key if t + f.sched_block in seg.slots and key not in taken else None), None + return (key if t + f.sched_block in seg.slots and key not in taken else None), out cap = rules.hour(f, t) - return None, cap[0] if cap else None + return None, out + ((cap[0],) if cap else ()) def ferries(task: Task, ends: Mapping[str, str], family: Mapping[str, str]) -> tuple[Ferry, ...]: diff --git a/src/agentenv_airops/v4day/__init__.py b/src/agentenv_airops/v4day/__init__.py new file mode 100644 index 0000000..1440f08 --- /dev/null +++ b/src/agentenv_airops/v4day/__init__.py @@ -0,0 +1,2 @@ +"""v4's playable day on the programs world (docs/v4/DESIGN.md §5): the clock, the situation, the actions and the +grade, and the live env that serves it (live.py, tasks.py, server.py).""" diff --git a/src/agentenv_airops/v4day/clock.py b/src/agentenv_airops/v4day/clock.py new file mode 100644 index 0000000..4ea44a2 --- /dev/null +++ b/src/agentenv_airops/v4day/clock.py @@ -0,0 +1,103 @@ +"""The playable day's clock (docs/v4/DESIGN.md §4, §5): the watches, the news each one delivers, and virtual time. + +The watches are the follower's (`references.watch_times`), so a desk playing the day and the follower decide at the +same minutes, a layered task's waking on its events (an advisory or SPECI reaching the desk, a ramp closure starting). +The news is the hubs' FAA advisories as the task carries them (`task.programs[hub].advisories`) and, on a layered task, +their METARs, SPECIs and TAFs (`Layers.weather`). Each is due at its signature, observation or issue plus its kind's +delivery lag (Task.lag: advisory 0, metar 5, speci 5, taf 10 by default) and reaches the desk at the first watch at or +after that: those due before the first watch come with the opening briefing, those after the last never arrive. A tool +call costs CALL_MIN minutes of the watch's virtual time, so thinking costs time +without tying the score to the provider's speed; `virtual_time` is that minute as the UTC instant a gateway clock +reads (v1's schedule.virtual_time).""" + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from zoneinfo import ZoneInfo + +from ..core.model import hhmm +from ..programs import references +from ..programs.advisories import GDP, GDP_CNX, GS, GS_CNX, PROPOSED, Advisory +from ..programs.model import Task + +CALL_MIN = 1 +TIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ" +KINDS = {GDP: "ground delay program", PROPOSED: "proposed ground delay program", GDP_CNX: "GDP cancelled", + GS: "ground stop", GS_CNX: "ground stop cancelled"} + + +WEATHER = ("metar", "speci", "taf") + + +@dataclass(frozen=True) +class News: + """One advisory or weather product as the desk reads it: `at` its signature, observation or issue, `due` the + minute it reaches the desk (`at` plus its kind's lag).""" + + id: str + hub: str + kind: str + at: int + text: str + due: int + + def to_dict(self) -> dict: + out = {"id": self.id, "hub": self.hub, "kind": self.kind, "at": hhmm(self.at), "text": self.text} + return out | ({"due": hhmm(self.due)} if self.due != self.at else {}) + + +@dataclass(frozen=True) +class Watch: + index: int + t: int + news: tuple[str, ...] + + +def watch_times(task: Task) -> list[int]: + return references.watch_times(task) + + +def news(task: Task) -> list[News]: + """Every hub's advisories and, on a layered task, its weather, in the order they reach the desk. A weather item's + id is its kind, hub and minute, the second of a kind at one hub and minute (an AUTO report beside the AO2 one, a + TAF COR beside its AMD) suffixed -2, and so on, so each reaches the desk once.""" + lag = task.lag("advisory") + out = [News(a.id, a.hub, a.kind, a.issued, text(a), a.issued + lag) + for p in task.programs.values() for a in p.advisories] + seen: dict[str, int] = {} + for minute, kind, hub, body in task.layers.weather if task.layers else (): + key = f"{kind}-{hub.lower()}-{minute}" + seen[key] = seen.get(key, 0) + 1 + out.append(News(key if seen[key] == 1 else f"{key}-{seen[key]}", hub, kind, minute, " ".join(body.split()), + minute + task.lag(kind))) + return sorted(out, key=lambda n: (n.due, n.at, n.id)) + + +def timetable(task: Task) -> list[Watch]: + times = watch_times(task) + due: dict[int, list[str]] = {k: [] for k in range(len(times))} + for n in news(task): + k = next((k for k, t in enumerate(times) if t >= n.due), None) + if k is not None: + due[k].append(n.id) + return [Watch(k, t, tuple(due[k])) for k, t in enumerate(times)] + + +def text(a: Advisory) -> str: + line = f"{a.hub} {KINDS.get(a.kind, a.kind)}" + if a.start is not None: + line += f" {hhmm(a.start)}" + (f"-{hhmm(a.end)}" if a.end is not None else "") + if a.rates: + line += f", rate {'/'.join(map(str, a.rates))} arrivals/h (all carriers)" + if a.avg_delay is not None: + line += f", average delay {a.avg_delay} min" + (f", max {a.max_delay}" if a.max_delay is not None else "") + if a.extension: + line += f", probability of extension {a.extension.lower()}" + if a.only: + line += f" (only {a.only})" + return line + f" [advisory {a.id}, signed {hhmm(a.issued)}]" + + +def virtual_time(task: Task, minute: int) -> str: + """The minute of the task's day, hub-local, as the UTC instant the gateway clock reads.""" + midnight = datetime.fromisoformat(task.date).replace(tzinfo=ZoneInfo(task.tz)) + return (midnight + timedelta(minutes=minute)).astimezone(UTC).strftime(TIME_FORMAT) diff --git a/src/agentenv_airops/v4day/day.py b/src/agentenv_airops/v4day/day.py new file mode 100644 index 0000000..cf9c4ec --- /dev/null +++ b/src/agentenv_airops/v4day/day.py @@ -0,0 +1,418 @@ +"""One playable day on the programs world (docs/v4/DESIGN.md §5), pure and deterministic: v1's day.py ported onto +programs.sim, the follower's day as known and its freeze. + +The desk plays the follower's watches (clock.watch_times). At a watch t the day so far is history and the rest is the +day as known: the plan runs as `references.so_far` has it at the clock (a flight that has left on the truth by then +left when it did, as a desk would know, every other one no earlier than the clock) in `sim.known(task, t)` (delays +known LOOK ahead, the programs as issued). From the +second watch on, what that projection has leaving before the freeze line t + FREEZE commits to fly, every +cancellation is final, and a projected auto-cancel decided before the line stands (`references.frozen`, kept as a +union over the watches); an open flight the plan would have leaving before the line (at the first watch, before the +clock) is held to it, as the follower's watch solve starts every open flight at the line. What is frozen and what has +gone by the clock stands: the sim reroutes the tails on every cancellation, the day so far included, so a change that +would move such a flight's outcome, departure or aircraft is refused, in the projection and on the truth the grade +prices (a flight gone on the truth by the clock), and a decision never reaches back past it. + +Actions are v1's: cancel, hold (not_before) and restore, read by v1's `day.read_changes` and confirmed as plan entries +`{"cancel": True, "decided": minute}` and `{"not_before": minute}`, and, with a finite reserve pool at a crew base +(docs/v4/DESIGN.md §3.4), call_reserve: the plan entry gains `"reserve": True`, so the flight may leave its base past +its crew deadline on a reserve crew, and release_reserve drops the call, refused once the flight's crew deadline is past +(it would auto-cancel back then, a decision reaching back past the clock). A call is refused for a flight that doesn't +leave a base with a pool, a cancelled one, or one the pool has no crew left for (it would take one from a flight already +holding it: sim.granted grants in flight-id order); a hold or a restore keeps the call, a cancellation drops it, and a +call stands, like any change, once its flight is frozen or gone. In a batch, as in v1, a flight's last change of each +kind counts (cancel, restore or hold; call or release); releases go first and calls last, so one batch can move a +reserve from one flight to another, and a flight both cancelled and called keeps the cancellation. Every tool call (the +situation, a check, a confirm) costs CALL_MIN minutes of the watch's virtual time and acts at the clock's minute when it +starts, so a cancellation is priced from the minute it was confirmed; when the clock reaches the next watch, the watch +ends by itself, and the last watch ends STEP minutes on with the day. advance() ends the watch at once. The news, the +hubs' advisories and on a layered day their weather, is queued at its watch (clock.timetable: the first at or after the +item's minute plus its delivery lag) for the desk to drain. The grade runs the final plan on the +truth (grade.grade).""" + +import json +from collections.abc import Callable, Mapping + +from ..core.known import FREEZE, STEP +from ..core.model import hhmm +from ..core.sim import AUTO_CANCELLED, CANCELLED, FLOWN +from ..day import USAGE, ChangeError, _flight, fid, read_changes +from ..programs import references, sim +from ..programs.model import Task +from . import situation as view +from .clock import CALL_MIN, News, news, timetable +from .grade import grade + +DESK = "Duty manager" + + +class Day: + def __init__(self, task: Task, anchor: int | None = None, follower: int | None = None): + """`anchor` is the anchor's cost when known (the grade solves it otherwise); `follower` the follower's, for the + grade to report alongside.""" + self.task = task + self.anchor, self.follower = anchor, follower + self.watches = timetable(task) + self.items: dict[str, News] = {n.id: n for n in news(task)} + self.watch = 0 + self.elapsed = 0 + self.calls = 0 + self.plan: dict[int, dict] = {} + self.frozen: set[int] = set() + self.delivered: list[str] = [] + self.queue: list[News] = [] + self.log: list[dict] = [] + self.changes: list[dict] = [] + self.refusals: list[dict] = [] + self.freezes = [{"watch": 0, "t": task.start, "line": None, "frozen": []}] + self.done = False + self.end_reason: str | None = None + self.result: dict | None = None + self._worlds: dict[int, sim.World] = {} + self._deliver("load") + + @property + def t(self) -> int: + return self.watches[self.watch].t + + @property + def clock(self) -> int: + """The virtual minute: the watch's time plus the minutes its calls have used.""" + return self.t + self.elapsed + + @property + def line(self) -> int | None: + """The freeze line; none at the first watch, where the desk sets the day up.""" + return self.t + FREEZE if self.t > self.task.start else None + + @property + def last(self) -> bool: + return self.watch == len(self.watches) - 1 + + @property + def ends(self) -> int: + """The minute the watch's time runs out: the next watch, or STEP after the last.""" + return self.t + STEP if self.last else self.watches[self.watch + 1].t + + def known(self) -> sim.World: + if self.watch not in self._worlds: + self._worlds[self.watch] = sim.known(self.task, self.t) + return self._worlds[self.watch] + + def project(self, plan: Mapping[int, Mapping]) -> sim.Execution: + """`plan` run on the day as known at the watch, the day so far (to the clock) as history.""" + return sim.execute(self.task, references.so_far(self.task, plan, self.clock), self.known()) + + def situation(self) -> dict: + return self._call(lambda at: view.build(self)) + + def text(self) -> str: + return view.render(self.task, view.build(self)) + + def check(self, raw) -> dict: + def act(at: int) -> dict: + plan, _, _, refused, problems, held = self._merge(raw, at) + now, then = self.project(self.plan), self.project(plan) + return {"cost": then.cost.to_dict(), "cost_now": now.cost.total, "flights": self._moved(now, then, plan), + "auto_cancelled": _auto(then), "held_to_freeze_line": [fid(i) for i in held], + "refused": refused, "problems": problems} + + return self._call(act) + + def confirm(self, raw) -> dict: + def act(at: int) -> dict: + plan, confirmed, unchanged, refused, problems, held = self._merge(raw, at) + for i in sorted(set(confirmed) | set(held)): + self.changes.append({"watch": self.watch, "t": at, "flight": i, "was": self.plan.get(i, {}), + "now": plan.get(i, {}), "by": "desk" if i in confirmed else "freeze_line"}) + self.plan = dict(sorted(plan.items())) + self.refusals += [{"watch": self.watch, **r} for r in refused] + run = self.project(self.plan) + return {"confirmed": [fid(i) for i in confirmed], "unchanged": [fid(i) for i in unchanged], + "held_to_freeze_line": [fid(i) for i in held], "refused": refused, "problems": problems, + "cost": run.cost.to_dict(), "auto_cancelled": _auto(run)} + + return self._call(act) + + def advance(self) -> dict: + """End the watch now: on to the next, or, after the last, the end of the day and its grade.""" + if self.done: + return {"error": "the day is over"} + return self._next("done") + + def drain(self) -> list[dict]: + """The news delivered since the last drain.""" + out, self.queue = [n.to_dict() for n in self.queue], [] + return out + + def _call(self, act: Callable[[int], dict]) -> dict: + if self.done: + return {"error": "the day is over"} + try: + out = act(self.clock) + except ChangeError as e: + out = {"error": str(e)} + self.calls += 1 + self.elapsed += CALL_MIN + out["now"] = hhmm(self.clock) + if self.clock >= self.ends: + out["watch_over"] = self._next("time") + return out + + def _next(self, reason: str) -> dict: + if self.last: + return self.finish(reason) + self.watch += 1 + self.elapsed = 0 + if self.line is not None: + self.frozen |= references.frozen(self.project(self.plan), self.t) + self.freezes.append({"watch": self.watch, "t": self.t, "line": self.line, "frozen": sorted(self.frozen)}) + got = self._deliver("watch") + return {"watch": self.watch, "time": hhmm(self.t), "freeze_line": _time(self.line), + "frozen": len(self.frozen), "news": got} + + def _deliver(self, via: str) -> list[str]: + got = list(self.watches[self.watch].news) + for n in got: + self.delivered.append(n) + self.queue.append(self.items[n]) + self.log.append({"news_id": n, "watch": self.watch, "t": self.t, "at": self.items[n].at, "via": via}) + return got + + def finish(self, reason: str) -> dict: + self.done, self.end_reason = True, reason + self.result = grade(self.task, self.plan, self.anchor, self.follower) + return {"done": True, "end_reason": reason, "cost": self.result["cost"]["total"], + "reward": self.result["reward"]} + + def _merge(self, raw, at: int) -> tuple[dict, list[int], list[int], list[dict], list[str], list[int]]: + """The plan with `raw`'s changes, one at a time: a change to a frozen flight, or one that would move a frozen + flight or one gone by `at` in the projection or on the truth (`sim.routes` reroutes the tails on every + cancellation, the day so far included), is refused; what is left open before the line is held to it.""" + rows, problems = _read(self.task, raw) + plan, confirmed, unchanged, refused, held = dict(self.plan), [], [], [], set() + base, truth = self.project(self.plan).flights, sim.execute(self.task, self.plan).flights + pinned = self.frozen | _gone(base, at) + gone = _gone(truth, at) + for i, entry in rows: + if (_called(plan.get(i)) == entry["reserve"] if "reserve" in entry else _entry(self.plan.get(i)) == entry): + unchanged.append(i) + continue + if i in pinned or i in gone: + why = self._pinned_reason(i, base[i] if i in pinned else truth[i], at) + refused.append({"flight": fid(i), "from": DESK, "reason": why}) + continue + then = dict(plan) + old = then.pop(i, None) or {} + kept = {"reserve": True} if old.get("reserve") else {} + if entry.get("reserve"): + if why := self._uncallable(i, plan): + refused.append({"flight": fid(i), "from": DESK, "reason": why}) + continue + then[i] = {**old, "reserve": True} + elif "reserve" in entry: + if why := self._unreleasable(i, at): + refused.append({"flight": fid(i), "from": DESK, "reason": why}) + continue + if rest := {k: v for k, v in old.items() if k != "reserve"}: + then[i] = rest + elif entry["cancel"]: + then[i] = {"cancel": True, "decided": at} + elif entry["not_before"] is not None: + then[i] = {"not_before": entry["not_before"]} | kept + elif kept: + then[i] = kept + early, run = self._settle(then, pinned, at) + after = sim.execute(self.task, then).flights + moved = [(k, base) for k in sorted(pinned) if _fixed(run.flights[k]) != _fixed(base[k])] + \ + [(k, truth) for k in sorted(gone) if _fixed(after[k]) != _fixed(truth[k])] + if moved: + k, runs = moved[0] + refused.append({"flight": fid(i), "from": DESK, "reason": self._moves_reason(i, k, runs, at)}) + continue + plan = then + confirmed.append(i) + held.update(early) + return plan, confirmed, unchanged, refused, problems, sorted(held) + + def _settle(self, plan: dict, pinned: set[int], at: int) -> tuple[list[int], sim.Execution]: + """Holds every open flight `plan` has leaving before the freeze line (at the first watch, the clock) to it; + returns them and the plan's projection.""" + line = max(at, self.line if self.line is not None else at) + held: set[int] = set() + while True: + run = self.project(plan) + early = [i for i, r in run.flights.items() if r.status == FLOWN and i not in pinned and r.dep < line] + if not early: + return sorted(held), run + for i in early: + plan[i] = {**plan.get(i, {}), "not_before": line} + held.update(early) + + def _uncallable(self, i: int, plan: Mapping[int, Mapping]) -> str | None: + """Why a reserve can't be called for i under `plan`, or None.""" + task, f = self.task, self.task.flight(i) + name = f"{fid(i)} ({f.flight} {f.orig}-{f.dest})" + if not task.reservable(f): + return f"{name} doesn't leave a crew base with a reserve pool: no reserve to call." + if (plan.get(i) or {}).get("cancel"): + return f"{name} is cancelled: restore it before calling a reserve." + held = sim.granted(task, plan) + after = sim.granted(task, {**plan, i: {**plan.get(i, {}), "reserve": True}}) + if i not in after or not held <= after: + on = " ".join(fid(j) for j in sorted(held) if task.flight(j).orig == f.orig) + pool = task.layers.reserves[f.orig] + return f"{name}: no reserve crew is left at {f.orig}: " + ( + f"its pool of {pool} is on {on}; release one first." if on else "its pool is empty.") + return None + + def _unreleasable(self, i: int, at: int) -> str | None: + """Why i's reserve can't be released at `at`, or None: past its crew deadline it would auto-cancel back then.""" + f = self.task.flight(i) + if (lapse := self.task.auto_at(f)) < at: + return f"{fid(i)} ({f.flight} {f.orig}-{f.dest}) keeps its reserve: its crew deadline passed at " \ + f"{hhmm(lapse)}, before now ({hhmm(at)}), so without the reserve it would have auto-cancelled " \ + "then; cancel it instead." + return None + + def _pinned_reason(self, i: int, r: sim.FlightRun, at: int) -> str: + if i in self.frozen: + return self._frozen_reason(i, r) + f = self.task.flight(i) + how = f"it left at {hhmm(r.dep)}" if r.status == FLOWN else f"it auto-cancelled at {hhmm(r.decided)}" + return f"{fid(i)} ({f.flight} {f.orig}-{f.dest}) is gone: {how}, before now ({hhmm(at)}); it stands." + + def _moves_reason(self, i: int, k: int, base: Mapping[int, sim.FlightRun], at: int) -> str: + f = self.task.flight(i) + return f"{fid(i)} ({f.flight} {f.orig}-{f.dest}) stays as it is: the change would reroute the aircraft and " \ + f"move {fid(k)}, which can't move. {self._pinned_reason(k, base[k], at)}" + + def _frozen_reason(self, i: int, r: sim.FlightRun) -> str: + f = self.task.flight(i) + if r.status == FLOWN: + how = f"the plan has it leaving at {hhmm(r.dep)}" + elif r.status == CANCELLED: + how = "it is cancelled, and a cancellation is final" + else: + how = f"it can't leave in time and auto-cancels at {hhmm(r.decided)}" + return f"{fid(i)} ({f.flight} {f.orig}-{f.dest}) is frozen: {how}, before the freeze line at " \ + f"{hhmm(self.line)}; it stands." + + def _moved(self, now: sim.Execution, then: sim.Execution, plan: Mapping[int, Mapping]) -> list[str]: + """The flights the changes alter, as they would be (was: as they are).""" + out = [] + for i in sorted(now.flights): + a, b = now.flights[i], then.flights[i] + if ((a.status, a.dep, a.arr) != (b.status, b.dep, b.arr) or _entry(self.plan.get(i)) != _entry(plan.get(i)) + or _called(self.plan.get(i)) != _called(plan.get(i))): + f = self.task.flight(i) + out.append(f"{fid(i)} {f.flight} {f.orig}-{f.dest} {view.plan_text(plan.get(i))} " + f"{view.proj_text(self.task, b)} (was {view.plan_text(self.plan.get(i))} " + f"{view.proj_text(self.task, a)})") + return out + + def data(self) -> dict: + return {"task_id": self.task.task_id, "watches": len(self.watches), "watch": self.watch, "t": self.t, + "clock": self.clock, "calls": self.calls, "done": self.done, "end_reason": self.end_reason, + "plan": [{"flight": i, **e} for i, e in sorted(self.plan.items())], "frozen": sorted(self.frozen), + "notices": self.log, "freezes": self.freezes, "changes": self.changes, "refusals": self.refusals, + "grade": self.result} + + +def changes_to(day: Day, target: Mapping[int, Mapping]) -> list[dict]: + """The changes that make the day's plan `target` (programs plan entries by flight id) on the flights that aren't + frozen.""" + want = {int(i): _entry(e) for i, e in target.items()} + calls = {int(i) for i, e in target.items() if e.get("reserve") and not e.get("cancel")} + rows = [] + for i in sorted(set(day.plan) | set(want)): + if i in day.frozen: + continue + a, b = _entry(day.plan.get(i)), want.get(i, _entry(None)) + if a != b: + action = "cancel" if b["cancel"] else "restore" if a["cancel"] else "hold" + rows.append({"flight": fid(i), "action": action, "not_before": _time(b["not_before"])}) + if i in calls and not _called(day.plan.get(i)): + rows.append({"flight": fid(i), "action": CALL}) + elif i not in calls and _called(day.plan.get(i)) and not b["cancel"] \ + and day.task.auto_at(day.task.flight(i)) >= day.clock: + rows.append({"flight": fid(i), "action": RELEASE}) + return rows + + +def play(task: Task, policy: Callable[[Day], Mapping[int, Mapping]], anchor: int | None = None) -> Day: + """The day a scripted policy (the plan it wants at each watch) plays: one confirm per watch, then advance.""" + day = Day(task, anchor) + while not day.done: + if rows := changes_to(day, policy(day)): + day.confirm(rows) + if not day.done: + day.advance() + return day + + +CALL, RELEASE = "call_reserve", "release_reserve" +V1_ACTIONS, ACTIONS = "cancel, restore or hold", "cancel, restore, hold, call_reserve or release_reserve" + + +def _read(task: Task, raw) -> tuple[list[tuple[int, dict]], list[str]]: + """v1's read_changes, plus call_reserve and release_reserve rows ({"flight": "f3", "action": "call_reserve"}) as + `{"reserve": True}` and `{"reserve": False}`: a flight's last row of each kind counts, the releases first, v1's + changes next, the calls last, each by flight; a call on a flight the batch cancels is dropped.""" + if isinstance(raw, str): + try: + raw = json.loads(raw) + except json.JSONDecodeError as e: + raise ChangeError(f"changes are not valid JSON ({e.msg}): send a list like {USAGE}") from None + if isinstance(raw, dict) and isinstance(raw.get("changes"), list): + raw = raw["changes"] + if not isinstance(raw, list): + raise ChangeError(f"changes must be a list like {USAGE}") + ours = [isinstance(c, dict) and c.get("action") in (CALL, RELEASE) for c in raw] + rows, problems = read_changes(task, [c for c, o in zip(raw, ours, strict=True) if not o]) + problems = [p.replace(V1_ACTIONS, ACTIONS) for p in problems] + reserve: dict[int, bool] = {} + for c in (c for c, o in zip(raw, ours, strict=True) if o): + i = _flight(task, c.get("flight")) + if i is None: + problems.append(f"unknown flight {json.dumps(c.get('flight'))}: use the ids from the flight table (f0, f1, " + "...)") + continue + if i in reserve: + problems.append(f"{fid(i)} has more than one reserve call or release: the last counts") + reserve[i] = c["action"] == CALL + for i, e in rows: + if e["cancel"] and reserve.get(i): + problems.append(f"{fid(i)} is cancelled and calls a reserve: the cancellation counts and drops the call") + del reserve[i] + return ([(i, {"reserve": False}) for i, r in sorted(reserve.items()) if not r] + rows + + [(i, {"reserve": True}) for i, r in sorted(reserve.items()) if r]), problems + + +def _gone(flights: Mapping[int, sim.FlightRun], at: int) -> set[int]: + """The flights a run has gone before `at`: left, or auto-cancelled.""" + return {i for i, r in flights.items() + if (r.status == FLOWN and r.dep < at) or (r.status == AUTO_CANCELLED and r.decided < at)} + + +def _fixed(r: sim.FlightRun) -> tuple: + """What a frozen or past flight keeps: its outcome, its departure and its aircraft.""" + return r.status, r.dep, r.tail + + +def _entry(e: Mapping | None) -> dict: + """A plan entry as an action reads it: cancelled, or flying no earlier than `not_before`.""" + cancel = bool(e and e.get("cancel")) + return {"cancel": cancel, "not_before": None if cancel or not e else e.get("not_before")} + + +def _called(e: Mapping | None) -> bool: + return bool(e and e.get("reserve")) + + +def _time(minute: int | None) -> str | None: + return None if minute is None else hhmm(minute) + + +def _auto(run: sim.Execution) -> list[str]: + return [fid(i) for i, r in run.flights.items() if r.status == AUTO_CANCELLED] diff --git a/src/agentenv_airops/v4day/grade.py b/src/agentenv_airops/v4day/grade.py new file mode 100644 index 0000000..5288767 --- /dev/null +++ b/src/agentenv_airops/v4day/grade.py @@ -0,0 +1,41 @@ +"""The playable day's grade (docs/v4/DESIGN.md §5): the desk's final plan run on the truth by sim.execute, against +doing nothing (`references.naive`) and the hindsight optimum (`references.anchor`), with the rule (`references.rule`) +and, when its cost is given, the follower as reference scores: each one's cost on the truth and its reward on the +same curve. The rule is `references.rule` as v2 plays it, its day so far recomputed under its later cancellations +(the Day refuses such a change), so on a rare day it is not quite the desk's rule. + +The reward is v2's (scripts/step0.py `reward`), so a v4 score sits on step 0's curve: 0.2 + 0.8 exp(-g / 0.5), g the +plan's excess over the anchor in units of doing nothing's excess (at least FLOOR_SHARE of the anchor): 1 at the +anchor, about 0.31 at doing nothing, never below 0.2 (v1's floor: the env never strands a flight).""" + +import math +from collections.abc import Mapping + +from ..programs import references, sim +from ..programs.model import Task + +TEMPERATURE, FLOOR_SHARE = 0.5, 0.1 + + +def reward(cost: float, naive: float, anchor: float) -> float: + """On a day that costs nothing at the anchor and doing nothing, any plan at the anchor's cost scores 1 and any + other 0.2.""" + scale = max(naive - anchor, FLOOR_SHARE * anchor) + if scale <= 0: + return 1.0 if cost <= anchor else 0.2 + g = max(0.0, cost - anchor) / scale + return 0.2 + 0.8 * math.exp(-g / TEMPERATURE) + + +def grade(task: Task, plan: Mapping[int, Mapping], anchor: int | None = None, follower: int | None = None) -> dict: + """The plan's cost on the truth with the references it is scored against; `anchor` is the anchor's cost when + known (references.anchor solves it otherwise), `follower` the follower's (a MIP solve per watch, so only when + given).""" + run = sim.execute(task, plan) + naive = references.naive(task).cost.total + best = references.anchor(task)[1].cost.total if anchor is None else anchor + refs = {k: None if c is None else {"cost": c, "reward": round(reward(c, naive, best), 6)} + for k, c in (("rule", references.rule(task).cost), ("follower", follower))} + return {"cost": run.cost.to_dict(), "counts": run.counts, "naive": naive, "anchor": best, "references": refs, + "reward": round(reward(run.cost.total, naive, best), 6), "executed": run.rows(), + "ferries": [x.to_dict() for x in run.ferries]} diff --git a/src/agentenv_airops/v4day/live.py b/src/agentenv_airops/v4day/live.py new file mode 100644 index 0000000..2076bbb --- /dev/null +++ b/src/agentenv_airops/v4day/live.py @@ -0,0 +1,245 @@ +"""AirOps v4 live: one storm date on the programs world (docs/v4/DESIGN.md §5), played on the gateway's virtual clock +as live.py plays v1's day, beside it. + +urn:airops:v4-live-load/v1 starts a day on a task of AIROPS_V4_TASKS (programs.model tasks, layered or not); the +planner's tools are v1's (get_situation, check_plan, confirm_changes, advance, with v1's change lists) on v4day.Day. +Every call costs the Day CALL_MIN minutes of its watch; before a call moves the Day to a new watch (advance, or a +call that runs the watch's time out) the env re-arms the gateway clock frozen at that watch, as v1's advance does, so a +call that could end any watch but the last needs the clock synced, and a failed re-arm leaves the Day where it was +(a call that fails after the re-arm arms the clock back at its watch). Planner calls run one at a time. +The Day delivers each watch's advisories itself, in every tool result's messages, so there are no news triggers. A +reply in which the day ends carries done, cost and reward. The grade needs the anchor's cost: the load takes it (and +the follower's) from its arguments, else from the pack's refs.json (scripts/v4_pack.py --refs: the costs per task +with the digest of the task they ran on, used only while it matches), and otherwise solves the hindsight MIP then, +before the clock starts, so the day's last call never waits on a solve. urn:airops:v4-end-day/v1 grades the plan on +the truth (v4day.grade) when the planner stopped early. data/get reports the Day with its reward and an audit of its +news log, as v1's verifier reads a day, and never the truth the desk doesn't know yet.""" + +import asyncio +import json +import os +from pathlib import Path +from typing import Annotated, Any, Literal + +import httpx +from agentenv_protocol import AgentEnvEnvironment, DataPart, environment_card, extension, get_data, reset_data, tool +from fastmcp import FastMCP +from fastmcp.server.middleware import Middleware +from pydantic import BaseModel, ConfigDict, Field + +from ..live import _raw, _strict +from ..programs import references +from ..programs.model import Task, load_tasks +from ..schedule import CLOCK_URI +from . import situation as view +from .clock import CALL_MIN, virtual_time +from .day import Day + + +class Change(BaseModel): + """v1's change (live.Change) with v4's reserve calls: a flight leaving a crew base past its pairing's crew deadline + flies only on a reserve crew, while the base's pool lasts.""" + + model_config = ConfigDict(extra="forbid", json_schema_extra=_strict) + + flight: str = Field(description='flight id from the table, e.g. "f12"') + action: Literal["cancel", "restore", "hold", "call_reserve", "release_reserve"] = Field( + description="cancel the flight; restore a cancelled flight; hold a flight until not_before (restoring it if " + "cancelled); call_reserve: give a flight leaving a crew base a reserve crew, so it may leave past " + "its crew's deadline; release_reserve: hand the reserve back before that deadline") + not_before: str | None = Field( + default=None, + description='local time the flight may not leave before, "HH:MM" ("00:30+1" after midnight); null to let it ' + "leave as early as it can, and with cancel and the reserve actions") + + +ChangesArg = Annotated[list[Change] | str, Field( + description='[{"flight": "f12", "action": "cancel", "not_before": null}, {"flight": "f30", "action": "hold", ' + '"not_before": "15:40"}, {"flight": "f7", "action": "call_reserve", "not_before": null}, ...]; ' + "flights left out keep their plan")] + + +ENV = "airops-v4-day" +LIVE_LOAD_URI = "urn:airops:v4-live-load/v1" +END_DAY_URI = "urn:airops:v4-end-day/v1" +TASKS_VAR = "AIROPS_V4_TASKS" +REFS_FILE = "refs.json" +TOOLS = ("get_situation", "check_plan", "confirm_changes", "advance") +EMPTY = {"task_id": None, "watches": None, "watch": None, "t": None, "clock": None, "calls": 0, "done": False, + "end_reason": None, "plan": [], "frozen": [], "notices": [], "freezes": [], "changes": [], "refusals": [], + "grade": None, "reward": None, "audit": None} + + +def tasks_by_id() -> dict[str, Task]: + """The programs tasks of AIROPS_V4_TASKS (pack folders or .jsonl files, separated by ':'); none when unset.""" + paths = os.environ.get(TASKS_VAR) + return {t.task_id: t for p in paths.split(":") for t in load_tasks(Path(p))} if paths else {} + + +def refs_by_id() -> dict[str, dict]: + """The stored reference costs beside AIROPS_V4_TASKS's tasks: the refs.json in each pack folder (or beside each + .jsonl), by task id; none where there is none.""" + paths = os.environ.get(TASKS_VAR) + out = {} + for p in map(Path, paths.split(":") if paths else ()): + f = (p if p.is_dir() else p.parent) / REFS_FILE + if f.is_file(): + out |= json.loads(f.read_text()) + return out + + +def stored(task: Task, refs: dict[str, dict]) -> dict[str, int]: + """The anchor's and the follower's stored costs of `task`, if they were run on this very task.""" + r = refs.get(task.task_id, {}) + return {k: r[k] for k in ("anchor", "follower") if k in r} if r.get("task") == references.digest(task) else {} + + +def audit(day: Day) -> dict: + """Whether every watch reached so far delivered its news, and only it, at that watch.""" + problems = [] + for k in range(day.watch + 1): + got = [e["news_id"] for e in day.log if e["watch"] == k] + if got != list(day.watches[k].news): + problems.append(f"watch {k} got {got} instead of {list(day.watches[k].news)}") + return {"ok": not problems, "problems": problems} + + +def _json(obj: Any) -> str: + return json.dumps(obj, ensure_ascii=False, separators=(",", ":")) + + +@environment_card(name=ENV) +class AirOpsV4LiveEnv(AgentEnvEnvironment): + def __init__(self): + self.tasks = tasks_by_id() + self.refs = refs_by_id() + self.set_time_url: str | None = None + self.loaded = False + self.playing = asyncio.Lock() + self.reset() + + def create_app(self) -> FastMCP: + return self.mount(FastMCP(ENV, middleware=[ToolOrder()])) + + @tool(description="The current watch: the time, the freeze line, the FAA programs as issued (GDPs with JetBlue's " + "slots, ground stops), the departure caps known so far, every flight (its status, its delay once " + "known, your plan, where your plan has it leave and land on the day as known, its slot and its " + f"crew's deadline), where the aircraft are, and new messages. Costs {CALL_MIN} min of the watch.") + async def get_situation(self) -> str: + return await self._play(_situation) + + @tool(description="Check changes without confirming them: your changes over your plan, run on the day as known " + "now. Gives the plan's cost, the flights whose plan or times change, the automatic " + "cancellations, the flights the env would hold to the freeze line, and the changes " + f"confirm_changes would refuse. Costs {CALL_MIN} min of the watch.") + async def check_plan(self, changes: ChangesArg) -> str: + return await self._play(lambda day: day.check(_raw(changes))) + + @tool(description="Commit changes to your plan; flights you leave out keep theirs. From watch 1 on, frozen flights " + "(those your plan has leaving before the freeze line, an hour after the watch) can't be " + "changed, a cancellation is final, and the env holds any other flight your plan would have " + f"leaving before the freeze line to it. Costs {CALL_MIN} min of the watch.") + async def confirm_changes(self, changes: ChangesArg) -> str: + return await self._play(lambda day: day.confirm(_raw(changes))) + + @tool(description="End this watch: the clock moves to the next watch, flights leave as your plan has them, and " + "flights your plan has leaving before the new freeze line freeze. The watch's news comes in " + "this result's messages. After the last watch the day is graded on your plan.") + async def advance(self) -> str: + return await self._play(lambda day: day.advance(), ends=True) + + @extension(LIVE_LOAD_URI, description="Start a live v4 day on the task with this id; an env plays one day.") + def live_load(self, task_id: str, anchor: int | None = None, follower: int | None = None) -> dict: + """`anchor` and `follower` are those references' costs on the truth, each taken from the pack's refs.json when + not given (`stored`); without the anchor's the load solves it (references.anchor, up to its time limit). + Loading again would replay the day with its news already seen; the news before the first watch is in the + opening prompt (tasks.briefing), so it is not repeated in the first reply.""" + if self.loaded: + raise ValueError("a day was already loaded in this env: each live day needs a new deploy") + if task_id not in self.tasks: + raise ValueError(f"unknown task id {task_id!r}") + self.reset() + task = self.tasks[task_id] + kept = stored(task, self.refs) + anchor = kept.get("anchor") if anchor is None else anchor + follower = kept.get("follower") if follower is None else follower + self.day = Day(task, references.anchor(task)[1].cost.total if anchor is None else anchor, follower) + self.day.drain() + self.loaded = True + return {"task_id": task_id, "watches": len(self.day.watches)} + + @extension(END_DAY_URI, description="Grade the plan on the truth if the planner didn't end the day.") + def end_day(self) -> dict: + day = self._day() + if not day.done: + day.finish("end_day") + return {"end_reason": day.end_reason, "watch": day.watch, "cost": day.result["cost"]["total"], + "reward": day.result["reward"], "audit": audit(day)} + + @extension(CLOCK_URI, description="Follow the gateway's virtual clock; each new watch re-arms it.") + async def sync_time(self, env_get_time_url: str) -> dict: + self.set_time_url = env_get_time_url.removesuffix("/clock/time") + "/clock/set-time" + async with httpx.AsyncClient() as http: + response = await http.get(env_get_time_url, timeout=10) + response.raise_for_status() + return {"env_get_time_url": env_get_time_url, "virtual_time": response.json()["virtual_time"]} + + @reset_data + def reset(self) -> None: + self.day: Day | None = None + + @get_data + def get(self) -> list[DataPart]: + if self.day is None: + return [DataPart(data=EMPTY)] + reward = self.day.result["reward"] if self.day.result else None + return [DataPart(data=self.day.data() | {"reward": reward, "audit": audit(self.day)})] + + def _day(self, open_only: bool = False) -> Day: + if self.day is None: + raise ValueError(f"no day loaded: load one with {LIVE_LOAD_URI}") + if open_only and self.day.done: + raise ValueError("the day is over") + return self.day + + async def _play(self, act, ends: bool = False) -> str: + """Runs a planner call on the Day, one at a time, first re-arming the gateway clock at the next watch if the + call will open it, and arming it back at this watch if the call then fails.""" + async with self.playing: + day = self._day(open_only=True) + rearm = not day.last and (ends or day.clock + CALL_MIN >= day.ends) + if rearm: + await self._arm(day.ends) + try: + out = act(day) + except Exception: + if rearm: + await self._arm(day.t) + raise + if day.done: + out |= {"done": True, "end_reason": day.end_reason, "cost": day.result["cost"]["total"], + "reward": day.result["reward"]} + return _json({**out, "messages": day.drain()}) + + async def _arm(self, t: int) -> None: + """Freezes the gateway's clock at minute `t` of the day.""" + if self.set_time_url is None: + raise ValueError(f"the gateway clock is not synced: call {CLOCK_URI} sync_time first") + async with httpx.AsyncClient() as http: + response = await http.put(self.set_time_url, timeout=10, json={ + "virtual_time": virtual_time(self.day.task, t), "virtual_seconds_per_real_second": 0}) + response.raise_for_status() + + +def _situation(day: Day) -> dict: + s = day.situation() + out = {"watch": s["watch"], "time": s["time"], "clock": s["clock"], "freeze_line": s["freeze_line"], + "situation": view.render(day.task, s), "now": s["now"]} + return out | ({"watch_over": s["watch_over"]} if "watch_over" in s else {}) + + +class ToolOrder(Middleware): + """Lists the planner's tools in the order of the rules.""" + + async def on_list_tools(self, context, call_next): + return sorted(await call_next(context), key=lambda t: TOOLS.index(t.name)) diff --git a/src/agentenv_airops/v4day/server.py b/src/agentenv_airops/v4day/server.py new file mode 100644 index 0000000..6049600 --- /dev/null +++ b/src/agentenv_airops/v4day/server.py @@ -0,0 +1,14 @@ +"""The v4 env server: airops-v4-day on streamable HTTP, beside v1's server.py.""" + +import os + +from .live import AirOpsV4LiveEnv + + +def main() -> None: + AirOpsV4LiveEnv().create_app().run(transport="http", host=os.environ.get("MCP_HOST", "0.0.0.0"), + port=int(os.environ.get("MCP_PORT", "18765")), show_banner=False) + + +if __name__ == "__main__": + main() diff --git a/src/agentenv_airops/v4day/situation.py b/src/agentenv_airops/v4day/situation.py new file mode 100644 index 0000000..bb279b2 --- /dev/null +++ b/src/agentenv_airops/v4day/situation.py @@ -0,0 +1,261 @@ +"""What the desk sees at a watch (docs/v4/DESIGN.md §5), as a JSON-able dict and as text in v1's style: the time and the +freeze line, the programs as issued (sim.known's GDP segments, a proposed one included, and ground-stop pieces), the +departure caps and outstation arrival caps known so far (the truth's `layers.dep_caps` and `layers.arr_caps` for the +hours within CAP_LOOK, as sim.known has them), the ramp closures under way or over (sim.known's: begun by the watch, +each with its end once reported, else the end expected), the latest METAR or SPECI and TAF delivered per hub, every +flight on the day as known (status, known delay, the plan, where the plan has it leave and land, its slot or hold, its +crew's deadline and rest, and for a departure from a base with a reserve pool whether the plan has a reserve crew on +it), each such base's reserve pool and what the plan's calls leave of it (sim.granted), the aircraft as the plan has +them now, and the news delivered so far.""" + +from collections import defaultdict +from collections.abc import Mapping +from datetime import date +from typing import TYPE_CHECKING + +from ..core.known import CAP_LOOK, LOOK +from ..core.model import hhmm +from ..core.sim import AUTO_CANCELLED, CANCELLED, FLOWN +from ..day import fid +from ..programs import sim +from ..programs.advisories import PROPOSED +from ..programs.model import Task +from .clock import WEATHER, News + +if TYPE_CHECKING: + from .day import Day + +KEYS = ("watch", "time", "clock", "freeze_line", "frozen", "cost", "programs", "dep_caps", "arr_caps", "closures", + "weather", "reserves", "flights", "aircraft", "news") + + +def build(day: "Day") -> dict: + task, t, run, world = day.task, day.t, day.project(day.plan), day.known() + return {"watch": day.watch, "time": hhmm(t), "clock": hhmm(day.clock), "freeze_line": _time(day.line), + "frozen": len(day.frozen), "cost": run.cost.to_dict() | {"counts": run.counts}, + "programs": programs(task, world), "dep_caps": dep_caps(task, t), "arr_caps": arr_caps(task, t), + "closures": closures(world, t), "weather": weather(day), + "reserves": reserves(task, run.reserved), + "flights": [flight_row(day, f.id, run.flights[f.id], run.reserved) + for f in sorted(task.flights, key=lambda f: (f.sched_dep, f.id))], + "aircraft": aircraft(task, run, day.clock), "news": [day.items[n].to_dict() for n in day.delivered]} + + +def programs(task: Task, world: sim.World) -> dict: + """The programs of the day as known, per hub: GDP segments (proposed or issued) and stop pieces.""" + kinds = {a.id: a.kind for p in task.programs.values() for a in p.advisories} + return {h: {"gdps": [{"id": s.id, "kind": "proposed" if kinds.get(s.id) == PROPOSED else "gdp", + "period": [hhmm(s.start), hhmm(s.end)], "governs": [hhmm(s.since), hhmm(s.until)], + "jetblue_slots": [hhmm(x) for x in s.slots]} for s in world.segments.get(h, ())], + "stops": [{"chain": p.chain, "from": hhmm(p.start), "to": hhmm(p.end)} for p in world.stops + if p.hub == h]} + for h in sorted(task.programs)} + + +def dep_caps(task: Task, t: int) -> dict[str, dict[str, int]]: + """JetBlue's departure caps per station and hour known at t: the hours starting within CAP_LOOK.""" + return _hours(task.layers.dep_caps if task.layers else {}, t) + + +def arr_caps(task: Task, t: int) -> dict[str, dict[str, int]]: + """JetBlue's arrival caps per outstation and hour known at t: the hours starting within CAP_LOOK.""" + return _hours(task.layers.arr_caps if task.layers else {}, t) + + +def _hours(caps: Mapping[str, Mapping[int, int]], t: int) -> dict[str, dict[str, int]]: + return {s: {f"{h % 24:02d}" + (f"+{h // 24}" if h >= 24 else ""): c for h, c in sorted(by.items()) + if 60 * h <= t + CAP_LOOK} for s, by in sorted(caps.items())} + + +def closures(world: sim.World, t: int) -> dict[str, list[dict]]: + """The ramp closures the day as known at watch t has per hub: from, to, whether that end is reported (it has come + by t; else it is expected, sim.known), and JetBlue's departure and arrival caps over it.""" + return {s: [{"from": hhmm(a), "to": hhmm(b), "reported": b <= t, "dep_cap": dep, "arr_cap": arr} + for a, b, dep, arr in spans] for s, spans in sorted(world.closures.items())} + + +def weather(day: "Day") -> dict[str, dict]: + """Per hub the latest observation (METAR or SPECI) and the latest TAF delivered so far: the one observed or issued + last, of those observed or issued at one minute the last delivered (kinds' lags differ, so an older one can + arrive with or after a newer one).""" + latest: dict[tuple[str, str], News] = {} + for n in (day.items[i] for i in day.delivered): + if n.kind in WEATHER: + key = (n.hub, "taf" if n.kind == "taf" else "observation") + if key not in latest or n.at >= latest[key].at: + latest[key] = n + out: dict[str, dict] = {} + for (hub, kind), n in sorted(latest.items()): + out.setdefault(hub, {})[kind] = n.to_dict() + return out + + +def reserves(task: Task, granted: frozenset[int]) -> dict[str, dict]: + """Each crew base's reserve pool, the flights the plan has on its reserve crews and how many are left.""" + pools = task.layers.reserves if task.layers else {} + on = {b: sorted(i for i in granted if task.flight(i).orig == b) for b in pools} + return {b: {"pool": n, "called": [fid(i) for i in on[b]], "left": n - len(on[b])} for b, n in sorted(pools.items())} + + +def flight_row(day: "Day", i: int, r: sim.FlightRun, granted: frozenset[int] = frozenset()) -> dict: + f, entry = day.task.flight(i), day.plan.get(i) + row = {"id": fid(i), "flight": f.flight, "orig": f.orig, "dest": f.dest, "sched_dep": hhmm(f.sched_dep), + "sched_arr": hhmm(f.sched_arr), "pax": f.pax, "family": f.family, "status": status(day, i, r), + "known_delay": f.lb if f.sched_dep <= day.t + LOOK else None, "plan": plan_text(entry), + "hold": _time(entry.get("not_before")) if entry and not entry.get("cancel") else None, + "dep": _time(r.dep), "arr": _time(r.arr), + "late": r.arr - f.sched_arr if r.status == FLOWN else None, "tail": r.tail, + "slot": {"program": r.program, "at": hhmm(r.slot)} if r.slot is not None else None} + if f.crew: + c = day.task.crew(f) + row["crew"] = {"pairing": c.pairing, "deadline": _time(c.deadline), "rest": _time(c.rest)} + if day.task.reservable(f): + row["crew"]["reserve"] = i in granted + return row + + +def status(day: "Day", i: int, r: sim.FlightRun) -> str: + if r.status == FLOWN and r.dep < day.clock: + return "flown" if r.arr <= day.clock else "airborne" + if i not in day.frozen: + return {CANCELLED: "cancelled", AUTO_CANCELLED: "auto-cancel"}.get(r.status, "open") + return "cancelled" if r.status == CANCELLED else "frozen" + + +def aircraft(task: Task, run: sim.Execution, t: int) -> dict: + """Where each aircraft is at t as the plan has it: at a station (ready then, after its turn and any Move it is + bound to), or airborne on a leg.""" + cancelled = {i for i, r in run.flights.items() if r.status == CANCELLED} + carried, own = sim.routes(task, cancelled) + at: dict[str, list[dict]] = defaultdict(list) + airborne = [] + for tail in task.tails: + station, ready = tail.pos, tail.ready + if tail.tail in own: + station, ready = sim.through(own[tail.tail], ready, {}) + for i in run.legs.get(tail.tail, ()): + r = run.flights[i] + if r.dep >= t: + break + f = task.flight(i) + if r.arr > t: + airborne.append({"tail": tail.tail, "flight": fid(i), "to": f.dest, "lands": hhmm(r.arr)}) + station = None + break + back = task.back(f, r.arr) + station, ready = sim.through(carried[i], back, {}) if i in carried else (f.dest, back) + if station is not None: + at[station].append({"tail": tail.tail, "family": tail.family, "ready": _time(ready if ready > t else None)}) + return {"stations": {s: at[s] for s in sorted(at, key=lambda s: (s not in task.storms, s))}, "airborne": airborne} + + +def render(task: Task, s: Mapping) -> str: + hubs = "/".join(task.storms) + freeze = ("First watch: nothing is frozen; changes take effect at once." if s["freeze_line"] is None else + f"Freeze line {s['freeze_line']}: flights your plan has leaving before it are frozen ({s['frozen']} " + "now), cancellations are final, and no other flight leaves before it.") + c, n = s["cost"], s["cost"]["counts"] + weekday = date.fromisoformat(task.date).strftime("%A") + parts = [f"# JetBlue operations desk, {hubs}, {weekday} {task.date}; times are local ({task.tz}).", + f"Now {s['clock']} (watch {s['watch']}, opened {s['time']}). {freeze}", + f"Your plan on the day as known costs {c['total']:,} (delay {c['delay']:,}, cancellations {c['cancel']:,}," + f" notice {c['notice']:,}, ferries {c['ferry']:,}): {n[CANCELLED]} cancelled, {n[AUTO_CANCELLED]} " + f"cancelled automatically, {n['slots']} on slots.", + _programs_text(s["programs"]), _caps_text(s["dep_caps"]), _arr_caps_text(s["arr_caps"]), + _closures_text(s["closures"]), _weather_text(s["weather"]), *_reserves_text(s["reserves"]), + _flights_text(s["flights"]), + _aircraft_text(s["aircraft"]), _news_text(s["news"])] + return "\n\n".join(parts) + + +def _programs_text(p: Mapping) -> str: + lines = [] + for h, by in p.items(): + for g in by["gdps"]: + name = "proposed ground delay program" if g["kind"] == "proposed" else "ground delay program" + slots = " ".join(g["jetblue_slots"]) or "none" + lines.append(f"- {h} {name} {g['id']}: arrivals {g['period'][0]}-{g['period'][1]}, governs departures " + f"{g['governs'][0]}-{g['governs'][1]}; JetBlue's slots {slots}") + lines += [f"- {h} ground stop {x['chain']}: {x['from']}-{x['to']}" for x in by["stops"]] + return "## Programs as issued (FAA Command Center)\n" + ("\n".join(lines) or "None.") + + +def _caps_text(caps: Mapping) -> str: + lines = [f"{st}: " + " ".join(f"{h}h {c}" for h, c in by.items()) for st, by in caps.items() if by] + return (f"## Departure caps: JetBlue departures per hour, known {CAP_LOOK} min ahead\n" + + ("\n".join(lines) or "None known.")) + + +def _arr_caps_text(caps: Mapping) -> str: + lines = [f"{st}: " + " ".join(f"{h}h {c}" for h, c in by.items()) for st, by in caps.items() if by] + return (f"## Outstation arrival caps: JetBlue arrivals per hour, known {CAP_LOOK} min ahead\n" + + ("\n".join(lines) or "None known.")) + + +def _closures_text(shut: Mapping) -> str: + lines = [f"- {st} {c['from']}-{c['to']}{'' if c['reported'] else ' (end expected)'}: at most {c['dep_cap']} " + f"departures and {c['arr_cap']} arrivals" for st, spans in shut.items() for c in spans] + return ("## Ramp closures (lightning at the field; known once begun)\n" + ("\n".join(lines) or "None.")) + + +def _weather_text(wx: Mapping) -> str: + lines = [f"[{n['at']}] {n['text']}" for by in wx.values() for n in by.values()] + return "## Latest weather (METAR or SPECI, and TAF)\n" + ("\n".join(lines) or "None.") + + +def _reserves_text(pools: Mapping) -> list[str]: + """The reserve pools, when the day has any: a base departure past its crew deadline flies only on one.""" + if not pools: + return [] + lines = [f"{b}: {p['left']} of {p['pool']} left" + (f" (called: {' '.join(p['called'])})" if p["called"] else "") + for b, p in pools.items()] + return ["## Reserve crews (call_reserve: a departure from the base may leave past its crew deadline on one; " + "granted in flight-id order while the pool lasts; release_reserve gives it back)\n" + "\n".join(lines)] + + +def _flights_text(rows: list[dict]) -> str: + out = ["## Flights (sched = scheduled dep-arr; delay = known delay, ? not yet; plan = cancel or hold@HH:MM; " + "proj = where your plan has it leave and land on the day as known; late = arrival delay; slot = its GDP " + "slot; crew = crew deadline/rest end, +R on a reserve crew)", + "id flight route sched pax type status delay plan proj late tail slot crew"] + for r in rows: + proj = f"{r['dep']}-{r['arr']}" if r["dep"] else "-" + late = f"{r['late']:+d}" if r["late"] is not None else "-" + slot = f"{r['slot']['program']}@{r['slot']['at']}" if r["slot"] else "-" + crew = f"{r['crew']['deadline'] or '-'}/{r['crew']['rest'] or '-'}" if r.get("crew") else "-" + crew += "+R" if r.get("crew", {}).get("reserve") else "" + delay = "?" if r["known_delay"] is None else str(r["known_delay"]) + out.append(f"{r['id']} {r['flight'].split()[-1]} {r['orig']}-{r['dest']} {r['sched_dep']}-{r['sched_arr']} " + f"{r['pax']} {r['family']} {r['status']} {delay} {r['plan']} {proj} {late} {r['tail'] or '-'} " + f"{slot} {crew}") + return "\n".join(out) + + +def _aircraft_text(a: Mapping) -> str: + lines = ["## Aircraft now, as your plan has them (@ = ready at)"] + for s, tails in a["stations"].items(): + lines.append(f"{s}: " + " ".join(x["tail"] + (f"@{x['ready']}" if x["ready"] else "") for x in tails)) + lines.append("airborne: " + (" ".join(f"{x['tail']} {x['flight']}>{x['to']}@{x['lands']}" for x in a["airborne"]) + or "none")) + return "\n".join(lines) + + +def _news_text(items: list[dict]) -> str: + return "## News so far\n" + ("\n".join(f"[{n['at']}] {n['text']}" for n in items) or "None.") + + +def plan_text(e: Mapping | None) -> str: + if e and e.get("cancel"): + return "cancel" + text = f"hold@{hhmm(e['not_before'])}" if e and e.get("not_before") is not None else "-" + return (text + "+reserve" if text != "-" else "reserve") if e and e.get("reserve") else text + + +def proj_text(task: Task, r: sim.FlightRun) -> str: + if r.status == FLOWN: + return f"{hhmm(r.dep)}-{hhmm(r.arr)} {r.arr - task.flight(r.id).sched_arr:+d} {r.tail}" + return "cancelled" if r.status == CANCELLED else "auto-cancel" + + +def _time(minute: int | None) -> str | None: + return None if minute is None else hhmm(minute) diff --git a/src/agentenv_airops/v4day/tasks.py b/src/agentenv_airops/v4day/tasks.py new file mode 100644 index 0000000..c13871c --- /dev/null +++ b/src/agentenv_airops/v4day/tasks.py @@ -0,0 +1,135 @@ +"""v4's task prompts and bundle steps, in tasks.py's style (v1's prompts stay as they are): the rules of a storm date +on the programs world as the system prompt, the first watch as the opening prompt, and the steps that play one task +live on airops-v4-day with airops-llm and grade it with airops-verifier.""" + +from ..core.known import CAP_LOOK, FREEZE, LOOK, STEP +from ..core.model import hhmm +from ..programs import references, sim +from ..programs.cost import NOTICE +from ..programs.model import Task +from ..schedule import AGENT, EXTRA_TURNS, TURNS_PER_WATCH, VERIFIER +from .clock import CALL_MIN, virtual_time +from .day import Day +from .live import END_DAY_URI, ENV, LIVE_LOAD_URI + +LOAD_SLACK = 120 # s past the anchor's time limit for a load that solves it: reading the day and building the MIP + +def rules(task: Task) -> str: + """The system prompt: v1's desk on the programs world, with the crew paragraph when the task has crews, and on a + layered task (docs/v4/DESIGN.md §4) its event-driven watches, the weather news and its delivery lags, the + outstation arrival caps and the ramp closures.""" + layered = task.layered + watches = (f"in watches starting at watch 0: one an hour, and one more when an advisory or a SPECI reaches " + f"the desk or a ramp closure begins or ends, so watches are {references.WATCH_MIN} to {STEP} minutes " + "apart" if layered else "in watches, one an hour, starting at watch 0") + news = (f"FAA Command Center advisories and the hubs' METARs, SPECIs and TAFs arrive as messages at the first " + f"watch after they reach the desk: an advisory {task.lag('advisory')} minutes after it is signed, a METAR " + f"{task.lag('metar')} and a SPECI {task.lag('speci')} minutes after its observation, a TAF " + f"{task.lag('taf')} minutes after its issue" if layered + else "FAA Command Center advisories arrive as messages as they are signed") + layers = (f"\n- Outstation arrival caps: an outstation may take only so many JetBlue arrivals in an hour (the " + f"outstation arrival caps table, known {CAP_LOOK // 60} hours ahead); a flight with no room in its " + "arrival hour leaves so as to land in the next one.\n- Ramp closures: lightning at the field cuts a " + "hub's ramp rate without stopping it. During a closure only so many JetBlue flights may leave the hub " + "and only so many arrive there; a departure with no room leaves at the closure's end, an arrival with " + "none leaves so as to land at it. A closure is known once it begins; until its end comes it is expected " + f"to last {sim.GUESS} minutes more, with caps for that long, and the closures table says which ends " + "are reported and which expected." if layered else "") + situation = (", the outstation arrival caps known so far, the ramp closures under way or over, the latest METAR " + "or SPECI and TAF per hub" if layered else "") + crew = ("\n- Crews: a flight's crew has a deadline, the latest it may leave under its duty limit; a flight not " + "gone by its crew deadline may leave only once its crew's legal rest ends (still by its deadline), and one " + "whose rest ends past its deadline is cancelled automatically at the deadline. The table shows each " + "flight's crew deadline and rest end." if any(f.crew for f in task.flights) else "") + return f"""You run the JetBlue operations desk on a storm day at JFK and BOS: thunderstorms, snow or wind, \ +and the FAA ground stops and ground delay programs (GDPs) that follow, cut how many JetBlue flights can land and take \ +off. Your job is to decide which flights to cancel and which to hold, watch by watch, so that passengers and the \ +airline lose as little as possible over the day. + +How the day runs: +- The day is played {watches}. The first message gives the day as the desk \ +knows it at watch 0. Every tool call takes {CALL_MIN} minute of the watch; when the watch's time runs out, the next \ +watch opens by itself, and advance opens it at once. +- What the desk knows: a flight's own departure delay (weather, air traffic control: anything but waiting for its \ +aircraft or a slot) becomes known {LOOK // 60} hours before its scheduled departure; until then it is assumed on \ +time. {news}, and the programs table shows the programs \ +as issued, proposed GDPs included; a program may end earlier or later than issued. +- Ground delay programs: a GDP meters arrivals at a hub over its period. A flight into the hub from an origin in its \ +scope, scheduled to land in the period, needs one of JetBlue's arrival slots in the program: it takes the earliest \ +free slot at or after the time it could land and leaves at its EDCT, the slot less its scheduled block. A cancelled \ +or late flight leaves its slot to the flights behind it. Arrivals outside a program fit under the hub's hourly rate. +- Ground stops: a flight to a stopped hub from an origin the stop covers waits on the ground until the stop ends; \ +flights already airborne fly on. +- Departure caps: an airport may take only so many JetBlue departures in an hour (the departure caps table, known \ +{CAP_LOOK // 60} hours ahead); a flight with no room in its hour tries again at the next hour's start.{layers}{crew} +- The env flies your plan on the day as it happens: each flight leaves at the earliest time its delay, its hold, a \ +slot, a stop, a cap and an aircraft allow. It takes the first aircraft of its type ready at its origin (aircraft of \ +one type at one airport are interchangeable; an aircraft needs its turn time on the ground between flights). A \ +flight that can't leave by its deadline is cancelled automatically there. At the end of the day the env ferries each \ +aircraft to the airport it is needed at tomorrow. +- Your decisions: cancel a flight (its aircraft stays on the ground, free for other flights, and its slot goes to the \ +flights behind), restore a cancelled flight, or hold a flight until a time (not_before) so that its slot or aircraft \ +goes to another flight first. A flight you don't touch leaves as early as it can. +- The freeze: at watch 0 nothing is frozen. From watch 1 on, flights your plan has leaving before the freeze line, \ +{FREEZE} minutes after the watch opens, are frozen: they can't be changed, and stay frozen. A cancellation is final. \ +The env holds any other flight your plan would have leaving before the freeze line to it, and refuses a change that \ +would move a frozen flight or one that has already gone. + +Tools: +- get_situation(): the time, the freeze line, the programs as issued with JetBlue's slots, the departure caps known \ +so far{situation}, every flight (its status, its known delay, your plan, where your plan has it leave and land on \ +the day as known, how late it lands, its aircraft, its slot and its crew), where the aircraft are, and the news so far. +- check_plan(changes): your changes over your plan, run on the day as known now: the plan's cost, the flights whose \ +plan or times change, the automatic cancellations, the flights the env would hold to the freeze line, and the changes \ +confirm_changes would refuse. It confirms nothing. +- confirm_changes(changes): commits the changes; flights you leave out keep their plan. +- advance(): ends the watch; its result has the new watch's news. After the last watch it returns "done": the day \ +is graded on your plan. +A change is {{"flight": "f12", "action": "cancel" | "restore" | "hold" | "call_reserve" | "release_reserve", \ +"not_before": "HH:MM" | null}}: cancel takes not_before null; restore and hold fly the flight, held until not_before \ +(local time, "00:30+1" after midnight), or as early as it can with null. Where a crew base has a reserve pool (the \ +situation's Reserve crews), a flight leaving it past its crew's deadline flies only on a reserve: call_reserve gives \ +it one while the pool lasts, release_reserve hands it back before that deadline. + +The cost, in passenger-minutes: every passenger's minutes of arrival delay; 523 for each passenger on a cancelled \ +flight, plus one for every minute the cancellation is decided later than {NOTICE // 60} hours before its scheduled \ +departure (an automatic cancellation is decided at its deadline, so it costs more than an early one); the airline's \ +own aircraft minutes (a late departure costs crew and fixed costs, a long block fuel too, and a cancellation saves \ +its fuel and maintenance); and the end-of-day ferries. A cancellation frees an aircraft and a slot; it pays when it \ +saves the others more than it costs its own passengers. The day is graded once, on your final plan flown on the day \ +as it really happened: the closer its cost to the best plan possible in hindsight, the higher the score. If you stop \ +before the last watch, the rest of the day runs on your plan.""" + + +def briefing(day: Day) -> str: + """The opening prompt: the first watch's situation, the news so far included; it costs the day no time.""" + return f"{day.text()}\n\nIt is the first watch, {hhmm(day.t)}. Nothing is frozen yet: set the day up with " \ + "confirm_changes, then call advance." + + +def max_turns(day: Day) -> int: + return TURNS_PER_WATCH * len(day.watches) + EXTRA_TURNS + + +def steps(task: Task, anchor: int | None = None, follower: int | None = None) -> list[dict]: + """One task's bundle steps: deploy airops-v4-day, load the day (with the anchor's and the follower's costs when + given, so the load needn't solve the anchor; without the anchor's the load step waits out the solve), play it + with airops-llm on the gateway's clock frozen at the first watch, end the day and grade it.""" + day = Day(task) + refs = {k: v for k, v in (("anchor", anchor), ("follower", follower)) if v is not None} + solve = {} if anchor is not None else {"timeout_seconds": int(references.TIME_LIMIT) + LOAD_SLACK} + return [ + {"id": "deploy", "type": "deploy_env", "env_id": ENV}, + {"id": "load-task", "type": "apply_server_config", "env_id": ENV, + "directives": [{"service": ENV, "uri": LIVE_LOAD_URI, "args": {"task_id": task.task_id, **refs}}], **solve}, + {"id": "deploy-agent", "type": "deploy_agent", "a2a_agent_id": AGENT, "env_ids": [ENV]}, + {"id": "clock", "type": "sync_env_clock", "env_id": ENV, "virtual_time": virtual_time(task, day.t), + "virtual_seconds_per_real_second": 0, "tolerate_missing_sync_time": False}, + {"id": "play", "type": "prompt_agent", "prompt_id": task.task_id, "system_prompt": rules(task), + "prompt": briefing(day), "max_turns": max_turns(day), "model_params": {"max_tokens": 32000}, + "timeout_seconds": 7200}, + {"id": "end-day", "type": "apply_server_config", "env_id": ENV, + "directives": [{"service": ENV, "uri": END_DAY_URI, "args": {}}]}, + {"id": "grade", "type": "env_outcome_verifier", "env_id": ENV, "file_artifact_id": VERIFIER, + "verifier_id": "airops", "score_aggregator": "weighted_average"}, + ] diff --git a/tests/programs/conftest.py b/tests/programs/conftest.py index dd92e5c..443afa2 100644 --- a/tests/programs/conftest.py +++ b/tests/programs/conftest.py @@ -8,6 +8,7 @@ from agentenv_airops.build import bts, pack from agentenv_airops.programs import advisories, candidates, rbs, repairs, unseen +from agentenv_airops.programs.layers.crew import EDGAR, Rule @pytest.fixture(scope="session") @@ -53,9 +54,22 @@ def crewed(slice_inputs, fits, tmp_path_factory): return build(slice_inputs, fits, tmp_path_factory.mktemp("derived"), repairs.BASES).task -def build(slice_inputs, fits, derived, bases=()): +@pytest.fixture(scope="session") +def v4crewed(slice_inputs, fits, tmp_path_factory): + """The same evening with the crew model at JetBlue's bases, its pairings cut legal on schedule (layers.crew).""" + return build(slice_inputs, fits, tmp_path_factory.mktemp("derived"), repairs.BASES, Rule("v4")).task + + +@pytest.fixture(scope="session") +def v4plain(slice_inputs, fits, tmp_path_factory): + """The evening built with v4's crew cut and no crew bases: no crew model at all.""" + return build(slice_inputs, fits, tmp_path_factory.mktemp("derived"), (), Rule("v4")).task + + +def build(slice_inputs, fits, derived, bases=(), crew=EDGAR): e_fit, e_median = unseen.table(fits, {"JFK": 1.0, "BOS": 1.0}) with pytest.MonkeyPatch.context() as mp: mp.setattr(pack, "MIN_TAILS", 5) return candidates.build_candidate(DATE, HUB, slice_inputs, derived, e_fit, e_median, - {"JFK": 1.0, "BOS": 1.0}, window=WINDOW, split="train", bases=bases) + {"JFK": 1.0, "BOS": 1.0}, window=WINDOW, split="train", bases=bases, + crew=crew) diff --git a/tests/programs/day_helpers.py b/tests/programs/day_helpers.py index ee6a0fd..b41b561 100644 --- a/tests/programs/day_helpers.py +++ b/tests/programs/day_helpers.py @@ -4,16 +4,21 @@ import random from dataclasses import replace +import pandas as pd from program_helpers import EVERY, adv from agentenv_airops.core.model import Airport -from agentenv_airops.programs import network +from agentenv_airops.programs import network, repairs from agentenv_airops.programs.advisories import HUBS, Advisory, Stop +from agentenv_airops.programs.candidates import midnight +from agentenv_airops.programs.layers.crew import Rule from agentenv_airops.programs.model import ( + EXTENSION, Busy, Connection, Crew, Flight, + Layers, Move, Outcome, Programs, @@ -83,6 +88,81 @@ def three_into_a_program(**kw) -> Task: segments=(GDP,)) +def capped(task: Task, caps: dict[str, dict[int, int]]) -> Task: + """The task with departure caps per station and hour as its only layer.""" + return replace(task, layers=Layers(dep_caps=caps)) + + +def capped_day() -> Task: + """Five departures from JFK, two of them on aircraft landing from a GDP with slots at 180, 200 and 260, under a + departure cap of one in hour 2 and two in hour 3.""" + flights = [flight(0, "BUF", "JFK", 100, 60), flight(1, "DCA", "JFK", 110, 60, pax=60), + flight(2, "JFK", "MCO", 125, 150, pax=180), flight(3, "JFK", "BUF", 130, 60, pax=50), + flight(4, "JFK", "DCA", 150, 60, pax=120), flight(5, "JFK", "PWM", 200, 70), + flight(6, "JFK", "BUF", 215, 60, pax=90)] + tails = [tail("N1", "BUF", end="PWM"), tail("N2", "DCA", end="BUF"), tail("N3", "JFK", end="MCO"), + tail("N4", "JFK", end="BUF"), tail("N5", "JFK", end="DCA")] + return capped(day(flights, tails, segments=(segment("g", 0, 1000, 150, 400, (180, 200, 260)),)), + {"JFK": {2: 1, 3: 2}}) + + +def shared_hour_day() -> Task: + """BOS into JFK's GDP (slots 130 and 200) and BOS to DCA, both ready in hour 1, which BOS caps at one departure: + the slotted one takes the hour.""" + return capped(day([flight(0, "BOS", "JFK", 60, 60), flight(1, "BOS", "DCA", 75, 60)], + [tail("N1", "BOS", end="JFK"), tail("N2", "BOS", end="DCA")], + segments=(segment("g", 0, 1000, 100, 400, (130, 200)),)), {"BOS": {1: 1}}) + + +def late_pin_day() -> Task: + """N1 is ready at JFK at 190 for flight 1, which a plan may pin to have left at 170 (hour 2); N2 is ready there + at 230 for flight 2 at 235 (hour 3). JFK caps hours 2 and 3 at one departure each.""" + flights = [flight(0, "BUF", "JFK", 100, 60), flight(1, "JFK", "DCA", 170, 60), + flight(2, "JFK", "BUF", 235, 60), flight(3, "DCA", "JFK", 140, 60)] + return capped(day(flights, [tail("N1", "BUF", end="DCA"), tail("N2", "DCA", end="BUF")]), {"JFK": {2: 1, 3: 1}}) + + +def two_from_jfk() -> Task: + """Two departures from JFK ready in hour 1, which JFK caps at one.""" + return capped(day([flight(0, "JFK", "BUF", 60, 60), flight(1, "JFK", "DCA", 65, 60)], + [tail("N1", "JFK", end="BUF"), tail("N2", "JFK", end="DCA")]), {"JFK": {1: 1}}) + + +def closed(task: Task, closures: dict[str, tuple[tuple[int, int, int, int], ...]], + rates: dict[str, float] | None = None) -> Task: + """The task with ramp closures per station (start, end, dep_cap, arr_cap), and the closure rates per station that + price one under way on the day as known, added to any layer it carries.""" + layers = task.layers or Layers() + return replace(task, layers=replace(layers, closures=closures, rates=layers.rates | (rates or {}))) + + +def closed_day(arr_cap: int = 2) -> Task: + """JFK's ramp is closed 120-180 with room for one departure and `arr_cap` arrivals (rate 0.5 as known): N1, N2 + and N3 are ready there for flights 0, 1 and 2 at 120, 130 and 140. N4 lands from BUF at 160 and N5 from DCA at + 170, both inside the closure, for flights 4 to PWM at 200 and 6 to BUF at 210.""" + flights = [flight(0, "JFK", "BUF", 120, 60), flight(1, "JFK", "DCA", 130, 60, pax=60), + flight(2, "JFK", "MCO", 140, 150, pax=180), flight(3, "BUF", "JFK", 100, 60), + flight(4, "JFK", "PWM", 200, 70), flight(5, "DCA", "JFK", 110, 60, pax=80), + flight(6, "JFK", "BUF", 210, 60)] + tails = [tail("N1", "JFK", end="BUF"), tail("N2", "JFK", end="DCA"), tail("N3", "JFK", end="MCO"), + tail("N4", "BUF", end="PWM"), tail("N5", "DCA", end="BUF")] + return closed(day(flights, tails), {"JFK": ((120, 180, 1, arr_cap),)}, {"JFK": 0.5}) + + +def outcapped(task: Task, caps: dict[str, dict[int, int]]) -> Task: + """The task with arrival caps per outstation and hour, added to any layer it carries.""" + return replace(task, layers=replace(task.layers or Layers(), arr_caps=caps)) + + +def outcapped_day() -> Task: + """N1 lands at BUF from JFK at 120 and N2 from DCA at 130, both in hour 2, which BUF caps at one arrival; N3 lands + at PWM, uncapped, at 125.""" + flights = [flight(0, "JFK", "BUF", 60, 60), flight(1, "DCA", "BUF", 70, 60, pax=60), + flight(2, "JFK", "PWM", 55, 70)] + tails = [tail("N1", "JFK", end="BUF"), tail("N2", "DCA", end="BUF"), tail("N3", "JFK", end="PWM")] + return outcapped(day(flights, tails), {"BUF": {2: 1}}) + + def shortened(task: Task, slack: int = 150) -> Task: """The task with every deadline at most `slack` after the earliest departure, to keep a test's MIP small.""" return replace(task, flights=tuple(replace(f, deadline=min(f.deadline, task.earliest(f, f.lb) + slack)) @@ -214,3 +294,57 @@ def random_day(rng: random.Random, n_tails: int = 3) -> Task: return replace(task, tails=tuple(replace(t, connections=tuple( Connection(a, b, rng.randrange(0, 2 * TURN)) for a, b in zip(t.legs, t.legs[1:], strict=False) if task.flight(a).dest == task.flight(b).orig and rng.random() < 0.6)) for t in task.tails)) + + +def based(f: Flight, deadline: int) -> Flight: + """f leaving a crew base on a pairing whose crew deadline, with a finite reserve pool there, is `deadline`.""" + report = f.sched_dep - 45 + return replace(f, crew=Crew(f"{f.actual.tail}/0", report, f.orig, 2, deadline - report - EXTENSION + f.sched_block, + None, None)) + + +def reserved(task: Task, pools: dict[str, int]) -> Task: + """The task with a reserve pool per crew base, added to any layer it carries.""" + return replace(task, layers=replace(task.layers or Layers(), reserves=pools)) + + +def reserve_day() -> Task: + """Two JFK departures held past their crew deadline (150) by a known delay, their rest ending past their deadline + (700), and a PWM departure whose outstation crew must leave by 480; JFK's pool holds one reserve.""" + flights = [based(flight(0, "JFK", "BUF", 100, 60, lb=100, deadline=700), 150), + based(flight(1, "JFK", "PWM", 105, 70, pax=120, lb=100, deadline=700), 150), + crewed(flight(2, "PWM", "JFK", 400, 70, deadline=640), 480)] + return reserved(day(flights, [tail("N1", "JFK", end="BUF"), tail("N2", "JFK", end="JFK")]), {"JFK": 1}) + + +def with_crews(task: Task, rng: random.Random, pools: dict[str, int]) -> Task: + """The task with a crew on every flight, its crew deadline 0-150 after its scheduled departure (from a hub, a + base with a pool of `pools`), and those pools.""" + flights = [] + for f in task.flights: + d = f.sched_dep + rng.randrange(0, 150) + flights.append(based(f, d) if f.orig in HUBS else crewed(f, d)) + return reserved(replace(task, flights=tuple(flights)), pools) + + +CREW_DATE = "2024-07-01" +BASE = midnight(CREW_DATE) +ZONES = dict.fromkeys(("JFK", "BOS", "BUF", "DCA", "PWM", "MCO", "RDU"), "America/New_York") +CHAIN = [("JFK", "BUF", 500, 100), ("BUF", "DCA", 660, 80), ("DCA", "PWM", 800, 90), ("PWM", "BUF", 950, 70), + ("BUF", "DCA", 1080, 80), ("DCA", "JFK", 1230, 70)] + + +def legs(rows, tail_: str = "N1") -> pd.DataFrame: + """A tail's scheduled legs (origin, destination, departure, block) as repairs.crews reads them, labelled 0 on.""" + return pd.DataFrame([(k, tail_, o, d, sd, b) for k, (o, d, sd, b) in enumerate(rows)], + columns=["label", "Tail_Number", "Origin", "Dest", "sd", "CRSElapsedTime"]).set_index("label") + + +def chain_day(rule: Rule, lb: int = 0, slack: int = 240) -> Task: + """One aircraft flying CHAIN out of JFK through four outstations and back on 1 July 2024, crewed by `rule`, its + first leg `lb` late, each deadline `slack` after its schedule: Edgar's one pairing runs past its limit on schedule + at the last leg, v4's crew changes at BUF.""" + crews = rule.crews(legs(CHAIN), BASE, repairs.BASES, ZONES) + flights = [replace(flight(k, o, d, sd, b, lb=lb if k == 0 else 0, deadline=sd + slack), crew=crews[k]) + for k, (o, d, sd, b) in enumerate(CHAIN)] + return replace(day(flights, [tail("N1", "JFK", end="JFK")]), date=CREW_DATE, base=BASE, bases=repairs.BASES) diff --git a/tests/programs/test_calibrate.py b/tests/programs/test_calibrate.py new file mode 100644 index 0000000..184dfa1 --- /dev/null +++ b/tests/programs/test_calibrate.py @@ -0,0 +1,214 @@ +from dataclasses import replace + +import pandas as pd +from day_helpers import based, chain_day, crewed, day, flight, tail + +from agentenv_airops.programs import references +from agentenv_airops.programs.candidates import midnight +from agentenv_airops.programs.layers import calibrate, config, crew +from agentenv_airops.programs.model import Outcome + +DATE = "2024-01-01" + + +def flew(i: int, orig: str, dest: str, sched: int, dep: int, tail_: str, deadline: int = 700): + """A base departure on a pairing whose crew deadline is 150, JetBlue leaving at `dep`.""" + return based(flight(i, orig, dest, sched, 60, deadline=deadline, + actual=Outcome(False, False, "", dep, dep + 60, dest, tail_)), 150) + + +def two_late_at_jfk(): + """JetBlue flew two JFK departures past their crew deadline (their rest ending past their deadline, so one without + a reserve auto-cancels) and one before it, and one BOS departure past it whose rest ends before its deadline (900): + without a reserve it waits out the rest and still flies.""" + flights = [flew(0, "JFK", "BUF", 100, 200, "N1"), flew(1, "JFK", "PWM", 105, 205, "N2"), + flew(2, "JFK", "DCA", 110, 120, "N3"), flew(3, "BOS", "BUF", 100, 200, "N4", deadline=900)] + tails = [tail("N1", "JFK", end="BUF"), tail("N2", "JFK", end="PWM"), tail("N3", "JFK", end="DCA"), + tail("N4", "BOS", end="BUF")] + return replace(day(flights, tails), bases=("BOS", "JFK"), base=midnight(DATE), date=DATE) + + +def with_on_time(task, n: int): + """The task with n more flights JetBlue flew on time, outstation to outstation without a crew deadline, so its + airline replay counts n more flights and auto-cancels none of them.""" + fill = [flight(len(task.flights) + k, "BUF", "PWM", 300 + k, 60, + actual=Outcome(False, False, "", 300 + k, 360 + k, "PWM", f"F{k}")) for k in range(n)] + return replace(task, flights=(*task.flights, *fill), + tails=(*task.tails, *(tail(f"F{k}", "BUF", end="PWM") for k in range(n)))) + + +def three_late_at_jfk(): + """two_late_at_jfk with a third JFK departure flown past its crew deadline, its rest ending past its deadline.""" + t = two_late_at_jfk() + return replace(t, flights=(*t.flights, flew(4, "JFK", "MCO", 115, 210, "N5")), + tails=(*t.tails, tail("N5", "JFK", end="MCO"))) + + +def one_late_at_jfk(): + t = two_late_at_jfk() + return replace(t, date="2024-01-02", base=midnight("2024-01-02"), + flights=(t.flights[0], replace(t.flights[1], actual=replace(t.flights[1].actual, dep=120, arr=190)), + *t.flights[2:])) + + +def late_at_bos_turning_at_jfk(): + """JetBlue flew a BOS departure past its crew deadline (150) and the aircraft's next leg out of JFK on time, before + that leg's crew deadline (400). Denied a reserve, the BOS crew waits out its rest (to 750) and still flies, so the + JFK leg leaves at 840: on unlimited JFK reserves it flies, but on a JFK pool its crew rests to 1000, past its + deadline (900), and it auto-cancels.""" + flights = [flew(0, "BOS", "JFK", 100, 200, "N1", deadline=900), + based(flight(1, "JFK", "BUF", 300, 60, deadline=900, + actual=Outcome(False, False, "", 300, 360, "BUF", "N1")), 400)] + return replace(day(flights, [tail("N1", "BOS", end="BUF")]), bases=("BOS", "JFK"), base=midnight(DATE), date=DATE) + + +def test_a_pool_that_loses_a_flight_only_with_another_bases_pool_lifts_the_bases_to_that_dates_calls(): + task = late_at_bos_turning_at_jfk() + assert calibrate.least(task) == ({"BOS": 0, "JFK": 0}, {"BOS": 1, "JFK": 0}) + assert calibrate.auto(task, {"BOS": 0, "JFK": 0}) == 1 > calibrate.auto(task, {}) == 0 + pools = calibrate.pools([task]) + assert pools == {"BOS": 1, "JFK": 0} and calibrate.auto(task, pools) == 0 + + +def test_a_bases_pool_is_the_smallest_that_keeps_the_replay_within_g1s_share_and_count_of_auto_cancels(): + """Two JFK crews JetBlue flew late need a reserve each to fly. Among 104 counted flights one auto-cancel is within + 1% and two are not, so JFK needs one reserve; among 204 two are within both, so it needs none; three late among + 305 are within 1% but over two, so it needs one.""" + small, large, three = with_on_time(two_late_at_jfk(), 100), with_on_time(two_late_at_jfk(), 200), \ + with_on_time(three_late_at_jfk(), 300) + assert [references.airline(t).flew for t in (small, large, three)] == [104, 204, 305] + assert [calibrate.auto(t, {"JFK": 0}) for t in (small, large, three)] == [2, 2, 3] + assert calibrate.least(small) == ({"BOS": 0, "JFK": 1}, {"BOS": 1, "JFK": 2}) + assert calibrate.least(large) == ({"BOS": 0, "JFK": 0}, {"BOS": 1, "JFK": 2}) + assert calibrate.least(three) == ({"BOS": 0, "JFK": 1}, {"BOS": 1, "JFK": 3}) + assert calibrate.pools([large]) == {"BOS": 0, "JFK": 0} + assert calibrate.pools([large, small]) == calibrate.pools([three]) == {"BOS": 0, "JFK": 1} + assert calibrate.auto(small, {"JFK": 1}) == 1 and calibrate.auto(three, {"JFK": 1}) == 2 + + +def test_two_bases_within_g1_alone_but_not_together_are_lifted_to_that_dates_calls(): + """A late JFK crew and a late BOS crew, each one whose rest ends past its deadline: among 150 counted flights + either base alone at 0 auto-cancels one (within 1%), together two (over 1%), so the date lifts both to its calls.""" + t = with_on_time(replace(day([flew(0, "JFK", "BUF", 100, 200, "N1"), flew(1, "BOS", "BUF", 100, 200, "N2")], + [tail("N1", "JFK", end="BUF"), tail("N2", "BOS", end="BUF")]), + bases=("BOS", "JFK"), base=midnight(DATE), date=DATE), 148) + assert references.airline(t).flew == 150 + assert calibrate.least(t) == ({"BOS": 0, "JFK": 0}, {"BOS": 1, "JFK": 1}) + assert calibrate.auto(t, {"BOS": 0}) == calibrate.auto(t, {"JFK": 0}) == 1 < calibrate.auto(t, {"BOS": 0, "JFK": 0}) + assert calibrate.pools([t]) == {"BOS": 1, "JFK": 1} + + +def test_a_date_out_of_g1_even_with_unlimited_reserves_holds_the_pool_to_no_more_auto_cancels_than_that(): + """A late JFK crew and an outstation crew JetBlue flew past its crew deadline, which auto-cancels whatever the + pool: on two flights that one is already out of G1, so the pool only has to keep the replay at it.""" + away = crewed(flight(1, "BUF", "PWM", 100, 60, deadline=300, + actual=Outcome(False, False, "", 200, 260, "PWM", "N2")), 150) + t = replace(day([flew(0, "JFK", "BUF", 100, 200, "N1"), away], + [tail("N1", "JFK", end="BUF"), tail("N2", "BUF", end="PWM")]), + bases=("BOS", "JFK"), base=midnight(DATE), date=DATE) + assert references.airline(t).flew == 2 and calibrate.auto(t, {}) == 1 < calibrate.auto(t, {"JFK": 0}) + assert calibrate.least(t) == ({"BOS": 0, "JFK": 1}, {"BOS": 0, "JFK": 1}) + assert calibrate.pools([t]) == {"BOS": 0, "JFK": 1} + + +def test_on_a_few_flights_g1_allows_no_auto_cancel_and_a_bases_pool_is_the_largest_over_the_dates(): + two, one = two_late_at_jfk(), one_late_at_jfk() + assert calibrate.least(two) == ({"BOS": 0, "JFK": 2}, {"BOS": 1, "JFK": 2}) + assert calibrate.least(one) == ({"BOS": 0, "JFK": 1}, {"BOS": 1, "JFK": 1}) + assert calibrate.auto(two, {"JFK": 1}) == 1 > calibrate.auto(two, {}) == 0 == calibrate.auto(two, {"BOS": 0}) + pools = calibrate.pools([two, one]) + assert pools == {"BOS": 0, "JFK": 2} + for t in (two, one): + assert references.airline(calibrate.pooled(t, pools)).run.flights[0].status == "flown" + assert calibrate.auto(t, pools) == calibrate.auto(t, {}) + + +def test_the_fit_rates_closures_on_the_train_dates_alone_and_pools_reserves_on_the_layered_train_tasks(): + """JFK pushed back 10 in the hour before a closure on the train date and 7 inside it; a closure on another date + (2 before, 2 inside) is left out. The cap clauses stay at 0.9 and 5.""" + train, other = midnight(DATE), midnight("2024-01-05") + spans = {"JFK": [(train + 600, train + 660), (other + 600, other + 660)], "BOS": []} + pushed = pd.DataFrame([("JFK", float(a + 600 + k)) for a, n_before, n_in in ((train, 10, 7), (other, 2, 2)) + for k in [*range(-60, -60 + n_before), *range(n_in)]], columns=["Origin", "act_dep"]) + empty = pd.DataFrame(columns=["Origin", "Dest", "sched_dep", "act_dep", "sched_arr", "act_arr"], dtype=float) + tasks = [two_late_at_jfk(), one_late_at_jfk()] + cfg = calibrate.fit([DATE, "2024-01-02"], tasks, lambda d: empty, spans, pushed) + assert cfg.rate == {"JFK": 0.7} and (cfg.short, cfg.min_sched) == (0.9, 5) + assert cfg.dates == ("2024-01-01", "2024-01-02") + layered = [replace(t, layers=config.build(t, replace(cfg, reserves={}), empty, spans)) for t in tasks] + assert layered[0].layers.closures == {"JFK": ((600, 660, 0, 0),)} + assert cfg.reserves == calibrate.pools(layered) == {"BOS": 0, "JFK": 2} + assert config.Config.from_dict(cfg.to_dict()) == cfg + assert calibrate.fit(["2024-01-02"], tasks, lambda d: empty, spans, pushed).reserves == {"BOS": 0, "JFK": 1} + + +def scored(naive: int, date: str = "d0", anchor: int = 100, recorded: int = 120, cancels: int = 1, jetblue: int = 2, + calm: int = 0, ferries: int = 0, proven: bool = True, flights: int = 200) -> dict: + return {"date": date, "naive": naive, "recorded": recorded, "anchor": anchor, "proven": proven, + "anchor_cancels": cancels, "jetblue_cancels": jetblue, "calm_cancels": calm, "calm_ferries": ferries, + "flights": flights} + + +RATE = 0.01 + + +def test_the_crew_fit_takes_the_hardest_cut_whose_guards_all_hold_ties_to_the_laxer(monkeypatch): + """Over two dates each, d1's calm day forcing 2 cancellations and 1 ferry without crews, within the fair-weather + bar (1% of its 200 flights): the hardest cut forces one more there, past it; the next costs the recorded day under + the anchor on one date, the next cancels more than JetBlue; of the three left at 20% (one matching the no-crew calm + day, one with d1 unproven, left out) the larger buffer wins.""" + rules = calibrate.grid((0, 30, 60), (4, 6)) + table = {(rules[0], 0): scored(150), (rules[0], 1): scored(150, "d1", calm=3, ferries=1), + (rules[1], 0): scored(140), (rules[1], 1): scored(140, "d1", recorded=90), + (rules[2], 0): scored(130, cancels=4), (rules[2], 1): scored(130, "d1"), + (rules[3], 0): scored(120), (rules[3], 1): scored(120, "d1", calm=5, proven=False), + (rules[4], 0): scored(120), (rules[4], 1): scored(120, "d1", calm=2, ferries=1), + (rules[5], 0): scored(110), (rules[5], 1): scored(110, "d1"), + ("plain", 0): scored(105), ("plain", 1): scored(105, "d1", calm=2, ferries=1)} + monkeypatch.setattr(calibrate, "score", lambda job, time_limit: table[job]) + chosen, rows = calibrate.crew_fit({r: [(r, 0), (r, 1)] for r in rules}, [("plain", 0), ("plain", 1)], RATE) + assert chosen == crew.Rule("v4", 60, 4) and [r["guards"] for r in rows[:6]] == [False] * 3 + [True] * 3 + assert [r["checks"] for r in rows[:3]] == [{"calm": False, "recorded": True, "cancels": True}, + {"calm": True, "recorded": False, "cancels": True}, + {"calm": True, "recorded": True, "cancels": False}] + assert (rows[3]["proven"], rows[3]["do_nothing"]) == (1, 0.2) + assert (rows[4]["calm_share"], rows[0]["calm_share"]) == (1.0, 0.5) + assert rows[6]["crew"] is None and rows[6]["do_nothing"] == 0.05 and rows[6]["checks"]["calm"] + monkeypatch.setattr(calibrate, "score", lambda job, time_limit: scored(150, calm=3 if job[0] != "plain" else 0)) + assert calibrate.crew_fit({r: [(r, 0)] for r in rules}, [("plain", 0)], RATE)[0] is None + + +def test_the_calm_guard_holds_a_crew_to_the_fair_weather_bar_not_to_the_no_crew_count(monkeypatch): + """A crew may cancel more than the no-crew calm day as long as it stays within JetBlue's fair-weather bar (2 of + 200 flights at 1%), and a date whose calm day is past the bar even without crews is the base's, not the crew's: + only a crew that pushes a date within the bar past it fails.""" + within, past = crew.Rule("v4", 0, 4), crew.Rule("v4", 30, 4) + table = {(within, 0): scored(150, calm=2), (within, 1): scored(150, "d1", calm=9), + (past, 0): scored(150, calm=3), (past, 1): scored(150, "d1", calm=9), + ("plain", 0): scored(105), ("plain", 1): scored(105, "d1", calm=5)} + monkeypatch.setattr(calibrate, "score", lambda job, time_limit: table[job]) + chosen, rows = calibrate.crew_fit({r: [(r, 0), (r, 1)] for r in (within, past)}, [("plain", 0), ("plain", 1)], + RATE) + assert chosen == within and [r["calm_share"] for r in rows[:2]] == [1.0, 0.5] + assert [r["checks"]["calm"] for r in rows[:2]] == [True, False] + + +def test_a_score_is_kept_under_the_cache_by_its_tasks_signature(tmp_path, monkeypatch): + task = chain_day(crew.Rule("v4")) + first = calibrate.kept_score((task, 60, tmp_path)) + monkeypatch.setattr(calibrate, "score", lambda t, time_limit: {"rescored": True}) + assert calibrate.kept_score((task, 60, tmp_path)) == first and len(list(tmp_path.iterdir())) == 1 + assert calibrate.kept_score((task, 30, tmp_path)) == {"rescored": True} + assert calibrate.kept_score((chain_day(crew.Rule()), 60, tmp_path)) == {"rescored": True} + + +def test_the_crew_fit_scores_a_day_by_its_references_and_samples_train_dates_by_seed(): + s = calibrate.score(chain_day(crew.Rule("v4")), 60) + assert (s["calm_cancels"], s["calm_ferries"], s["jetblue_cancels"], s["proven"]) == (0, 0, 0, True) + assert s["flights"] == len(chain_day(crew.Rule("v4")).flights) + assert s["naive"] >= s["anchor"] and s["date"] == "2024-07-01" + dates = [f"2024-07-{d:02d}" for d in range(1, 31)] + picked = calibrate.sample(dates) + assert len(picked) == calibrate.SAMPLE == 12 and list(picked) == sorted(set(picked)) and set(picked) <= set(dates) + assert calibrate.sample(dates) == picked != calibrate.sample(dates, seed=1) + assert calibrate.sample(dates[:5]) == tuple(dates[:5]) diff --git a/tests/programs/test_closures.py b/tests/programs/test_closures.py new file mode 100644 index 0000000..b83dc58 --- /dev/null +++ b/tests/programs/test_closures.py @@ -0,0 +1,223 @@ +import csv +from dataclasses import replace +from datetime import UTC, datetime + +import pandas as pd +import pytest +from day_helpers import closed, day, flight, tail +from program_helpers import SLICE + +from agentenv_airops.build import bts +from agentenv_airops.flow import sources +from agentenv_airops.programs import references +from agentenv_airops.programs.candidates import midnight +from agentenv_airops.programs.layers import closures +from agentenv_airops.programs.model import Outcome + + +def at(s: str) -> int: + return bts.epoch_minutes(datetime.strptime(s, "%Y-%m-%d %H:%M").replace(tzinfo=UTC)) + + +def reports(path, rows): + with open(path, "w", newline="") as fh: + w = csv.writer(fh) + w.writerow(["station", "valid", "wxcodes", "metar"]) + for station, valid, wx, remarks in rows: + w.writerow([station, valid, wx, f"K{station} 000000Z 10SM {wx} A2990 RMK {remarks}"]) + return path + + +ROUTINE = [("JFK", "2024-06-14 21:51", "", "AO2"), + ("JFK", "2024-06-14 22:51", "TS", "AO2 LTG DSNT SW TSB47"), + ("JFK", "2024-06-14 23:51", "+TSRA", "AO2"), + ("JFK", "2024-06-15 00:51", "RA", "AO2 LTG DSNT NE TSE10"), + ("JFK", "2024-06-15 01:51", "VCTS", "AO2 LTG DSNT ALQDS"), + ("JFK", "2024-06-15 02:51", "TS", "AO2"), + ("JFK", "2024-06-15 03:51", "", "AO2"), + ("JFK", "2024-06-15 04:51", "", "AO2 TSB0412E0430"), + ("BOS", "2024-06-15 00:51", "TS", "AO2 TSB30")] +SPECI = [("JFK", "2024-06-15 02:20", "TS", "AO2 TSB18"), + ("JFK", "2024-06-15 03:05", "", "AO2 TSE03")] + + +def test_intervals_read_ts_at_the_field_from_its_begin_and_end_remarks_and_skip_vcts(tmp_path): + metar = reports(tmp_path / "JFK_2024_06.csv", ROUTINE) + assert closures.intervals("JFK", [metar], [tmp_path / "missing.csv"]) == [ + (at("2024-06-14 22:47"), at("2024-06-15 00:10")), (at("2024-06-15 02:51"), at("2024-06-15 03:51")), + (at("2024-06-15 04:12"), at("2024-06-15 04:30"))] + + +def test_intervals_take_onset_and_end_to_the_minute_from_the_specis(tmp_path): + metar = reports(tmp_path / "metar.csv", ROUTINE) + speci = reports(tmp_path / "speci.csv", SPECI) + assert closures.intervals("JFK", [metar], [speci]) == [ + (at("2024-06-14 22:47"), at("2024-06-15 00:10")), (at("2024-06-15 02:18"), at("2024-06-15 03:03")), + (at("2024-06-15 04:12"), at("2024-06-15 04:30"))] + assert closures.intervals("BOS", [metar], [speci]) == [(at("2024-06-15 00:30"), at("2024-06-15 00:51"))] + + +def test_intervals_on_the_slice_close_one_still_open_at_the_last_report(): + month = "2023-06" + metar = SLICE / "iem" / "metar" / "JFK_2023_06.csv" + assert not sources.speci_path(SLICE, "JFK", month).exists() + assert closures.intervals("JFK", [metar], [sources.speci_path(SLICE, "JFK", month)]) == [ + (at("2023-06-26 14:29"), at("2023-06-26 15:00")), (at("2023-06-27 05:16"), at("2023-06-27 05:51"))] + + +def leaving(i: int, orig: str, dep: int, pushed: int | None): + f = flight(i, orig, "BUF", dep, 60) + return replace(f, actual=replace(f.actual, cancelled=pushed is None, dep=pushed, arr=None)) + + +def test_build_caps_each_closure_at_the_rate_of_jetblues_normal_hourly_departures_never_below_what_it_pushed_back(): + """JFK closed 600-660: 4 scheduled in hour 10, 1 pushed back inside, 0.5 x 4/h x 1 h = 2. Closed 700-760: 1 + scheduled in hours 11-12, 2 pushed back inside (flight 2, late, and flight 4), so 2. One before the first watch and + one past the span drop. BOS closes 650-700 with no JetBlue flight scheduled inside it but 3 in hours 10-11, all + pushed back before it: 0.75 x 1.5/h x 50/60 h = 0.94, so 1 (to the nearest flight, not down).""" + base = 1_000_000 + flights = [leaving(0, "JFK", 600, 605), leaving(1, "JFK", 610, 680), leaving(2, "JFK", 620, 700), + leaving(3, "JFK", 650, None), leaving(4, "JFK", 720, 725), leaving(5, "BOS", 600, 600), + leaving(6, "BOS", 610, 610), leaving(7, "BOS", 615, 615)] + task = replace(day(flights, [tail("N1", "JFK")], start=300), base=base) + spans = {"JFK": [(base + 100, base + 200), (base + 600, base + 660), (base + 700, base + 760), + (base + 5000, base + 5100)], "BOS": [(base + 650, base + 700)]} + assert closures.build(task, spans, {"JFK": 0.5, "BOS": 0.75}) == { + "JFK": ((600, 660, 2, 0), (700, 760, 2, 0)), "BOS": ((650, 700, 1, 0),)} + assert closures.build(task, spans, {"JFK": 1.0, "BOS": 0.75})["JFK"] == ((600, 660, 4, 0), (700, 760, 2, 0)) + assert closures.build(task, spans, {"JFK": 1.0, "BOS": 0.25})["BOS"] == ((650, 700, 0, 0),) + + +def landing(i: int, dest: str, arr: int, gated: int | None, end: str | None = None): + f = flight(i, "BUF", dest, arr - 60, 60) + return replace(f, actual=replace(f.actual, cancelled=gated is None, dep=None if gated is None else gated - 60, + arr=gated, end=None if gated is None else end or dest)) + + +def test_build_caps_each_closures_gate_ins_at_the_rate_of_jetblues_normal_hourly_arrivals_never_below_what_arrived(): + """JFK closed 600-660: 5 scheduled to arrive in hour 10 (flight 3 cancelled), and 5 arrive inside as the sim times + them, at pushback plus truth block: flights 0, 1, 2, 4 (scheduled at 580, late) and 5, which diverted to EWR but + the sim flies to JFK. At 0.5, 0.5 x 5/h x 1 h = 2.5, so 3, floored at 5; at 2.0, 10. Flight 6, scheduled into BOS + and diverted to JFK, arrives inside at neither. BOS closes 650-680 with no arrival scheduled inside but 4 in hours + 10-11: 0.75 x 2/h x 0.5 h = 0.75, so 1.""" + base = 1_000_000 + flights = [landing(0, "JFK", 600, 610), landing(1, "JFK", 620, 630), landing(2, "JFK", 640, 650), + landing(3, "JFK", 650, None), landing(4, "JFK", 580, 655), landing(5, "JFK", 630, 640, "EWR"), + landing(6, "BOS", 600, 620, "JFK"), landing(7, "BOS", 610, 610), landing(8, "BOS", 615, 615), + landing(9, "BOS", 620, 620)] + task = replace(day(flights, [tail("N1", "BUF")], start=300), base=base) + spans = {"JFK": [(base + 600, base + 660)], "BOS": [(base + 650, base + 680)]} + assert closures.build(task, spans, {"JFK": 0.5, "BOS": 0.75}) == { + "JFK": ((600, 660, 0, 5),), "BOS": ((650, 680, 0, 1),)} + assert closures.build(task, spans, {"JFK": 2.0, "BOS": 0.75})["JFK"] == ((600, 660, 0, 10),) + + +def test_fit_rate_is_every_carriers_pushbacks_inside_over_the_same_minutes_before_on_the_dates_given(): + """JFK closes 30 min on the 14th: 3 pushbacks inside, 4 in the 30 min before; its closure on the 15th, and BOS's + with nothing before it, don't count.""" + a = midnight("2024-06-14") + 1200 + b = midnight("2024-06-15") + 1200 + rows = [("JFK", a + k) for k in (2, 9, 20)] + [("JFK", a - 30 + k) for k in (0, 5, 10, 29)] + [("JFK", a - 31)] + rows += [("JFK", b + k) for k in range(20)] + [("JFK", float("nan")), ("BOS", a + 1)] + frame = pd.DataFrame(rows, columns=["Origin", "act_dep"]) + spans = {"JFK": [(a, a + 30), (b, b + 30)], "BOS": [(a, a + 10)]} + assert closures.fit_rate(["2024-06-14"], frame, spans) == {"JFK": 0.75} + assert closures.fit_rate(["2024-06-14", "2024-06-15"], frame, spans) == {"JFK": 23 / 4} + + +def test_fit_rate_leaves_out_a_closure_whose_minutes_before_reach_into_another(): + """A steady pushback a minute, one every two inside closures 100-160 and 170-230: the second's 60 minutes before + hold the first closure, so only the first counts.""" + m = midnight("2024-06-14") + deps = [m + t for t in range(400) if not ((100 <= t < 160 or 170 <= t < 230) and t % 2)] + frame = pd.DataFrame({"Origin": "JFK", "act_dep": [float(x) for x in deps]}) + spans = {"JFK": [(m + 100, m + 160), (m + 170, m + 230)]} + assert closures.fit_rate(["2024-06-14"], frame, spans) == {"JFK": 0.5} + + +def test_an_end_remark_older_than_the_open_closure_belongs_to_an_earlier_storm(tmp_path): + """BOS 2024-06-14 as reported: TSB22 at 17:24, "TSE13B24" at 17:40 and 17:54, TS until TSE36 at 18:36.""" + rows = [("BOS", "2024-06-14 16:54", "", "AO2 LTG DSNT W"), + ("BOS", "2024-06-14 17:24", "TS", "AO2 TSB22"), + ("BOS", "2024-06-14 17:40", "+TSRA", "AO2 RAB30 TSE13B24"), + ("BOS", "2024-06-14 17:54", "TSRA BR", "AO2 RAB30 TSE13B24"), + ("BOS", "2024-06-14 18:36", "", "AO2 RAE36 TSE36")] + assert closures.intervals("BOS", [reports(tmp_path / "m.csv", rows)]) == [ + (at("2024-06-14 17:22"), at("2024-06-14 18:36"))] + + +def test_a_remark_minute_past_its_reports_is_in_the_hour_or_day_before(tmp_path): + """TSB55 in the 00:51 report began at 23:55 the day before; TSE2358 in the 00:20 report ended at 23:58.""" + rows = [("JFK", "2024-06-15 00:51", "TS", "AO2 TSB55"), ("JFK", "2024-06-15 01:51", "", "AO2 TSE20"), + ("BOS", "2024-06-14 23:51", "TS", "AO2"), ("BOS", "2024-06-15 00:20", "", "AO2 TSE2358")] + path = reports(tmp_path / "m.csv", rows) + assert closures.intervals("JFK", [path]) == [(at("2024-06-14 23:55"), at("2024-06-15 01:20"))] + assert closures.intervals("BOS", [path]) == [(at("2024-06-14 23:51"), at("2024-06-14 23:58"))] + + +def test_build_names_a_closed_hub_that_fit_rate_gave_no_rate(): + """fit_rate gives BOS no rate (nothing pushed back before its closure); building a task over that closure says so + rather than failing on a bare key, and a hub with no closure over the task needs no rate.""" + m = midnight("2024-06-14") + frame = pd.DataFrame({"Origin": ["JFK", "JFK"], "act_dep": [float(m + 50), float(m + 105)]}) + spans = {"JFK": [(m + 100, m + 160)], "BOS": [(m + 100, m + 160)]} + rate = closures.fit_rate(["2024-06-14"], frame, spans) + assert rate == {"JFK": 1.0} + task = replace(day([flight(0, "JFK", "BUF", 120, 60), flight(1, "BOS", "JFK", 120, 60)], + [tail("N1", "JFK"), tail("N2", "BOS")]), base=m) + with pytest.raises(ValueError, match="BOS"): + closures.build(task, spans, rate) + assert closures.build(task, {"JFK": spans["JFK"], "BOS": [(m + 5000, m + 5100)]}, rate) == { + "JFK": ((100, 160, 1, 0),)} + + +def test_the_airline_replay_of_a_recorded_turn_inside_a_built_closure_flies_as_on_the_open_day(): + """N4 landed from BUF at 160 and pushed back for PWM at 195, both inside JFK's closure 150-220: the floors keep + both inside its caps, so the replay is the open day's (a gate hold to 220 took it from 670 to 19740).""" + base = 1_000_000 + task = replace(day([flight(3, "BUF", "JFK", 100, 60, actual=Outcome(False, False, "", 100, 160, "JFK", "N4")), + flight(4, "JFK", "PWM", 190, 70, actual=Outcome(False, False, "", 195, 265, "PWM", "N4"))], + [tail("N4", "BUF", end="PWM")]), base=base) + shut = closed(task, closures.build(task, {"JFK": [(base + 150, base + 220)]}, {"JFK": 0.75})) + assert shut.layers.closures == {"JFK": ((150, 220, 1, 1),)} + held, open_ = references.airline(shut), references.airline(task) + assert held.auto == open_.auto == 0 and held.run.cost == open_.run.cost and held.run.rows() == open_.run.rows() + + +def test_the_airline_replay_of_a_diversion_the_sim_lands_inside_a_built_closure_flies_as_on_the_open_day(): + """Flight 5 left DCA at 92 and diverted to EWR, but the replay flies it to JFK, landing at 152, inside JFK's + closure 150-220 with N4's recorded gate-in at 160: the floor counts both, so neither is held.""" + base = 1_000_000 + task = replace(day([flight(3, "BUF", "JFK", 100, 60, actual=Outcome(False, False, "", 100, 160, "JFK", "N4")), + flight(4, "JFK", "PWM", 190, 70, actual=Outcome(False, False, "", 195, 265, "PWM", "N4")), + flight(5, "DCA", "JFK", 90, 60, actual=Outcome(False, True, "", 92, 175, "EWR", "N5"))], + [tail("N4", "BUF", end="PWM"), tail("N5", "DCA", end="JFK")]), base=base) + shut = closed(task, closures.build(task, {"JFK": [(base + 150, base + 220)]}, {"JFK": 0.0})) + assert shut.layers.closures == {"JFK": ((150, 220, 1, 2),)} + held, open_ = references.airline(shut), references.airline(task) + assert held.run.cost == open_.run.cost and held.run.rows() == open_.run.rows() + + +def test_the_airline_replay_of_a_held_taxi_the_sim_lands_inside_a_built_closure_flies_as_on_the_open_day(): + """Flight 3 left BUF at 100 and gated in at JFK at 175 after a held taxi-out its truth block of 60 leaves out, so + the replay lands it at 160, inside JFK's closure 150-170 though its gate-in is past it: the floor counts it.""" + base = 1_000_000 + task = replace(day([flight(3, "BUF", "JFK", 100, 60, actual=Outcome(False, False, "", 100, 175, "JFK", "N4")), + flight(4, "JFK", "PWM", 190, 70, actual=Outcome(False, False, "", 190, 260, "PWM", "N4"))], + [tail("N4", "BUF", end="PWM")]), base=base) + shut = closed(task, closures.build(task, {"JFK": [(base + 150, base + 170)]}, {"JFK": 0.0})) + assert shut.layers.closures == {"JFK": ((150, 170, 0, 1),)} + held, open_ = references.airline(shut), references.airline(task) + assert held.run.cost == open_.run.cost and held.run.rows() == open_.run.rows() + + +def test_the_airline_replay_under_a_built_closure_in_any_hour_of_the_fixture_day_auto_cancels_nothing_more(task): + """The replay flies every flight it flies on the open day: a closure can only delay one, as when the GDP's slots + move a recorded arrival into a closure its recorded gate-in was outside of.""" + open_ = references.airline(task) + status = {i: r.status for i, r in open_.run.flights.items()} + for rate in (0.0, 0.3, 0.7): + for a in range(task.start, task.start + 600, 60): + shut = closed(task, closures.build(task, {"JFK": [(task.base + a, task.base + a + 60)]}, {"JFK": rate})) + held = references.airline(shut) + assert held.auto == open_.auto and {i: r.status for i, r in held.run.flights.items()} == status, (rate, a) diff --git a/tests/programs/test_crew.py b/tests/programs/test_crew.py new file mode 100644 index 0000000..e0f3cf9 --- /dev/null +++ b/tests/programs/test_crew.py @@ -0,0 +1,115 @@ +from dataclasses import replace + +import pandas as pd +import pytest +from day_helpers import BASE, CHAIN, ZONES, chain_day, legs, reserved + +from agentenv_airops.programs import references, repairs +from agentenv_airops.programs.layers import crew +from agentenv_airops.programs.layers.crew import Rule +from agentenv_airops.programs.model import EXTENSION, REST, Crew + + +def legal(crews: dict, x: pd.DataFrame, buffer: int) -> bool: + """Every duty ends on schedule `buffer` inside its limit, and every crew deadline is the report + the limit + the + extension less the leg's block, from an outstation, with the rest REST after it.""" + by = {} + for i, c in crews.items(): + by.setdefault(c.pairing, []).append(i) + for ids in by.values(): + c = crews[ids[0]] + if max(int(x.sd[i] + x.CRSElapsedTime[i]) for i in ids) + buffer > c.report + c.fdp: + return False + if c.report != int(x.sd[min(ids, key=lambda i: x.sd[i])]) - repairs.REPORT or len(ids) != c.segments: + return False + return all((c.deadline, c.rest) == ((None, None) if x.Origin[i] in repairs.BASES else + (c.report + c.fdp + EXTENSION - int(x.CRSElapsedTime[i]), + c.report + c.fdp + EXTENSION - int(x.CRSElapsedTime[i]) + REST)) + for i, c in crews.items()) + + +def test_a_long_outstation_chain_is_split_where_edgars_runs_past_its_limit_on_schedule(): + x = legs(CHAIN) + edgar = repairs.crews(x, BASE, repairs.BASES, ZONES) + assert {c.pairing for c in edgar.values()} == {"N1/0"} and edgar[5].deadline < 1230 + v4 = crew.crews(x, BASE, repairs.BASES, ZONES) + assert [v4[k].pairing for k in range(6)] == ["N1/0"] * 4 + ["N1/1"] * 2 + assert (v4[0].report, v4[0].fdp, v4[4].report, v4[4].fdp) == (455, 780, 1035, 720) + assert legal(v4, x, crew.BUFFER) and not legal(edgar, x, 0) + assert crew.crews(x, BASE, repairs.BASES, ZONES, buffer=0, max_segments=6)[4].pairing == "N1/0" + + +def test_a_duty_takes_no_leg_that_would_end_inside_the_buffer_of_its_limit(): + rows = [("BUF", "DCA", 500, 80), ("DCA", "PWM", 640, 90), ("PWM", "BUF", 800, 70), ("BUF", "RDU", 960, 260)] + x = legs(rows) + # Reported at 455 (07:35), four legs may run 13 h, to 1235: the fourth ends at 1220, inside it but not by 30. + assert [c.pairing for c in crew.crews(x, BASE, (), ZONES, buffer=0).values()] == ["N1/0"] * 4 + assert [c.pairing for c in crew.crews(x, BASE, (), ZONES, buffer=30).values()] == ["N1/0"] * 3 + ["N1/1"] + assert legal(crew.crews(x, BASE, (), ZONES, buffer=30), x, 30) + + +def test_a_duty_holds_at_most_max_segments_and_a_rest_long_gap_starts_another(): + rows = [("BUF", "DCA", 300, 40), ("DCA", "PWM", 360, 40), ("PWM", "BUF", 420, 40), ("BUF", "DCA", 480, 40), + ("DCA", "PWM", 540, 40), ("PWM", "BUF", 1180, 40)] + x = legs(rows) + assert [c.pairing for c in crew.crews(x, BASE, (), ZONES, max_segments=3).values()] == \ + ["N1/0"] * 3 + ["N1/1"] * 2 + ["N1/2"] + six = crew.crews(x, BASE, (), ZONES, max_segments=6) + assert [c.pairing for c in six.values()] == ["N1/0"] * 5 + ["N1/1"] + assert 1180 - (540 + 40) == REST and six[0].segments == 5 + + +def test_every_base_departure_starts_a_duty_and_gets_its_deadline_under_a_pool(): + rows = [("JFK", "BUF", 300, 70), ("BUF", "JFK", 420, 70), ("JFK", "DCA", 540, 60), ("DCA", "BOS", 660, 80), + ("BOS", "PWM", 800, 50)] + v4 = crew.crews(legs(rows), BASE, repairs.BASES, ZONES) + assert [v4[k].pairing for k in range(5)] == ["N1/0", "N1/0", "N1/1", "N1/1", "N1/2"] + assert [v4[k].deadline is None for k in range(5)] == [True, False, True, False, True] + task = chain_day(Rule("v4")) + pooled = reserved(task, {"JFK": 0}) + f = task.flight(0) + assert task.crew(f).deadline is None and pooled.reservable(f) + assert pooled.crew(f).deadline == f.crew.report + f.crew.fdp + EXTENSION - f.sched_block >= f.sched_dep + 60 + + +def test_the_rule_is_edgars_crews_by_default_and_names_its_mode(): + x = legs(CHAIN) + assert Rule().crews(x, BASE, repairs.BASES, ZONES) == repairs.crews(x, BASE, repairs.BASES, ZONES) + assert Rule("v4", 30, 5).crews(x, BASE, repairs.BASES, ZONES) == crew.crews(x, BASE, repairs.BASES, ZONES, 30, 5) + assert Rule().to_dict() == {"crew": "edgar", "buffer": None, "max_segments": None} + assert Rule("v4").to_dict() == {"crew": "v4", "buffer": 60, "max_segments": 4} + with pytest.raises(ValueError): + Rule("strict") + + +def test_the_calm_day_of_a_v4_crewed_chain_cancels_nothing_where_edgars_must(): + for pools in ({}, {"JFK": 0}): + assert references.calm(reserved(chain_day(Rule("v4")), pools), 60).cancelled == 0 + assert references.calm(chain_day(Rule()), 60).cancelled >= 1 + + +def test_a_v4_crewed_build_changes_only_the_crews_and_without_bases_nothing(task, crewed, v4crewed, v4plain): + assert v4crewed.bases == crewed.bases == repairs.BASES + assert replace(v4crewed, bases=(), flights=tuple(replace(f, crew=None) for f in v4crewed.flights)) == task + assert v4plain.to_json() == task.to_json() + assert all(isinstance(f.crew, Crew) for f in v4crewed.flights) + for f in v4crewed.flights: + c = f.crew + assert c.report <= f.sched_dep - repairs.REPORT + assert f.sched_dep + f.sched_block + crew.BUFFER <= c.report + c.fdp + assert (c.deadline is None) == (f.orig in repairs.BASES) + + +def test_the_calm_day_of_the_v4_crewed_fixture_day_cancels_nothing_for_crews(task, v4crewed): + none = references.calm(task, 120) + for t in (v4crewed, reserved(v4crewed, dict.fromkeys(v4crewed.bases, 0))): + calm = references.calm(t, 120) + assert (sorted(i for i, d in calm.solution.deps.items() if d is None), calm.ferries) == \ + (sorted(i for i, d in none.solution.deps.items() if d is None), none.ferries) + + +def test_a_delay_still_times_a_v4_crew_out_past_its_limit(): + task = chain_day(Rule("v4"), lb=450, slack=900) + run = references.naive(task) + assert run.flights[3].status == "auto_cancelled" and task.flight(3).crew.deadline == 1285 + assert references.naive(chain_day(Rule("v4"), slack=900)).flights[3].status == "flown" diff --git a/tests/programs/test_day_sim.py b/tests/programs/test_day_sim.py index b0fd8f9..af17858 100644 --- a/tests/programs/test_day_sim.py +++ b/tests/programs/test_day_sim.py @@ -4,24 +4,44 @@ GDP, broken_day, busy_day, + capped, + capped_day, + closed, + closed_day, crewed, day, flight, + late_pin_day, moved_day, + outcapped, + outcapped_day, quick_day, + reserve_day, + reserved, + shared_hour_day, stop, tail, three_into_a_program, turned_through_day, + two_from_jfk, ) from agentenv_airops.programs import sim +def deps(run): + return {i: r.dep for i, r in run.flights.items()} + + def slots(run): return {i: (r.dep, r.slot) for i, r in run.flights.items()} +def arrivals(run): + """Departure and arrival of the flights into JFK on closed_day.""" + return {i: (run.flights[i].dep, run.flights[i].arr) for i in (3, 5)} + + def test_controlled_flights_leave_at_the_edct_of_the_earliest_free_slot_and_a_cancellation_moves_the_queue_up(): task = three_into_a_program() assert slots(sim.execute(task, {})) == {0: (120, 180), 1: (180, 240), 2: (240, 300)} @@ -167,6 +187,45 @@ def test_a_crew_past_its_deadline_rests_ten_hours_or_its_flight_auto_cancels_the assert sim.execute(replace(task, start=200), {}).flights[1].decided == 200 +def test_with_one_reserve_the_first_late_base_departure_by_id_flies_on_it_and_the_second_auto_cancels_at_its_deadline(): + task = reserve_day() + calls = {0: {"reserve": True}, 1: {"reserve": True}} + run = sim.execute(task, calls) + assert run.reserved == sim.granted(task, calls) == {0} + assert [(r.status, r.dep, r.decided) for r in run.flights.values()][:2] == [ + ("flown", 200, None), ("auto_cancelled", None, 150)] + assert (task.auto_at(task.flight(1)), task.auto_at(task.flight(1), reserve=True)) == (150, 700) + assert task.crew(task.flight(0)).deadline == 150 and task.flight(0).crew.deadline is None + uncalled = sim.execute(task, {}) + assert [uncalled.flights[i].status for i in (0, 1)] == ["auto_cancelled", "auto_cancelled"] + second = sim.execute(task, {1: {"reserve": True}}) + assert (second.reserved, second.flights[0].status, second.flights[1].dep) == ({1}, "auto_cancelled", 205) + assert sim.granted(task, {0: {"reserve": True, "cancel": True}, 1: {"reserve": True}}) == {1} + two = sim.execute(reserved(task, {"JFK": 2}), calls) + assert two.reserved == {0, 1} and [two.flights[i].dep for i in (0, 1)] == [200, 205] + + +def test_a_plan_entry_for_a_flight_outside_the_task_changes_nothing_with_or_without_a_pool(): + for task in (two_from_jfk(), reserve_day()): + assert sim.execute(task, {999: {"not_before": 0, "reserve": True}}).rows() == sim.execute(task, {}).rows() + assert sim.granted(reserve_day(), {999: {"reserve": True}, "1": {"reserve": True}}) == {1} + + +def test_without_a_pool_a_base_departure_keeps_unlimited_reserves_and_a_call_changes_nothing(): + task = replace(reserve_day(), layers=None) + run = sim.execute(task, {}) + assert [run.flights[i].dep for i in (0, 1)] == [200, 205] and run.reserved == frozenset() + assert sim.execute(task, {0: {"reserve": True}}).rows() == run.rows() + elsewhere = reserved(task, {"BOS": 3}) + assert sim.execute(elsewhere, {}).rows() == run.rows() and not elsewhere.reservable(elsewhere.flight(0)) + + +def test_a_pinned_base_departure_leaves_at_its_minute_past_its_crew_deadline_and_holds_the_reserve_it_called(): + task = reserve_day() + run = sim.execute(task, {0: {"left": 210, "reserve": True}, 1: {"reserve": True}}) + assert run.flights[0].dep == 210 and run.reserved == {0} and run.flights[1].status == "auto_cancelled" + + def test_a_turn_jetblue_flew_faster_than_the_rule_holds_through_the_moves_its_aircraft_passes(): run = sim.execute(turned_through_day(), {1: {"cancel": True}}) assert (run.flights[2].tail, run.flights[2].dep, run.moves) == ("N1", 100, {1: 110}) @@ -182,3 +241,109 @@ def test_a_turn_jetblue_flew_faster_than_the_rule_is_allowed_for_that_connection assert sim.execute(alone, {1: {"cancel": True}}).flights[2].dep == 190 assert sim.execute(alone, {}).flights[1].dep == 170 assert sim.execute(quick_day(40), {}).flights[1].dep == sim.execute(quick_day(None), {}).flights[1].dep + + +def test_a_full_departure_hour_pushes_a_departure_to_the_next_hour(): + flights = [flight(0, "JFK", "BUF", 60, 60), flight(1, "JFK", "DCA", 70, 60), flight(2, "JFK", "MCO", 80, 150)] + tails = [tail("N1", "JFK", end="BUF"), tail("N2", "JFK", end="DCA"), tail("N3", "JFK", end="MCO")] + task = capped(day(flights, tails), {"JFK": {1: 1, 2: 1}}) + assert deps(sim.execute(task, {})) == {0: 60, 1: 120, 2: 180} + assert deps(sim.execute(task, {0: {"cancel": True}})) == {0: None, 1: 70, 2: 120} + + +def test_a_capped_hub_spreads_its_departures_over_the_hours_and_leaves_its_arrivals_alone(): + run = sim.execute(capped_day(), {}) + assert {i: (r.dep, r.slot) for i, r in run.flights.items()} == { + 0: (120, 180), 1: (140, 200), 2: (125, None), 3: (180, None), 4: (180, None), 5: (240, None), 6: (240, None)} + assert deps(sim.execute(replace(capped_day(), layers=None), {})) == { + 0: 120, 1: 140, 2: 125, 3: 130, 4: 150, 5: 210, 6: 230} + + +def test_a_pinned_departure_uses_up_its_hour_before_the_others_leave(): + run = sim.execute(two_from_jfk(), {1: {"left": 100}}) + assert (run.flights[0].dep, run.flights[1].dep) == (120, 100) + + +def test_a_pinned_departure_its_aircraft_makes_late_counts_in_the_hour_it_leaves(): + task = late_pin_day() + assert deps(sim.execute(task, {1: {"left": 170}})) == {0: 100, 1: 190, 2: 240, 3: 140} + assert deps(sim.execute(task, {})) == {0: 100, 1: 190, 2: 240, 3: 140} + + +def test_a_slotted_departure_uses_up_its_hour_of_leaving(): + assert slots(sim.execute(shared_hour_day(), {})) == {0: (70, 130), 1: (120, None)} + + +def test_a_ramp_closure_cuts_departures_to_its_cap_and_the_rest_leave_at_its_end(): + run = sim.execute(closed_day(), {}) + assert deps(run) == {0: 120, 1: 180, 2: 180, 3: 100, 4: 200, 5: 110, 6: 210} + assert deps(sim.execute(replace(closed_day(), layers=None), {})) == { + 0: 120, 1: 130, 2: 140, 3: 100, 4: 200, 5: 110, 6: 210} + assert deps(sim.execute(closed_day(), {0: {"cancel": True}})) == { + 0: None, 1: 130, 2: 180, 3: 100, 4: 200, 5: 110, 6: 210} + + +def test_a_full_ramp_closure_holds_an_arrival_so_that_it_arrives_at_its_end_and_its_aircraft_turns_from_there(): + """Room for one arrival: flight 3 lands at 160, inside, and flight 5, which would land at 170, leaves DCA at 120 + to arrive at 180. With none, both arrive at 180, and flight 4 waits for an aircraft until 210. An arrival inside a + closure with room reaches its gate on landing: no gate hold.""" + room, one, none = (sim.execute(closed_day(n), {}) for n in (2, 1, 0)) + assert arrivals(room) == {3: (100, 160), 5: (110, 170)} + assert arrivals(one) == {3: (100, 160), 5: (120, 180)} + assert arrivals(none) == {3: (120, 180), 5: (120, 180)} + assert (one.flights[4].dep, none.flights[4].dep) == (200, 210) + assert none.cost.total > one.cost.total > room.cost.total + + +def test_a_pinned_arrival_uses_up_its_ramp_closure_before_the_others_arrive(): + assert arrivals(sim.execute(closed_day(1), {5: {"left": 110}})) == {3: (120, 180), 5: (110, 170)} + + +def test_a_pinned_departure_uses_up_its_ramp_closure_before_the_others_leave(): + run = sim.execute(closed_day(), {1: {"left": 125}}) + assert deps(run) == {0: 180, 1: 125, 2: 180, 3: 100, 4: 200, 5: 110, 6: 210} + + +def test_a_ramp_closure_with_no_room_holds_its_stations_departures_to_its_end(): + task = closed(closed_day(), {"JFK": ((120, 180, 0, 2),), "BUF": ((0, 130, 0, 2),)}) + run = sim.execute(task, {}) + assert deps(run) == {0: 180, 1: 180, 2: 180, 3: 130, 4: 200, 5: 110, 6: 220} and run.flights[3].arr == 190 + + +def test_a_ramp_closure_counts_an_arrival_at_its_truth_block_not_its_scheduled_one(): + """Flight 0 leaves BUF at 120 with a 60-minute scheduled block but a 45-minute truth one: it would land at 165, + inside JFK's closure 150-170 with no room for arrivals (by its scheduled block, 180, it would not), so it leaves at + 125 to arrive at 170.""" + task = closed(day([flight(0, "BUF", "JFK", 120, 45, sched_block=60)], [tail("N1", "BUF", end="JFK")]), + {"JFK": ((150, 170, 9, 0),)}) + run = sim.execute(task, {}) + assert (run.flights[0].dep, run.flights[0].arr) == (125, 170) + + +def times(run): + """Every flight's departure and arrival.""" + return {i: (r.dep, r.arr) for i, r in run.flights.items()} + + +def test_a_full_outstation_hour_pushes_an_arrival_on_to_the_next_hour_and_leaves_other_stations_alone(): + """BUF caps hour 2 at one arrival: flight 0 lands at 120, so flight 1, ready to land at 130, leaves DCA at 120 to + land at 180; PWM is uncapped.""" + run = sim.execute(outcapped_day(), {}) + assert times(run) == {0: (60, 120), 1: (120, 180), 2: (55, 125)} + assert times(sim.execute(replace(outcapped_day(), layers=None), {})) == {0: (60, 120), 1: (70, 130), + 2: (55, 125)} + assert times(sim.execute(outcapped(outcapped_day(), {"BUF": {2: 2}}), {})) == { + 0: (60, 120), 1: (70, 130), 2: (55, 125)} + + +def test_a_pinned_arrival_uses_up_its_outstation_hour_before_the_later_ones_arrive(): + """At a watch at 75, flight 1 left DCA at 70 to land at 130: flight 0 can't land in hour 2 too.""" + run = sim.execute(outcapped_day(), {0: {"not_before": 75}, 1: {"left": 70}}) + assert times(run) == {0: (120, 180), 1: (70, 130), 2: (55, 125)} + + +def test_outstation_arrival_caps_join_the_truths_nominal_caps_the_known_days_within_cap_look_and_not_the_calm_days(): + task = outcapped_day() + assert sim.truth(task).nominal == sim.nominal_caps(task) | {"BUF": {2: 1}} + assert sim.known(task, 0).nominal == sim.truth(task).nominal and "BUF" not in sim.calm(task).nominal + assert "BUF" not in sim.known(outcapped(task, {"BUF": {9: 1}}), 0).nominal diff --git a/tests/programs/test_desk_view.py b/tests/programs/test_desk_view.py new file mode 100644 index 0000000..707b82c --- /dev/null +++ b/tests/programs/test_desk_view.py @@ -0,0 +1,237 @@ +import csv +import io +import json +import random +import shutil +from dataclasses import replace +from datetime import UTC, datetime + +import pytest +from day_helpers import ( + capped, + closed, + closed_day, + day, + flight, + outcapped, + random_day, + reserve_day, + tail, + three_into_a_program, +) +from program_helpers import adv +from test_reference_plays import stopped_then_released + +from agentenv_airops.build import bts +from agentenv_airops.core.known import CAP_LOOK, FREEZE, STEP +from agentenv_airops.programs import references, sim +from agentenv_airops.programs.layers import closures, weather +from agentenv_airops.programs.model import LAGS, Layers, Task +from agentenv_airops.v4day import clock +from agentenv_airops.v4day.day import Day + + +def two_days_of_caps() -> Task: + """JFK caps hours 2 and 5 at one departure, BUF hours 2 and 6 at one arrival.""" + task = day([flight(0, "JFK", "BUF", 100, 60), flight(1, "JFK", "MCO", 500, 150)], + [tail("N1", "JFK", end="BUF"), tail("N2", "JFK", end="MCO")]) + return outcapped(capped(task, {"JFK": {2: 1, 5: 1}}), {"BUF": {2: 1, 6: 1}}) + + +def test_known_carries_dep_and_outstation_caps_only_cap_look_ahead(): + task = two_days_of_caps() + w = sim.known(task, 0) + assert w.dep_caps == {"JFK": {2: 1}} and w.nominal["BUF"] == {2: 1} + w = sim.known(task, 120) + assert w.dep_caps == {"JFK": {2: 1, 5: 1}} and w.nominal["BUF"] == {2: 1, 6: 1} + assert all(60 * h <= 60 + CAP_LOOK for h in sim.known(task, 60).dep_caps["JFK"]) + assert sim.known(task, 0).nominal["JFK"] == sim.truth(task).nominal["JFK"] + + +def test_known_carries_a_closure_once_begun_expected_guess_on_until_its_end_comes(): + task = closed_day() + assert sim.known(task, 119).closures == {} + assert sim.known(task, 120).closures == {"JFK": ((120, 120 + sim.GUESS, 1, 1),)} + assert sim.known(task, 170).closures == {"JFK": ((120, 170 + sim.GUESS, 3, 1),)} + assert sim.known(task, 180).closures == sim.known(task, 300).closures == {"JFK": ((120, 180, 1, 2),)} + + +def two_closures_but_their_ends() -> tuple[Task, Task]: + """Eighteen departures from JFK every 10 minutes from 120, its ramp closed from 120 to 150 on one day and to 300 + on the other, each priced at rate 0.75 over its true length (closures.build).""" + base = day([flight(i, "JFK", "BUF", 120 + 10 * i, 60) for i in range(18)], + [tail(f"N{i}", "JFK", end="BUF") for i in range(18)]) + return tuple(closed(base, closures.build(base, {"JFK": [(base.base + 120, base.base + end)]}, {"JFK": 0.75}), + {"JFK": 0.75}) for end in (150, 300)) + + +def test_two_closures_differing_only_in_their_true_end_look_the_same_until_it_comes(): + short, long = two_closures_but_their_ends() + assert short.layers.closures["JFK"][0][2] < long.layers.closures["JFK"][0][2] + for t in (120, 135): + assert sim.known(short, t).closures == sim.known(long, t).closures + assert sim.known(short, t).closures["JFK"][0][:2] == (120, t + sim.GUESS) + assert closures_situation(short, 120) == closures_situation(long, 120) + assert sim.known(short, 150).closures == short.layers.closures != sim.known(long, 150).closures + + +def closures_situation(task: Task, t: int) -> list[dict]: + d = Day(task) + while d.t < t: + d.advance() + return d.situation()["closures"]["JFK"] + + +def test_a_closure_under_way_at_a_hub_without_a_rate_cant_be_known(): + task = closed(day([flight(0, "JFK", "BUF", 400, 60)], [tail("N1", "JFK", end="BUF")]), {"JFK": ((120, 180, 1, 1),)}) + assert sim.known(task, 60).closures == {} and sim.known(task, 180).closures == {"JFK": ((120, 180, 1, 1),)} + with pytest.raises(ValueError, match="no rate"): + sim.known(task, 150) + + +def test_a_layered_tasks_watches_run_until_every_cap_is_known(): + task = outcapped(day([flight(0, "JFK", "SFO", 300, 360)], [tail("N1", "JFK", end="SFO")]), {"SFO": {11: 0}}) + ts = references.watch_times(task) + assert ts[-1] >= 60 * 11 - CAP_LOOK and sim.known(task, ts[-1]).nominal["SFO"] == {11: 0} + known = sim.execute(task, {}, sim.known(task, ts[-1])).flights[0] + assert known == sim.execute(task, {}).flights[0] and known.dep == 360 + + +def test_known_keeps_the_reserve_pools_of_the_truth(): + task = reserve_day() + plan = {0: {"reserve": True}, 1: {"reserve": True}} + assert sim.execute(task, plan, sim.known(task, 0)).reserved == sim.execute(task, plan).reserved == {0} + + +@pytest.mark.parametrize("seed", range(4)) +def test_without_layers_known_and_watch_times_are_v2s(seed): + for task in (random_day(random.Random(seed)), three_into_a_program(), stopped_then_released()): + for t in references.watch_times(task): + w = sim.known(task, t) + assert w == sim.known(replace(task, layers=Layers()), t) + assert w.dep_caps == {} and w.closures == {} and w.nominal == sim.nominal_caps(task) + assert repr(w) == repr(sim.World(w.lb, w.block, w.segments, w.stops, sim.nominal_caps(task))) + ts = references.watch_times(task) + assert ts == references.watch_times(replace(task, layers=Layers())) + assert ts == list(range(task.start, ts[-1] + 1, STEP)) + + +def eventful() -> Task: + """closed_day (JFK closed 120-180) with a ground-stop advisory signed at 200 (for 11:40-12:00, when nothing lands) + and a SPECI observed at 300.""" + task = closed_day() + jfk = task.programs["JFK"] + task = replace(task, programs=task.programs | {"JFK": replace(jfk, advisories=jfk.advisories + ( + adv("a1", "gs", 200, 700, 720),))}) + return replace(task, layers=replace(task.layers, weather=((300, "speci", "JFK", "KJFK 2605Z TS"),))) + + +def test_a_layered_tasks_watches_wake_on_news_and_closures_15_to_60_apart(): + task = eventful() + ts = references.watch_times(task) + assert references.events(task) == [120, 180, 200, 300 + LAGS["speci"]] + assert {200, 305} <= set(ts) and 120 in ts + assert ts == [0, 60, 120, 180, 200, 260, 305] + assert all(references.WATCH_MIN <= b - a <= STEP for a, b in zip(ts, ts[1:], strict=False)) + assert references.watch_times(replace(task, layers=None)) == [0, 60, 120] + + +def test_an_event_close_to_a_watch_moves_to_watch_min_after_it_absorbing_the_rest(): + task = closed(day([flight(0, "JFK", "BUF", 400, 60)], [tail("N1", "JFK", end="BUF")]), + {"JFK": ((125, 160, 1, 1), (130, 170, 1, 1))}) + ts = references.watch_times(task) + assert ts[:4] == [0, 60, 120, 135] and ts[4:7] == [160, 175, 190] + + + +def test_a_closure_outlasting_the_hourly_watches_is_watched_until_its_end_comes(): + flights = [flight(i, "JFK", "BUF", 200 + 20 * i, 60, deadline=600 + 20 * i) for i in range(6)] + task = closed(day(flights, [tail(f"N{i}", "JFK", end="BUF") for i in range(6)]), {"JFK": ((200, 420, 1, 1),)}, + {"JFK": 0.5}) + ts = references.watch_times(task) + assert ts == [0, 60, 120, 180, 200, 260, 320, 380, 420] + assert sim.known(task, 380).closures["JFK"][0][1] == 380 + sim.GUESS + assert sim.known(task, ts[-1]).closures == task.layers.closures + assert all(references.WATCH_MIN <= b - a <= STEP for a, b in zip(ts, ts[1:], strict=False)) + + +def test_an_event_just_before_the_last_hourly_watch_moves_it_onto_the_last_minute(): + f = flight(0, "JFK", "BUF", 600, 60, deadline=665) + capped_far = replace(day([f], [tail("N1", "JFK", end="BUF")]), layers=Layers(dep_caps={"JFK": {30: 5}})) + last = f.deadline - FREEZE + assert references.watch_times(capped_far)[-1] == 600 + task = replace(capped_far, layers=replace(capped_far.layers, weather=((590, "speci", "JFK", "KJFK SPECI TS"), + (598, "speci", "JFK", "KJFK SPECI +TS")))) + ts = references.watch_times(task) + assert ts[-3:] == [540, 595, last] + news = {n for w in clock.timetable(task) for n in w.news} + assert {"speci-jfk-590", "speci-jfk-598"} <= news + + +def test_a_negative_lag_and_an_advisory_lag_the_known_programs_would_not_wait_for_are_refused(): + with pytest.raises(ValueError, match="not a whole number"): + Layers(lags={"speci": -60}) + with pytest.raises(ValueError, match="not a whole number"): + Layers(lags={"taf": 2.5}) + with pytest.raises(ValueError, match="advisory lag must be 0"): + Layers(lags={"advisory": 30}) + assert Layers(lags={"advisory": 0, "speci": 0}).lags == LAGS | {"speci": 0} + +def test_the_follower_on_a_layered_day_wakes_on_events_and_its_plan_replays(): + task = eventful() + followed = references.follow(task, time_limit=60) + ts = [w["t"] for w in followed.watches] + assert ts == references.watch_times(task) and 305 in ts and any(t % STEP for t in ts) + again = sim.execute(task, followed.plan) + assert again.rows() == followed.run.rows() and again.cost == followed.run.cost + _, anchor = references.anchor(task, time_limit=60) + assert followed.cost >= anchor.cost.total + + +def test_lags_left_out_keep_their_default_and_an_unknown_kind_is_refused(): + layers = Layers.from_dict({"dep_caps": {}, "arr_caps": {}, "closures": {}, "reserves": {}, + "lags": {"speci": 7}}) + assert layers.lags == LAGS | {"speci": 7} and layers.to_dict()["lags"] == layers.lags + assert replace(closed_day(), layers=layers).lag("taf") == 10 + with pytest.raises(ValueError, match="unknown kind"): + Layers(lags={"metra": 5}) + with pytest.raises(KeyError): + closed_day().lag("metra") + + +def test_weather_and_lags_round_trip_and_are_written_only_when_set(): + task = closed_day() + assert "weather" not in task.layers.to_dict() and "lags" not in task.layers.to_dict() + layers = replace(task.layers, weather=((50, "metar", "JFK", "KJFK 260051Z"),), lags=LAGS | {"taf": 20}) + back = Task.from_dict(json.loads(replace(task, layers=layers).to_json())) + assert back.layers == layers and back.lag("taf") == 20 and back.lag("advisory") == 0 + assert day([flight(0, "JFK", "BUF", 100, 60)], [tail("N1", "JFK")]).lag("metar") == LAGS["metar"] == 5 + + +def minute(row: dict, task: Task) -> int: + return bts.epoch_minutes(datetime.strptime(row["valid"], "%Y-%m-%d %H:%M").replace(tzinfo=UTC)) - task.base + + +def test_the_weather_builder_reads_the_hubs_metars_specis_and_tafs_over_the_span(task, slice_inputs, tmp_path): + root = tmp_path / "inputs" + shutil.copytree(slice_inputs / "iem", root / "iem") + metars = (root / "iem" / "metar" / "JFK_2023_06.csv").read_text() + rows = list(csv.DictReader(io.StringIO(metars))) + hi = max(f.deadline for f in task.flights) + seen = next(r for r in rows if task.start <= minute(r, task) <= hi) + special = dict(seen, valid=seen["valid"][:-2] + "07", metar=seen["metar"].replace("51Z", "07Z")) + out = io.StringIO() + w = csv.DictWriter(out, fieldnames=list(rows[0])) + w.writeheader() + w.writerows([seen, special]) + path = root / "iem" / "speci" / "JFK_2023_06.csv" + path.parent.mkdir(parents=True) + path.write_text(out.getvalue()) + items = weather.build(task, root) + kinds = {k for _, k, _, _ in items} + assert kinds == {"metar", "speci", "taf"} and {s for _, _, s, _ in items} == set(task.storms) + assert [x[0] for x in items] == sorted(x[0] for x in items) + assert all(task.start - weather.LEAD_TAF <= m <= hi for m, _, _, _ in items) + assert sum(k == "speci" for _, k, _, _ in items) == 1 + assert any(k == "taf" and m <= task.start for m, k, _, _ in items) + assert any(k == "metar" and task.start - weather.LEAD_OBS <= m <= task.start for m, k, _, _ in items) diff --git a/tests/programs/test_fairday.py b/tests/programs/test_fairday.py new file mode 100644 index 0000000..70a2890 --- /dev/null +++ b/tests/programs/test_fairday.py @@ -0,0 +1,51 @@ +import pandas as pd +import pytest + +from agentenv_airops.programs.layers import fairday + + +def flight(date, orig, dest, tail, cancelled=0, code=None, carrier="B6"): + return {"FlightDate": date, "Reporting_Airline": carrier, "Tail_Number": tail, "Origin": orig, "Dest": dest, + "Cancelled": float(cancelled), "CancellationCode": code} + + +def test_a_days_scope_is_every_leg_of_a_tail_that_touched_a_hub_and_every_hub_flight_of_the_carrier(): + """On d1, N1 touches JFK so its ORD-DEN leg is in scope (cancelled for the carrier), N2 never does and is out, a + hub flight with no tail is in, and another carrier's hub flight is not; d2's hub flight is cancelled for weather.""" + frame = pd.DataFrame([ + flight("2024-07-01", "JFK", "ORD", "N1"), flight("2024-07-01", "ORD", "DEN", "N1", 1, "A"), + flight("2024-07-01", "LAX", "SFO", "N2", 1, "A"), flight("2024-07-01", "BOS", "MCO", None, 1, "C"), + flight("2024-07-01", "JFK", "MIA", "N9", 1, "A", carrier="AA"), + flight("2024-07-02", "JFK", "BOS", "N1", 1, "B"), flight("2024-07-02", "BOS", "JFK", "N3"), + ]) + d = fairday.days(frame, ("JFK", "BOS")) + assert d.to_dict("records") == [ + {"date": "2024-07-01", "flights": 3, "A": 1, "B": 0, "C": 1, "D": 0}, + {"date": "2024-07-02", "flights": 2, "A": 0, "B": 1, "C": 0, "D": 0}, + ] + + +def test_the_rate_is_the_quantile_of_the_carriers_share_over_fair_days_alone(): + """Fair days: d1 (1 of 100 cancelled for the carrier), d2 (none of 100), d4 (3 of 100); d3 has a weather + cancellation and is not one, whatever its carrier cancellations.""" + rows = [] + for day, carrier_cancels, weather in (("2024-07-01", 1, 0), ("2024-07-02", 0, 0), ("2024-07-03", 40, 1), + ("2024-07-04", 3, 0)): + rows += [flight(day, "JFK", "ORD", f"N{k}", 1 if k < carrier_cancels else 0, "A") for k in range(100 - weather)] + rows += [flight(day, "JFK", "BOS", "W", 1, "B")] * weather + bar = fairday.rate([pd.DataFrame(rows)], ("JFK", "BOS"), q=0.5) + assert (bar["fair_days"], bar["days"], bar["quantile"], bar["ferries"]) == (3, 4, 0.5, fairday.FERRIES) + assert bar["rate"] == pytest.approx(0.01) + assert fairday.rate([pd.DataFrame(rows)], ("JFK", "BOS"))["rate"] == pytest.approx(0.026) + + +def test_months_with_no_fair_day_have_no_bar(): + frame = pd.DataFrame([flight("2024-07-01", "JFK", "BOS", "N1", 1, "B")]) + with pytest.raises(ValueError, match="no fair day"): + fairday.rate([frame], ("JFK",)) + + +def test_a_calm_day_is_within_the_bar_at_the_rate_times_its_flights_at_least_one_and_two_ferries(): + assert [fairday.allowance(0.0092, n) for n in (50, 108, 109, 687)] == [1, 1, 1, 6] + assert fairday.within(6, 2, 687, 0.0092) and not fairday.within(7, 0, 687, 0.0092) + assert fairday.within(1, 0, 50, 0.0092) and not fairday.within(0, 3, 50, 0.0092) diff --git a/tests/programs/test_hindsight_mip.py b/tests/programs/test_hindsight_mip.py index fd81005..d39475b 100644 --- a/tests/programs/test_hindsight_mip.py +++ b/tests/programs/test_hindsight_mip.py @@ -4,29 +4,53 @@ import numpy as np import pytest from day_helpers import ( + PLACES, + based, broken_day, busy_day, + capped, + capped_day, + chain_day, + closed, + closed_day, crewed, day, flight, + late_pin_day, moved_day, network_day, + outcapped, + outcapped_day, quick_day, random_day, + reserve_day, + reserved, segment, + shared_hour_day, shortened, stop, tail, three_into_a_program, turned_through_day, + two_from_jfk, + with_crews, ) -from agentenv_airops.programs import mip, references, sim +from agentenv_airops.programs import candidates, mip, references, sim +from agentenv_airops.programs.advisories import HUBS from agentenv_airops.programs.cost import cancel_price +from agentenv_airops.programs.layers import arrcaps, closures, depcaps +from agentenv_airops.programs.layers.crew import Rule from agentenv_airops.programs.model import Task RANDOM = range(8) NETWORK = range(8) +CAPPED = range(4) +TIGHT = {c: {h: h % 3 for h in range(24)} for c in PLACES} +OUT_TIGHT = {c: {h: h % 3 for h in range(24)} for c in PLACES if c not in HUBS} +POOLS = {"JFK": 1, "BOS": 0} +SHUT = {"JFK": ((80, 160, 1, 0), (220, 250, 0, 1)), "BOS": ((180, 230, 0, 1),), "BUF": ((290, 330, 1, 0),), + "DCA": ((210, 280, 0, 0),)} def days() -> dict[str, Task]: @@ -42,7 +66,27 @@ def days() -> dict[str, Task]: "lone_quick": replace(quick_day(), tails=quick_day().tails[:1]), "families": families(), "crew": crew(), "turned_through": turned_through_day(), **{f"random{seed}": random_day(random.Random(seed)) for seed in RANDOM}, - **{f"network{seed}": network_day(random.Random(seed)) for seed in NETWORK}} + **{f"network{seed}": network_day(random.Random(seed)) for seed in NETWORK}, + "capped": capped_day(), "shared_hour": shared_hour_day(), + **{f"capped_random{seed}": capped(random_day(random.Random(seed)), TIGHT) for seed in CAPPED}, + **{f"capped_network{seed}": capped(network_day(random.Random(seed)), TIGHT) for seed in CAPPED}, + "closed": closed_day(1), "closed_shut": closed_day(0), + **{f"closed_random{seed}": closed(random_day(random.Random(seed)), SHUT) for seed in CAPPED}, + **{f"closed_network{seed}": closed(network_day(random.Random(seed)), SHUT) for seed in CAPPED}, + "closed_capped_network1": closed(capped(network_day(random.Random(1)), TIGHT), SHUT), + "outcapped": outcapped_day(), + **{f"outcapped_random{seed}": outcapped(random_day(random.Random(seed)), OUT_TIGHT) for seed in CAPPED}, + **{f"outcapped_network{seed}": outcapped(network_day(random.Random(seed)), OUT_TIGHT) for seed in CAPPED}, + "outcapped_closed_capped_network1": outcapped(closed(capped(network_day(random.Random(1)), TIGHT), SHUT), + OUT_TIGHT), + "reserves": reserve_day(), "reserves_none": reserved(reserve_day(), {"JFK": 0}), + "edgar_chain": chain_day(Rule()), "v4_chain": chain_day(Rule("v4")), + "v4_chain_pooled": reserved(chain_day(Rule("v4")), {"JFK": 0}), + "v4_chain_late": reserved(chain_day(Rule("v4"), lb=450, slack=900), {"JFK": 0}), + **{f"reserves_random{seed}": with_crews(random_day(random.Random(seed)), random.Random(seed), POOLS) + for seed in CAPPED}, + **{f"reserves_network{seed}": with_crews(network_day(random.Random(seed)), random.Random(seed), POOLS) + for seed in CAPPED}} def crew() -> Task: @@ -67,9 +111,38 @@ def e190(f): return replace(day(flights, tails, stops=(stop(100, 160),)), turn={"A320": 30, "E190": 25}) -@pytest.fixture(scope="module", params=["task", "crewed"]) +@pytest.fixture(scope="module") +def capped_task(task, slice_inputs, tmp_path_factory) -> Task: + """The fixture day under the departure caps the slice's BTS pushbacks give it.""" + return capped(task, depcaps.build(task, candidates.frame(slice_inputs, tmp_path_factory.mktemp("derived"), + task.date))) + + +@pytest.fixture(scope="module") +def closed_task(task) -> Task: + """The fixture day with JFK's ramp closed 18:00-19:00 at half its departure and arrival rates.""" + return closed(task, closures.build(task, {"JFK": [(task.base + 1080, task.base + 1140)]}, {"JFK": 0.5})) + + +@pytest.fixture(scope="module") +def outcapped_task(task, slice_inputs, tmp_path_factory) -> Task: + """The fixture day under the outstation arrival caps the slice's BTS arrivals give it, every hour with one + arrival scheduled counted (the slice holds few other carriers' flights).""" + return outcapped(task, arrcaps.build(task, candidates.frame(slice_inputs, tmp_path_factory.mktemp("derived"), + task.date), min_sched=1)) + + +@pytest.fixture(scope="module") +def v4pooled(v4crewed) -> Task: + """The fixture day with v4's crews and an empty reserve pool at every base.""" + return reserved(v4crewed, dict.fromkeys(v4crewed.bases, 0)) + + +@pytest.fixture(scope="module", params=["task", "crewed", "v4crewed", "v4pooled", "capped_task", "closed_task", + "outcapped_task"]) def fixture_day(request) -> Task: - """The fixture day, and the same day with the crew model.""" + """The fixture day, the same day with Edgar's crew model, with v4's (and empty pools), under departure caps, with a + ramp closure, and under outstation arrival caps.""" return shortened(request.getfixturevalue(request.param)) @@ -141,7 +214,11 @@ def test_a_day_with_legs_cancelled_on_either_side_of_its_breaks_is_a_solution_at assert sol.deps[3] is None and replays(task, sol) -@pytest.mark.parametrize("name", ["slots", "stop", "moved", "broken", "busy", "quick", "crew", "random3", "network2"]) +@pytest.mark.parametrize("name", ["slots", "stop", "moved", "broken", "busy", "quick", "crew", "random3", "network2", + "capped", "capped_random1", "capped_network2", "closed", "closed_shut", + "closed_random1", "closed_network0", "closed_capped_network1", "outcapped", + "outcapped_random1", "outcapped_network2", "outcapped_closed_capped_network1", + "reserves", "reserves_random1", "reserves_network2"]) def test_a_day_with_its_history_pinned_is_a_solution_of_the_model_with_those_pins(name): task = days()[name] first = sim.execute(task, {}) @@ -155,6 +232,112 @@ def test_a_day_with_its_history_pinned_is_a_solution_of_the_model_with_those_pin assert round(sum(c * v for c, v in zip(net.m.cost, x, strict=True))) == run.cost.total +RESERVED = [n for n in days() if n.startswith("reserves")] + + +def reserve_plans(task: Task, rng: random.Random) -> list[dict]: + """Random plans that call reserves for most flights, so the pools bind.""" + return [{f.id: {"cancel": rng.random() < 0.15, "decided": rng.randrange(0, 200), + "not_before": rng.randrange(0, 400), "reserve": rng.random() < 0.7} + for f in task.flights if rng.random() < 0.8} for _ in range(10)] + + +@pytest.mark.parametrize("name", RESERVED) +def test_every_day_that_calls_reserves_from_a_finite_pool_is_a_solution_of_the_model_at_its_cost(name): + task = days()[name] + for plan in reserve_plans(task, random.Random(13)): + run = sim.execute(task, plan) + assert run.reserved == sim.granted(task, plan) + decided = {f.id: run.flights[f.id].decided for f in task.flights} + net = mip.Network(task, price={f.id: cancel_price(f, decided[f.id] or 0) for f in task.flights}) + x = net.vector(run) + assert x is not None and feasible(net, x) + assert round(sum(c * v for c, v in zip(net.m.cost, x, strict=True))) == run.cost.total + + +@pytest.mark.parametrize("name", RESERVED) +def test_a_reserve_day_with_its_history_pinned_on_its_reserves_is_a_solution_with_those_pins(name): + """Pinned flights that called a reserve hold it: the pool row has room for the rest only, and a pinned flight + auto-cancelling on a reserve crew is priced at its deadline.""" + task = days()[name] + for plan in reserve_plans(task, random.Random(17))[:5]: + first = sim.execute(task, plan) + pins = {i for i, r in first.flights.items() if i % 2 == 0 and r.status != "cancelled"} + pinned = {i: {"left": first.flights[i].dep} | ({"reserve": True} if plan.get(i, {}).get("reserve") else {}) + if first.flights[i].status == "flown" else dict(plan.get(i, {})) for i in pins} + run = sim.execute(task, {**plan, **pinned}) + decided = {f.id: run.flights[f.id].decided for f in task.flights} + fixed = {i: run.flights[i].dep for i in pins} + lapsed = {i for i in pins if run.flights[i].status == "auto_cancelled"} + net = mip.Network(task, price={f.id: cancel_price(f, decided[f.id] or 0) for f in task.flights}, + fixed=fixed, lapsed=lapsed, reserved=run.reserved & pins) + x = net.vector(run) + assert x is not None and feasible(net, x) + assert round(sum(c * v for c, v in zip(net.m.cost, x, strict=True))) == run.cost.total + + +def test_a_binding_pool_is_what_the_optimum_spends_and_its_plan_calls_exactly_those_reserves(): + """With one reserve at JFK the optimum flies the larger of the two late JFK departures on it and cancels the + other; with none it cancels both; the plan calls the reserve it uses and the sim grants it.""" + one, none = reserve_day(), reserved(reserve_day(), {"JFK": 0}) + sol = mip.solve(one) + assert sol.proven and replays(one, sol) and sol.reserve == {1} + assert sol.plan(one.start)[1]["reserve"] and sim.execute(one, sol.plan(one.start)).reserved == {1} + assert sol.deps[1] is not None and sol.deps[0] is None + nil = mip.solve(none) + assert nil.proven and replays(none, nil) and nil.reserve == frozenset() + assert nil.deps[0] is None and nil.deps[1] is None and nil.objective > sol.objective + unlimited = replace(one, layers=None) + free = mip.solve(unlimited) + assert free.deps[0] is not None and free.deps[1] is not None and free.objective < sol.objective + + +def test_a_pinned_flight_holding_the_pools_reserve_leaves_none_for_the_rest_of_the_re_solve(): + """f1 left at 205 on JFK's one reserve: re-solving from that pin, f0 can't fly past its crew deadline (150) too and + is cancelled, the re-solve replays with the pin, and it costs more than one that forgets f1's reserve.""" + task = reserve_day() + pin = {1: {"left": 205, "reserve": True}} + sol = mip.solve(task, fixed={1: 205}, reserved={1}) + assert sol.proven and sol.reserve == {1} and sol.deps[0] is None + run = sim.execute(task, {**sol.plan(task.start), **pin}) + assert run.reserved == {1} and run.cost.total == sol.objective and run.flights[0].status != "flown" + forgot = mip.solve(task, fixed={1: 205}) + assert forgot.deps[0] is not None and forgot.objective < sol.objective + + +def test_a_coarse_step_keeps_the_minute_a_pooled_base_crews_rest_ends(): + """With no reserve left at JFK, f0 (known 100 minutes late, crew deadline 150, rest to 750, deadline 900) leaves + when its crew's rest ends, 750, a minute a 7-minute step doesn't reach: the sim's day is still a solution.""" + task = reserved(day([based(flight(0, "JFK", "BUF", 100, 60, lb=100, deadline=900), 150)], + [tail("N1", "JFK", end="BUF")]), + {"JFK": 0}) + run = sim.execute(task, {}) + assert run.flights[0].dep == 750 and task.crew(task.flight(0)).rest == 750 + net = mip.Network(task, step=7) + x = net.vector(run) + assert x is not None and feasible(net, x) + assert round(sum(c * v for c, v in zip(net.m.cost, x, strict=True))) == run.cost.total + sol = mip.solve(task, step=7) + assert sol.proven and replays(task, sol) and sol.objective <= run.cost.total + + +def test_a_day_with_a_departure_pinned_before_its_aircraft_is_ready_is_a_solution_pinned_where_it_left(): + task = late_pin_day() + run = sim.execute(task, {1: {"left": 170}}) + net = mip.Network(task, fixed={1: run.flights[1].dep}) + x = net.vector(run) + assert x is not None and feasible(net, x) + assert round(sum(c * v for c, v in zip(net.m.cost, x, strict=True))) == run.cost.total + + +def test_a_pinned_departure_leaves_the_others_only_the_rest_of_its_hours_cap(): + task = two_from_jfk() + sol = mip.solve(task, fixed={1: 100}) + run = sim.execute(task, {**sol.plan(task.start), 1: {"left": 100}}) + assert sol.deps == {0: 120, 1: 100} == {i: r.dep for i, r in run.flights.items()} + assert run.cost.total == sol.objective + + def test_a_canonical_pass_out_of_time_keeps_the_proven_plan_flagged_as_not_canonical(monkeypatch): task = days()["slots"] proven = mip.solve(task, canonical=False) @@ -195,6 +378,15 @@ def test_a_coarse_step_keeps_every_slots_edct(): assert {d for d in sol.deps.values()} == {120, 180, 240} +def test_a_coarse_step_keeps_every_ramp_closures_end(): + """At a 7-minute step the closure's end, 180, is still a minute flights 0 and 1 may leave JFK at, and with no room + for arrivals the end less the block, 120, one flights 3 and 5 may leave for JFK at.""" + task = closed_day(0) + sol = mip.solve(task, step=7) + assert sol.proven and replays(task, sol) + assert (sol.deps[0], sol.deps[1], sol.deps[3], sol.deps[5]) == (180, 180, 120, 120) + + def test_pins_and_the_earliest_minute_are_kept(fixture_day): task = fixture_day a, b = task.flights[3], task.flights[10] @@ -267,3 +459,49 @@ def test_a_day_whose_lp_relaxation_is_fractional_proves_through_the_mip_to_the_s fast, slow = mip.solve(task), mip.solve(task, fix=False) assert fast.proven and fast.canonical and replays(task, fast) assert (fast.objective, fast.deps) == (slow.objective, slow.deps) + + +def test_a_ramp_closure_raises_the_optimum_and_the_optimum_replays_under_it(): + task = closed_day(1) + sol = mip.solve(task) + assert sol.proven and replays(task, sol) + assert sol.objective > mip.solve(closed_day()).objective > mip.solve(replace(task, layers=None)).objective + assert sum(sol.deps[i] is not None and 120 <= sol.deps[i] < 180 for i in (0, 1, 2, 4, 6)) <= 1 + assert sum(sol.deps[i] is not None and 120 <= sol.deps[i] + 60 < 180 for i in (3, 5)) == 1 + + +def test_an_outstation_arrival_cap_raises_the_optimum_and_the_optimum_replays_under_it(): + """BUF takes one arrival in hour 2: the optimum lands the 60-pax flight 1 at 180, at 60 x 50 minutes late, plus + its aircraft's 50 minutes.""" + task = outcapped_day() + sol = mip.solve(task) + assert sol.proven and replays(task, sol) + assert sol.deps == {0: 60, 1: 120, 2: 55} and sol.objective > mip.solve(replace(task, layers=None)).objective == 0 + + +def test_a_pinned_arrival_at_an_outstation_leaves_the_others_the_rest_of_its_hour(): + task = outcapped_day() + sol = mip.solve(task, fixed={1: 70}) + run = sim.execute(task, {**sol.plan(task.start), 1: {"left": 70}}) + assert sol.deps == {0: 120, 1: 70, 2: 55} == {i: r.dep for i, r in run.flights.items()} + assert run.cost.total == sol.objective + + +def test_a_pinned_departure_inside_a_ramp_closure_leaves_the_others_the_rest_of_its_cap(): + task = closed_day() + sol = mip.solve(task, fixed={1: 125}) + run = sim.execute(task, {**sol.plan(task.start), 1: {"left": 125}}) + assert all(d is None or not 120 <= d < 180 for i, d in sol.deps.items() if i != 1 and task.flight(i).orig == "JFK") + assert {i: r.dep for i, r in run.flights.items() if r.status == "flown"} == { + i: d for i, d in sol.deps.items() if d is not None} + assert run.cost.total == sol.objective + + +def test_a_pinned_arrival_inside_a_ramp_closure_leaves_the_others_the_rest_of_its_cap(): + task = closed_day(1) + sol = mip.solve(task, fixed={5: 110}) + run = sim.execute(task, {**sol.plan(task.start), 5: {"left": 110}}) + assert sol.deps[3] + 60 >= 180 + assert {i: r.dep for i, r in run.flights.items() if r.status == "flown"} == { + i: d for i, d in sol.deps.items() if d is not None} + assert run.cost.total == sol.objective diff --git a/tests/programs/test_layers.py b/tests/programs/test_layers.py new file mode 100644 index 0000000..0e1f4a5 --- /dev/null +++ b/tests/programs/test_layers.py @@ -0,0 +1,152 @@ +import json +from collections import Counter +from dataclasses import replace + +import pandas as pd +import pytest +from day_helpers import day, flight, outcapped, tail + +from agentenv_airops.programs import candidates, references +from agentenv_airops.programs.layers import arrcaps, depcaps +from agentenv_airops.programs.model import LAYERED, VERSION, Layers, Outcome, Task + + +@pytest.mark.parametrize("name", ["task", "crewed"]) +def test_a_task_without_layers_serialises_as_a_v2_task_byte_for_byte(name, request): + task = request.getfixturevalue(name) + line = task.to_json() + d = json.loads(line) + assert task.layers is None and "layers" not in d and d["version"] == VERSION + assert list(d)[-1] == "needs" + assert Task.from_dict(d).to_json() == line + + +def test_a_task_with_layers_round_trips_at_version_4(): + layers = Layers(dep_caps={"JFK": {17: 9, 18: 7}}, arr_caps={"PWM": {19: 2}}, + closures={"BOS": ((1000, 1045, 3, 2),)}, reserves={"JFK": 6}) + task = replace(day([flight(0, "JFK", "BUF", 1000, 60)], [tail("N1", "JFK", end="BUF")]), layers=layers) + d = json.loads(task.to_json()) + assert d["version"] == LAYERED == 4 and d["layers"]["dep_caps"] == {"JFK": {"17": 9, "18": 7}} + back = Task.from_dict(d) + assert back == task and back.to_json() == task.to_json() + + +def test_a_task_whose_layers_are_stripped_or_empty_serialises_as_the_v2_task(): + task = day([flight(0, "JFK", "BUF", 1000, 60)], [tail("N1", "JFK", end="BUF")]) + loaded = Task.from_dict(json.loads(replace(task, layers=Layers(dep_caps={"JFK": {16: 1}})).to_json())) + assert replace(loaded, layers=None).to_json() == task.to_json() + assert replace(task, layers=Layers()).to_json() == task.to_json() + + +def leaving_jfk(i: int, dep: int, pushed: int | None): + return flight(i, "JFK", "BUF", dep, 60, actual=Outcome(pushed is None, False, "", pushed, None, None, "N1")) + + +def test_depcaps_caps_only_short_hours_at_the_share_never_below_what_jetblue_pushed(): + """Every carrier at JFK, per hour (scheduled, pushed back): 17 (10, 6) and 18 (10, 5) short of 90%, 19 (10, 9) + not, 20 (4, 1) under MIN_SCHED, 21 (10, 2) short but with no JetBlue flight scheduled in it. JetBlue scheduled 4 + in hour 17 and pushed back 3 in it: max(3, 4/10 x 6) = 3; 2 in 18, one cancelled and one pushed back in 19: + max(0, 2/10 x 5) = 1. BOS had no JetBlue flight in the task.""" + base = 1_000_000 + flights = [leaving_jfk(0, 1020, 1030), leaving_jfk(1, 1030, 1040), leaving_jfk(2, 1040, 1050), + leaving_jfk(3, 1050, 1150), leaving_jfk(4, 1090, 1150), leaving_jfk(5, 1100, None), + leaving_jfk(6, 1140, 1145), leaving_jfk(7, 1200, 1210)] + task = replace(day(flights, [tail("N1", "JFK")], start=960), base=base) + rows = [("JFK", base + 60 * h + k, base + 60 * h + k + 5 if k < pushed else None) + for h, sched, pushed in ((17, 10, 6), (18, 10, 5), (19, 10, 9), (20, 4, 1), (21, 10, 2)) + for k in range(sched)] + rows += [("BOS", base + 60 * 17 + k, None) for k in range(10)] + frame = pd.DataFrame(rows, columns=["Origin", "sched_dep", "act_dep"]).astype({"act_dep": float}) + assert depcaps.build(task, frame) == {"JFK": {17: 3, 18: 1}} + assert depcaps.build(task, frame, short=0.5) == {} + assert depcaps.build(task, frame, min_sched=4) == {"JFK": {17: 3, 18: 1, 20: 1}} + + +def test_depcaps_on_the_slice_reads_the_bts_frame_and_keeps_jetblues_pushbacks_feasible(task, slice_inputs, tmp_path): + caps = depcaps.build(task, candidates.frame(slice_inputs, tmp_path, task.date)) + flew = Counter((f.orig, f.actual.dep // 60) for f in task.flights if f.actual.dep is not None) + assert caps == {"JFK": {17: 1, 18: 1, 19: 1}} + assert all(cap >= flew[(hub, h)] for hub, by in caps.items() for h, cap in by.items()) + + +@pytest.mark.parametrize("hub", ["JFK", "BOS"]) +def test_an_arrival_cap_at_a_hub_is_rejected_as_its_arrivals_answer_to_its_programs(hub): + with pytest.raises(ValueError, match=f"arrival caps at hub.*{hub}"): + Layers(arr_caps={"PWM": {19: 2}, hub: {19: 3}}) + d = Layers(arr_caps={"PWM": {19: 2}}).to_dict() + with pytest.raises(ValueError, match=hub): + Layers.from_dict(d | {"arr_caps": d["arr_caps"] | {hub: {"19": 3}}}) + + +def into(i: int, orig: str, dest: str, dep: int, block: int, gated: int | None, end: str | None = None): + """A JetBlue flight scheduled to land at dep + block, gated in at `gated` (None: it didn't fly) at `end`.""" + return flight(i, orig, dest, dep, block, actual=Outcome(gated is None, False, "", None if gated is None else dep, + gated, end or (None if gated is None else dest), "N1")) + + +def test_arrcaps_caps_only_short_hours_at_outstations_at_the_share_never_below_what_the_replay_lands(): + """Every carrier at PWM, per hour (scheduled, arrived): 14 (10, 2), before the first watch's hour 15; 16 (10, 5), + 17 (10, 6) and 18 (10, 2) short of 90%; 19 (10, 9) exactly at it, not short; 20 (4, 1) under MIN_SCHED; 21 (10, 2) + short but with no JetBlue flight scheduled to land in it. JetBlue's flights into PWM, by the hour the replay lands + them (pushback plus block): in 14 one, outside the span; in 16 three, two cancelled: max(1, 3/10 x 5 = 1.5) = 2, + rounded to the nearest flight; in 17 four, one of them gating in only in hour 19: max(4, 4/10 x 6 = 2.4) = 4; in 18 + two, one cancelled and one diverted to BOS, which the replay still lands at PWM: max(1, 2/10 x 2 = 0.4) = 1; in 19 + one; in 20 one: max(1, 1/4 x 1) = 1. A flight bound for BUF that diverted into PWM in hour 17 doesn't count there. + JFK, short at 17, is a hub: its arrivals answer to its programs.""" + base = 1_000_000 + flights = [into(0, "JFK", "PWM", 960, 60, 1030), into(1, "JFK", "PWM", 970, 60, 1040), + into(2, "BOS", "PWM", 990, 40, 1050), into(3, "JFK", "PWM", 1000, 60, 1150), + into(4, "JFK", "PWM", 1030, 60, None), into(5, "JFK", "PWM", 1040, 60, 1110, end="BOS"), + into(6, "JFK", "PWM", 1100, 60, 1165), into(7, "PWM", "JFK", 1000, 60, 1070), + into(8, "JFK", "BUF", 980, 60, 1060, end="PWM"), into(9, "JFK", "PWM", 1150, 60, 1215), + into(10, "JFK", "PWM", 790, 60, 850), into(11, "JFK", "PWM", 920, 60, 985), + into(12, "JFK", "PWM", 930, 60, None), into(13, "JFK", "PWM", 940, 60, None)] + task = replace(day(flights, [tail("N1", "JFK")], start=900), base=base) + rows = [("PWM", base + 60 * h + k, base + 60 * h + k + 5 if k < arrived else None) + for h, sched, arrived in ((14, 10, 2), (16, 10, 5), (17, 10, 6), (18, 10, 2), (19, 10, 9), (20, 4, 1), + (21, 10, 2)) + for k in range(sched)] + rows += [("JFK", base + 60 * 17 + k, None) for k in range(10)] + frame = pd.DataFrame(rows, columns=["Dest", "sched_arr", "act_arr"]).astype({"act_arr": float}) + assert arrcaps.build(task, frame) == {"PWM": {16: 2, 17: 4, 18: 1}} + assert arrcaps.build(task, frame, short=0.5) == {"PWM": {18: 1}} + assert arrcaps.build(task, frame, min_sched=4) == {"PWM": {16: 2, 17: 4, 18: 1, 20: 1}} + + +def test_the_airline_replay_of_a_diversion_and_a_late_gate_in_inside_built_caps_flies_as_on_the_uncapped_day(): + """N1's JFK-PWM pushed back at 100 and diverted to BOS; N2's JFK-BUF pushed back at 110 and gated in only at 300. + The replay lands both in hour 2, which every carrier's arrivals (one of five) cap at none by the share alone: the + floor at what the replay lands keeps both in it, so the replay is the uncapped day's.""" + base = 1_000_000 + task = replace(day([into(0, "JFK", "PWM", 100, 60, 200, end="BOS"), into(1, "JFK", "BUF", 110, 60, 300)], + [tail("N1", "JFK", end="BOS"), tail("N2", "JFK", end="BUF")]), base=base) + task = replace(task, flights=(task.flights[0], replace(task.flights[1], actual=replace(task.flights[1].actual, + tail="N2")))) + rows = [(s, base + 120 + k, base + 125 if k == 0 else None) for s in ("PWM", "BUF") for k in range(5)] + frame = pd.DataFrame(rows, columns=["Dest", "sched_arr", "act_arr"]).astype({"act_arr": float}) + caps = arrcaps.build(task, frame) + assert caps == {"BUF": {2: 1}, "PWM": {2: 1}} + capped, free = references.airline(outcapped(task, caps)), references.airline(task) + assert capped.auto == free.auto == 0 and capped.run.cost == free.run.cost and capped.run.rows() == free.run.rows() + + +def test_the_airline_replay_under_built_caps_on_the_fixture_day_flies_as_on_the_uncapped_day(task, slice_inputs, + tmp_path): + caps = arrcaps.build(task, candidates.frame(slice_inputs, tmp_path, task.date), min_sched=1) + capped, free = references.airline(outcapped(task, caps)), references.airline(task) + assert capped.auto == free.auto and capped.run.cost == free.run.cost and capped.run.rows() == free.run.rows() + + +def test_arrcaps_on_the_slice_reads_the_bts_frame_and_keeps_the_replays_arrivals_within_its_caps(task, slice_inputs, + tmp_path): + """The slice holds few flights per outstation hour, under MIN_SCHED; counting every hour with one scheduled caps + PBI's hour 20 (JetBlue's two scheduled, one of the two arrived there) at one, and the hours whose one flight, + JetBlue's cancelled one, never arrived at none.""" + frame = candidates.frame(slice_inputs, tmp_path, task.date) + assert arrcaps.build(task, frame) == {} + caps = arrcaps.build(task, frame, min_sched=1) + assert caps == {"ATL": {23: 0}, "BUF": {21: 0}, "DTW": {23: 0}, "PBI": {20: 1}, "PWM": {19: 0}, "RNO": {26: 0}, + "SMF": {24: 0}} + landed = Counter((f.dest, (f.actual.dep + f.block) // 60) for f in task.flights if f.actual.dep is not None) + assert all(cap >= landed[(s, h)] for s, by in caps.items() for h, cap in by.items()) + assert not set(caps) & {"JFK", "BOS"} diff --git a/tests/programs/test_network.py b/tests/programs/test_network.py index 797a637..b7e29bd 100644 --- a/tests/programs/test_network.py +++ b/tests/programs/test_network.py @@ -4,13 +4,17 @@ from datetime import date, timedelta import pandas as pd +import pytest from day_helpers import TURN, day, flight, tail from program_helpers import CACHE, DATE, DERIVED, WINDOW, needs_cache from agentenv_airops.build import pack from agentenv_airops.build.fleet import Aircraft -from agentenv_airops.programs import candidates, mip, network, references, sim, unseen -from agentenv_airops.programs.model import Busy, Move, Outcome, Tail, Task +from agentenv_airops.programs import candidates, mip, network, references, repairs, sim, unseen +from agentenv_airops.programs.advisories import HUBS +from agentenv_airops.programs.cost import cancel_price +from agentenv_airops.programs.layers import arrcaps, closures, config, depcaps, weather +from agentenv_airops.programs.model import LAGS, LAYERED, Busy, Layers, Move, Outcome, Tail, Task START = 360 RECORD = ("Tail_Number", "Origin", "Dest", "sd", "sa", "ad", "aa", "CRSElapsedTime", "Cancelled", "end") @@ -262,3 +266,88 @@ def test_storm_dates_union_the_hub_candidates_and_the_control_rule_picks_two_dat assert Counter(storms.values()) == {("JFK", "BOS"): 87, ("JFK",): 67, ("BOS",): 53} assert network.control_dates(CACHE, DERIVED) == {"2024-07-03": {"JFK": (1080, 1320), "BOS": (1020, 1260)}, "2025-01-15": {"JFK": (1080, 1320), "BOS": (420, 660)}} + + + +def test_a_storm_date_with_crew_and_layers_carries_the_builders_layers_and_without_them_is_unchanged( + slice_inputs, fits, tmp_path_factory): + """With crew bases every leg has build_candidate's crew and the task its bases; with a layer config its layers are + the builders' on the date's frame and closures, the pools at its bases, the closure rates and the hubs' weather + news at the default lags (the desk's view, sim.known); stripped of both it is the plain build + byte for byte. Layered, its airline replay auto-cancels what the crew alone does (from 04:00 the crew model times + out a leg on this date), flies what the plain replay flies, is a solution of the model at its cost, and the + optimum replays exactly.""" + derived = tmp_path_factory.mktemp("derived") + ones = {"JFK": 1.0, "BOS": 1.0} + e_fit, e_median = unseen.table(fits, ones) + args = (DATE, ("JFK",), slice_inputs, derived, e_fit, e_median, ones) + plain = network.build(*args, windows={"JFK": WINDOW}, split="control").task + cfg = config.Config(rate={"JFK": 0.7, "BOS": 0.75}, reserves={"JFK": 2, "BOS": 1, "SJU": 3}, dates=("2023-06-25",)) + built = network.build_all([(DATE, ("JFK",), {"JFK": WINDOW}, "control")], slice_inputs, derived, e_fit, e_median, + ones, bases=repairs.BASES, layers=cfg) + task = built[0].task + assert task.bases == repairs.BASES and all(f.crew is not None for f in task.flights) + assert any(f.crew.deadline is not None for f in task.flights) + frame = candidates.frame(slice_inputs, derived, DATE) + spans = config.spans(slice_inputs, tuple(candidates.months_of(DATE))) + assert task.layers == Layers(depcaps.build(task, frame), arrcaps.build(task, frame), + closures.build(task, spans, cfg.rate), {"JFK": 2, "BOS": 1}, + weather=weather.build(task, slice_inputs), rates=cfg.rate) + assert task.layers.dep_caps and task.layers.closures and task.layers.weather and task.layers.lags == LAGS + line = task.to_json() + assert json.loads(line)["version"] == LAYERED and Task.from_dict(json.loads(line)).to_json() == line + bare = replace(task, bases=(), layers=None, flights=tuple(replace(f, crew=None) for f in task.flights)) + assert bare.to_json() == plain.to_json() + replay, crewed = references.airline(task), references.airline(replace(task, layers=None)) + assert replay.auto == crewed.auto and replay.flew == references.airline(plain).flew + price = {f.id: cancel_price(f, task.start if replay.run.flights[f.id].decided is None + else replay.run.flights[f.id].decided) for f in task.flights} + net = mip.Network(task, price=price) + x = net.vector(replay.run) + assert x is not None and all(lo - 1e-6 <= sum(v * x[c] for c, v in coefs.items()) <= up + 1e-6 + for coefs, lo, up in net.m.rows) + assert round(sum(c * v for c, v in zip(net.m.cost, x, strict=True))) == replay.run.cost.total + sol = mip.solve(task, start=replay.run) + run = sim.execute(task, sol.plan(task.start)) + assert sol.proven and run.cost.total == sol.objective <= replay.run.cost.total + assert all((run.flights[i].dep if run.flights[i].status == "flown" else None) == d for i, d in sol.deps.items()) + + +def test_the_follower_plays_a_layered_storm_date_through_a_closure_the_desk_prices_at_the_fitted_rate( + slice_inputs, fits, tmp_path_factory): + """The slice's layered date has a watch inside a JFK closure: the day as known there prices the closure at + the rate the build carries (without it, sim.known can't), and the follower plays every watch to a proven optimum, + no cheaper than hindsight's.""" + ones = {"JFK": 1.0, "BOS": 1.0} + e_fit, e_median = unseen.table(fits, ones) + cfg = config.Config(rate={"JFK": 0.7, "BOS": 0.75}, reserves={"JFK": 2, "BOS": 1}, dates=("2023-06-25",)) + task = network.build(DATE, ("JFK",), slice_inputs, tmp_path_factory.mktemp("derived"), e_fit, e_median, ones, + windows={"JFK": WINDOW}, split="control", bases=repairs.BASES, layers=cfg).task + ts = references.watch_times(task) + a, t = next((a, t) for a, b, _, _ in task.layers.closures["JFK"] for t in ts if a <= t < b) + assert task.layers.rates == cfg.rate and sim.known(task, t).closures["JFK"][-1][:2] == (a, t + sim.GUESS) + with pytest.raises(ValueError, match="no rate"): + sim.known(replace(task, layers=replace(task.layers, rates={})), t) + followed = references.follow(task) + assert [w["t"] for w in followed.watches] == ts and all(w["status"] == "Optimal" for w in followed.watches) + assert followed.cost == sim.execute(task, followed.plan).cost.total >= mip.solve(task).objective + + +@needs_cache +def test_the_follower_plays_a_real_layered_storm_date_through_its_closures_and_event_watches(): + """15 July 2024 (BOS closes three times) with crew and every layer at the pack's fitted constants: the watches wake + on its events, the day as known prices the closures under way at the build's rates, and the follower proves every + watch, no cheaper than hindsight's optimum.""" + d = "2024-07-15" + storms, _ = network.storms(CACHE, ("2024-07",)) + ones = dict.fromkeys(HUBS, 1.0) + e_fit, e_median = unseen.table(candidates.e_table(CACHE, DERIVED), ones) + cfg = config.Config(rate={"BOS": 0.678, "JFK": 0.755}, reserves={"BOS": 1, "FLL": 1, "JFK": 1, "MCO": 3}) + task = network.build(d, storms[d], CACHE, DERIVED, e_fit, e_median, ones, bases=repairs.BASES, layers=cfg).task + ts = references.watch_times(task) + assert task.layers.closures["BOS"] and task.layers.weather and any(t % 60 != task.start % 60 for t in ts) + under = [t for t in ts if any(a <= t < b for a, b, _, _ in task.layers.closures["BOS"])] + assert under and all(sim.known(task, t).closures["BOS"][-1][1] == t + sim.GUESS for t in under) + followed = references.follow(task, time_limit=600.0) + assert [w["t"] for w in followed.watches] == ts and all(w["status"] == "Optimal" for w in followed.watches) + assert followed.cost == sim.execute(task, followed.plan).cost.total >= mip.solve(task).objective diff --git a/tests/programs/test_probe.py b/tests/programs/test_probe.py new file mode 100644 index 0000000..074b33b --- /dev/null +++ b/tests/programs/test_probe.py @@ -0,0 +1,55 @@ +from day_helpers import day, flight, tail + +from agentenv_airops.programs import probe, references, sim +from agentenv_airops.programs.model import Outcome + + +def cancelled(i, orig, dest, dep, block=60): + return flight(i, orig, dest, dep, block, actual=Outcome(True, False, "B", None, None, None, "N1")) + + +def out_and_back(): + """N1 flies JFK-BUF and back; JetBlue cancelled the JFK-BOS turn after it (the aircraft is due at JFK).""" + return day([flight(0, "JFK", "BUF", 100, 60), flight(1, "BUF", "JFK", 200, 60), + cancelled(2, "JFK", "BOS", 320), cancelled(3, "BOS", "JFK", 440)], + [tail("N1", "JFK", end="JFK")]) + + +def test_an_out_and_back_flies_for_free_only_as_a_pair_and_its_restore_counts_once(): + task = out_and_back() + plan = references.airline_plan(task) + base = sim.execute(task, plan) + alone = probe.restore(task, plan, base, [2]) + assert alone.flew and not alone.knocked and alone.d_ferry > 0 and alone.verdict == "strands" + assert probe.restore(task, plan, base, [3]).verdict == "forced" + assert probe.partner(task.flight(3), [task.flight(2), task.flight(3)]) == task.flight(2) + p = probe.probe(task) + assert p.rows == {2: "pair_clean", 3: "pair_clean"} and p.restores == {(2, 3): "pair_clean"} + assert (p.cancelled, p.feasible, p.feasible_rows) == (2, 1, 2) + assert p.to_dict()["pairs"] == 1 and p.to_dict()["restore_verdicts"] == {"pair_clean": 1} + + +def test_a_restore_that_takes_a_flown_flights_aircraft_displaces_it(): + task = day([cancelled(0, "JFK", "BOS", 100), flight(1, "JFK", "PWM", 110, 60, deadline=200)], + [tail("N1", "JFK", end="BOS")]) + p = probe.probe(task) + assert p.rows == {0: "displaces"} and p.feasible == 0 + plan = references.airline_plan(task) + r = probe.restore(task, plan, sim.execute(task, plan), [0]) + assert r.knocked == (1,) and r.flew + + +def test_restoring_nothing_is_clean_and_a_day_without_cancellations_has_none(): + task = out_and_back() + plan = references.airline_plan(task) + none = probe.restore(task, plan, sim.execute(task, plan), []) + assert none.verdict == "clean" and none.d_cost == 0 + flown = day([flight(0, "JFK", "BUF", 100, 60)], [tail("N1", "JFK", end="BUF")]) + assert probe.probe(flown).to_dict() == {"cancelled": 0, "feasible": 0, "feasible_rows": 0, "restores": 0, + "pairs": 0, "verdicts": {}, "restore_verdicts": {}} + + +def test_a_restore_that_moves_another_flight_past_shift_ok_displaces_it(): + late = probe.Restore((5,), True, (), probe.SHIFT_OK + 1, 0, 0) + assert late.verdict == "displaces" and probe.Restore((5,), True, (), probe.SHIFT_OK, 0, 0).verdict == "clean" + assert probe.Restore((5,), False, (), 0, 0, 0).verdict == "forced" diff --git a/tests/programs/test_reference_plays.py b/tests/programs/test_reference_plays.py index 4eab8b4..c8efcdf 100644 --- a/tests/programs/test_reference_plays.py +++ b/tests/programs/test_reference_plays.py @@ -2,7 +2,20 @@ from dataclasses import replace import pytest -from day_helpers import GDP, crewed, day, flight, network_day, random_day, shortened, stop, tail +from day_helpers import ( + GDP, + based, + crewed, + day, + flight, + network_day, + random_day, + reserve_day, + reserved, + shortened, + stop, + tail, +) from program_helpers import adv from agentenv_airops.programs import mip, references, sim @@ -178,6 +191,62 @@ def test_the_airline_replay_counts_no_leg_whose_aircraft_came_on_a_leg_flown_pas assert references.counted(task) == [2] and (replay.flew, replay.auto) == (1, 0) +def test_the_airline_replay_calls_a_reserve_for_each_base_departure_flown_past_its_crew_deadline(): + """JetBlue flew both JFK departures past their crew deadline (150): with two reserves the replay is the one with + unlimited reserves; with one the first by id flies on it and the other auto-cancels at its crew deadline.""" + flights = [based(flight(0, "JFK", "BUF", 100, 60, deadline=700, + actual=Outcome(False, False, "", 200, 260, "BUF", "N1")), 150), + based(flight(1, "JFK", "PWM", 105, 70, deadline=700, + actual=Outcome(False, False, "", 205, 275, "PWM", "N2")), 150), + based(flight(2, "JFK", "DCA", 110, 60, actual=Outcome(False, False, "", 120, 180, "DCA", "N3")), 150)] + task = day(flights, [tail("N1", "JFK", end="BUF"), tail("N2", "JFK", end="PWM"), tail("N3", "JFK", end="DCA")]) + v2 = references.airline(task).run + plan = references.airline_plan(reserved(task, {"JFK": 1})) + assert [plan[i].get("reserve", False) for i in range(3)] == [True, True, False] + assert "reserve" not in str(references.airline_plan(task)) + assert references.airline(reserved(task, {"JFK": 2})).run.rows() == v2.rows() + one = references.airline(reserved(task, {"JFK": 1})) + assert [(r.status, r.dep, r.decided) for r in one.run.flights.values()] == [ + ("flown", 200, None), ("auto_cancelled", None, 150), ("flown", 120, None)] + assert one.auto == 1 and references.airline(task).auto == 0 + + +def test_the_airline_replay_calls_a_reserve_for_a_base_departure_its_aircraft_makes_leave_past_its_crew_deadline(): + """JetBlue left at 140, before the crew deadline (150), but the replay's aircraft is ready only at 170: the call + follows the replay's departure, so with a pool to spare the replay is the one with unlimited reserves.""" + flights = [based(flight(0, "JFK", "BUF", 100, 60, deadline=700, + actual=Outcome(False, False, "", 140, 200, "BUF", "N1")), 150)] + task = day(flights, [tail("N1", "JFK", ready=170, end="BUF")]) + v2 = references.airline(task) + assert [(r.status, r.dep) for r in v2.run.flights.values()] == [("flown", 170)] + assert references.airline_plan(reserved(task, {"JFK": 5}))[0]["reserve"] + assert references.airline(reserved(task, {"JFK": 5})).run.rows() == v2.run.rows() + none = references.airline(reserved(task, {"JFK": 0})) + assert [(r.status, r.decided) for r in none.run.flights.values()] == [("auto_cancelled", 150)] and none.auto == 1 + + +def test_the_airline_replay_on_the_crewed_evening_with_empty_pools_is_its_v2_replay(crewed): + """Neither JetBlue nor its replay with unlimited reserves flew a departure from a base past its crew deadline on the + fixture evening, so with every base's pool empty the replay calls no reserve and flies as with unlimited + reserves.""" + task = reserved(crewed, {b: 0 for b in crewed.bases}) + assert sum(task.reservable(f) for f in task.flights) > 0 + assert not [f for f in task.flights if f.actual.dep is not None and task.late(f, f.actual.dep)] + assert "reserve" not in str(references.airline_plan(task)) + assert references.airline(task).run.rows() == references.airline(crewed).run.rows() + + +def test_the_follower_keeps_its_solves_reserve_calls_and_the_day_so_far_keeps_them_on_flights_gone(): + task = reserve_day() + followed = references.follow(task) + assert followed.plan[1].get("reserve") and followed.run.reserved == {1} + assert followed.run.flights[1].status == "flown" and followed.run.flights[0].status != "flown" + assert followed.cost == references.anchor(task)[1].cost.total + past = references.so_far(task, followed.plan, followed.run.flights[1].dep + 1) + assert past[1] == {"left": followed.run.flights[1].dep, "reserve": True} + assert "reserve" not in str(references.follow(replace(task, layers=None)).plan) + + def test_no_reference_costs_less_than_the_anchor(fixture_day): none_below_the_anchor(fixture_day) diff --git a/tests/programs/test_v4_check.py b/tests/programs/test_v4_check.py new file mode 100644 index 0000000..f2962d4 --- /dev/null +++ b/tests/programs/test_v4_check.py @@ -0,0 +1,120 @@ +import importlib.util +import sys + +from program_helpers import ROOT + +sys.path.insert(0, str(ROOT / "scripts")) # v4_check imports v4_pack and world_check beside it, as a script run does +SPEC = importlib.util.spec_from_file_location("v4_check", ROOT / "scripts" / "v4_check.py") +v4_check = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(v4_check) + + +def refs(anchor=100, naive=110, follower=104, recorded=120, cancels=(3, 1), jetblue=(10, 90), free=6, flown=88, + within=80, proven=True, exact=True, split="eval", flights=200): + """A task's references as v4_pack.refs writes them, the numbers the criteria read.""" + return {"split": split, "flights": flights, "refs": ["naive", "anchor", "follower", "recorded"], + "anchor": {"objective": anchor, "total": anchor, "proven": proven, "exact": exact, "status": "Optimal", + "gap_pct": 0.0 if proven else 4.0, "cancelled": cancels[0], "auto_cancelled": cancels[1]}, + "naive": {"total": naive}, "follower": {"total": follower}, "recorded": {"total": recorded}, + "jetblue": {"cancelled": jetblue[0], "flown": jetblue[1]}, + "probe": {"feasible": free, "cancelled": jetblue[0]}, + "fidelity": {"flown": flown, "jetblue_flown": jetblue[1], "within": within}} + + +TESTS = {"agreement": {"pass": True, "summary": "40 passed"}, "playable": {"pass": True, "summary": "1 passed"}} + + +def test_harder_compares_the_median_excesses_over_the_anchor_on_the_same_dates(): + v2 = {"a": refs(naive=110, follower=102), "b": refs(naive=106, follower=104), "c": refs(naive=200)} + v4 = {"a": refs(naive=115, follower=103), "b": refs(naive=113, follower=106)} + c = v4_check.criteria(v4, v2, TESTS) + do = c["harder_do_nothing"] + assert (do["n"], do["v4"], do["v2"], do["value"], do["pass"]) == (2, 0.14, 0.08, 1.75, True) + f = c["harder_follower"] + assert (f["v4"], f["v2"], f["value"], f["pass"]) == (0.045, 0.03, 1.5, True) + v4["b"]["follower"]["total"] = 105 + assert not v4_check.criteria(v4, v2, TESTS)["harder_follower"]["pass"] + + +def test_an_unproven_anchor_drops_its_date_from_the_excesses_and_is_flagged_not_failed(): + v2 = {"a": refs(), "b": refs(naive=150)} + v4 = {"a": refs(naive=130), "b": refs(naive=101, proven=False)} + c = v4_check.criteria(v4, v2, TESTS) + assert c["harder_do_nothing"]["n"] == 1 and c["harder_do_nothing"]["value"] == 3.0 + p = c["sound_anchors_proven"] + assert p["pass"] and p["flagged"] == {"b": {"status": "Optimal", "gap_pct": 4.0}} and p["value"] == 1 + v4["b"]["anchor"] = {"error": "RuntimeError: boom"} + p = v4_check.criteria(v4, v2, TESTS)["sound_anchors_proven"] + assert not p["pass"] and p["failed"] == ["b"] + assert not v4_check.criteria(v4, v2, TESTS)["sound_anchors_exact"]["pass"] + + +def test_realistic_pools_the_counts_over_the_dates_and_compares_with_v2(): + v2 = {"a": refs(cancels=(2, 0), free=7, within=86), "b": refs(cancels=(1, 0), free=6, within=84)} + v4 = {"a": refs(cancels=(3, 1), free=5, flown=88, within=80), + "b": refs(cancels=(3, 0), free=4, flown=87, within=79), "x": refs(split="control", cancels=(0, 0))} + c = v4_check.criteria(v4, v2, TESTS) + o = c["realistic_optimum_cancels"] + assert (o["v4"], o["v2"], o["v4_counts"], o["pass"]) == (0.35, 0.15, [7, 20], True) + free = c["realistic_free_share"] + assert (free["v4"], free["v2"], free["pass"]) == (0.45, 0.65, True) + flown = c["realistic_replay_flown"] + assert (flown["v4"], flown["v4_counts"], flown["pass"]) == (0.9722, [175, 180], True) + within = c["realistic_replay_within_15"] + assert within["v4"] == round(159 / 175, 4) and within["v2"] == round(170 / 176, 4) and not within["pass"] + assert c["realistic_recorded_excess"]["v4"] == c["realistic_recorded_excess"]["v2"] == 0.2 + assert not c["realistic_recorded_excess"]["pass"] + v4["a"]["probe"]["feasible"] = 8 + assert not v4_check.criteria(v4, v2, TESTS)["realistic_free_share"]["pass"] + + +def test_sound_and_playable_read_the_anchors_and_the_tests_and_a_test_not_run_fails(): + v4 = {"a": refs(), "x": refs(split="control", exact=False)} + c = v4_check.criteria(v4, {"a": refs()}, TESTS) + assert c["sound_anchors_exact"]["not_exact"] == ["x"] and not c["sound_anchors_exact"]["pass"] + assert c["sound_agreement_tests"]["pass"] and c["playable"]["value"] == "1 passed" + c = v4_check.criteria(v4, {"a": refs()}, {}) + assert not c["playable"]["pass"] and c["playable"]["value"] == "not run" + + +def with_calm(r: dict, cancelled: int, ferries: int) -> dict: + return {**r, "calm": {"cancelled": cancelled, "ferries": ferries}} + + +RATE = 0.01 + + +def test_a_calm_day_is_within_the_fair_weather_bar_at_the_rate_times_its_flights(): + """At 1% a day of 200 flights may force 2 cancellations and 2 ferries, one of 50 flights 1.""" + assert v4_check.within(with_calm(refs(), 2, 2), RATE) and not v4_check.within(with_calm(refs(), 3, 0), RATE) + assert not v4_check.within(with_calm(refs(), 0, 3), RATE) + assert v4_check.within(with_calm(refs(flights=50), 1, 0), RATE) + assert not v4_check.within(with_calm(refs(flights=50), 2, 0), RATE) + + +def test_a_crew_pack_adds_the_crews_guards_and_a_no_crew_pack_keeps_its_criteria(tmp_path): + """The calm guard counts a date against the crew only where the crew pushes a calm day within the bar past it: + c's no-crew calm day forces 1 and the crew's 3 (past 2 of its 200 flights), a's crew forces 2 where the no-crew + day forces 0 (more, still within).""" + v2 = {"a": refs(within=84), "b": refs(within=85), "c": refs(within=80)} + v4 = {"a": with_calm(refs(recorded=120, cancels=(5, 0), jetblue=(10, 90), within=83), 2, 1), + "b": with_calm(refs(recorded=99, cancels=(4, 0), jetblue=(2, 90), within=84), 0, 0), + "c": with_calm(refs(recorded=130, cancels=(1, 0), jetblue=(0, 90), within=80), 3, 0), + "x": with_calm(refs(split="control"), 9, 9)} + plain = {"a": with_calm(refs(), 0, 0), "b": with_calm(refs(), 0, 0), "c": with_calm(refs(), 1, 0)} + assert not any(k.startswith("guard_") for k in v4_check.criteria(v4, v2, TESTS)) + g = v4_check.criteria(v4, v2, TESTS, True, plain, RATE) + assert (g["guard_recorded"]["value"], g["guard_recorded"]["pass"]) == (round(2 / 3, 4), False) + assert (g["guard_anchor_cancels"]["value"], g["guard_anchor_cancels"]["pass"]) == (0.8333, True) + calm = g["guard_calm_vs_nocrew"] + assert (calm["n"], calm["value"], calm["worse"], calm["pass"]) == (3, 2, ["c"], False) + assert g["guard_within_15"]["v4"] == round(247 / 264, 4) and g["guard_within_15"]["pass"] + v4["c"]["fidelity"]["within"] = 75 + assert not v4_check.criteria(v4, v2, TESTS, True, plain, RATE)["guard_within_15"]["pass"] + assert not v4_check.criteria(v4, v2, TESTS, True, None, RATE)["guard_calm_vs_nocrew"]["pass"] + plain["c"]["calm"]["cancelled"] = 3 + assert v4_check.criteria(v4, v2, TESTS, True, plain, RATE)["guard_calm_vs_nocrew"]["pass"] + (tmp_path / "refs").mkdir() + (tmp_path / "refs" / "a.json").write_text('{"calm": {"cancelled": 1, "ferries": 0}}') + (tmp_path / "refs" / "b.json").write_text('{"cal') + assert v4_check.read_refs(tmp_path, ["a", "b", "z"]) == {"a": {"calm": {"cancelled": 1, "ferries": 0}}} diff --git a/tests/programs/test_v4_pack.py b/tests/programs/test_v4_pack.py new file mode 100644 index 0000000..ebd39be --- /dev/null +++ b/tests/programs/test_v4_pack.py @@ -0,0 +1,212 @@ +import errno +import importlib.util +import json +import sys +from dataclasses import replace + +import pytest +from day_helpers import day, flight, tail +from program_helpers import CACHE, DERIVED, ROOT, needs_cache + +from agentenv_airops.build import fetch +from agentenv_airops.programs import candidates, network, rbs, references, unseen +from agentenv_airops.programs.candidates import Built +from agentenv_airops.programs.layers import calibrate, config, crew +from agentenv_airops.programs.model import LAYERED, Task + +sys.path.insert(0, str(ROOT / "scripts")) # v4_pack imports world_check beside it, as a script run does +SPEC = importlib.util.spec_from_file_location("v4_pack", ROOT / "scripts" / "v4_pack.py") +v4_pack = importlib.util.module_from_spec(SPEC) +sys.modules["v4_pack"] = v4_pack # its build workers' function is pickled by module name +SPEC.loader.exec_module(v4_pack) +MONTHS = ("2023-12", "2024-01", "2025-01", "2026-01") +CONTROL = ("2025-02-04", {"BOS": (420, 660)}) + + +@needs_cache +def test_the_pack_splits_and_fits_on_its_train_dates_and_a_quick_run_writes_the_full_packs_tasks(tmp_path, + monkeypatch): + """Over the storm dates of four winter months (so the test builds 23, not 207; with two every event has an eval + date, network.split, and none trains) and one control date: every split + date is written with layers and no crews (`--bases none`) at constants fitted on the train dates, rescaled to the + unseen scale fitted on them, the control after them alike; `--only` two of them, resumed, writes those same + lines.""" + storms, counts = network.storms(CACHE, MONTHS) + monkeypatch.setattr(network, "storms", lambda inputs: (storms, counts)) + monkeypatch.setattr(network, "control_dates", lambda inputs, derived: dict([CONTROL])) + v4_pack.main(["--out", str(tmp_path), "--workers", "4", "--bases", "none"]) + manifest = json.loads((tmp_path / "manifest.json").read_text()) + assert manifest["calm"]["quantile"] == 0.9 and 0 < manifest["calm"]["rate"] < 0.05 and manifest["calm"]["fair_days"] + lines = (tmp_path / "tasks.jsonl").read_text().splitlines() + control = Task.from_dict(json.loads(lines.pop())) + assert control.split == "control" and control.date == CONTROL[0] and not control.bases and control.layered + assert manifest["controls"] == {CONTROL[0]: {"hubs": ["BOS"], "windows": {"BOS": [420, 660]}, + "task_id": control.task_id}} + rows = {d: json.loads((tmp_path / "built" / f"{d}.json").read_text())["task"] for d in storms} + built = {d: tuple(task["hubs"]) for d, task in rows.items() if task} + split = network.split(built, candidates.events(storms), 0) + train = sorted(d for d, v in split.items() if v == "train") + assert manifest["split"] == split and manifest["constants"]["dates"] == train and train + assert manifest["constants"]["reserves"] == {} and manifest["bases"] == [] + assert (manifest["constants"]["short"], manifest["constants"]["min_sched"]) == (0.9, 5) + tasks = [Task.from_dict(json.loads(line)) for line in lines] + assert [t.date for t in tasks] == sorted(split) and all(t.split == split[t.date] for t in tasks) + assert all(json.loads(line)["version"] == LAYERED for line in lines) + assert all(not t.bases and not any(f.crew for f in t.flights) and not t.layers.reserves for t in tasks) + assert manifest["counts"]["total"]["flights"] == sum(len(t.flights) for t in [*tasks, control]) + fits = candidates.e_table(CACHE, DERIVED) + plain = {d: Task.from_dict(json.loads((tmp_path / "built" / f"{d}.json").read_text())["task"]) for d in split} + scale = unseen.fit_scale([plain[d] for d in train], fits) + assert manifest["scale"] == scale and scale != dict.fromkeys(scale, 1.0) + cfg, shipped = config.Config.from_dict(manifest["constants"]), unseen.table(fits, scale) + assert [t.to_json() for t in tasks] == [ + network.layered(rbs.rescale(replace(plain[d], split=split[d]), scale, *shipped), cfg, CACHE, DERIVED).to_json() + for d in sorted(split)] + held = Task.from_dict(json.loads((tmp_path / "built" / f"control-{CONTROL[0]}.json").read_text())["task"]) + assert control.to_json() == network.layered(rbs.rescale(replace(held, split="control"), scale, *shipped), cfg, + CACHE, DERIVED).to_json() + assert any(t.layers.dep_caps for t in tasks) and any(t.layers.arr_caps for t in tasks) + assert all(t.layers.rates == manifest["constants"]["rate"] and t.layers.weather for t in tasks) + tafs = sum(k == "taf" for t in [*tasks, control] for _, k, _, _ in t.layers.weather) + assert manifest["counts"]["total"]["weather"]["taf"] == tafs > 0 + for key in ("iem/speci/JFK_2026_01.csv", "iem/metar/BOS_2023_12.csv", "bts/ontime_2026_07.zip", + "iem/afos/TAFBOS_2023_12.txt"): + assert manifest["inputs"][key] == fetch.sha256(CACHE / key) + + chosen = [min(train), min(d for d, v in split.items() if v == "eval")] + stamps = {d: (tmp_path / "built" / f"{d}.json").stat().st_mtime_ns for d in [*storms, f"control-{CONTROL[0]}"]} + v4_pack.main(["--out", str(tmp_path), "--workers", "4", "--bases", "none", "--resume", "--only", ",".join(chosen)]) + assert stamps == {d: (tmp_path / "built" / f"{d}.json").stat().st_mtime_ns for d in stamps} + quick = (tmp_path / "tasks.jsonl").read_text().splitlines() + assert quick == [lines[sorted(split).index(d)] for d in sorted(chosen)] + assert json.loads((tmp_path / "manifest.json").read_text())["constants"] == manifest["constants"] + + +@needs_cache +def test_a_v4_crewed_pack_fits_its_cut_on_a_train_sample_and_builds_every_date_at_it(tmp_path, monkeypatch): + """By default (crews at JetBlue's bases, v4's cut) with `--max-segments 4` the buffer is fitted (the fit stubbed + to pick 30) at the pack's fair-weather bar: each buffer of the grid builds the train sample at its cut, layered at + the fitted rates with unlimited reserves; every date is then built at the pick, its pools fitted there, and the + manifest records the crew, its fit, its sample and the bar.""" + storms, counts = network.storms(CACHE, MONTHS) + monkeypatch.setattr(network, "storms", lambda inputs: (storms, counts)) + monkeypatch.setattr(network, "control_dates", lambda inputs, derived: dict([CONTROL])) + seen = {} + + def fit(grid, plain, rate, workers, cache): + seen.update(grid) + seen[None], bar[0] = plain, rate + assert cache == tmp_path / "crew-fit" + return crew.Rule("v4", 30, 4), [{"stub": True}] + + bar = [None] + monkeypatch.setattr(calibrate, "crew_fit", fit) + v4_pack.main(["--out", str(tmp_path), "--workers", "4", "--max-segments", "4"]) + manifest = json.loads((tmp_path / "manifest.json").read_text()) + assert bar[0] == manifest["calm"]["rate"] and 0 < bar[0] < 0.05 + train = manifest["constants"]["dates"] + sample = list(calibrate.sample(train)) + assert manifest["crew"] == {"crew": "v4", "buffer": 30, "max_segments": 4, "fitted": True, + "fit": {"sample": sample, "seed": 0, "time_limit_s": 600.0, "rules": [{"stub": True}], + "chosen": {"crew": "v4", "buffer": 30, "max_segments": 4}}} + plain = seen.pop(None) + assert [t.date for t in plain] == sample and not any(t.bases or t.flights[0].crew for t in plain) + assert list(seen) == calibrate.grid(calibrate.BUFFERS, (4,)) + for rule, tasks in seen.items(): + assert [t.date for t in tasks] == sample and all(t.layers and not t.layers.reserves for t in tasks) + x = Task.from_dict(json.loads((tmp_path / v4_pack.folder_of(tmp_path, rule).name / f"{sample[0]}.json") + .read_text())["task"]) + assert [f.crew for f in tasks[0].flights] == [f.crew for f in x.flights] + assert all(f.sched_dep + f.sched_block + rule.buffer <= f.crew.report + f.crew.fdp for f in x.flights) + assert seen[crew.Rule("v4", 0, 4)][0].flights != seen[crew.Rule("v4", 120, 4)][0].flights + tasks = [Task.from_dict(json.loads(line)) for line in (tmp_path / "tasks.jsonl").read_text().splitlines()] + at = {d: Task.from_dict(json.loads((tmp_path / "built-v4-30-4" / f"{d}.json").read_text())["task"]) + for d in manifest["split"]} + for t in tasks: + if t.split != "control": + assert [f.crew for f in t.flights] == [f.crew for f in at[t.date].flights] + assert all(t.bases == ("JFK", "BOS", "FLL", "MCO") for t in tasks) + assert {t.layers.reserves.keys() <= set(t.bases) for t in tasks} == {True} + + +def test_a_resumed_pack_keeps_each_dates_build_and_rebuilds_a_date_left_unbuilt_or_cut_short(tmp_path): + """A date an earlier run couldn't build (its input since fetched) or whose file a killed run cut short is built + again; a built date is read back as it was written, and one the build's rules left out is kept left out.""" + task = replace(day([flight(0, "JFK", "BUF", 100, 60)], [tail("N1", "JFK")]), date="2023-12-03") + v4_pack.save(tmp_path / "2023-12-03.json", Built(task, {"date": "2023-12-03"})) + v4_pack.save(tmp_path / "2023-12-04.json", Built(None, {"date": "2023-12-04", "reason": "error: registry"})) + (tmp_path / "2023-12-05.json").write_text('{"row": {"date": "2023-12-05", "hu') + left = {"date": "2023-12-07", "reason": "fewer than 5 JetBlue flights controlled or held"} + v4_pack.save(tmp_path / "2023-12-07.json", Built(None, left)) + kept = v4_pack.kept(tmp_path, ["2023-12-03", "2023-12-04", "2023-12-05", "2023-12-06", "2023-12-07"]) + assert list(kept) == ["2023-12-03", "2023-12-07"] and kept["2023-12-03"].task.to_json() == task.to_json() + assert kept["2023-12-07"] == Built(None, left) + assert sorted(p.name for p in tmp_path.iterdir()) == ["2023-12-03.json", "2023-12-04.json", "2023-12-05.json", + "2023-12-07.json"] + + +def test_a_date_missing_an_input_is_unbuilt_with_its_reason_and_any_other_error_stops_the_pack(monkeypatch): + def missing(job): + raise FileNotFoundError("cache/inputs/faa/ReleasableAircraft.zip") + + monkeypatch.setattr(network, "build_one", missing) + b = v4_pack.build_one(("2026-07-02", ("JFK",))) + assert b.task is None and b.row == {"date": "2026-07-02", "hubs": ["JFK"], "task_id": None, + "reason": "error: FileNotFoundError: cache/inputs/faa/ReleasableAircraft.zip"} + monkeypatch.setattr(network, "build_one", lambda job: {}["N3291J"]) + with pytest.raises(KeyError): + v4_pack.build_one(("2026-07-02", ("JFK",))) + + def too_many(job): + raise OSError(errno.EMFILE, "Too many open files") + + monkeypatch.setattr(network, "build_one", too_many) + with pytest.raises(OSError): + v4_pack.build_one(("2026-07-02", ("JFK",))) + + +def test_only_names_each_date_once(): + assert v4_pack.dates_of(" 2024-08-06,2024-07-15,,2024-08-06 ") == ["2024-07-15", "2024-08-06"] + assert v4_pack.dates_of("") == [] + + +def test_the_refs_stage_stores_world_checks_references_the_probe_and_the_costs_a_load_reads(tmp_path, crewed): + """On the fixture slice's crewed evening and the same day as a control, at small time limits: one JSON per task + with world_check's references (the flat anchor on the control alone), the replay's fidelity and the probe; refs.json + the anchor's and the follower's costs with the task's digest; a rerun keeps every result and runs nothing.""" + control = replace(crewed, task_id=crewed.task_id + "-c", split="control") + got = v4_pack.refs(tmp_path, [crewed, control], 2, (60.0, 30.0)) + r, c = got[crewed.task_id], got[control.task_id] + for k in (*v4_pack.REFS, "fidelity", "probe", "jetblue"): + assert "error" not in r[k], (k, r[k]) + assert "flat" in c and "flat" not in r and not v4_pack.failed(got) + assert r["anchor"]["proven"] and r["anchor"]["exact"] and r["follower"]["total"] >= r["anchor"]["objective"] + assert r["jetblue"]["cancelled"] == r["probe"]["cancelled"] == sum(f.actual.dep is None for f in crewed.flights) + assert r["fidelity"]["flown"] <= r["fidelity"]["jetblue_flown"] == r["jetblue"]["flown"] + assert json.loads((tmp_path / "refs" / f"{crewed.task_id}.json").read_text()) == r + assert json.loads((tmp_path / "refs.json").read_text())[crewed.task_id] == { + "anchor": r["anchor"]["total"], "follower": r["follower"]["total"], "task": references.digest(crewed)} + + def rerun(job): + raise AssertionError(f"ran {job[0]} again") + + monkey = pytest.MonkeyPatch() + monkey.setattr(v4_pack, "refs_job", rerun) + try: + assert v4_pack.refs(tmp_path, [crewed, control], 2, (60.0, 30.0)) == got + finally: + monkey.undo() + + +def test_a_follower_stopped_by_an_unproven_watch_is_captured_and_flagged(monkeypatch, crewed): + def unproven(task, *a, **k): + raise RuntimeError(f"{task.task_id}: watch 60 solve Time limit reached at gap 3.1%") + + monkeypatch.setattr(references, "follow", unproven) + names = ("naive", "anchor", "follower") + r = v4_pack.refs_job((crewed.task_id, crewed.to_json(), "sig", names, (60.0, 30.0))) + assert r["follower"]["unproven_watch"] and "at gap 3.1%" in r["follower"]["error"] + assert "total" in r["naive"] and r["anchor"]["proven"] + assert v4_pack.failed({crewed.task_id: r}) == {crewed.task_id: ["follower"]} + assert v4_pack.stored([crewed], {crewed.task_id: r}) == { + crewed.task_id: {"anchor": r["anchor"]["total"], "task": references.digest(crewed)}} diff --git a/tests/v4day/conftest.py b/tests/v4day/conftest.py new file mode 100644 index 0000000..2b05581 --- /dev/null +++ b/tests/v4day/conftest.py @@ -0,0 +1,22 @@ +"""The programs tests' hand-made days and fixture slice, shared with the playable day's tests, and the fake LiteLLM +and airops-llm the live tests play with.""" + +import importlib.util +import sys +from pathlib import Path + +import pytest + +TESTS = Path(__file__).resolve().parents[1] +PROGRAMS = TESTS / "programs" +sys.path[:0] = [str(PROGRAMS), str(TESTS), str(TESTS / "agent")] +_spec = importlib.util.spec_from_file_location("programs_conftest", PROGRAMS / "conftest.py") +_programs = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_programs) +slice_inputs, fits, built, task, crewed = (_programs.slice_inputs, _programs.fits, _programs.built, _programs.task, + _programs.crewed) + + +@pytest.fixture +def anyio_backend(): + return "asyncio" diff --git a/tests/v4day/served_v4.py b/tests/v4day/served_v4.py new file mode 100644 index 0000000..666b4f9 --- /dev/null +++ b/tests/v4day/served_v4.py @@ -0,0 +1,62 @@ +"""A real airops-v4-day env serving programs tasks from a tasks.jsonl on a free port, with a stand-in for the +gateway's clock routes on the same server that records each re-arm, and a client calling its tools over MCP.""" + +import json +from contextlib import contextmanager +from pathlib import Path + +from fastmcp import Client +from served import serve +from starlette.responses import JSONResponse + +from agentenv_airops.programs.model import Task, write_tasks +from agentenv_airops.v4day.clock import virtual_time +from agentenv_airops.v4day.live import TASKS_VAR, AirOpsV4LiveEnv + + +@contextmanager +def served_v4(monkeypatch, tmp_path: Path, tasks: list[Task], task_id: str | None = None, synced: bool = True, + refuse: int = 0, **refs): + """A fresh env on `tasks`, the day `task_id` (default the first) loaded with `refs` (anchor, follower): (base + url, env, the clock's PUTs). The clock answers the first `refuse` PUTs with a 503, as a gateway hiccup.""" + monkeypatch.setenv(TASKS_VAR, str(write_tasks(tasks, tmp_path / "tasks.jsonl"))) + env = AirOpsV4LiveEnv() + mcp = env.create_app() + puts: list[dict] = [] + refused: list[dict] = [] + task = next(t for t in tasks if t.task_id == (task_id or tasks[0].task_id)) + + @mcp.custom_route("/clock/time", methods=["GET"]) + async def time(request): + return JSONResponse({"virtual_time": puts[-1]["virtual_time"] if puts else virtual_time(task, task.start)}) + + @mcp.custom_route("/clock/set-time", methods=["PUT"]) + async def set_time(request): + if len(refused) < refuse: + refused.append(await request.json()) + return JSONResponse({"error": "gateway hiccup"}, status_code=503) + puts.append(await request.json()) + return JSONResponse({"armed": True}) + + env.live_load(task.task_id, **refs) + with serve(mcp.http_app()) as url: + if synced: + env.set_time_url = f"{url}/clock/set-time" + yield url, env, puts + + +class Tools: + """Calls tools as an MCP client does, through argument validation and the env's middleware; results are JSON.""" + + def __init__(self, client: Client): + self.client = client + + async def __call__(self, tool: str, /, **arguments) -> dict: + result = await self.client.call_tool_mcp(tool, arguments) + assert not result.isError, result.content[0].text + return json.loads(result.content[0].text) + + async def error(self, tool: str, /, **arguments) -> str: + result = await self.client.call_tool_mcp(tool, arguments) + assert result.isError + return result.content[0].text diff --git a/tests/v4day/test_v4_agent.py b/tests/v4day/test_v4_agent.py new file mode 100644 index 0000000..a5c59d9 --- /dev/null +++ b/tests/v4day/test_v4_agent.py @@ -0,0 +1,67 @@ +"""airops-llm on scripted turns from the fake LiteLLM plays a v4 day end to end against a real airops-v4-day env +serving the fixture slice's JFK evening: the v4 rules and briefing as its prompts, a cancellation at the first watch, +then advance to the last watch's done. The reward it reports is the env's grade, and the grade's cost is +sim.execute of the plan on the truth. No model is called.""" + +import json + +import airops_llm +import pytest +from agentenv_protocol.a2a_agent import TaskOutcome, TaskRequest, TextPart +from fake_litellm import FakeLiteLLM +from served_v4 import served_v4 + +from agentenv_airops.programs import sim +from agentenv_airops.v4day import tasks as v4_tasks +from agentenv_airops.v4day.clock import virtual_time +from agentenv_airops.v4day.day import Day +from agentenv_airops.v4day.grade import grade +from agentenv_airops.v4day.live import ENV + +pytestmark = pytest.mark.anyio +SONNET = "anthropic/claude-sonnet-5-5" + + +def call(name: str, arguments: dict, i: int) -> dict: + return {"id": f"call_{name}_{i}", "name": name, "arguments": json.dumps(arguments)} + + +def turn(*calls: dict) -> dict: + return {"content": "", "reasoning": "", "stop": "tool_use", "tool_calls": list(calls), "usage": {}} + + +@pytest.fixture(autouse=True) +def environ(monkeypatch): + for var in ["LITELLM_BASE_URL", "LITELLM_API_KEY", "AIROPS_MAX_COST_USD", "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", "OPENAI_API_KEY", "HF_BILL_TO"]: + monkeypatch.delenv(var, raising=False) + + +@pytest.mark.parametrize("name", ["task", "crewed"]) +async def test_an_llm_plays_a_v3_day_through_the_server_graded_on_the_truth(name, request, monkeypatch, tmp_path): + task = request.getfixturevalue(name) + watches = len(Day(task).watches) + cancel = [{"flight": "f3", "action": "cancel", "not_before": None}] + turns = [turn(call("get_situation", {}, 0), call("check_plan", {"changes": cancel}, 1), + call("confirm_changes", {"changes": cancel}, 2), call("advance", {}, 3))] + turns += [turn(call("advance", {}, 4 + k)) for k in range(watches - 1)] + with served_v4(monkeypatch, tmp_path, [task]) as (url, env, puts), FakeLiteLLM(turns) as fake: + monkeypatch.setenv("LITELLM_BASE_URL", fake.url) + monkeypatch.setenv("LITELLM_API_KEY", "sk-fake") + result = await airops_llm.AirOpsLLM().run(TaskRequest( + task_id="t", context_id="c", parts=(TextPart(text=v4_tasks.briefing(Day(task))),), + config=airops_llm.AirOpsConfig(model=SONNET, system_prompt=v4_tasks.rules(task), + max_turns=v4_tasks.max_turns(Day(task))), + mcp_servers={ENV: {"url": f"{url}/mcp"}})) + d, summary = env.day, result.parts[1].data["structured_output"] + first = fake.requests[0][2] + assert first["system"] == v4_tasks.rules(task) + assert first["messages"][0]["content"][0]["text"] == v4_tasks.briefing(Day(task)) + assert result.outcome is TaskOutcome.SUCCEEDED and len(fake.requests) == watches + assert (summary["end_reason"], summary["checks"], summary["confirms"]) == ("done", 1, 1) + plan = {3: {"cancel": True, "decided": task.start + 2}} + assert d.done and d.end_reason == "done" and d.plan == plan and d.refusals == [] + assert d.result == grade(task, plan) and d.result["cost"] == sim.execute(task, plan).cost.to_dict() + assert summary["reward"] == d.result["reward"] + assert puts == [{"virtual_time": virtual_time(task, w.t), "virtual_seconds_per_real_second": 0} + for w in d.watches[1:]] diff --git a/tests/v4day/test_v4_day.py b/tests/v4day/test_v4_day.py new file mode 100644 index 0000000..b3b092b --- /dev/null +++ b/tests/v4day/test_v4_day.py @@ -0,0 +1,443 @@ +import json +import math +import random +from dataclasses import replace + +from day_helpers import ( + based, + capped, + crewed, + day, + flight, + network_day, + random_day, + reserve_day, + reserved, + tail, + three_into_a_program, +) +from program_helpers import adv +from test_reference_plays import committed_then_stopped, delay_revealed_late, stopped_then_released + +from agentenv_airops.core.model import hhmm +from agentenv_airops.core.sim import FLOWN +from agentenv_airops.programs import mip, references, sim +from agentenv_airops.v4day import clock +from agentenv_airops.v4day.day import Day, changes_to, play +from agentenv_airops.v4day.grade import grade, reward +from agentenv_airops.v4day.situation import KEYS + + +def held_then_released(): + """f0 JFK-BUF at 01:40 and f1 JFK-MCO at 08:20, each on its own aircraft at JFK.""" + return day([flight(0, "JFK", "BUF", 100, 60), flight(1, "JFK", "MCO", 500, 150)], + [tail("N1", "JFK", end="BUF"), tail("N2", "JFK", end="MCO")]) + + +def cancel(i: int) -> list[dict]: + return [{"flight": f"f{i}", "action": "cancel"}] + + +def test_watches_are_the_followers_watch_times(): + task = stopped_then_released() + assert [w.t for w in Day(task).watches] == references.watch_times(task) == clock.watch_times(task) + + +def test_news_arrives_at_the_first_watch_after_its_signature(): + d = Day(stopped_then_released()) + assert [n["id"] for n in d.drain()] == ["s1"] and d.drain() == [] + for _ in range(3): + assert d.advance()["news"] == [] + assert d.t == 180 and d.drain() == [] + assert d.advance() == {"watch": 4, "time": "04:00", "freeze_line": "05:00", "frozen": 1, "news": ["c1"]} + assert [(n["id"], n["at"], n["kind"]) for n in d.drain()] == [("c1", "03:20", "gs_cnx")] + assert [(e["news_id"], e["watch"], e["via"]) for e in d.log] == [("s1", 0, "load"), ("c1", 4, "watch")] + + +def test_each_call_costs_a_minute_of_the_watch_and_acts_at_the_clock(): + d = Day(stopped_then_released()) + assert d.clock == 0 + assert d.situation()["clock"] == "00:00" and d.clock == clock.CALL_MIN + assert d.check(cancel(1))["now"] == "00:02" + d.confirm(cancel(1)) + assert d.plan == {1: {"cancel": True, "decided": 2}} and d.clock == 3 and d.calls == 3 + assert clock.virtual_time(d.task, d.clock) == "2024-01-01T05:03:00Z" + + +def test_the_watch_ends_when_its_minutes_run_out(): + d = Day(stopped_then_released()) + for _ in range(59): + assert "watch_over" not in d.situation() + out = d.situation() + assert out["watch_over"]["watch"] == 1 and d.t == 60 and d.clock == 60 + while not d.last: + d.advance() + for _ in range(59): + d.situation() + assert d.situation()["watch_over"]["done"] and d.done and d.end_reason == "time" + assert d.situation() == {"error": "the day is over"} + + +def test_a_flight_leaving_before_the_freeze_line_commits(): + task = committed_then_stopped() + d = Day(task) + d.advance() + assert d.t == 60 and d.frozen == {0} + out = d.confirm(cancel(0)) + assert out["confirmed"] == [] and out["refused"][0]["flight"] == "f0" and "frozen" in out["refused"][0]["reason"] + assert d.plan == {} and d.situation()["flights"][0]["status"] == "frozen" + + +def test_a_cancellation_is_final_from_the_next_watch(): + d = Day(stopped_then_released()) + d.confirm(cancel(0)) + d.confirm([{"flight": "f0", "action": "restore"}]) + assert d.plan == {} + d.confirm(cancel(0)) + d.advance() + assert 0 in d.frozen + out = d.confirm([{"flight": "f0", "action": "restore"}]) + assert out["refused"] and d.plan == {0: {"cancel": True, "decided": 2}} + assert d.situation()["flights"][0]["status"] == "cancelled" + + +def test_an_open_flight_is_held_to_the_freeze_line(): + d = Day(held_then_released()) + d.confirm([{"flight": "f0", "action": "hold", "not_before": "03:20"}]) + d.advance() + assert d.t == 60 and 0 not in d.frozen + preview = d.check([{"flight": "f0", "action": "restore"}]) + assert preview["held_to_freeze_line"] == ["f0"] and d.plan == {0: {"not_before": 200}} + out = d.confirm([{"flight": "f0", "action": "restore"}]) + assert out["confirmed"] == ["f0"] and out["held_to_freeze_line"] == ["f0"] + assert d.plan == {0: {"not_before": 120}} and d.project(d.plan).flights[0].dep == 120 + assert d.changes[-1]["now"] == {"not_before": 120} + + +def test_check_previews_without_changing_the_plan(): + d = Day(stopped_then_released()) + out = d.check(cancel(1)) + assert d.plan == {} and out["cost_now"] == d.project({}).cost.total + assert out["cost"]["total"] == d.project({1: {"cancel": True, "decided": 0}}).cost.total + assert [r.split()[0] for r in out["flights"]] == ["f1"] and "cancelled" in out["flights"][0] + + +def test_bad_changes_are_reported(): + d = Day(stopped_then_released()) + assert "not valid JSON" in d.confirm("cancel f0")["error"] + out = d.confirm([{"flight": "f9", "action": "cancel"}, {"flight": "f0", "action": "divert"}, + {"flight": "f1", "action": "hold", "not_before": "9pm"}]) + assert len(out["problems"]) == 3 and d.plan == {} and d.calls == 2 + + +def test_situation_keys_are_all_there_and_json_able(): + d = Day(stopped_then_released()) + s = d.situation() + assert set(KEYS) <= set(s) and json.loads(json.dumps(s))["watch"] == 0 + assert s["programs"]["JFK"]["stops"] == [{"chain": "s1", "from": "00:00", "to": "10:00"}] + row = s["flights"][0] + assert {"id", "status", "known_delay", "plan", "dep", "arr", "late", "tail", "slot", "hold"} <= set(row) + assert row["status"] == "auto-cancel" and row["dep"] is None and s["aircraft"]["stations"]["BUF"][0]["tail"] == "N1" + assert "First watch" in d.text() and "## Flights" in d.text() + d.advance() + assert "Freeze line 02:00" in d.text() + + +def test_programs_as_issued_show_a_gdp_and_its_slots(): + s = Day(three_into_a_program()).situation() + gdps = s["programs"]["JFK"]["gdps"] + assert [g["id"] for g in gdps] == [s.id for s in sim.known(three_into_a_program(), 0).segments["JFK"]] + assert gdps[0]["kind"] == "gdp" and gdps[0]["period"] == ["02:30", "06:40"] + + +def test_departure_caps_are_known_cap_look_ahead(): + task = capped(held_then_released(), {"JFK": {2: 1, 5: 1}}) + d = Day(task) + assert d.situation()["dep_caps"] == {"JFK": {"02": 1}} + d.advance() + d.advance() + assert d.t == 120 and d.situation()["dep_caps"] == {"JFK": {"02": 1, "05": 1}} + assert Day(held_then_released()).situation()["dep_caps"] == {} + + +def test_crew_deadline_and_rest_are_shown(): + task = held_then_released() + task = replace(task, flights=(crewed(task.flight(0), 200), task.flight(1))) + rows = Day(task).situation()["flights"] + assert rows[0]["crew"] == {"pairing": "N1/0", "deadline": "03:20", "rest": "13:20"} and "crew" not in rows[1] + + +def call(i: int) -> list[dict]: + return [{"flight": f"f{i}", "action": "call_reserve"}] + + +def test_a_reserve_call_flies_a_base_departure_past_its_crew_deadline_while_the_pool_lasts(): + d = Day(reserve_day()) + before = d.situation() + assert before["reserves"] == {"JFK": {"pool": 1, "called": [], "left": 1}} + assert before["flights"][0]["crew"] == {"pairing": "N1/0", "deadline": "02:30", "rest": "12:30", "reserve": False} + assert before["flights"][0]["status"] == "auto-cancel" + out = d.confirm(call(1)) + assert out["confirmed"] == ["f1"] and d.plan == {1: {"reserve": True}} and out["auto_cancelled"] == ["f0"] + s = d.situation() + assert s["reserves"]["JFK"] == {"pool": 1, "called": ["f1"], "left": 0} + row = next(r for r in s["flights"] if r["id"] == "f1") + assert row["crew"]["reserve"] and row["dep"] == "03:25" and row["plan"] == "reserve" + assert "JFK: 0 of 1 left (called: f1)" in d.text() and "02:30/12:30+R" in d.text() + refused = d.confirm(call(0))["refused"] + assert len(refused) == 1 and "no reserve crew is left at JFK" in refused[0]["reason"] and 0 not in d.plan + + +def test_a_reserve_is_refused_off_a_pooled_base_and_on_a_cancelled_flight_and_kept_by_a_hold(): + d = Day(reserve_day()) + assert "doesn't leave a crew base" in d.confirm(call(2))["refused"][0]["reason"] + d.confirm(cancel(1)) + assert "is cancelled" in d.confirm(call(1))["refused"][0]["reason"] + d.confirm([{"flight": "f1", "action": "restore"}, *call(1), {"flight": "f1", "action": "hold", + "not_before": "03:40"}]) + assert d.plan[1] == {"not_before": 220, "reserve": True} + assert d.check(call(1))["refused"] == [] and d.confirm(call(1))["unchanged"] == ["f1"] + d.confirm(cancel(1)) + assert d.plan[1] == {"cancel": True, "decided": d.clock - 1} and d.situation()["reserves"]["JFK"]["left"] == 1 + + +def test_a_reserve_is_released_and_a_target_that_moves_it_to_another_flight_is_reached(): + d = Day(reserve_day()) + d.confirm(call(1)) + assert "release one first" in d.confirm(call(0))["refused"][0]["reason"] + rows = changes_to(d, {0: {"reserve": True}}) + assert rows == [{"flight": "f0", "action": "call_reserve"}, {"flight": "f1", "action": "release_reserve"}] + out = d.confirm(rows) + assert out["confirmed"] == ["f1", "f0"] and out["refused"] == [] and d.plan == {0: {"reserve": True}} + assert d.situation()["reserves"]["JFK"] == {"pool": 1, "called": ["f0"], "left": 0} + d.confirm([{"flight": "f0", "action": "hold", "not_before": "03:40"}]) + assert d.confirm([{"flight": "f0", "action": "release_reserve"}])["confirmed"] == ["f0"] + assert d.plan == {0: {"not_before": 220}} and d.confirm([{"flight": "f0", "action": "release_reserve"}])[ + "unchanged"] == ["f0"] + + +def test_a_reserve_past_its_flights_crew_deadline_stands_and_a_cancellation_then_is_priced_at_the_clock(): + f0 = based(flight(0, "JFK", "BUF", 100, 60, lb=400, deadline=700), 150) + task = reserved(day([f0, flight(1, "BOS", "DCA", 700, 60, deadline=1200)], + [tail("N1", "JFK", end="BUF"), tail("N2", "BOS", end="DCA")]), {"JFK": 1}) + d = Day(task) + d.confirm(call(0)) + while d.t < 240: + d.advance() + assert changes_to(d, {}) == [] + out = d.confirm([{"flight": "f0", "action": "release_reserve"}]) + assert out["confirmed"] == [] + assert "keeps its reserve: its crew deadline passed at 02:30" in out["refused"][0]["reason"] + assert d.plan == {0: {"reserve": True}} and d.project(d.plan).flights[0].dep == 500 + at = d.clock + assert d.confirm(cancel(0))["confirmed"] == ["f0"] and sim.execute(task, d.plan).flights[0].decided == at + + +def test_a_batch_counts_a_flights_last_reserve_row_and_a_cancellation_over_a_call_whatever_their_order(): + for rows in ([*call(1), *cancel(1)], [*cancel(1), *call(1)]): + d = Day(reserve_day()) + out = d.confirm(rows) + assert out["confirmed"] == ["f1"] and out["refused"] == [] and d.plan[1] == {"cancel": True, "decided": 0} + assert out["problems"] == ["f1 is cancelled and calls a reserve: the cancellation counts and drops the call"] + d = Day(reserve_day()) + out = d.confirm([*call(0), *call(0)]) + assert out["confirmed"] == ["f0"] and out["problems"] == ["f0 has more than one reserve call or release: the last " + "counts"] + out = Day(reserve_day()).check([{"flight": "f0", "action": "reserve"}]) + assert out["problems"] == ['f0: action must be cancel, restore, hold, call_reserve or release_reserve, not ' + '"reserve"'] + assert "its pool is empty" in Day(reserved(reserve_day(), {"JFK": 0})).confirm(call(0))["refused"][0]["reason"] + + +def test_a_scripted_policy_playing_the_anchors_reserve_calls_scores_the_anchors_cost(): + task = reserve_day() + sol = mip.solve(task) + target = sol.plan(task.start) + rows = changes_to(Day(task), target) + assert {"flight": "f1", "action": "call_reserve"} in rows + played = play(task, lambda d: target, anchor=sol.objective) + assert played.plan[1]["reserve"] and played.result["cost"]["total"] == sol.objective + + +def test_without_a_pool_the_situation_shows_no_reserves(): + task = replace(reserve_day(), layers=None) + d = Day(task) + assert d.situation()["reserves"] == {} and "Reserve crews" not in d.text() + assert "reserve" not in d.situation()["flights"][0]["crew"] + + +def test_reward_is_step0s_curve_one_at_the_anchor_and_never_below_the_floor_even_on_a_day_costing_nothing(): + assert reward(50, 100, 50) == 1.0 and reward(40, 100, 50) == 1.0 + assert reward(100, 100, 50) == 0.2 + 0.8 * math.exp(-2) + assert reward(75, 100, 50) == 0.2 + 0.8 * math.exp(-1) + assert 0.2 <= reward(10_000, 100, 50) < 0.2 + 1e-9 + assert reward(101, 100, 100) == 0.2 + 0.8 * math.exp(-0.1 / 0.5) + assert (reward(0, 0, 0), reward(5, 0, 0)) == (1.0, 0.2) + + +def test_grade_prices_the_plan_on_the_truth_against_naive_and_anchor(): + task = stopped_then_released() + plan = {0: {"cancel": True, "decided": 0}} + g = grade(task, plan) + assert g["cost"]["total"] == sim.execute(task, plan).cost.total + assert g["naive"] == references.naive(task).cost.total and g["anchor"] == references.anchor(task)[1].cost.total + assert g["reward"] == round(reward(g["cost"]["total"], g["naive"], g["anchor"]), 6) < 1 + rule = references.rule(task).cost + assert g["references"] == {"rule": {"cost": rule, "reward": round(reward(rule, g["naive"], g["anchor"]), 6)}, + "follower": None} + assert grade(task, {}, anchor=g["anchor"])["reward"] == reward(g["naive"], g["naive"], g["anchor"]) + assert grade(task, plan, anchor=g["anchor"], follower=g["naive"])["references"]["follower"] == \ + {"cost": g["naive"], "reward": round(reward(g["naive"], g["naive"], g["anchor"]), 6)} + + +def test_the_hubs_advisories_are_the_news(): + task = day([flight(0, "BUF", "JFK", 100, 60)], [tail("N1", "BUF", end="JFK")], + advisories=(adv("a2", "gdp_proposed", 50, 200, 400, (30,) * 4), adv("a1", "gs", 10, 10, 90))) + assert [(n.id, n.at) for n in clock.news(task)] == [("a1", 10), ("a2", 50)] + assert "proposed ground delay program 03:20-06:40" in clock.news(task)[1].text + + +def early_leaver(): + """f0 JFK-BOS at 00:10, ten minutes into the first watch, and f1 back at 03:20, on one aircraft.""" + return day([flight(0, "JFK", "BOS", 10, 60), flight(1, "BOS", "JFK", 200, 60)], [tail("N1", "JFK")]) + + +def test_a_flight_gone_by_the_clock_at_the_first_watch_stands(): + d = Day(early_leaver()) + for _ in range(40): + d.situation() + assert d.clock == 40 and d.situation()["flights"][0]["status"] == "airborne" + for change in (cancel(0), [{"flight": "f0", "action": "hold", "not_before": "00:50"}]): + out = d.confirm(change) + assert out["confirmed"] == [] and "is gone: it left at 00:10" in out["refused"][0]["reason"] + assert d.plan == {} and d.project(d.plan).flights[0].dep == 10 + + +def test_a_flight_restored_at_the_first_watch_leaves_no_earlier_than_the_clock(): + d = Day(early_leaver()) + d.confirm(cancel(0)) + for _ in range(20): + d.situation() + out = d.confirm([{"flight": "f0", "action": "restore"}]) + assert out["confirmed"] == ["f0"] and out["held_to_freeze_line"] == ["f0"] + assert d.plan == {0: {"not_before": 21}} and d.project(d.plan).flights[0].dep == 21 + + +def changes_keep_the_day_so_far(seed: int) -> list[tuple]: + """Random cancels, restores and holds at every watch after the first; what moved among the flights frozen or + gone by the clock (outcome, departure, aircraft), and the open flights left before the freeze line.""" + rng = random.Random(seed) + task = network_day(rng) if seed % 2 else random_day(rng) + d, bad = Day(task, anchor=0), [] + while not d.done: + if d.watch > 0: + before, at = d.project(d.plan).flights, d.clock + pinned = d.frozen | {i for i, r in before.items() if r.status == FLOWN and r.dep < at} + rows = [{"flight": f"f{i}", "action": (a := rng.choice(["cancel", "restore", "hold"])), + "not_before": hhmm(task.flight(i).sched_dep + rng.randint(0, 200)) if a == "hold" else None} + for i in rng.sample([f.id for f in task.flights], min(3, len(task.flights)))] + out = d.confirm(rows) + if d.done or "watch_over" in out: + continue + after, truth = d.project(d.plan).flights, sim.execute(task, d.plan).flights + bad += [(seed, d.watch, i) for i in pinned if _kept(before[i]) != _kept(after[i])] + bad += [(seed, d.watch, i, "open") for i, r in after.items() + if r.status == FLOWN and i not in d.frozen and at <= r.dep < d.line + and not (truth[i].status == FLOWN and truth[i].dep < d.clock)] + d.advance() + return bad + + +def _kept(r) -> tuple: + return r.status, r.dep, r.tail + + +def test_no_change_reaches_back_past_the_freeze_or_the_clock(): + assert [b for seed in range(130) for b in changes_keep_the_day_so_far(seed)] == [] + + +def changes_keep_the_truth_so_far(seed: int) -> list[tuple]: + """Random changes after a random stretch of thinking at every watch; what moved, on the truth the grade prices, + among the flights that had left or auto-cancelled on the truth by the clock.""" + rng = random.Random(seed) + task = network_day(rng) if seed % 2 else random_day(rng) + d, bad = Day(task, anchor=0), [] + while not d.done: + for _ in range(rng.randint(0, 50)): + if "watch_over" in d.situation(): + break + if d.done: + break + before, at = sim.execute(task, d.plan).flights, d.clock + gone = {i for i, r in before.items() if (r.status == FLOWN and r.dep < at) + or (r.status == "auto_cancelled" and r.decided < at)} + rows = [{"flight": f"f{i}", "action": (a := rng.choice(["cancel", "restore", "hold"])), + "not_before": hhmm(task.flight(i).sched_dep + rng.randint(0, 200)) if a == "hold" else None} + for i in rng.sample([f.id for f in task.flights], min(3, len(task.flights)))] + out = d.confirm(rows) + after = sim.execute(task, d.plan).flights + bad += [(seed, d.watch, i) for i in gone if _kept(before[i]) != _kept(after[i])] + if not d.done and "watch_over" not in out: + d.advance() + return bad + + +def test_no_change_reaches_back_past_what_has_left_on_the_truth(): + assert [b for seed in range(60) for b in changes_keep_the_truth_so_far(seed)] == [] + + +def test_a_flight_gone_on_the_truth_by_the_clock_is_shown_gone_and_stands(): + task = random_day(random.Random(48)) + d = Day(task, anchor=0) + d.advance() + while d.clock < 109: + d.situation() + assert (sim.execute(task, d.plan).flights[1].status, sim.execute(task, d.plan).flights[1].dep) == (FLOWN, 62) + assert d.situation()["flights"][1]["status"] in ("airborne", "flown") + out = d.confirm(cancel(1)) + assert out["confirmed"] == [] and "is gone: it left at 01:02" in out["refused"][0]["reason"] + assert d.plan == {} and sim.execute(task, d.plan).flights[1].status == FLOWN + + +def test_a_delay_shows_only_once_it_is_known(): + d = Day(delay_revealed_late()) + assert d.situation()["flights"][0]["known_delay"] is None + assert "B6 100 DCA-JFK".split()[-1] in d.text() and " ? " in d.text().split("## Flights")[1] + while d.t + 120 < 400: + d.advance() + assert d.situation()["flights"][0]["known_delay"] == 300 and " 300 " in d.text().split("## Flights")[1] + + +def test_news_signed_on_a_watch_minute_arrives_at_that_watch(): + task = replace(held_then_released(), programs=day( + [flight(0, "JFK", "BUF", 100, 60)], [tail("N1", "JFK")], + advisories=(adv("a1", "gs", 60, 60, 120),)).programs) + d = Day(task) + assert d.drain() == [] and d.advance()["news"] == ["a1"] and d.t == 60 + + +def test_each_flight_row_is_its_projection(): + d = Day(three_into_a_program()) + run, rows = d.project(d.plan).flights, d.situation()["flights"] + for row in rows: + r, f = run[int(row["id"][1:])], d.task.flight(int(row["id"][1:])) + assert (row["dep"], row["arr"], row["tail"]) == (r.dep and hhmm(r.dep), r.arr and hhmm(r.arr), r.tail) + assert row["late"] == (r.arr - f.sched_arr if r.status == FLOWN else None) + assert row["slot"] == (r.slot and {"program": r.program, "at": hhmm(r.slot)}) + assert [(r["status"], r["dep"], r["arr"], r["late"], r["tail"]) for r in rows] == [ + ("auto-cancel", None, None, None, None), ("auto-cancel", None, None, None, None), + ("open", "05:41", "06:41", 241, "N3")] + + +def test_every_aircraft_is_somewhere_at_every_watch(): + rng = random.Random(4) + for _ in range(6): + d = Day(network_day(rng), anchor=0) + while not d.done: + a = d.situation()["aircraft"] + if "watch_over" in a: + break + where = [x["tail"] for xs in a["stations"].values() for x in xs] + [x["tail"] for x in a["airborne"]] + assert sorted(where) == sorted(t.tail for t in d.task.tails) + assert all(x["lands"] > hhmm(d.clock - 1) for x in a["airborne"]) + d.advance() diff --git a/tests/v4day/test_v4_desk.py b/tests/v4day/test_v4_desk.py new file mode 100644 index 0000000..5fac4e1 --- /dev/null +++ b/tests/v4day/test_v4_desk.py @@ -0,0 +1,140 @@ +import json +from dataclasses import replace + +from day_helpers import closed, closed_day, day, flight, outcapped, tail + +from agentenv_airops.programs import references, sim +from agentenv_airops.programs.model import LAGS, Task +from agentenv_airops.v4day import clock +from agentenv_airops.v4day import tasks as v4_tasks +from agentenv_airops.v4day.day import Day +from agentenv_airops.v4day.situation import KEYS + +WEATHER = ((-100, "taf", "JFK", "TAF\nKJFK 260420Z 2605/2706 18010KT P6SM"), + (-9, "metar", "JFK", "KJFK 260451Z 18010KT 10SM"), + (50, "metar", "JFK", "KJFK 260551Z 18012KT 10SM"), + (150, "speci", "JFK", "KJFK 260730Z 27020G35KT 1SM +TSRA"), + (140, "taf", "JFK", "TAF AMD\nKJFK 260720Z 2608/2712 27015KT 3SM TSRA")) + + +def stormy() -> Task: + """closed_day (JFK's ramp closed 120-180) with JFK's weather, and BUF capping hours 3 and 6 at one arrival.""" + task = outcapped(closed_day(), {"BUF": {3: 1, 6: 1}}) + return replace(task, layers=replace(task.layers, weather=WEATHER)) + + +def test_weather_reaches_the_desk_at_the_first_watch_after_its_minute_plus_its_lag(): + task = stormy() + d = Day(task) + assert [w.t for w in d.watches] == references.watch_times(task) == clock.watch_times(task) + due = {n.id: n.due for n in clock.news(task)} + assert due == {f"{k}-jfk-{m}": m + LAGS[k] for m, k, _, _ in WEATHER} + while not d.done: + d.advance() + when = {e["news_id"]: e["t"] for e in d.log} + times = [w.t for w in d.watches] + assert set(when) == set(due) + for n, t in when.items(): + assert t == next(x for x in times if x >= due[n]) + assert times == [0, 60, 120, 155, 180] and when["speci-jfk-150"] == 155 and when["taf-jfk-140"] == 155 + assert when["metar-jfk-50"] == 60 and when["taf-jfk--100"] == when["metar-jfk--9"] == 0 + + +def test_news_lags_carry_it_past_a_watch_and_the_tasks_own_lags_move_its_delivery_and_its_watch(): + task = replace(stormy(), layers=replace(stormy().layers, weather=WEATHER + ((58, "metar", "JFK", "KJFK 260558Z"),))) + when = delivered(task) + assert when["metar-jfk-58"] == 120 and next(t for t in clock.watch_times(task) if t >= 58) == 60 + slow = replace(task, layers=replace(task.layers, lags=LAGS | {"metar": 15, "speci": 20})) + assert 170 in references.events(slow) and 155 not in references.events(slow) + when = delivered(slow) + assert when["speci-jfk-150"] == 170 and when["metar-jfk-50"] == 120 + + +def delivered(task: Task) -> dict[str, int]: + d = Day(task) + while not d.done: + d.advance() + return {e["news_id"]: e["t"] for e in d.log} + + +def test_the_latest_observation_is_the_one_observed_last_when_lags_bring_an_older_one_with_it(): + wx = ((40, "speci", "JFK", "KJFK 260540Z TS"), (51, "metar", "JFK", "KJFK 260551Z")) + task = replace(closed_day(), layers=replace(closed_day().layers, weather=wx, lags=LAGS | {"speci": 20})) + d = Day(task) + d.advance() + assert d.t == 60 and d.delivered.index("speci-jfk-40") > d.delivered.index("metar-jfk-51") + assert d.situation()["weather"]["JFK"]["observation"]["id"] == "metar-jfk-51" + + +def test_two_weather_items_of_one_kind_hub_and_minute_each_reach_the_desk_once(): + same = ((50, "metar", "JFK", "KJFK 260550Z 18012KT 10SM RMK AO2"), + (50, "metar", "JFK", "KJFK 260550Z AUTO 18012KT 10SM RMK MADISHF")) + task = replace(closed_day(), layers=replace(closed_day().layers, weather=same)) + ids = [n.id for n in clock.news(task)] + assert len(set(ids)) == 2 + d = Day(task) + d.advance() + texts = [d.items[i].text for i in d.delivered] + assert len(d.delivered) == 2 and sorted(texts) == sorted(" ".join(x[3].split()) for x in same) + + +def test_the_situation_shows_outstation_caps_closures_and_the_latest_weather(): + d = Day(stormy()) + s = d.situation() + assert set(KEYS) <= set(s) and json.loads(json.dumps(s)) == s + assert s["arr_caps"] == {"BUF": {"03": 1}} and s["closures"] == {} + assert s["weather"]["JFK"]["observation"]["text"].startswith("KJFK 260451Z") + assert s["weather"]["JFK"]["taf"]["text"].startswith("TAF KJFK 260420Z") + while d.t < 155: + d.advance() + s = d.situation() + assert s["arr_caps"] == {"BUF": {"03": 1, "06": 1}} + assert s["closures"] == {"JFK": [{"from": "02:00", "to": "03:05", "reported": False, "dep_cap": 3, + "arr_cap": 1}]} + assert "+TSRA" in s["weather"]["JFK"]["observation"]["text"] and s["weather"]["JFK"]["observation"]["due"] + text = d.text() + assert "## Outstation arrival caps" in text and "BUF: 03h 1 06h 1" in text + assert "JFK 02:00-03:05 (end expected)" in text and "+TSRA" in text + assert s["weather"]["JFK"]["taf"]["text"].startswith("TAF AMD KJFK 260720Z") + + +def test_a_day_without_layers_shows_no_caps_closures_or_weather(): + task = replace(closed_day(), layers=None) + s = Day(task).situation() + assert s["arr_caps"] == s["closures"] == s["weather"] == {} and s["news"] == [] + text = Day(task).text() + assert "## Ramp closures (lightning at the field; known once begun)\nNone." in text + assert "known 240 min ahead\nNone known." in text and "## Latest weather (METAR or SPECI, and TAF)\nNone." in text + + +def test_a_closure_ending_on_a_watch_is_reported_over_at_it(): + d = Day(closed(day([flight(0, "JFK", "BUF", 400, 60)], [tail("N1", "JFK", end="BUF")]), + {"JFK": ((120, 180, 1, 2),)}, {"JFK": 0.5})) + while d.t < 180: + d.advance() + assert d.t == 180 and d.situation()["closures"]["JFK"] == [ + {"from": "02:00", "to": "03:00", "reported": True, "dep_cap": 1, "arr_cap": 2}] + + +def test_a_closure_is_reported_over_only_once_its_end_has_come_even_when_the_guess_matches_it(): + task = closed(day([flight(0, "JFK", "BUF", 400, 60)], [tail("N1", "JFK", end="BUF")]), + {"JFK": ((120, 120 + sim.GUESS, 1, 1),)}, {"JFK": 0.5}) + d = Day(task) + while d.t < 120: + d.advance() + assert d.situation()["closures"]["JFK"][0]["reported"] is False + d.advance() + assert d.t == 120 + sim.GUESS and d.situation()["closures"]["JFK"] == [ + {"from": "02:00", "to": "02:30", "reported": True, "dep_cap": 1, "arr_cap": 1}] + + +def test_the_prompt_on_a_layered_day_tells_of_event_watches_lags_outstation_caps_closures_and_weather(): + rules, plain = v4_tasks.rules(stormy()), v4_tasks.rules(replace(stormy(), layers=None)) + assert "in watches, one an hour, starting at watch 0" in plain and "one an hour" in rules + assert "Outstation arrival caps" not in plain and "SPECI" not in plain and "Ramp closures" not in plain + for words in ["15 to 60 minutes apart", "a METAR 5 and a SPECI 5 minutes after its observation", + "a TAF 10 minutes after its issue", "- Outstation arrival caps:", "- Ramp closures:", + f"expected to last {sim.GUESS} minutes more", "the latest METAR or SPECI and TAF per hub"]: + assert words in rules + slow = replace(stormy(), layers=replace(stormy().layers, lags=LAGS | {"taf": 25})) + assert "a TAF 25 minutes after its issue" in v4_tasks.rules(slow) diff --git a/tests/v4day/test_v4_live.py b/tests/v4day/test_v4_live.py new file mode 100644 index 0000000..32e947a --- /dev/null +++ b/tests/v4day/test_v4_live.py @@ -0,0 +1,366 @@ +"""airops-v4-day served on a free port: its card, routes and tools beside v1's, a layered task loaded from a +tasks.jsonl line, a scripted desk playing whole days through the tools to the sim's cost on the truth, bad changes, +the clock (each call's minute, the re-arm at every new watch, before the Day moves), the news in each reply, the +news audit v1's verifier reads, the anchor solved at load, the end-day grade and the v4 prompts.""" + +import asyncio +import json +import types +from dataclasses import replace +from importlib.resources import files + +import httpx +import pytest +from agent_env.task_step.task_steps.apply_server_config import ApplyServerConfigStep +from agentenv_protocol import client +from day_helpers import adv, capped, day, flight, reserve_day, tail +from fastmcp import Client +from served_v4 import Tools, served_v4 +from test_v4_play import airline, airline_expected + +from agentenv_airops import live as v1_live +from agentenv_airops import tasks as v1_tasks +from agentenv_airops.core.model import hhmm +from agentenv_airops.programs import references, sim +from agentenv_airops.programs.model import LAYERED, Layers, load_tasks +from agentenv_airops.schedule import CLOCK_URI +from agentenv_airops.v4day import tasks as v4_tasks +from agentenv_airops.v4day.clock import CALL_MIN, virtual_time +from agentenv_airops.v4day.day import Day, changes_to +from agentenv_airops.v4day.grade import grade +from agentenv_airops.v4day.live import END_DAY_URI, ENV, LIVE_LOAD_URI, TOOLS, AirOpsV4LiveEnv, audit + +pytestmark = pytest.mark.anyio + + +def two_flights(): + """f0 JFK-BUF at 01:40 and f1 JFK-MCO at 08:20, each on its own aircraft at JFK: hourly watches from 00:00.""" + return day([flight(0, "JFK", "BUF", 100, 60), flight(1, "JFK", "MCO", 500, 150)], + [tail("N1", "JFK", end="BUF"), tail("N2", "JFK", end="MCO")]) + + +def with_news(): + """two_flights with a ground stop advisory signed before the first watch and one signed at 00:10, which reaches + the desk at the 01:00 watch.""" + return replace(two_flights(), programs={h: replace(p, advisories=(adv("a0", "gs", 0, 0, 30), + adv("a1", "gs", 10, 10, 90))) + if h == "JFK" else p for h, p in two_flights().programs.items()}) + + +def v1_verifier() -> types.ModuleType: + """v1's airops-verifier as its step runs it.""" + path = files("agentenv_airops.bundles").joinpath("airops/artifacts/airops-verifier/verify.py") + module = types.ModuleType("airops_verifier") + exec(compile(path.read_text(), str(path), "exec"), module.__dict__) + return module + + +async def invoke(http, uri: str, **params) -> httpx.Response: + card = (await http.get("/.well-known/agent-env.json")).json() + return await http.post(client.extension_params(card, uri)["endpoint"], json=params) + + +async def data(http) -> dict: + response = await http.post("/agentenv", json={"jsonrpc": "2.0", "id": 1, "method": "data/get"}) + return response.json()["result"]["parts"][0]["data"] + + +async def test_the_card_routes_and_tools_sit_beside_v1s(monkeypatch, tmp_path): + with served_v4(monkeypatch, tmp_path, [two_flights()]) as (url, env, _): + async with httpx.AsyncClient(base_url=url) as http: + card = (await http.get("/.well-known/agent-env.json")).json() + async with Client(f"{url}/mcp") as mcp: + listed = await mcp.list_tools() + assert card["name"] == ENV != v1_live.ENV and client.mcp_path(card) == "/mcp" + assert {e["uri"] for e in card["capabilities"]["extensions"]} == {LIVE_LOAD_URI, END_DAY_URI, CLOCK_URI} + assert LIVE_LOAD_URI != v1_live.LIVE_LOAD_URI and END_DAY_URI != v1_live.END_DAY_URI + assert [t.name for t in listed] == list(TOOLS) == ["get_situation", "check_plan", "confirm_changes", "advance"] + assert all("\n" not in t.description for t in listed) + item = listed[1].inputSchema["properties"]["changes"]["anyOf"][0]["items"] + assert (item["required"], item["additionalProperties"]) == (["flight", "action", "not_before"], False) + assert item["properties"]["action"]["enum"] == ["cancel", "restore", "hold", "call_reserve", "release_reserve"] + + +async def test_a_layered_task_loads_from_a_tasks_jsonl_line_and_its_caps_show(monkeypatch, tmp_path, task): + layered = replace(capped(task, {"JFK": {18: 1, 19: 2}}), task_id=f"{task.task_id}-capped") + with served_v4(monkeypatch, tmp_path, [task, layered], layered.task_id) as (url, env, _): + async with Client(f"{url}/mcp") as mcp: + out = await Tools(mcp)("get_situation") + line = (tmp_path / "tasks.jsonl").read_text().splitlines()[1] + assert '"version":4' in line.replace(" ", "") and LAYERED == 4 + assert load_tasks(tmp_path / "tasks.jsonl") == [task, layered] and set(env.tasks) == {task.task_id, + layered.task_id} + assert env.day.task.layers == Layers(dep_caps={"JFK": {18: 1, 19: 2}}) + assert "## Departure caps" in out["situation"] and "JFK: 18h 1 19h 2" in out["situation"] + assert (out["watch"], out["clock"], out["now"]) == (0, hhmm(task.start), hhmm(task.start + CALL_MIN)) + + +@pytest.mark.parametrize("name", ["task", "crewed"]) +async def test_a_scripted_desk_plays_the_day_through_the_tools_to_the_sims_cost_on_the_truth( + name, request, monkeypatch, tmp_path): + task = request.getfixturevalue(name) + with served_v4(monkeypatch, tmp_path, [task]) as (url, env, puts): + async with Client(f"{url}/mcp") as mcp, httpx.AsyncClient(base_url=url) as http: + tools, d = Tools(mcp), env.day + while True: + if rows := changes_to(d, airline(d)): + assert (await tools("confirm_changes", changes=rows))["refused"] == [] + out = await tools("advance") + if out.get("done"): + break + got = await data(http) + result = d.result + again = (await invoke(http, END_DAY_URI)).json() + assert await tools.error("advance") == "Error calling tool 'advance': the day is over" + assert (again["end_reason"], again["cost"], again["reward"]) == ("done", out["cost"], out["reward"]) + assert d.end_reason == "done" and d.result is result + expected = airline_expected(task) + assert d.plan == expected and out["cost"] == sim.execute(task, expected).cost.total + assert out["reward"] == d.result["reward"] and got["grade"] == grade(task, expected) == d.result + assert got["done"] and got["reward"] == out["reward"] and got["audit"] == {"ok": True, "problems": []} + assert puts == [{"virtual_time": virtual_time(task, w.t), "virtual_seconds_per_real_second": 0} + for w in d.watches[1:]] + + +async def test_bad_changes_come_back_as_errors_problems_and_refusals(monkeypatch, tmp_path): + with served_v4(monkeypatch, tmp_path, [two_flights()]) as (url, env, _): + async with Client(f"{url}/mcp") as mcp: + tools = Tools(mcp) + bad = await tools("confirm_changes", changes="[{flight") + assert bad["error"].startswith("changes are not valid JSON") + unknown = await tools("check_plan", changes=[{"flight": "f9", "action": "cancel", "not_before": None}]) + assert unknown["problems"] == ['unknown flight "f9": use the ids from the flight table (f0, f1, ...)'] + soon = await tools("confirm_changes", changes=[{"flight": "f1", "action": "hold", "not_before": "soon"}]) + assert soon["confirmed"] == [] and soon["problems"] == ['f1: not_before "soon" is not a time: write HH:MM, ' + "or HH:MM+1 after midnight"] + assert "validation error" in await tools.error("confirm_changes", changes=[{"flight": "f1"}]) + await tools("advance") + frozen = await tools("confirm_changes", changes=[{"flight": "f0", "action": "cancel", "not_before": None}]) + assert env.day.plan == {} and frozen["confirmed"] == [] + assert [r["flight"] for r in frozen["refused"]] == ["f0"] and "is frozen" in frozen["refused"][0]["reason"] + assert env.day.calls == 4 and env.day.refusals[0]["flight"] == "f0" + + +async def test_each_call_costs_a_minute_and_the_watch_running_out_rearms_the_clock(monkeypatch, tmp_path): + task = two_flights() + with served_v4(monkeypatch, tmp_path, [task]) as (url, env, puts): + async with Client(f"{url}/mcp") as mcp: + tools, step = Tools(mcp), env.day.watches[1].t - env.day.watches[0].t + for k in range(1, step // CALL_MIN): + assert (await tools("get_situation"))["now"] == hhmm(k * CALL_MIN) and puts == [] + out = await tools("check_plan", changes=[]) + assert out["watch_over"] == {"watch": 1, "time": hhmm(step), "freeze_line": hhmm(step + 60), + "frozen": 1, "news": []} + assert puts == [{"virtual_time": virtual_time(task, step), "virtual_seconds_per_real_second": 0}] + assert (env.day.watch, env.day.clock) == (1, step) + out = await tools("advance") + assert (out["watch"], out["time"]) == (2, hhmm(2 * step)) and len(puts) == 2 + + +async def test_a_watch_change_needs_the_synced_clock_except_after_the_last_watch(monkeypatch, tmp_path): + with served_v4(monkeypatch, tmp_path, [two_flights()], synced=False) as (url, env, puts): + async with Client(f"{url}/mcp") as mcp: + tools = Tools(mcp) + assert (await tools("get_situation"))["watch"] == 0 + assert await tools.error("advance") == ("Error calling tool 'advance': the gateway clock is not synced: " + f"call {CLOCK_URI} sync_time first") + while env.day.clock + CALL_MIN < env.day.ends: + await tools("get_situation") + at = (env.day.watch, env.day.clock, env.day.calls) + assert "the gateway clock is not synced" in await tools.error("get_situation") + assert (env.day.watch, env.day.clock, env.day.calls) == at + while not env.day.last: + env.day.advance() + out = await tools("advance") + assert out["done"] and out["end_reason"] == "done" and out["cost"] == env.day.result["cost"]["total"] + assert puts == [] + + +async def test_a_failed_rearm_leaves_the_day_at_its_watch_and_the_retry_opens_the_next(monkeypatch, tmp_path): + task = two_flights() + with served_v4(monkeypatch, tmp_path, [task], refuse=2) as (url, env, puts): + async with Client(f"{url}/mcp") as mcp: + tools = Tools(mcp) + assert "503 Service Unavailable" in await tools.error("advance") + assert (env.day.watch, env.day.clock, env.day.frozen, puts) == (0, task.start, set(), []) + while env.day.clock + CALL_MIN < env.day.ends: + await tools("get_situation") + cancel = [{"flight": "f1", "action": "cancel", "not_before": None}] + assert "503 Service Unavailable" in await tools.error("confirm_changes", changes=cancel) + assert (env.day.watch, env.day.plan, env.day.changes) == (0, {}, []) + out = await tools("confirm_changes", changes=cancel) + assert out["confirmed"] == ["f1"] and out["watch_over"]["watch"] == env.day.watch == 1 + assert puts == [{"virtual_time": virtual_time(task, env.day.t), "virtual_seconds_per_real_second": 0}] + + +async def test_a_call_that_fails_after_the_rearm_puts_the_gateway_clock_back_at_its_watch(monkeypatch, tmp_path): + task = two_flights() + with served_v4(monkeypatch, tmp_path, [task]) as (url, env, puts): + async with Client(f"{url}/mcp") as mcp: + tools = Tools(mcp) + while env.day.clock + CALL_MIN < env.day.ends: + await tools("get_situation") + at = (env.day.watch, env.day.clock, env.day.calls) + assert "invalid literal" in await tools.error( + "check_plan", changes=[{"flight": "f\u00b2", "action": "cancel", "not_before": None}]) + assert (env.day.watch, env.day.clock, env.day.calls) == at + assert puts[-1]["virtual_time"] == virtual_time(task, env.day.t) + out = await tools("get_situation") + assert out["watch_over"]["watch"] == env.day.watch == 1 + assert puts[-1]["virtual_time"] == virtual_time(task, env.day.t) + + +async def test_planner_calls_made_at_once_run_one_at_a_time_on_the_clock(monkeypatch, tmp_path): + task = two_flights() + with served_v4(monkeypatch, tmp_path, [task]) as (url, env, puts): + async with Client(f"{url}/mcp") as mcp: + await asyncio.gather(mcp.call_tool_mcp("advance", {}), mcp.call_tool_mcp("advance", {})) + assert env.day.watch == 2 + assert [p["virtual_time"] for p in puts] == [virtual_time(task, task.start + 60), virtual_time(task, env.day.t)] + + +async def test_each_watchs_news_comes_once_in_the_reply_that_opens_it_and_v1s_verifier_scores_the_day( + monkeypatch, tmp_path): + verifier = v1_verifier() + with served_v4(monkeypatch, tmp_path, [with_news()]) as (url, env, _): + assert [w.news for w in env.day.watches[:2]] == [("a0",), ("a1",)] + async with Client(f"{url}/mcp") as mcp: + tools = Tools(mcp) + assert (await tools("get_situation"))["messages"] == [] + with pytest.raises(RuntimeError, match="can't be scored"): + await verifier.verify(f"{url}/mcp") + opened = await tools("advance") + assert opened["news"] == ["a1"] and [m["id"] for m in opened["messages"]] == ["a1"] + assert (await tools("get_situation"))["messages"] == [] + while not (await tools("advance")).get("done"): + pass + rows = await verifier.verify(f"{url}/mcp") + assert [(e["news_id"], e["watch"], e["via"]) for e in env.day.log] == [("a0", 0, "load"), ("a1", 1, "watch")] + assert rows[0]["score"] == env.day.result["reward"] + + +def test_the_audit_names_news_dropped_or_delivered_at_the_wrong_watch(): + d = Day(with_news()) + d.advance() + assert audit(d) == {"ok": True, "problems": []} + d.log[1]["watch"] = 0 + assert audit(d) == {"ok": False, "problems": ["watch 0 got ['a0', 'a1'] instead of ['a0']", + "watch 1 got [] instead of ['a1']"]} + del d.log[0] + assert audit(d)["problems"][0] == "watch 0 got ['a1'] instead of ['a0']" + + +async def test_the_anchor_is_solved_at_load_so_the_days_last_call_runs_no_mip(monkeypatch, tmp_path): + task, solves = two_flights(), [] + solve = references.anchor + monkeypatch.setattr(references, "anchor", lambda t, *a, **k: solves.append(t.task_id) or solve(t, *a, **k)) + with served_v4(monkeypatch, tmp_path, [task]) as (url, env, _): + assert solves == [task.task_id] + async with Client(f"{url}/mcp") as mcp: + while not (out := await Tools(mcp)("advance")).get("done"): + pass + assert solves == [task.task_id] and env.day.result["anchor"] == solve(task)[1].cost.total + assert out["reward"] == env.day.result["reward"] + + +async def test_the_anchor_and_follower_given_at_load_are_graded_against_without_a_solve(monkeypatch, tmp_path): + task, solves = two_flights(), [] + monkeypatch.setattr(references, "anchor", lambda t, *a, **k: solves.append(t.task_id)) + with served_v4(monkeypatch, tmp_path, [task], anchor=0, follower=0) as (url, env, _): + async with httpx.AsyncClient(base_url=url) as http: + out = (await invoke(http, END_DAY_URI)).json() + got = await data(http) + assert solves == [] and got["grade"]["anchor"] == 0 + assert got["grade"]["references"]["follower"] == {"cost": 0, "reward": 1.0} and out["reward"] == got["reward"] + + +async def test_the_load_takes_the_packs_stored_costs_for_the_task_they_ran_on_and_solves_a_stale_ones(monkeypatch, + tmp_path): + task, other, solves = two_flights(), replace(two_flights(), task_id="b6-jfk-20240102-0100"), [] + solve = references.anchor + monkeypatch.setattr(references, "anchor", lambda t, *a, **k: solves.append(t.task_id) or solve(t, *a, **k)) + stale = replace(other, cutoff=other.cutoff - 1) + (tmp_path / "refs.json").write_text(json.dumps({ + task.task_id: {"anchor": 3, "follower": 4, "task": references.digest(task)}, + other.task_id: {"anchor": 3, "follower": 4, "task": references.digest(stale)}})) + with served_v4(monkeypatch, tmp_path, [task, other]) as (url, env, _): + async with httpx.AsyncClient(base_url=url) as http: + await invoke(http, END_DAY_URI) + got = await data(http) + assert solves == [] and got["grade"]["anchor"] == 3 and got["grade"]["references"]["follower"]["cost"] == 4 + with served_v4(monkeypatch, tmp_path, [task, other], task_id=other.task_id) as (url, env, _): + assert solves == [other.task_id] and (env.day.anchor, env.day.follower) == (solve(other)[1].cost.total, None) + + +async def test_sync_time_reads_the_gateway_clock(monkeypatch, tmp_path): + task = two_flights() + with served_v4(monkeypatch, tmp_path, [task], synced=False) as (url, env, _): + async with httpx.AsyncClient(base_url=url) as http: + got = (await invoke(http, CLOCK_URI, env_get_time_url=f"{url}/clock/time")).json() + assert got == {"env_get_time_url": f"{url}/clock/time", "virtual_time": virtual_time(task, task.start)} + assert env.set_time_url == f"{url}/clock/set-time" + + +async def test_end_day_grades_the_plan_on_the_truth_and_data_get_reports_it(monkeypatch, tmp_path): + task = two_flights() + cancel = [{"flight": "f1", "action": "cancel", "not_before": None}] + with served_v4(monkeypatch, tmp_path, [task]) as (url, env, _): + async with Client(f"{url}/mcp") as mcp, httpx.AsyncClient(base_url=url) as http: + assert (await Tools(mcp)("confirm_changes", changes=cancel))["confirmed"] == ["f1"] + out = (await invoke(http, END_DAY_URI)).json() + got = await data(http) + plan = {1: {"cancel": True, "decided": 0}} + assert env.day.plan == plan and got["grade"] == grade(task, plan) + assert out == {"end_reason": "end_day", "watch": 0, "cost": sim.execute(task, plan).cost.total, + "reward": got["reward"], "audit": {"ok": True, "problems": []}} + + +async def test_no_day_loaded_and_one_day_per_env(monkeypatch, tmp_path): + monkeypatch.setenv("AIROPS_V4_TASKS", str(tmp_path / "none.jsonl")) + (tmp_path / "none.jsonl").write_text("") + env = AirOpsV4LiveEnv() + env.create_app() + async with Client(env.mcp) as mcp: + no_day = f"no day loaded: load one with {LIVE_LOAD_URI}" + assert await Tools(mcp).error("get_situation") == f"Error calling tool 'get_situation': {no_day}" + with pytest.raises(ValueError, match="unknown task id 'x'"): + env.live_load("x") + env.tasks = {"d": two_flights()} + assert env.live_load("d") == {"task_id": "d", "watches": len(env.day.watches)} + with pytest.raises(ValueError, match="already loaded"): + env.live_load("d") + + +def test_the_v3_prompts_describe_the_programs_world(task, crewed): + rules, crew_rules = v4_tasks.rules(task), v4_tasks.rules(crewed) + for words in ["ground delay programs", "EDCT", "Ground stops", "Departure caps", "check_plan(changes)", + "decided later than 2 hours"]: + assert words in rules + assert "Crews:" in crew_rules and "Crews:" not in rules and rules != v1_tasks.AIROPS_RULES + d = Day(task) + assert v4_tasks.briefing(d).startswith(d.text()) and d.calls == 0 + steps = v4_tasks.steps(task) + assert [s["id"] for s in steps] == ["deploy", "load-task", "deploy-agent", "clock", "play", "end-day", "grade"] + assert steps[1]["directives"] == [{"service": ENV, "uri": LIVE_LOAD_URI, "args": {"task_id": task.task_id}}] + assert ApplyServerConfigStep.from_dict(steps[1]).timeout_seconds > references.TIME_LIMIT + given = v4_tasks.steps(task, anchor=5, follower=7)[1] + assert given["directives"][0]["args"] == {"task_id": task.task_id, "anchor": 5, "follower": 7} + assert ApplyServerConfigStep.from_dict(given).timeout_seconds < references.TIME_LIMIT + assert steps[3]["virtual_time"] == virtual_time(task, task.start) + assert steps[4]["system_prompt"] == rules and steps[4]["max_turns"] == 5 * len(d.watches) + 4 + + +async def test_a_reserve_call_in_list_form_flies_a_late_base_departure_through_the_tools(monkeypatch, tmp_path): + """The live tools take v4's reserve actions as list items (the strict schema's enum), as the Day does.""" + task = reserve_day() + with served_v4(monkeypatch, tmp_path, [task]) as (url, env, _): + async with Client(f"{url}/mcp") as mcp: + tools = Tools(mcp) + before = await tools("get_situation") + assert "Reserve crews" in before["situation"] + out = await tools("confirm_changes", changes=[{"flight": "f1", "action": "call_reserve", + "not_before": None}]) + assert out["confirmed"] == ["f1"] and env.day.plan[1] == {"reserve": True} + again = await tools("confirm_changes", changes=[{"flight": "f0", "action": "call_reserve", + "not_before": None}]) + assert "no reserve crew is left at JFK" in again["refused"][0]["reason"] diff --git a/tests/v4day/test_v4_play.py b/tests/v4day/test_v4_play.py new file mode 100644 index 0000000..9587bdd --- /dev/null +++ b/tests/v4day/test_v4_play.py @@ -0,0 +1,93 @@ +import math +import random + +import pytest +from day_helpers import network_day, random_day +from test_reference_plays import committed_then_stopped, released_early, stopped_then_released + +from agentenv_airops.core.known import FREEZE +from agentenv_airops.core.sim import AUTO_CANCELLED +from agentenv_airops.programs import references, sim +from agentenv_airops.programs.cost import NOTICE +from agentenv_airops.v4day.day import Day, play +from agentenv_airops.v4day.grade import reward + + +def nothing(day: Day) -> dict: + return {} + + +def airline(day: Day) -> dict: + """JetBlue's decisions: its recorded departures as holds from the first watch, each cancellation at the last + watch at or before it was due (sched - NOTICE).""" + upto = math.inf if day.last else day.watches[day.watch + 1].t + return {f.id: {"cancel": True} if f.actual.dep is None else {"not_before": f.actual.dep} + for f in day.task.flights if f.actual.dep is not None or max(f.sched_dep - NOTICE, day.task.start) < upto} + + +def airline_expected(task) -> dict: + times = references.watch_times(task) + return {f.id: {"not_before": f.actual.dep} if f.actual.dep is not None else + {"cancel": True, "decided": max(t for t in times if t <= max(f.sched_dep - NOTICE, task.start))} + for f in task.flights} + + +def rule(day: Day) -> dict: + """references.rule as a desk: cancel what the plan auto-cancels on the day as known while it is still open.""" + run = day.project(day.plan) + return day.plan | {i: {"cancel": True} for i, r in run.flights.items() + if r.status == AUTO_CANCELLED and i not in day.frozen and r.decided >= day.t + FREEZE} + + +def hand_and_random_days(): + rng = random.Random(9) + return ([stopped_then_released(), committed_then_stopped(), released_early()] + + [random_day(rng) for _ in range(12)] + [network_day(rng) for _ in range(12)]) + + +@pytest.mark.parametrize("name", ["task", "crewed"]) +def test_airline_replay_through_the_day_costs_what_the_sim_says(name, request): + task = request.getfixturevalue(name) + d = play(task, airline, anchor=0) + expected = airline_expected(task) + assert d.refusals == [] and [c for c in d.changes if c["by"] == "freeze_line"] == [] + assert d.plan == expected and d.result["cost"] == sim.execute(task, expected).cost.to_dict() + + +def test_the_airline_replay_on_random_network_days_costs_what_the_sim_says(): + rng = random.Random(4) + for _ in range(20): + task = network_day(rng) + d = play(task, airline, anchor=0) + assert d.plan == airline_expected(task) and d.refusals == [] + + +@pytest.mark.parametrize("name", ["task", "crewed"]) +def test_doing_nothing_on_the_fixture_costs_the_naive_day(name, request): + task = request.getfixturevalue(name) + d = play(task, nothing, anchor=0) + assert d.plan == {} and d.result["cost"]["total"] == d.result["naive"] == references.naive(task).cost.total + + +def test_doing_nothing_costs_the_naive_day_and_scores_zero(): + for task in hand_and_random_days(): + d = play(task, nothing) + assert d.plan == {} and d.result["cost"]["total"] == d.result["naive"] == references.naive(task).cost.total + assert d.result["reward"] == round(reward(d.result["naive"], d.result["naive"], d.result["anchor"]), 6) + + +def test_the_rule_played_through_the_day_is_the_rule_reference(): + """Except on day 15, where the reference's cancellation of f12 at 08:00 reroutes N1 and auto-cancels f6, which + left at 03:38: the desk can't reach back past the day so far, so the Day refuses it.""" + differ = [] + for k, task in enumerate(hand_and_random_days()): + d, ref = play(task, rule, anchor=0), references.rule(task) + if d.plan == ref.plan: + assert d.result["cost"]["total"] == ref.cost and d.refusals == [] + else: + differ.append(k) + assert {r["flight"] for r in d.refusals} == {"f12"} and "move f6" in d.refusals[0]["reason"] + assert d.plan == {i: e for i, e in ref.plan.items() if i != 12} + assert sim.execute(task, ref.plan).flights[6].status == AUTO_CANCELLED + assert sim.execute(task, d.plan).flights[6].dep == 218 + assert differ == [15]