How we built the orbital scheduler.
Two repos, one pass: a Python + FastAPI service that propagates orbits and scores schedules, and a Next.js console that renders them. Below is the path from a blank editor to a mission total that lands inside 99% of the geometric ceiling.
Case definition
GET /api/casesEach case is a fixed JSON record describing a 12-minute pass: a TLE pair (orbit), an AOI polygon (1° × 1.26° box centred near 45 N / 10 E for the bundled cases), pass start/end UTC, and spacecraft parameters (FOV 2.0°, body inertia [0.12, 0.12, 0.08]).
The three bundled cases differ only in the second TLE — case1 puts the satellite directly over the AOI, case2 ~330 km cross-track, case3 places it past the geometric horizon for a 60° off-nadir gate.
Contest weights are 0.25 / 0.35 / 0.40 for case1 / case2 / case3. They are absolute (sum to 1.0) and applied to the per-case score at the very end.
Ephemeris
GET /api/cases/{id}/ephemeris · core/propagator.pySGP4 is propagated from `pass_start_utc` to `pass_end_utc` at 1 Hz (configurable via `dt_s`). Each sample carries r_eci, v_eci, geodetic lat/lon/alt, and the off-nadir angle from the satellite's nadir direction to the AOI centroid.
The closest-approach instant is min(off_nadir_to_aoi_center_deg) over the pass — the natural anchor for any nadir-style strategy.
Tiling
POST /api/plan · core/tiling.pyThe AOI bounding box is gridded at roughly the FOV footprint size. For the bundled cases this yields a ~10 × 10 grid of square sub-targets, labelled t_RR_CC by row/column.
Each tile is a {id, lat_deg, lon_deg, size_deg} record. The size depends on satellite altitude and FOV — backend ships ~0.20° square tiles at 506 km / 2° FOV.
Schedule
POST /api/plan · core/planner.pyThe planner walks the 1 Hz time grid and, for every (time, tile) pair, computes the off-nadir angle if the satellite were to point at that tile. Pairs that violate the adaptive gate (default 60°, relaxed for hard cases) are dropped.
Boustrophedon / global-argmin / center-first strategies pick shutter assignments that minimise off-nadir while respecting a slew block (≈ ±0.8 s around each chosen shutter so attitude has time to settle).
Output: a Schedule of attitude samples (q_BN quaternions, scalar-last) at high rate plus a list of shutters {t_start, t_end, tile_id, tile_lat_deg, tile_lon_deg, off_nadir_deg, q_BN, footprint}, plus diagnostics including the adaptive `off_nadir_limit_deg` actually used and the list of `skipped_tile_ids`.
The frontend downsamples the attitude array to 1024 samples at the API boundary — the simulator only reads it at shutter midpoints, so high-rate samples are wasted bytes.
Simulation
POST /api/simulate · routes/simulation.pyThe simulator replays the schedule. For each shutter it samples the attitude quaternion at t_mid ± 0.01 s and estimates body rate via finite difference (`estimate_body_rate(q_a, q_b, dt)`).
Wheel momentum used (Δh) is integrated across the full attitude trace using the body-x inertia (Ix = 0.12 kg·m² for the bundled cases). Active time is the interval from the first attitude sample to the last shutter end.
Coverage is computed by sampling an 80 × 80 equirectangular grid inside the AOI and counting points covered by at least one shutter footprint (Sutherland-Hodgman polygon clip).
Score
core/scorer.pyPer pass: S_orbit = C × (1 + 0.25·η_E + 0.10·η_T) × Q_smear
C — coverage fraction (0–1) from the grid sampler.
η_E = max(0, 1 − Δh / 0.200 Nms). 0.200 Nms is the per-pass momentum budget.
η_T = max(0, 1 − T_active / 720 s). T_active = slew + shutter time across the full pass.
Q_smear = fraction of shutters with body rate ≤ 0.05°/s at midpoint.
Mission total: S_total = 0.25·score₁ + 0.35·score₂ + 0.40·score₃. The Console mirrors this exact formula; cases that haven't simulated yet contribute zero.
Validation
POST /api/validate · routes/validation.pyIndependent of the scorer, the validator re-checks the three hard gates per shutter: wheel saturation (|H_i| ≤ 0.030 Nms continuously), smear (|ω_body| ≤ 0.05°/s continuously), off-nadir (≤ 60° at the boresight ground hit).
A shutter that fails any gate yields zero coverage credit. The simulator already accounts for this through Q_smear; the validator exists so submissions can audit themselves before grading.
The brief was deceptively simple: take a satellite, a 12-minute pass, and a polygon on the ground. Plan an attitude profile that images as much of the polygon as possible without smearing the photos, saturating the reaction wheels, or pointing past a 60° off-nadir gate. Score it. Beat the reference.
We split the problem in half on day one. One side took the physics — SGP4 propagation, frame conversions, attitude quaternions, the scoring contract. The other side took the interface — a dashboard you could trust to show what the planner was actually doing, frame by frame, instead of a single opaque number.
The first plans hit ~0.08 / 1.35 per case. Slews were too aggressive, the wheel momentum integral blew through its 0.200 Nms budget, and half the shutters fired with the spacecraft still settling. Three things fixed it: a serpentine tile order so neighbouring shots stayed close, a global argmin scheduler that picks the cheapest (time, tile) pair under the gate instead of greedy left-to-right, and identical-quaternion hold brackets around each 120 ms exposure to satisfy the smear gate without slowing the rest of the trajectory.
On the web side the priority was honesty. Every value the UI shows comes from the backend payload — the closest-approach off-nadir is recomputed from SGP4 each load, the per-case score is the raw S_orbit returned by /api/simulate, the frames-kept badge mirrors the planner’s own diagnostics. When we discovered the front-end had been hard-coding the wrong contest weights (1.0 / 0.5 / 0.25 instead of 0.25 / 0.35 / 0.40), we corrected it to the hackathon weights — those are what the grader uses.
Case 3 took a day of dead ends. Cross-track distance is roughly 1000 km; the maximum reachable arc from 506 km altitude under a 60° gate is about 1022 km, and the AOI’s nearest corner sits a few kilometres past that horizon. After four independent verifications — geometric, harness sweep, wide footprint sweep, and the organiser’s own reference solution — we accepted the 0 and stopped trying to exploit it. The ceiling is 0.660. The current solver hits 0.6543.
- Case 3 is provably infeasible. Cross-track ≈ 1000 km; max reachable arc from 506 km under a 60° gate ≈ 1022 km. All four reference submissions (identity, nadir-greedy, stop-and-stare, organiser solution) score case3 = 0. Ceiling for S_total ≈ 0.660; current solver lands at 0.6543 (99.1% of ceiling).
- Mock simulator only. Trajectory uses pure SLERP between attitude samples; angular rate is `np.gradient(q_BN)`. There is no plant dynamics — inter-frame slews can be arbitrarily fast, and only the integrated Δh penalises them through η_E.
- Quaternion convention. Scalar-last `[qx, qy, qz, qw]`, body-to-inertial. Boresight is +z_body; pointing builder uses `y_body = z × v / |z × v|` for yaw resolution.
Source of truth for this page: chunkyapi/HANDOFF.md and chunkyapi/app/core/{propagator,tiling,planner,scorer}.py. If the backend changes, update this page — nothing here is computed dynamically.