diff --git a/Cargo.lock b/Cargo.lock index 47bb4492c5..cccabb40bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7236,7 +7236,7 @@ version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "itertools 0.14.0", "log", "multimap", @@ -11454,6 +11454,17 @@ dependencies = [ "ruvector-mincut 2.2.3", ] +[[package]] +name = "sgp4" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9467b9a7be8485ed8be0f336d399c8f32c0fcd60686e7dd2ed3dab75c9a73eb3" +dependencies = [ + "chrono", + "serde", + "serde_json", +] + [[package]] name = "sha1" version = "0.10.6" @@ -11636,6 +11647,34 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +[[package]] +name = "sky-monitor" +version = "0.1.0" +dependencies = [ + "chrono", + "criterion 0.5.1", + "ruvector-core 2.2.3", + "ruvector-graph", + "serde", + "serde_json", + "thiserror 2.0.18", + "uuid", +] + +[[package]] +name = "sky-monitor-wasm" +version = "0.1.0" +dependencies = [ + "chrono", + "js-sys", + "serde", + "serde-wasm-bindgen", + "serde_json", + "sgp4", + "sky-monitor", + "wasm-bindgen", +] + [[package]] name = "slab" version = "0.4.12" @@ -12280,7 +12319,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", diff --git a/Cargo.toml b/Cargo.toml index d2464666e7..d5e5d6ffde 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -171,6 +171,10 @@ members = [ "examples/a2a-swarm", # ETL pipeline example "examples/train-discoveries", + # RuView SkyGraph appliance core (ADR-199 Phases 1-4, synthetic ADS-B) + "examples/sky-monitor", + # Browser-facing WASM projection engine for the SkyGraph dashboard (ADR-199 presentation plane) + "examples/sky-monitor/wasm", # Spectral graph sparsification "crates/ruvector-sparsifier", "crates/ruvector-sparsifier-wasm", diff --git a/docs/adr/ADR-199-sky-monitor-skygraph-appliance.md b/docs/adr/ADR-199-sky-monitor-skygraph-appliance.md new file mode 100644 index 0000000000..3139d10820 --- /dev/null +++ b/docs/adr/ADR-199-sky-monitor-skygraph-appliance.md @@ -0,0 +1,639 @@ +--- +adr: 199 +title: "Sky Monitor and SkyGraph Appliance" +status: proposed +date: 2026-06-09 +authors: [Reuven Cohen] +project: "RuView Sky Monitor" +related: [ADR-196, ADR-197, ADR-198] +tags: [ruview, skygraph, adsb, weather, sensing, edge-ai, anomaly, worldgraph, ruvector-core, ruvector-graph, projection, appliance] +--- + +# ADR-199 — Sky Monitor and SkyGraph Appliance + +## Status + +**Proposed.** Owner: Reuven Cohen. Project: RuView Sky Monitor. +Date: 2026-06-09. + +A reference implementation of Phases 1–4 (with synthetic ADS-B data) lives at +`examples/sky-monitor/` in this repository, built on `crates/ruvector-core` and +`crates/ruvector-graph` (see §13 and §29). + +## 1. Decision + +Build a **local sky monitoring appliance** that observes, projects, records, and +explains activity above a fixed physical location. + +The appliance starts with ADS-B aircraft tracking, weather overlays, and a +projector display. It then evolves into a multi-sensor **SkyGraph** that +correlates aircraft, weather, RF activity, satellites, acoustic events, camera +observations, and environmental signals. + +The core decision: **treat the sky as a continuously changing spatial graph, not +a dashboard.** The appliance ingests live signals, normalizes them into common +spatial and temporal coordinates, stores observations in RuVector plus graph +storage, and exposes a local assistant that can answer questions like: + +- "What aircraft crossed overhead today?" +- "Was that sound correlated with an aircraft?" +- "What changed versus normal Tuesday patterns?" + +## 2. Context + +The required upstream signals and decoders already exist and are publicly +accessible: + +- **ADS-B / Mode S state vectors** carry position, velocity, and identity for + aircraft broadcasting in the clear at 1090 MHz. +- **OpenSky Network API** provides live state vectors over HTTP, usable for + bootstrap, comparison, and gap filling. +- **dump1090** is a mature Mode S decoder for cheap RTL-SDR receivers, exposing + decoded aircraft state as local JSON. +- **MSC GeoMet APIs** publish Canadian weather (ECCC): radar, precipitation, + wind, visibility, and alerts. + +The value here is *not* another flight tracker. It is **local ambient +intelligence** that learns the sky above a specific property, building, event, +or city zone — a persistent, queryable, observer-centric model of "above us". + +## 3. Problem + +Current tools show individual streams: flight trackers show aircraft, weather +apps show radar, satellite apps show passes, RF tools show spectrum, cameras +show frames. None of them create a **persistent local model of the sky** that +explains relationships across time, space, signals, and events. + +The missing layer is a **governed sensing harness** that converts raw +observations into a queryable intelligence substrate — with provenance, +retention policy, and explanation, not just pixels. + +## 4. Goals + +1. **Live visual sky display** — observer-relative aircraft and weather, on a + dashboard or projector. +2. **Local history of sky events** — replayable, indexed, durable. +3. **Anomaly detection** — unusual paths, low-altitude passes, RF patterns, + rare weather, acoustic signatures. +4. **Cross-signal correlation** — aircraft ↔ audio ↔ weather ↔ RF ↔ camera. +5. **Local-first** — fully functional without cloud; cloud is optional. +6. **Field appliance for RuView / RuVector** — a concrete testbed for ambient + sensing, vector memory, WorldGraph, and edge intelligence. + +## 5. Non-Goals + +- Not air traffic control. +- Not safety-critical. +- Not a replacement for certified aviation, weather, or emergency systems. +- Not personal surveillance. +- **Never transmits** — receive-only sensors plus licensed public APIs only. + +## 6. Architecture Decision + +The appliance is an **event-driven local system**. Every observation is indexed +three ways: + +1. **Time index** — enables replay, sliding windows, and trails. +2. **Spatial index** — local observer coordinates: azimuth, elevation, range. +3. **Semantic vector index** — enables similarity, anomaly scoring, + explanation, and long-term memory. + +Four planes structure the system: + +| Plane | Responsibility | +|-------|----------------| +| **Sensor plane** | Receive raw signals (ADS-B, weather API, camera, audio, RF, …) | +| **Normalization plane** | Align time, convert coordinates, score confidence, resolve entities | +| **Intelligence plane** | Track stitching, anomaly detection, correlation, pattern learning, NL query | +| **Presentation plane** | Dashboard, projector, replay, timeline, alerts, daily brief | + +## 7. Reference Architecture + +``` +Sensors +├── ADS-B receiver (RTL-SDR + dump1090) +├── OpenSky API (fallback / comparison) +├── Weather API (MSC GeoMet) +├── Camera (optional) +├── Microphone / mic array (optional) +├── SDR spectrum scan (optional) +├── BLE / WiFi environmental sensing (optional) +└── Satellite pass prediction (optional, TLE) + │ + ▼ +Collectors +├── adsb-collector +├── weather-collector +├── camera-collector +├── audio-collector +├── rf-collector +└── satellite-collector + │ + ▼ +Normalization +├── timestamp alignment (UTC, monotonic drift correction) +├── coordinate conversion (WGS-84 → ECEF → ENU → az/el/range) +├── confidence scoring +├── entity resolution (icao24, track ids, weather cells) +└── sensor calibration profiles + │ + ▼ +Storage +├── time-series store (metrics, state vectors) +├── object store (raw frames, audio clips, IQ snippets) +├── graph store (SkyGraph nodes + edges) +├── RuVector index (embeddings) +└── raw archive (hash-chained) + │ + ▼ +Intelligence +├── track stitching +├── anomaly detection +├── causal correlation +├── pattern learning +├── natural-language query +└── local assistant + │ + ▼ +Presentation +├── web dashboard +├── HDMI projector output +├── sky overlay (observer-relative) +├── replay +├── timeline +└── alerts + daily brief +``` + +## 8. Deployment Target + +| Component | Choice | Notes | +|-----------|--------|-------| +| Compute | Orange Pi 5 Plus or Raspberry Pi 5 | ARM SBC, low power, NVMe-capable | +| SDR | RTL-SDR v4 or Airspy Mini | 1090 MHz reception | +| Antenna | 1090 MHz outdoor antenna | Roofline/mast mount, short low-loss feed | +| Storage | 1 TB NVMe | Tiered retention (see §18) | +| Display | HDMI projector or monitor | Browser kiosk mode | +| Camera | USB or CSI camera | Optional, disabled by default (see §16) | +| Audio | USB mic array | Optional | +| Network | Ethernet preferred | Local-only admin network | +| Optional | Intel BE200 (WiFi sensing), LoRa module | Later phases | + +**v1 prioritizes stable ADS-B reception + weather over everything else.** + +## 9. Data Sources + +### 9.1 ADS-B local (primary) + +dump1090 JSON output from local Mode S decode; state vectors yield: identity +(icao24, callsign, squawk), lat/lon, barometric altitude, ground speed, track +angle, vertical rate, signal strength, last-seen timestamp. + +### 9.2 OpenSky API (fallback) + +Used for: bootstrap before the antenna is installed, comparison against local +decode (coverage QA), gap filling when local reception drops, and historical +enrichment. + +### 9.3 MSC GeoMet (Canadian weather, ECCC) + +Radar overlay, precipitation type, wind, visibility, official alerts, and +storm-event correlation against aircraft/audio observations. + +### 9.4 Optional sources (later phases) + +TLE-based satellite pass prediction, camera observations, audio events, RF +spectrum events, and WiFi/BLE **environmental sensing only — never personal +identification** (see §16). + +## 10. Coordinate Model + +Inputs: observer lat/lon/alt, aircraft lat/lon/alt, timestamp. +Outputs: range, bearing, elevation angle, azimuth, apparent screen position, +confidence. + +Pipeline: **WGS-84 → ECEF → ENU → azimuth/elevation/range** (observer-centric +local tangent plane). + +### Projector calibration + +| Level | Requirements | +|-------|--------------| +| **Minimum** | North reference, horizon line, field of view, one alignment point, observer position | +| **Better** | Star or known-aircraft anchor points, continuous refinement, persisted per-setup calibration profile | + +## 11. Canonical Observation Schema + +Every normalized observation, from any sensor, conforms to one schema: + +```json +{ + "observation_id": "uuid", + "timestamp_utc": "2026-06-09T19:00:00Z", + "source": "adsb_local", + "sensor_id": "sky_node_001", + "entity_type": "aircraft", + "entity_id": "icao24_or_internal_id", + "location": { "lat": 43.4675, "lon": -79.6877, "alt_m": 1200 }, + "observer_frame": { "range_m": 8500, "azimuth_deg": 72.4, "elevation_deg": 8.2, "bearing_deg": 72.4 }, + "motion": { "speed_mps": 210, "track_deg": 247, "vertical_rate_mps": -3.1 }, + "attributes": { "callsign": "ACA123", "squawk": "1234", "signal_dbfs": -18.4 }, + "confidence": 0.92, + "raw_ref": "object_store_key", + "embedding_ref": "ruvector_key" +} +``` + +`raw_ref` links every insight back to evidence; `embedding_ref` links every +observation into vector memory. + +## 12. SkyGraph Model + +### Nodes + +| Node type | Meaning | +|-----------|---------| +| `Aircraft` | A resolved physical aircraft (icao24 or internal id) | +| `Track` | A stitched flight path segment through local airspace | +| `WeatherCell` | A radar/precipitation/wind cell over a time window | +| `RFEvent` | A detected RF spectrum event | +| `AudioEvent` | A detected acoustic event | +| `CameraEvent` | A detected visual event | +| `Satellite` | A predicted or observed satellite pass | +| `Observer` | A physical sensor node (location + calibration) | +| `TimeWindow` | A bounded interval used for grouping and replay | +| `Anomaly` | A scored deviation from baseline | + +### Edges + +| Edge type | Meaning | +|-----------|---------| +| `observed_by` | Entity ← sensor/observer that produced the observation | +| `near` | Spatial proximity in the observer frame | +| `during` | Membership in a TimeWindow | +| `correlated_with` | Cross-signal correlation (e.g. audio ↔ aircraft) | +| `caused_candidate` | Hypothesized causal link (never asserted as fact) | +| `part_of_track` | Observation → stitched Track | +| `similar_to` | Vector-similarity link between embeddings | +| `anomalous_relative_to` | Anomaly → the baseline it deviates from | + +## 13. RuVector Usage + +RuVector provides the semantic memory layer: + +- **Similarity search** — "find tracks like this one." +- **Anomaly detection** — distance from local historical neighborhoods. +- **Explanatory retrieval** — pull the precedents the assistant cites. +- **Compression** — condense time windows into semantic memory summaries. +- **Contrastive separation** — keep normal vs unusual well-separated in + embedding space. + +Example embeddings: + +| Embedding | Encodes | +|-----------|---------| +| Aircraft-track | Path shape, speed profile, altitude profile, time of day, route class | +| Weather-window | Radar/precip/wind state over a window | +| Audio-event | Spectral signature of an acoustic event | +| RF-event | Spectrum shape, power, recurrence | +| Scene | Fused snapshot of the sky state in a window | + +### Mapping to actual crates in this repository + +| Role | Crate | Notes | +|------|-------|-------| +| Vector store + ANN search | `crates/ruvector-core` | `VectorDB`, HNSW index, `DistanceMetric` | +| SkyGraph storage | `crates/ruvector-graph` | `GraphDB` property graph with a Cypher subset for node/edge queries | +| Browser-side search | `crates/ruvector-wasm`, `crates/micro-hnsw-wasm` | Dashboard-local similarity without round trips | +| Future intelligence plane | `crates/ruvector-attention`, `crates/ruvector-gnn` | Attention over windows; GNN reasoning over the SkyGraph | + +## 14. Rule Layer + +Rules give **auditability**; pattern learning gives **flexibility**. Use both — +rules gate actions and produce explainable triggers; learned similarity ranks +and contextualizes. Example rules: + +1. **Overhead candidate**: range < 10 km AND elevation > 5° → mark + `overhead_candidate`. +2. **Audio ↔ aircraft correlation**: audio event within 30 s of an aircraft + closest approach AND aircraft altitude < 3000 m → create `correlated_with` + edge. +3. **Weather suppression**: active weather alert → suppress weak audio + anomalies (storm noise floor). +4. **Track deviation**: track deviates > 3σ from its historical corridor → + create `Anomaly` candidate. +5. **Recurring RF**: an uncorrelated RF event that recurs → escalate for + review. + +## 15. Anomaly Scoring + +Composite score: + +``` +anomaly_score = 0.30 * route_deviation + + 0.20 * altitude_deviation + + 0.15 * time_of_day_rarity + + 0.15 * signal_unusualness + + 0.10 * cross_sensor_confirmation + + 0.10 * novelty_score +``` + +Interpretation bands: + +| Score | Interpretation | Action | +|-------|----------------|--------| +| 0.00–0.30 | Normal | Store | +| 0.31–0.55 | Mildly unusual | Timeline marker | +| 0.56–0.75 | Interesting | Include in summary | +| 0.76–0.90 | Strong anomaly | Local alert | +| 0.91–1.00 | Rare | Preserve raw data + generate report | + +## 16. Privacy and Governance + +- No face recognition. No person identification. No license-plate reading. +- Local storage by default; configurable retention per data class. +- Camera redaction (when camera is enabled at all — disabled by default). +- Maintained **sensor inventory** — every active sensor is declared. +- Query and export **audit log**. +- **Location generalization** on any shared output. +- **Synthetic or delayed data** for public demos. + +## 17. Security + +### Threats + +API key leakage; poisoned external data (OpenSky/weather); sensor spoofing +(fake ADS-B); untrusted network access; location-history inference; event-history +tampering; malicious dashboard access. + +### Controls + +- Local-only admin network. +- Read-only sensor containers. +- Signed event batches. +- Hash-chained raw archives (tamper-evident, cf. the witness chain in ADR-198). +- Public vs admin dashboard separation. +- No cloud sync by default. +- Secrets kept outside the repository. +- Assistant rate limits. +- RBAC for export and delete operations. +- Daily integrity check over the hash chains. + +## 18. Storage Design + +| Store | Contents | Retention | +|-------|----------|-----------| +| Raw archive | dump1090 frames, audio clips, IQ snippets | 7–30 d normal; 180 d interesting; manual hold for rare events | +| Time series | Aircraft count, message rate, signal strength, wind, precipitation, RF power, audio amplitude | Long-lived, compact | +| Graph store | SkyGraph nodes + edges | Long-lived | +| RuVector | Embeddings + semantic summaries | Long-lived | +| Reports | Daily briefs, anomaly reports | Long-lived | + +## 19. Services + +| Service | Responsibilities | +|---------|------------------| +| **adsb-collector** | Poll dump1090 JSON; decode state vectors; emit raw + normalized observations; fall back to OpenSky; tag source and confidence | +| **weather-collector** | Poll MSC GeoMet; normalize radar/precip/wind/alerts into WeatherCell observations; align frames to local time windows | +| **projection-engine** | WGS-84 → ECEF → ENU → az/el/range conversion; calibration profiles; apparent screen positions for dashboard/projector | +| **skygraph-builder** | Track stitching; entity resolution; node/edge creation; rule-layer evaluation (§14); TimeWindow management | +| **ruvector-indexer** | Compute embeddings; insert into `ruvector-core` `VectorDB`; maintain similarity/novelty links; window compression into semantic memory | +| **assistant-service** | NL query over SkyGraph + RuVector; answer with cited observation ids; enforce governance rules (§27); rate-limited | + +## 20. APIs + +``` +GET /v1/sky/events?start={iso}&end={iso} # observations in a window +GET /v1/sky/aircraft/overhead # current overhead candidates +GET /v1/sky/anomalies # scored anomalies +GET /v1/sky/replay/{window_id} # replay a stored window +POST /v1/sky/query # natural-language assistant +``` + +Example assistant exchange: + +```json +// POST /v1/sky/query +{ "question": "What flew over the house around 9 pm?" } +``` + +```json +{ + "answer": "One aircraft passed overhead at 21:14 local: an eastbound track at unusually low altitude (1180 m). A loud audio event 11 seconds after closest approach is correlated with this track. Weather was calm with no precipitation.", + "cited_observations": ["aircraft_track_123", "audio_event_456", "weather_window_789"], + "confidence": 0.84 +} +``` + +## 21. UX + +### Live mode + +Aircraft dots with callsigns, altitude, and direction; motion trails; weather +overlay; alert badge; RF activity indicator; audio event markers. + +### Replay mode + +Scrub any stored TimeWindow; trails and correlations replay in observer frame. + +### Daily brief (sample — Oakville node) + +> **Sky brief — Oakville, 2026-06-09.** 812 aircraft observed; 37 overhead +> candidates; 4 unusual tracks. Light rain 14:10–15:30. 2 RF anomalies. Most +> unusual event: low-altitude eastbound pass at 21:14 (confidence 0.78). + +## 22. Build Plan + +| Phase | Scope | Inputs | Outputs | Acceptance | +|-------|-------|--------|---------|------------| +| **1** | ADS-B sky display | dump1090 JSON (OpenSky fallback) | Live observer-relative display | Aircraft appear within 5 s of decode; azimuth within 10°; 24 h uptime | +| **2** | Weather context | MSC GeoMet | Weather overlay + alert suppression context | Radar overlay aligned in time and frame with aircraft | +| **3** | SkyGraph | Normalized observations | Graph store + rule layer | Query by time window; query "near observer"; explain an unusual track | +| **4** | RuVector memory | SkyGraph + embeddings | Similarity, novelty, assistant | Similar tracks plausible; novelty separates commercial routes from unusual paths; assistant cites event ids | +| **5** | Audio / RF / camera | Optional sensors | Cross-signal correlation | Correlated_with edges with confidence; governance controls active | + +## 23. Technology Choices + +| Concern | Choice | Notes | +|---------|--------|-------| +| Collectors | Rust | Reliability, low footprint on SBC | +| UI rendering | WebGL / Canvas | Browser-native sky overlay | +| Local API | Axum | Rust HTTP service | +| Time series | SQLite / DuckDB | Embedded, zero-ops | +| Graph | SQLite tables first, then graph engine | In this repo: `crates/ruvector-graph` (`GraphDB`) | +| Vectors | RuVector | `crates/ruvector-core` (`VectorDB`, HNSW) | +| Messaging | NATS or local channels | Local channels for v1 | +| Containers | Podman / Docker | Read-only sensor containers | +| Display | Browser kiosk over HDMI | Projector and monitor both | + +## 24. Decision Matrix + +Scores 1 = weak … 5 = strong. + +| Option | Cost | Latency | Control | Complexity | Strategic fit | +|--------|------|---------|---------|------------|---------------| +| OpenSky only | 5 | 3 | 2 | 5 | 3 | +| Local ADS-B only | 4 | 5 | 5 | 3 | 4 | +| Local ADS-B + OpenSky | 4 | 5 | 5 | 4 | 5 | +| Full sensor fusion day one | 2 | 4 | 5 | 1 | 2 | +| **Phased SkyGraph appliance** | **5** | **5** | **5** | **4** | **5** | + +**Decision: phased SkyGraph appliance — local ADS-B first, OpenSky as +fallback.** + +## 25. Key Tradeoffs + +| Tradeoff | Resolution | +|----------|------------| +| Local receiver vs API | Both; **local is the source of truth**, API is bootstrap/fallback/QA | +| Graph-first vs vector-first | Graph for facts and provenance; RuVector for similarity, novelty, and memory | +| Projector vs dashboard | **Dashboard first**; projector is a calibrated presentation mode on top | +| Edge-only vs cloud-assisted | **Edge first**; cloud strictly optional | + +## 26. Failure Modes + +| Failure mode | Mitigation | +|--------------|------------| +| Wrong sky position on display | Calibration wizard (north, horizon, FoV, anchor point) | +| Missing aircraft | Antenna placement review + OpenSky comparison for coverage QA | +| False anomalies | Require ≥ 14 days of baseline before alerting | +| Weather overlay mismatch | Normalize time and coordinate frames across sources | +| Audio false positives | Weather and time-of-day filters | +| RF noise overload | Event compression + thresholds | +| Privacy concern raised | Disable person-related analysis; redact; camera off by default | +| Storage growth | Tiered retention (§18) | + +## 27. Governance Rules + +1. Every insight links back to raw and normalized observations. +2. Every anomaly includes a stated reason. +3. Assistant answers distinguish **fact**, **inference**, and **uncertainty**. +4. Every sensor appears in the sensor inventory. +5. Every external API call is logged. +6. Every retained camera/audio event has an explicit retention policy. +7. Exported reports remove precise location unless explicitly enabled. + +## 28. Example Event Explanation + +The 21:14 loud-aircraft event, as the assistant should explain it: + +> An aircraft was 7.8 km east at 1180 m altitude (8.4° elevation). The audio +> event occurred 11 seconds after closest approach. Weather was calm — no +> thunder. No RF anomaly in the window. **Conclusion: likely aircraft related +> (confidence 0.82).** Uncertainty: aircraft type unknown; no camera +> confirmation available. + +This is the product bar: cited evidence, stated mechanism, explicit +uncertainty — not a dot on a map. + +## 29. Repository Layout + +A reference implementation exists in this repository at +`examples/sky-monitor/`: + +- a SkyGraph **core pipeline** crate (collectors → normalization → graph → + anomaly scoring), +- a **WASM projection engine** (WGS-84 → ECEF → ENU → az/el/range), +- a **Canvas dashboard** (observer-relative live + replay views), +- **Criterion benches** for the projection and indexing hot paths. + +It implements **Phases 1–4 with synthetic ADS-B data**, storing vectors in +`crates/ruvector-core` and the SkyGraph in `crates/ruvector-graph`. Phase 5 +sensors (audio/RF/camera) and live dump1090 ingestion are the appliance +deployment steps on top of it. + +## 30. Configuration Sketch + +```toml +[observer] +name = "oakville_node" +latitude = 43.4675 +longitude = -79.6877 +altitude_m = 100 +[adsb] +mode = "local_plus_opensky" +dump1090_url = "http://localhost:8080/data/aircraft.json" +opensky_enabled = true +[weather] +provider = "msc_geomet" +country = "CA" +[projection] +mode = "dashboard_first" +field_of_view_deg = 90 +north_offset_deg = 0 +[privacy] +camera_enabled = false +audio_retention_days = 7 +raw_retention_days = 14 +precise_location_export = false +[anomaly] +min_history_days = 14 +alert_threshold = 0.76 +``` + +## 31. Acceptance Tests + +### System acceptance + +1. Receive local ADS-B messages. +2. Convert state vectors to azimuth/elevation/range. +3. Display aircraft observer-relative. +4. Store raw + normalized observations. +5. Replay a 30-minute window. +6. Answer "what flew overhead" for a given period. +7. Generate a daily brief. +8. Flag unusual tracks after the baseline period. +9. Cite observations in every assistant answer. +10. Run fully without cloud connectivity. + +### Business value acceptance + +1. A demo is understandable to a non-technical viewer in under 60 seconds. +2. It feels different from a flight tracker. +3. The assistant **explains**, not merely displays. +4. The architecture extends to elder care, event safety, municipal sensing, + and Arista-style edge deployments. +5. The primitives are reusable for RuView ambient intelligence. + +## 32. Open Questions (with recommendations) + +| Question | Recommendation | +|----------|----------------| +| Browser-first UI? | **Yes** — kiosk browser covers monitor and projector | +| Camera/audio in v1? | **No** — Phase 5; privacy posture first | +| Cognitum One edge demo? | **Yes** — natural showcase deployment | +| Is RuVector required in v1? | **Yes** for summaries/similarity; **not** for basic display | +| Arista integration? | **Separate ADR** | + +## 33. Consequences + +### Positive + +- A concrete RuView field appliance — hardware + software + governance. +- Local intelligence that goes beyond chat: it perceives, remembers, explains. +- RuVector exercised on real-world temporal sensing data. +- A strong ambient-AI demonstration. +- Reusable primitives: observation schema, projection engine, SkyGraph, + anomaly scoring, governed assistant. + +### Negative + +- Sensor fusion complexity grows with each phase. +- Projector calibration friction. +- Raw data growth pressure on edge storage. +- False anomalies during the baseline period. +- External API coverage limits (OpenSky rate limits, GeoMet geography). + +### Mitigation (phased path) + +Aircraft + weather → graph → RuVector → projector → audio/RF/camera. Each phase +is independently useful and independently demonstrable. + +## 34. Final Recommendation + +Build this as the **RuView SkyGraph Appliance**. Do not position it as a flight +tracker; position it as **local intelligence for the atmosphere above you**: + +> **See the sky. Remember the sky. Explain the sky.** + +It is a real-world testbed for edge AI, vector memory, sensor fusion, anomaly +detection, and governed local agents. diff --git a/examples/sky-monitor/BENCHMARKS.md b/examples/sky-monitor/BENCHMARKS.md new file mode 100644 index 0000000000..3b170a9732 --- /dev/null +++ b/examples/sky-monitor/BENCHMARKS.md @@ -0,0 +1,118 @@ +# sky-monitor benchmarks and validation + +Criterion results for the ADR-199 SkyGraph appliance example +(`cargo bench -p sky-monitor`), plus the mapping from the integration +test suite to the ADR-199 acceptance criteria. + +## Environment + +| | | +|---|---| +| CPU | Intel(R) Xeon(R) Processor @ 2.80 GHz (4 cores, virtualized CI container) | +| rustc | 1.94.1 | +| harness | criterion (workspace version), `--release` | +| scenario | deterministic synthetic day: 10 aircraft, 2,820 observations, seed 42 | + +Numbers below are criterion midpoints. The container is shared, so +treat ±5% as noise. + +## Results + +| Benchmark | Baseline | After tuning | Delta | +|---|---|---|---| +| `coords/observer_frame_single` (WGS-84 → az/el/range/bearing) | 149.0 ns | 131.5 ns | **−12%** | +| `coords/observer_frame_batch/10k_targets` | 1.458 ms (~146 ns/target) | 1.291 ms (~129 ns/target) | **−10%** (criterion-confirmed, p < 0.05) | +| `embedding/track_embedding` (32-dim, ~280-point track) | 21.77 µs | 20.83 µs | −4% | +| `ruvector/insert_1000_then_search` (VectorDB, euclidean flat) | 4.23 ms | 4.51 ms | noise (untouched code) | +| `anomaly/score_track_full` (full §15 composite) | 151.6 µs | 161.8 µs | see note | +| `pipeline/end_to_end_standard_scenario` | 6.57 ms | 7.17 ms | see note | + +### Optimizations applied + +- **`coords::observer_frame`** — inlined the + `geodetic_to_ecef → ecef_to_enu → initial_bearing_deg` composition so + each `sin`/`cos` is computed exactly once via `sin_cos()` (the helper + composition recomputed observer trig three times and target trig + twice). The public helpers are unchanged and the geometry unit tests + pin the math. +- **`embedding::track_embedding`** — single pass over the point series + accumulating all per-point statistics at once, instead of one full + iteration per feature (~14 walks) plus a temporary `Vec` of + altitudes. + +### Note on the anomaly/pipeline rows + +Between the two runs, the anomaly module gained the `TrackSummary` +adapter (`BaselineStats::from_summaries` / `score_summary`) so the +**native and WASM scorers share one exact code path** (the +`sky-monitor-wasm` `AnomalyScorer` calls the same scorer the appliance +uses). The ~10 µs/track delta is the cost of that indirection plus run +noise; it was accepted deliberately — scoring parity between the +appliance and the browser dashboard is worth more than 10 µs on a path +that runs once per completed track. + +## Real-time headroom (ADR-199 §22 Phase 1 acceptance: aircraft visible within 5 s of decode) + +| Budget | Measured | Headroom at 1 Hz update | +|---|---|---| +| Projection per aircraft | ~129 ns | ~7.7 M projections/s/core → tens of thousands of aircraft trivially; a busy sky (500 aircraft) costs ~65 µs/frame | +| Track embedding | ~21 µs per completed track | embeddings are per-track, not per-frame | +| RuVector index, 1,000 tracks | 4.5 ms to insert 1,000 + search | similarity/novelty queries run per completed track, far below 1 Hz budget | +| Anomaly composite score | ~162 µs per track | ~6,000 tracks/s sustainable | +| Whole synthetic day (ingest → tracks → index → score → graph → brief) | ~7 ms | a full day of data replays ~12,000,000× faster than real time | + +Conclusion: on appliance-class hardware (Pi 5 is slower than this Xeon +but same order), the pipeline is **not** compute-bound; the binding +constraints are radio reception and storage, exactly as ADR-199 §26 +anticipates. + +## ADR-199 acceptance mapping (`tests/acceptance.rs`, 8/8 passing) + +| Test | Proves (ADR-199 §31 / §22) | +|---|---| +| `acceptance_1_positions_convert_to_az_el_range` | §31.2 — positions convert to azimuth/elevation/range (§22 Phase 1: azimuth within 10°) | +| `acceptance_2_overhead_query_excludes_en_route` | §31.6 — "what flew overhead" returns the low pass, not 10 km cruisers (§14 rule 1) | +| `acceptance_3_aircraft_by_time_window` | §31.5/§22 Phase 3 — SkyGraph query returns aircraft by time window | +| `acceptance_4_anomaly_scoring_separates_corridor_traffic` | §31.8/§22 Phase 4 — anomalous track ≥ 0.76 alert threshold, corridor traffic ≤ 0.55 with baseline history | +| `acceptance_5_explain_cites_observation_ids` | §31.9/§27 rule 1 — explanations cite underlying observation ids | +| `acceptance_6_similarity_prefers_same_corridor` | §22 Phase 4 — RuVector similar-track search returns plausible prior matches | +| `acceptance_7_brief_renders_with_counts` | §31.7 — daily sky brief renders with non-zero counts | +| `acceptance_8_dump1090_parser` | §31.1 path — dump1090 `aircraft.json` parses into the canonical pipeline | + +Plus 19 unit tests (geometry, schema round-trip, stitching, embeddings, +scoring bands, graph queries) and 5 native tests in `sky-monitor-wasm` +(polar screen mapping). Full suite: **32/32 green**, `clippy` clean for +both crates, `wasm32-unknown-unknown` builds clean (debug + release). + +## WASM functional verification + +`wasm-pack build --target web` produces a 150 KB `sky_monitor_wasm_bg.wasm` +(with `wasm-opt = false`; smaller with binaryen available). The module was +exercised end-to-end in Node against a `--target nodejs` build: + +- projection: observer at Oakville, target 10 km due north at 1,000 m → + azimuth 0.00°, elevation 5.10°, range 10,029 m (matches native `coords`) +- screen mapping: zenith → canvas center, below-horizon invisible +- `project_batch`: `[lat,lon,alt]×N → [az,el,range,bearing]×N` shape verified +- anomaly parity: with an 8-track corridor baseline, the low off-corridor + night track scores **0.900 (strong anomaly)** with cited reasons while a + corridor flight scores 0.055 (normal) — identical code path to the native + scorer via `TrackSummary` +- `parse_dump1090_json` parses a live-format `aircraft.json` payload + +## Recommendations not applied (file-ownership / scope) + +- `pipeline.rs` re-stitches tracks once per baseline pass; caching the + stitched tracks between the baseline split and scoring would shave + ~1 ms off the end-to-end run. Low value at current scale. +- The flat (non-HNSW) VectorDB index is fine for ≤10⁴ tracks; switching + the indexer to the HNSW index in `ruvector-core` is the right move + when local history exceeds that (ADR-199 §18.4 retention makes this + unlikely for a single observer). + +Reproduce with: + +```bash +cargo bench -p sky-monitor # full criterion suite +cargo bench -p sky-monitor -- --test # fast smoke mode +``` diff --git a/examples/sky-monitor/Cargo.toml b/examples/sky-monitor/Cargo.toml new file mode 100644 index 0000000000..cb289f5cf2 --- /dev/null +++ b/examples/sky-monitor/Cargo.toml @@ -0,0 +1,54 @@ +[package] +name = "sky-monitor" +version = "0.1.0" +edition = "2021" +publish = false +license = "MIT" +description = "RuView SkyGraph Appliance core (ADR-199 Phases 1-4): synthetic ADS-B -> observer-relative sky coordinates -> track stitching -> SkyGraph -> embeddings -> anomaly scoring -> daily sky brief" + +[features] +# `appliance` (default, native-only): pulls in the heavy stores — ruvector-core +# (VectorDB) and ruvector-graph (GraphDB) — and the modules built on them: +# `indexer`, `skygraph`, `pipeline` (plus the demo binary, the acceptance +# tests, and the benches). With `--no-default-features` the remaining pure +# modules (config, coords, observation, adsb, track, weather, embedding, +# anomaly, brief) compile for wasm32-unknown-unknown; that subset is what the +# `sky-monitor-wasm` browser crate (ADR-199 presentation plane) builds on. +default = ["appliance"] +appliance = ["dep:ruvector-core", "dep:ruvector-graph"] + +[dependencies] +# Vector memory (ADR-199 §13). default-features off: no on-disk storage / HTTP +# embedding clients are needed here — the in-memory backend keeps the demo +# hermetic. `simd` + `parallel` match what ruvector-graph already requires. +ruvector-core = { path = "../../crates/ruvector-core", default-features = false, features = ["simd", "parallel"], optional = true } +# SkyGraph storage (ADR-199 §12): property-graph nodes/edges with label and +# property indexes. Default features (in-memory GraphDB::new is used; the redb +# storage backend stays unused). +ruvector-graph = { path = "../../crates/ruvector-graph", optional = true } + +serde = { workspace = true } +serde_json = { workspace = true } +chrono = { workspace = true } +uuid = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +criterion = { workspace = true } + +[lib] +bench = false + +[[bin]] +name = "sky-monitor" +path = "src/main.rs" +required-features = ["appliance"] + +[[test]] +name = "acceptance" +required-features = ["appliance"] + +[[bench]] +name = "sky_bench" +harness = false +required-features = ["appliance"] diff --git a/examples/sky-monitor/README.md b/examples/sky-monitor/README.md new file mode 100644 index 0000000000..d9d889ce33 --- /dev/null +++ b/examples/sky-monitor/README.md @@ -0,0 +1,196 @@ +# sky-monitor — RuView SkyGraph Appliance core (ADR-199, Phases 1–4) + +> **See the sky. Remember the sky. Explain the sky.** + +A local sky-monitoring appliance core that observes, projects, records, and +explains activity above a fixed observer (the reference *Oakville node*, +43.4675 N / −79.6877 E / 100 m). The sky is treated as a continuously changing +spatial graph, not a dashboard. + +Everything runs on a **deterministic synthetic ADS-B + weather scenario** — +no network, no SDR hardware — while a `dump1090` `aircraft.json` parser keeps +the door open for real RTL-SDR data. Vectors live in +[`ruvector-core`](../../crates/ruvector-core) (`VectorDB`), the SkyGraph in +[`ruvector-graph`](../../crates/ruvector-graph) (`GraphDB`). + +## Module map → ADR-199 sections + +| Module | ADR-199 | What it does | +|--------|---------|--------------| +| `config` | §30 | `ObserverConfig` (Oakville node defaults), `AnomalyConfig` (§15 weights, 0.76 alert threshold, baseline `min_history`) | +| `coords` | §10 | WGS-84 → ECEF → ENU → azimuth/elevation/range/bearing (`ObserverFrame`), pure `f64` | +| `observation` | §11 | Canonical observation schema (uuid, UTC time, entity, location, observer frame, motion, attributes, confidence, `raw_ref`/`embedding_ref`) | +| `adsb` | §9.1, Phase 1 | Seeded synthetic scenario (corridor / arrivals / GA overhead / one anomalous night track) + `parse_dump1090` for real data | +| `track` | §19 skygraph-builder | Gap-based track stitching, summary stats, §14 rule 1 overhead candidates | +| `weather` | §9.3, Phase 2 | Synthetic hourly `WeatherWindow`s aligned to the timeline (incl. the sample-brief rain band) | +| `embedding` | §13 | Deterministic 32-dim track embeddings + 8-dim weather embeddings (separate collections), every dimension documented | +| `indexer` | §19 ruvector-indexer, Phase 4 | `VectorDB` wrapper: similarity search + calibrated novelty score | +| `skygraph` | §12, Phase 3 | `GraphDB` nodes (Observer/Aircraft/Track/Observation/WeatherCell/TimeWindow/Anomaly) + §12 edge vocabulary; time-window / overhead queries; citeable `explain()` | +| `anomaly` | §15 | Exact composite formula, interpretation bands, mandatory reasons (§27 rule 2) | +| `brief` | §21.3 | Daily sky brief with `Display` text block | +| `pipeline` | §22 | One `Pipeline::run()` shared by demo, tests, and benches | + +## Run + +```bash +# demo (from the repository root) +cargo run -p sky-monitor --release + +# demo + JSON export of the synthetic day (writes `const SKY_DATA = {...};` for .js paths) +cargo run -p sky-monitor --release -- --emit-json target/sky-demo-data.js + +# acceptance + unit tests (mapped to ADR-199 §31 / §22) +cargo test -p sky-monitor + +# criterion benches (projection, embedding, VectorDB, anomaly, end-to-end) +cargo bench -p sky-monitor # full run +cargo bench -p sky-monitor -- --test # smoke mode +``` + +## Feature flags + +| Feature | Default | Contents | +|---------|---------|----------| +| `appliance` | **yes** | The heavy native stores — `ruvector-core` (VectorDB) and `ruvector-graph` (GraphDB) — and the modules built on them: `indexer`, `skygraph`, `pipeline`, plus the demo binary, the acceptance tests, and the benches | +| *(none)* | — | `--no-default-features` leaves the pure subset: `config`, `coords`, `observation`, `adsb`, `track`, `weather`, `embedding`, `anomaly`, `brief` — this compiles for `wasm32-unknown-unknown` and is what `sky-monitor-wasm` builds on | + +```bash +# verify the wasm-compatible subset +cargo build -p sky-monitor --no-default-features --target wasm32-unknown-unknown +``` + +## WASM projection engine (`wasm/` → `sky-monitor-wasm`) + +Browser-facing bindings for the ADR-199 presentation plane ("dashboard +first"), wrapping the pure subset with `wasm-bindgen`: + +* `SkyProjector` — §10 WGS-84 → az/el/range/bearing projection, single + (`project`) and batched (`project_batch`, `Float64Array` of `[lat,lon,alt]` + triplets → `[az,el,range,bearing]` quadruplets for fast trail rendering), + plus `screen_position` — the polar "fisheye" all-sky mapping (zenith at the + canvas centre, horizon at the edge, azimuth 0° = North = up). +* `AnomalyScorer` — `baseline_from(tracksJson)` + `score(trackJson, novelty)` + over track summaries (`{icao24, callsign, mean_alt_m, dominant_heading_deg, + start_hour, mean_signal_dbfs, min_range_m, max_elevation_deg}`), reusing the + exact core §15 scorer (`anomaly::BaselineStats::from_summaries` / + `anomaly::score_summary`) so browser scores match the native pipeline. +* `SatPropagator` — SGP4 satellite propagation from TLEs (`sgp4` crate): + TEME → GMST-rotated ECEF → geodetic (Bowring) → the same §10 observer + frame, batched per frame for the dashboard's satellite layer; plus + `predict_passes(start, hours, step)` — a 24 h pass timeline (rise / + culmination / set / max elevation per pass, with a low-precision Rust sun + model flagging naked-eye-visible passes: sunlit satellite, sun < −6°). +* `embed_track` / `novelty` (`embed.rs`) — the §13 32-dim track embedding + from live points (per-point motion derived by finite differences, then the + canonical `embedding::track_embedding_from_samples`) and the §15 + vector-novelty score with the native indexer calibration + (mean top-3 distance / 1.2, neutral 0.5 with no priors). +* `parse_dump1090_json` — the core dump1090 parser for live feeds, and + `band_for(score)` / `version()` helpers. + +```bash +cargo test -p sky-monitor-wasm # native unit tests (screen mapping) +cargo build -p sky-monitor-wasm --target wasm32-unknown-unknown --release +wasm-pack build examples/sky-monitor/wasm --target web --out-dir ../ui/dashboard/pkg # for the dashboard +``` + +## Canvas dashboard (`ui/dashboard/`) + +Vanilla JS + Canvas, no build tooling (see `ui/dashboard/README.md`): a +**realtime** all-sky polar plot (elevation rings at 0/30/60°, compass labels) +showing live ADS-B aircraft (airplanes.live primary, adsb.lol fallback; +key-free, CORS-friendly) as labelled dots with fading trails and smoothed +dead-reckoned motion between polls, Open-Meteo weather + NOAA SWPC Kp in the +weather card, and a side panel with the live track table, per-track details, +and a 24 h naked-eye satellite **pass timeline** (wasm `predict_passes`, +optional Notification alerts). A satellite layer (CelesTrak `visual` / +`stations` / `starlink` TLEs + wasm SGP4) draws satellites as diamonds — +flagging sunlit-against-dark-sky passes ✦ — with an experimental **WebGPU** +instanced-sprite path (drawer toggle, automatic Canvas2D fallback). Live +tracks are scored through the wasm §15 `AnomalyScorer` with **real §13 +vector novelty** (wasm `embed_track` + IndexedDB rolling store of past track +embeddings), annotated with **behavior badges** (holding / survey grid / +go-around / formation) and pairwise **CPA conflict prediction** (< 1 km & +< 300 m within 90 s → dashed alert line + predicted-path cone), and enriched +with readsb metadata plus **adsbdb routes** (airline, origin → destination, +on selection, 24 h cache). A footer scrubber **replays the last hour of +recorded real traffic** from an IndexedDB ring buffer (no synthetic data). +There is no embedded scenario; offline, the dome stays up with a retrying +status line. Projection runs in JS by default and switches to +`sky-monitor-wasm` automatically when the wasm-pack output is present at +`ui/dashboard/pkg/` (satellites, scoring, novelty, and passes require it). + +```bash +cd examples/sky-monitor/ui/dashboard && python3 -m http.server 8000 +# open http://localhost:8000/ +``` + +## Sample output (trimmed) + +```text +RuView SkyGraph Appliance — synthetic demo (ADR-199 Phases 1-4) +Observer: oakville_node (43.4675, -79.6877, 100 m) | seed 42 | 2820 observations + +== Tracks (observer-relative at closest approach) == +track call range_km az_deg el_deg alt_m hdg speed_mps overhead +track-c01a01-0 ACA101 13.2 162 52.6 10600 72 236 +track-c07e07-0 JZA707 7.0 121 30.6 3679 32 145 yes +track-c0a9a9-0 CGSKY 1.1 187 67.8 1100 88 62 yes +track-deadbf-0 - 2.0 254 9.9 450 165 48 yes +... + +== SkyGraph == +nodes: 109 edges: 111 +overhead candidates: ["track-c07e07-0", "track-c08f08-0", "track-c0a9a9-0", "track-deadbf-0"] + +== Top similar-track pairs (RuVector, euclidean) == + track-a03c03-0 <-> track-c01a01-0 distance 0.378 + track-c01a01-0 <-> track-c04d04-0 distance 0.448 + +== Anomaly scores (ADR-199 §15) == +track call score band reasons +track-c04d04-0 WJA404 0.165 normal within normal envelope: heading 73°, ... +track-c0a9a9-0 CGSKY 0.570 interesting mean altitude 1100 m deviates 2.9σ ... +track-deadbf-0 - 0.860 strong anomaly heading 165° is 77° off the nearest known + corridor | mean altitude 450 m deviates + 2.0σ | start time 03:xx UTC has 0 prior + tracks within ±2 h | signal -3.0 dBFS is + 3.3σ | vector novelty 1.00 | no callsign + +== Explain track-deadbf-0 (strong anomaly, action: local alert) == + - track track-deadbf-0 stitched from 420 observations; evidence observation ids: + first 1964af06-..., closest approach 6a29bba3-..., last 46473de0-... + - geometry: closest approach 2034 m at azimuth 254°, max elevation 10.2°, ... + - near observer:oakville_node (closest approach inside 10 km) + - during window:2026-06-09T03 + - anomalous_relative_to baseline track-c0a9a9-0 + - correlated_with weather:2026-06-09T03 (clear, wind 2.8 m/s) + +== Daily sky brief (ADR-199 §21.3) == +Sky brief — oakville_node, 2026-06-08. 10 aircraft observed; 4 overhead candidates; +2 unusual tracks. Weather: rain 14:00–16:00 UTC. Most unusual event: low-altitude +pass by icao24 deadbf heading 165° at 03:13 UTC (450 m): heading 165° is 77° off +the nearest known corridor (confidence 0.86). +``` + +## Synthetic scenario + +One day over the observer, seed-deterministic (`Pipeline::default()`, seed 42): + +* 4 eastbound + 2 westbound **en-route corridor** flights (~072°/252°, + 10.5–11.2 km, ~230 m/s), +* 2 **arrivals** descending through the area (~032°, 4.8 km → 2.6 km), +* 1 low **general-aviation overhead pass** (1.1 km, within 1.1 km slant range), +* 1 **anomalous track**: 450 m, 48 m/s, heading 165° (off-corridor), 03:10 UTC + the following night, unusually strong signal, no callsign — scores **0.86 → + strong anomaly → local alert**, while scored corridor flights stay ≤ 0.23. + +The first `min_history` (5) tracks form the unscored baseline; later tracks are +scored against strictly prior tracks (ADR §26: baseline before alerting). + +## What is deliberately out of scope here + +Phase 5 sensors (audio/RF/camera — `cross_sensor_confirmation` is a documented +placeholder at 0), live dump1090/OpenSky ingestion (the parser and the +browser-side `parse_dump1090_json` exist, but nothing polls a receiver), +retention / hash-chained raw archive, and the NL assistant service. diff --git a/examples/sky-monitor/benches/sky_bench.rs b/examples/sky-monitor/benches/sky_bench.rs new file mode 100644 index 0000000000..66f571b078 --- /dev/null +++ b/examples/sky-monitor/benches/sky_bench.rs @@ -0,0 +1,123 @@ +//! Criterion benches for the ADR-199 hot paths (§29): coordinate projection, +//! track embedding, RuVector insert+search, anomaly scoring, and the full +//! synthetic pipeline. + +use criterion::{black_box, criterion_group, criterion_main, Criterion, Throughput}; +use sky_monitor::{ + anomaly::{score_track, BaselineStats}, + embedding::{track_embedding, TRACK_EMBEDDING_DIM}, + indexer::TrackIndexer, + observer_frame, AnomalyConfig, ObserverConfig, Pipeline, +}; + +fn bench_projection(c: &mut Criterion) { + let cfg = ObserverConfig::default(); + c.bench_function("coords/observer_frame_single", |b| { + b.iter(|| { + observer_frame( + black_box(cfg.lat), + black_box(cfg.lon), + black_box(cfg.alt_m), + black_box(43.62), + black_box(-79.40), + black_box(10_500.0), + ) + }) + }); + + // 10k-target batch (a busy wide-area sweep). + let targets: Vec<(f64, f64, f64)> = (0..10_000) + .map(|i| { + let t = i as f64; + ( + 43.0 + (t * 0.731).fract(), + -80.5 + (t * 0.377).fract() * 2.0, + 500.0 + (t * 13.7) % 11_000.0, + ) + }) + .collect(); + let mut g = c.benchmark_group("coords/observer_frame_batch"); + g.throughput(Throughput::Elements(targets.len() as u64)); + g.bench_function("10k_targets", |b| { + b.iter(|| { + targets + .iter() + .map(|(la, lo, al)| { + observer_frame(cfg.lat, cfg.lon, cfg.alt_m, *la, *lo, *al).range_m + }) + .sum::() + }) + }); + g.finish(); +} + +fn bench_embedding(c: &mut Criterion) { + let (tracks, _) = Pipeline::default().tracks_and_embeddings().unwrap(); + let track = tracks.iter().max_by_key(|t| t.points.len()).unwrap(); + c.bench_function("embedding/track_embedding", |b| { + b.iter(|| track_embedding(black_box(track))) + }); +} + +fn bench_vector_db(c: &mut Criterion) { + let (tracks, embeddings) = Pipeline::default().tracks_and_embeddings().unwrap(); + // 1000 jittered variants of the real embeddings. + let corpus: Vec> = (0..1_000) + .map(|i| { + let base = &embeddings[i % embeddings.len()]; + base.iter() + .enumerate() + .map(|(d, v)| v + ((i * 31 + d) % 17) as f32 * 1e-3) + .collect() + }) + .collect(); + c.bench_function("ruvector/insert_1000_then_search", |b| { + b.iter(|| { + let mut idx = TrackIndexer::new(TRACK_EMBEDDING_DIM).unwrap(); + for (i, e) in corpus.iter().enumerate() { + let mut t = tracks[i % tracks.len()].clone(); + t.track_id = format!("bench-{i}"); + idx.insert_track(&t, e.clone()).unwrap(); + } + idx.similar_tracks(black_box(&embeddings[0]), None, 10) + .unwrap() + }) + }); +} + +fn bench_anomaly(c: &mut Criterion) { + let (tracks, embeddings) = Pipeline::default().tracks_and_embeddings().unwrap(); + let cfg = AnomalyConfig::default(); + let baseline = BaselineStats::from_tracks(&tracks[..tracks.len() - 1]); + let mut idx = TrackIndexer::new(TRACK_EMBEDDING_DIM).unwrap(); + for (t, e) in tracks.iter().zip(&embeddings).take(tracks.len() - 1) { + idx.insert_track(t, e.clone()).unwrap(); + } + let target = tracks.last().unwrap(); + let target_emb = embeddings.last().unwrap(); + c.bench_function("anomaly/score_track_full", |b| { + b.iter(|| { + let novelty = idx.novelty_score(black_box(target_emb)).unwrap() as f64; + score_track(&cfg, black_box(target), &baseline, novelty, 0.0) + }) + }); +} + +fn bench_pipeline(c: &mut Criterion) { + let mut g = c.benchmark_group("pipeline"); + g.sample_size(10); // end-to-end run: keep the bench fast + g.bench_function("end_to_end_standard_scenario", |b| { + b.iter(|| Pipeline::default().run().unwrap()) + }); + g.finish(); +} + +criterion_group!( + benches, + bench_projection, + bench_embedding, + bench_vector_db, + bench_anomaly, + bench_pipeline +); +criterion_main!(benches); diff --git a/examples/sky-monitor/src/adsb.rs b/examples/sky-monitor/src/adsb.rs new file mode 100644 index 0000000000..987e128520 --- /dev/null +++ b/examples/sky-monitor/src/adsb.rs @@ -0,0 +1,389 @@ +//! ADS-B sources (ADR-199 §9.1, Phase 1). +//! +//! Two front-ends produce the same [`AircraftState`] samples: +//! +//! 1. [`generate_scenario`] — a **deterministic seeded synthetic generator** +//! (no network, no SDR) producing a realistic mixed day of traffic over the +//! observer: en-route corridor flights, arrivals/departures, a low GA +//! overhead pass, and one anomalous low/slow off-corridor night track. +//! 2. [`parse_dump1090`] — a parser for dump1090-style `aircraft.json` +//! payloads, so a real RTL-SDR + dump1090 feed can be plugged into the same +//! pipeline unchanged. + +use chrono::{DateTime, Duration, TimeZone, Utc}; +use serde::{Deserialize, Serialize}; + +/// icao24 of the single anomalous synthetic track (low, slow, off-corridor, +/// 03:10 UTC). Exposed so tests and demos can identify it. +pub const ANOMALOUS_ICAO24: &str = "deadbf"; +/// icao24 of the low-altitude general-aviation overhead pass. +pub const GA_OVERHEAD_ICAO24: &str = "c0a9a9"; + +/// One decoded ADS-B state vector sample. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AircraftState { + /// 24-bit ICAO transponder address, lowercase hex. + pub icao24: String, + /// Callsign (may be empty — itself a mildly unusual attribute). + pub callsign: String, + pub lat: f64, + pub lon: f64, + /// Barometric altitude, metres. + pub alt_m: f64, + /// Ground speed, m/s. + pub speed_mps: f64, + /// Ground track, degrees from true north. + pub track_deg: f64, + /// Vertical rate, m/s (positive = climb). + pub vertical_rate_mps: f64, + /// Receiver signal strength, dBFS (0 = full scale, more negative = weaker). + pub signal_dbfs: f64, + /// Sample timestamp (UTC). + pub ts: DateTime, +} + +/// Tiny deterministic linear congruential generator (Numerical Recipes +/// constants). Avoids a `rand` dependency while keeping the scenario fully +/// reproducible from a single `u64` seed. +pub struct Lcg(u64); + +impl Lcg { + pub fn new(seed: u64) -> Self { + Self( + seed.wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407), + ) + } + pub fn next_u64(&mut self) -> u64 { + self.0 = self + .0 + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + self.0 ^ (self.0 >> 33) + } + /// Uniform in `[0, 1)`. + pub fn next_f64(&mut self) -> f64 { + (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64 + } + /// Uniform in `[-1, 1)` — handy for jitter. + pub fn jitter(&mut self) -> f64 { + self.next_f64() * 2.0 - 1.0 + } +} + +/// Internal flight description used by the synthetic generator. +struct FlightPlan { + icao24: &'static str, + callsign: &'static str, + /// Start time as seconds offset from `day_start`. + start_offset_s: i64, + duration_s: i64, + heading_deg: f64, + speed_mps: f64, + /// Altitude at the start of the segment, metres. + alt0_m: f64, + vertical_rate_mps: f64, + /// Cross-track offset of the closest point of approach from the observer, + /// km, positive = right of the heading direction. + cross_offset_km: f64, + /// Mean receiver signal strength for this flight, dBFS. + signal_dbfs: f64, +} + +/// The standard synthetic day of traffic over the observer (12 a.m. day-start +/// based timeline; see each row's offset). All headings/altitudes/speeds are +/// realistic for the Toronto-area corridor the Oakville node sits under. +fn scenario_plans() -> Vec { + const H: i64 = 3600; + vec![ + // (a) En-route commercial corridor: eastbound ~072 deg at FL350-ish. + FlightPlan { + icao24: "c01a01", + callsign: "ACA101", + start_offset_s: 11 * H + 300, + duration_s: 240, + heading_deg: 72.0, + speed_mps: 236.0, + alt0_m: 10_600.0, + vertical_rate_mps: 0.0, + cross_offset_km: 8.0, + signal_dbfs: -18.0, + }, + FlightPlan { + icao24: "a02b02", + callsign: "DAL202", + start_offset_s: 13 * H + 2400, + duration_s: 240, + heading_deg: 74.0, + speed_mps: 232.0, + alt0_m: 10_800.0, + vertical_rate_mps: 0.0, + cross_offset_km: -6.0, + signal_dbfs: -19.0, + }, + FlightPlan { + icao24: "a03c03", + callsign: "UAL303", + start_offset_s: 15 * H + 1200, + duration_s: 240, + heading_deg: 71.0, + speed_mps: 238.0, + alt0_m: 10_700.0, + vertical_rate_mps: 0.0, + cross_offset_km: 12.0, + signal_dbfs: -20.0, + }, + FlightPlan { + icao24: "c04d04", + callsign: "WJA404", + start_offset_s: 18 * H + 1800, + duration_s: 240, + heading_deg: 73.0, + speed_mps: 234.0, + alt0_m: 10_500.0, + vertical_rate_mps: 0.0, + cross_offset_km: 6.0, + signal_dbfs: -18.0, + }, + // Westbound return corridor ~252 deg. + FlightPlan { + icao24: "400a05", + callsign: "BAW505", + start_offset_s: 12 * H + 600, + duration_s: 240, + heading_deg: 252.0, + speed_mps: 228.0, + alt0_m: 11_200.0, + vertical_rate_mps: 0.0, + cross_offset_km: -10.0, + signal_dbfs: -19.0, + }, + FlightPlan { + icao24: "39a006", + callsign: "AFR606", + start_offset_s: 17 * H + 300, + duration_s: 240, + heading_deg: 251.0, + speed_mps: 230.0, + alt0_m: 11_000.0, + vertical_rate_mps: 0.0, + cross_offset_km: 7.0, + signal_dbfs: -18.0, + }, + // (b) Arrivals descending through the area toward Pearson (~032 deg). + FlightPlan { + icao24: "c07e07", + callsign: "JZA707", + start_offset_s: 14 * H + 900, + duration_s: 300, + heading_deg: 32.0, + speed_mps: 145.0, + alt0_m: 4_800.0, + vertical_rate_mps: -7.5, + cross_offset_km: 6.0, + signal_dbfs: -12.0, + }, + FlightPlan { + icao24: "c08f08", + callsign: "SKV808", + start_offset_s: 19 * H + 2700, + duration_s: 300, + heading_deg: 34.0, + speed_mps: 150.0, + alt0_m: 4_600.0, + vertical_rate_mps: -7.0, + cross_offset_km: -4.0, + signal_dbfs: -12.0, + }, + // (c) Low-altitude general-aviation overhead pass (lake-shore VFR). + FlightPlan { + icao24: GA_OVERHEAD_ICAO24, + callsign: "CGSKY", + start_offset_s: 16 * H + 1800, + duration_s: 360, + heading_deg: 88.0, + speed_mps: 62.0, + alt0_m: 1_100.0, + vertical_rate_mps: 0.0, + cross_offset_km: 0.4, + signal_dbfs: -8.0, + }, + // (d) Anomalous track: low altitude, slow, off-corridor heading 165, + // at 03:10 the following night, very strong signal, no callsign. + FlightPlan { + icao24: ANOMALOUS_ICAO24, + callsign: "", + start_offset_s: 27 * H + 600, + duration_s: 420, + heading_deg: 165.0, + speed_mps: 48.0, + alt0_m: 450.0, + vertical_rate_mps: 0.0, + cross_offset_km: 2.0, + signal_dbfs: -3.0, + }, + ] +} + +/// Default scenario day start: 2026-06-08T00:00:00Z. +pub fn default_day_start() -> DateTime { + Utc.with_ymd_and_hms(2026, 6, 8, 0, 0, 0).unwrap() +} + +/// Generate the deterministic synthetic scenario at ~1 Hz, sorted by time. +/// +/// The geometry places each flight so its closest point of approach to the +/// observer happens mid-segment at `cross_offset_km` perpendicular distance, +/// using a flat-earth metre→degree step (fine at < 100 km scales; the precise +/// observer-relative frame is recomputed later via the §10 ECEF/ENU pipeline). +pub fn generate_scenario( + observer_lat: f64, + observer_lon: f64, + seed: u64, + day_start: DateTime, +) -> Vec { + let mut rng = Lcg::new(seed); + let m_per_deg_lat = 111_132.0; + let m_per_deg_lon = 111_320.0 * observer_lat.to_radians().cos(); + let mut out = Vec::new(); + + for plan in scenario_plans() { + let h = plan.heading_deg.to_radians(); + // Unit vectors in local (east, north) metres. + let dir = (h.sin(), h.cos()); + let perp = (h.cos(), -h.sin()); // 90 deg right of heading + // Closest point of approach, then back up half the segment. + let cpa = ( + perp.0 * plan.cross_offset_km * 1_000.0, + perp.1 * plan.cross_offset_km * 1_000.0, + ); + let half = plan.speed_mps * plan.duration_s as f64 / 2.0; + let mut east = cpa.0 - dir.0 * half; + let mut north = cpa.1 - dir.1 * half; + let mut alt = plan.alt0_m; + + for t in 0..plan.duration_s { + let ts = day_start + Duration::seconds(plan.start_offset_s + t); + let track = plan.heading_deg + rng.jitter() * 1.5; + let speed = plan.speed_mps + rng.jitter() * 3.0; + out.push(AircraftState { + icao24: plan.icao24.to_string(), + callsign: plan.callsign.to_string(), + lat: observer_lat + north / m_per_deg_lat, + lon: observer_lon + east / m_per_deg_lon, + alt_m: alt + rng.jitter() * 10.0, + speed_mps: speed, + track_deg: crate::coords::normalize_deg(track), + vertical_rate_mps: plan.vertical_rate_mps + rng.jitter() * 0.4, + signal_dbfs: plan.signal_dbfs + rng.jitter() * 1.5, + ts, + }); + east += dir.0 * plan.speed_mps; + north += dir.1 * plan.speed_mps; + alt += plan.vertical_rate_mps; + } + } + out.sort_by_key(|s| s.ts); + out +} + +/// Parse a dump1090-style `aircraft.json` payload into state vectors. +/// +/// Unit conversions: `alt_baro` ft → m, `gs` knots → m/s, `baro_rate` ft/min → +/// m/s. Entries without a position fix (`lat`/`lon`) are skipped. `rssi` maps +/// to `signal_dbfs`. The `now` field (epoch seconds) timestamps all entries. +pub fn parse_dump1090(json: &str) -> Result, serde_json::Error> { + const FT: f64 = 0.3048; + const KT: f64 = 0.514_444; + let v: serde_json::Value = serde_json::from_str(json)?; + let now = v.get("now").and_then(|n| n.as_f64()).unwrap_or(0.0); + let ts = Utc + .timestamp_opt(now as i64, ((now.fract()) * 1e9) as u32) + .single() + .unwrap_or_else(|| Utc.timestamp_opt(0, 0).single().unwrap()); + let mut out = Vec::new(); + if let Some(list) = v.get("aircraft").and_then(|a| a.as_array()) { + for ac in list { + let (Some(lat), Some(lon)) = ( + ac.get("lat").and_then(|x| x.as_f64()), + ac.get("lon").and_then(|x| x.as_f64()), + ) else { + continue; // no position fix yet + }; + let f = |k: &str| ac.get(k).and_then(|x| x.as_f64()); + out.push(AircraftState { + icao24: ac + .get("hex") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_lowercase(), + callsign: ac + .get("flight") + .and_then(|x| x.as_str()) + .unwrap_or("") + .trim() + .to_string(), + lat, + lon, + alt_m: f("alt_baro").unwrap_or(0.0) * FT, + speed_mps: f("gs").unwrap_or(0.0) * KT, + track_deg: f("track").unwrap_or(0.0), + vertical_rate_mps: f("baro_rate").unwrap_or(0.0) * FT / 60.0, + signal_dbfs: f("rssi").unwrap_or(-30.0), + ts, + }); + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scenario_is_deterministic_and_realistic() { + let a = generate_scenario(43.4675, -79.6877, 42, default_day_start()); + let b = generate_scenario(43.4675, -79.6877, 42, default_day_start()); + assert_eq!(a.len(), b.len()); + assert!( + a.len() > 2_500, + "expected ~1 Hz day of samples, got {}", + a.len() + ); + assert_eq!( + a[100].lat.to_bits(), + b[100].lat.to_bits(), + "must be bit-deterministic" + ); + assert!(a.iter().any(|s| s.icao24 == ANOMALOUS_ICAO24)); + // Sorted by time. + assert!(a.windows(2).all(|w| w[0].ts <= w[1].ts)); + } + + #[test] + fn parses_dump1090_aircraft_json() { + let json = r#"{ + "now": 1765219200.5, + "messages": 142001, + "aircraft": [ + { "hex": "C01A01", "flight": "ACA123 ", "alt_baro": 36000, "gs": 451.2, + "track": 72.4, "baro_rate": -64, "lat": 43.5121, "lon": -79.5512, + "rssi": -18.4, "squawk": "3417", "seen": 0.2 }, + { "hex": "a9b8c7", "alt_baro": 12000, "gs": 220.0, "track": 250.1, + "lat": 43.4011, "lon": -79.8821, "rssi": -22.1 }, + { "hex": "ffffff", "seen": 12.0 } + ] + }"#; + let states = parse_dump1090(json).unwrap(); + assert_eq!(states.len(), 2, "entry without lat/lon must be skipped"); + let s = &states[0]; + assert_eq!(s.icao24, "c01a01"); + assert_eq!(s.callsign, "ACA123"); + assert!((s.alt_m - 36000.0 * 0.3048).abs() < 0.1); + assert!((s.speed_mps - 451.2 * 0.514444).abs() < 0.01); + assert!((s.vertical_rate_mps - (-64.0 * 0.3048 / 60.0)).abs() < 1e-6); + assert!((s.signal_dbfs + 18.4).abs() < 1e-9); + assert_eq!(s.ts.timestamp(), 1_765_219_200); + } +} diff --git a/examples/sky-monitor/src/anomaly.rs b/examples/sky-monitor/src/anomaly.rs new file mode 100644 index 0000000000..920740ee4e --- /dev/null +++ b/examples/sky-monitor/src/anomaly.rs @@ -0,0 +1,386 @@ +//! Composite anomaly scoring (ADR-199 §15). +//! +//! ```text +//! anomaly_score = 0.30 * route_deviation +//! + 0.20 * altitude_deviation +//! + 0.15 * time_of_day_rarity +//! + 0.15 * signal_unusualness +//! + 0.10 * cross_sensor_confirmation +//! + 0.10 * novelty_score +//! ``` +//! +//! Every component is `[0, 1]`, computed against [`BaselineStats`] built from +//! the tracks observed **before** the one being scored. Governance rule 2 +//! (ADR §27): every anomaly report carries human-readable reasons. + +use crate::config::AnomalyConfig; +use crate::track::Track; +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// Divisor squashing |z|-scores into `[0, 1]` (saturates at 2.5 σ). +const Z_SQUASH: f64 = 2.5; +/// Heading deviation (degrees from nearest baseline corridor) that saturates +/// the route component. +const ROUTE_SATURATION_DEG: f64 = 60.0; +/// ±hours window and saturation count for time-of-day rarity. +const HOUR_WINDOW: i64 = 2; +const HOUR_SATURATION: f64 = 3.0; + +/// Minimal per-track summary carrying exactly the features §15 scoring needs. +/// +/// This is the shared scoring input for **both** ingestion paths: the native +/// pipeline derives it from a full [`Track`] (`TrackSummary::from(&track)`), +/// while the browser dashboard (`sky-monitor-wasm`) deserializes it straight +/// from JSON — no `Track` reconstruction required. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrackSummary { + pub icao24: String, + #[serde(default)] + pub callsign: String, + /// Mean barometric altitude over the track, metres. + pub mean_alt_m: f64, + /// Circular-mean ground track, degrees in `[0, 360)`. + pub dominant_heading_deg: f64, + /// UTC hour (0–23) the track started. + pub start_hour: u32, + /// Mean receiver signal strength, dBFS. + pub mean_signal_dbfs: f64, + /// Minimum slant range to the observer, metres (context for consumers). + #[serde(default)] + pub min_range_m: f64, + /// Maximum elevation seen, degrees (context for consumers). + #[serde(default)] + pub max_elevation_deg: f64, +} + +impl From<&Track> for TrackSummary { + fn from(t: &Track) -> Self { + // One pass over the points for the altitude/signal means and the + // circular-mean heading (the dedicated Track methods would each walk + // the point list separately). + let mut alt_sum = 0.0f64; + let mut sig_sum = 0.0f64; + let mut head_s = 0.0f64; + let mut head_c = 0.0f64; + for p in &t.points { + alt_sum += p.alt_m; + sig_sum += p.signal_dbfs; + let r = p.track_deg.to_radians(); + head_s += r.sin(); + head_c += r.cos(); + } + let np = t.points.len(); + let (mean_alt_m, mean_signal_dbfs) = if np == 0 { + (0.0, 0.0) + } else { + (alt_sum / np as f64, sig_sum / np as f64) + }; + Self { + icao24: t.icao24.clone(), + callsign: t.callsign.clone(), + mean_alt_m, + dominant_heading_deg: crate::coords::normalize_deg(head_s.atan2(head_c).to_degrees()), + start_hour: t.start_hour_utc(), + mean_signal_dbfs, + min_range_m: t.min_range_m, + max_elevation_deg: t.max_elevation_deg, + } + } +} + +/// Baseline statistics over prior tracks. +#[derive(Debug, Clone, Default)] +pub struct BaselineStats { + /// Dominant headings of prior tracks (the known corridors), degrees. + pub corridor_headings: Vec, + pub altitude_mean_m: f64, + pub altitude_std_m: f64, + pub signal_mean_dbfs: f64, + pub signal_std_dbfs: f64, + /// Start-hour histogram (UTC) of prior tracks. + pub hour_counts: [u32; 24], + pub n_tracks: usize, +} + +impl BaselineStats { + /// Build baseline statistics from prior tracks. + pub fn from_tracks(prior: &[Track]) -> Self { + Self::from_summaries(&prior.iter().map(TrackSummary::from).collect::>()) + } + + /// Build baseline statistics from prior track summaries (shared by the + /// native pipeline and the wasm dashboard scorer). + pub fn from_summaries(prior: &[TrackSummary]) -> Self { + let n = prior.len(); + if n == 0 { + return Self::default(); + } + let alts: Vec = prior.iter().map(|t| t.mean_alt_m).collect(); + let sigs: Vec = prior.iter().map(|t| t.mean_signal_dbfs).collect(); + let mean = |v: &[f64]| v.iter().sum::() / v.len() as f64; + let std = |v: &[f64], m: f64| { + (v.iter().map(|x| (x - m) * (x - m)).sum::() / v.len() as f64).sqrt() + }; + let am = mean(&alts); + let sm = mean(&sigs); + let mut hours = [0u32; 24]; + for t in prior { + hours[(t.start_hour % 24) as usize] += 1; + } + Self { + corridor_headings: prior.iter().map(|t| t.dominant_heading_deg).collect(), + altitude_mean_m: am, + altitude_std_m: std(&alts, am).max(500.0), // floor: avoid div-by-~0 + signal_mean_dbfs: sm, + signal_std_dbfs: std(&sigs, sm).max(1.0), + hour_counts: hours, + n_tracks: n, + } + } +} + +/// Smallest circular difference between two headings, degrees in `[0, 180]`. +pub fn circular_diff_deg(a: f64, b: f64) -> f64 { + let d = (a - b).rem_euclid(360.0); + d.min(360.0 - d) +} + +/// The six §15 components, each in `[0, 1]`. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct AnomalyComponents { + pub route_deviation: f64, + pub altitude_deviation: f64, + pub time_of_day_rarity: f64, + pub signal_unusualness: f64, + pub cross_sensor_confirmation: f64, + pub novelty_score: f64, +} + +/// ADR-199 §15 interpretation bands. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum Interpretation { + /// 0.00–0.30 — store. + Normal, + /// 0.31–0.55 — timeline marker. + MildlyUnusual, + /// 0.56–0.75 — include in summary. + Interesting, + /// 0.76–0.90 — local alert. + StrongAnomaly, + /// 0.91–1.00 — preserve raw data + generate report. + Rare, +} + +impl Interpretation { + /// Band for a composite score. + pub fn band(score: f64) -> Self { + match score { + s if s <= 0.30 => Interpretation::Normal, + s if s <= 0.55 => Interpretation::MildlyUnusual, + s if s <= 0.75 => Interpretation::Interesting, + s if s <= 0.90 => Interpretation::StrongAnomaly, + _ => Interpretation::Rare, + } + } + + /// Action column of the §15 table. + pub fn action(&self) -> &'static str { + match self { + Interpretation::Normal => "store", + Interpretation::MildlyUnusual => "timeline marker", + Interpretation::Interesting => "include in summary", + Interpretation::StrongAnomaly => "local alert", + Interpretation::Rare => "preserve raw data + report", + } + } +} + +impl fmt::Display for Interpretation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Interpretation::Normal => "normal", + Interpretation::MildlyUnusual => "mildly unusual", + Interpretation::Interesting => "interesting", + Interpretation::StrongAnomaly => "strong anomaly", + Interpretation::Rare => "rare", + }; + f.write_str(s) + } +} + +/// Scored anomaly for one track, with mandatory reasons (governance rule 2). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnomalyReport { + pub track_id: String, + pub icao24: String, + pub callsign: String, + pub score: f64, + pub components: AnomalyComponents, + pub band: Interpretation, + /// Human-readable reasons — never empty. + pub reasons: Vec, +} + +/// Score one track against the baseline (§15). `novelty` comes from the +/// RuVector indexer; `cross_sensor` is the Phase 5 corroboration placeholder +/// (0 when no second sensor confirms the entity). +pub fn score_track( + cfg: &AnomalyConfig, + track: &Track, + baseline: &BaselineStats, + novelty: f64, + cross_sensor: f64, +) -> AnomalyReport { + score_summary_as( + cfg, + &track.track_id, + &TrackSummary::from(track), + baseline, + novelty, + cross_sensor, + ) +} + +/// Score a [`TrackSummary`] against the baseline — same formula, reasons, and +/// bands as [`score_track`], for callers (e.g. the wasm dashboard) that only +/// have summary features. The report's `track_id` is derived from the icao24. +pub fn score_summary( + cfg: &AnomalyConfig, + summary: &TrackSummary, + baseline: &BaselineStats, + novelty: f64, + cross_sensor: f64, +) -> AnomalyReport { + let track_id = format!("track-{}", summary.icao24); + score_summary_as(cfg, &track_id, summary, baseline, novelty, cross_sensor) +} + +/// Shared §15 scorer over summary features. +fn score_summary_as( + cfg: &AnomalyConfig, + track_id: &str, + track: &TrackSummary, + baseline: &BaselineStats, + novelty: f64, + cross_sensor: f64, +) -> AnomalyReport { + let heading = track.dominant_heading_deg; + let nearest_corridor = baseline + .corridor_headings + .iter() + .map(|c| circular_diff_deg(heading, *c)) + .fold(f64::INFINITY, f64::min); + let route_deviation = if nearest_corridor.is_finite() { + (nearest_corridor / ROUTE_SATURATION_DEG).min(1.0) + } else { + 0.5 // no baseline corridors yet + }; + + let alt_z = + (track.mean_alt_m - baseline.altitude_mean_m).abs() / baseline.altitude_std_m.max(1.0); + let altitude_deviation = (alt_z / Z_SQUASH).min(1.0); + + // Rarity of the start hour: how many prior tracks started within ±2 h + // (circular over the day); 3+ neighbours = fully ordinary. + let hour = track.start_hour as i64; + let mut window_count = 0u32; + for dh in -HOUR_WINDOW..=HOUR_WINDOW { + window_count += baseline.hour_counts[((hour + dh).rem_euclid(24)) as usize]; + } + let time_of_day_rarity = (1.0 - f64::from(window_count) / HOUR_SATURATION).max(0.0); + + let sig_z = + (track.mean_signal_dbfs - baseline.signal_mean_dbfs).abs() / baseline.signal_std_dbfs; + let signal_unusualness = (sig_z / Z_SQUASH).min(1.0); + + let components = AnomalyComponents { + route_deviation, + altitude_deviation, + time_of_day_rarity, + signal_unusualness, + cross_sensor_confirmation: cross_sensor.clamp(0.0, 1.0), + novelty_score: novelty.clamp(0.0, 1.0), + }; + let score = cfg.w_route_deviation * components.route_deviation + + cfg.w_altitude_deviation * components.altitude_deviation + + cfg.w_time_of_day_rarity * components.time_of_day_rarity + + cfg.w_signal_unusualness * components.signal_unusualness + + cfg.w_cross_sensor_confirmation * components.cross_sensor_confirmation + + cfg.w_novelty_score * components.novelty_score; + + let mut reasons = Vec::new(); + if components.route_deviation > 0.5 { + reasons.push(format!( + "heading {heading:.0}° is {nearest_corridor:.0}° off the nearest known corridor" + )); + } + if components.altitude_deviation > 0.5 { + reasons.push(format!( + "mean altitude {:.0} m deviates {alt_z:.1}σ from the local baseline ({:.0} m)", + track.mean_alt_m, baseline.altitude_mean_m + )); + } + if components.time_of_day_rarity > 0.5 { + reasons.push(format!( + "start time {:02}:xx UTC has {window_count} prior tracks within ±{HOUR_WINDOW} h", + track.start_hour + )); + } + if components.signal_unusualness > 0.5 { + reasons.push(format!( + "signal {:.1} dBFS is {sig_z:.1}σ from baseline {:.1} dBFS (unusually close/strong)", + track.mean_signal_dbfs, baseline.signal_mean_dbfs + )); + } + if components.cross_sensor_confirmation > 0.5 { + reasons.push("corroborated by a second sensor modality".to_string()); + } + if components.novelty_score > 0.5 { + reasons.push(format!( + "vector novelty {:.2}: no similar track in RuVector memory", + components.novelty_score + )); + } + if track.callsign.is_empty() && score > 0.30 { + reasons.push("no callsign broadcast".to_string()); + } + if reasons.is_empty() { + reasons.push(format!( + "within normal envelope: heading {heading:.0}°, altitude {:.0} m, score {score:.2}", + track.mean_alt_m + )); + } + + AnomalyReport { + track_id: track_id.to_string(), + icao24: track.icao24.clone(), + callsign: track.callsign.clone(), + score, + components, + band: Interpretation::band(score), + reasons, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bands_match_adr_table() { + assert_eq!(Interpretation::band(0.10), Interpretation::Normal); + assert_eq!(Interpretation::band(0.30), Interpretation::Normal); + assert_eq!(Interpretation::band(0.42), Interpretation::MildlyUnusual); + assert_eq!(Interpretation::band(0.60), Interpretation::Interesting); + assert_eq!(Interpretation::band(0.76), Interpretation::StrongAnomaly); + assert_eq!(Interpretation::band(0.95), Interpretation::Rare); + assert_eq!(Interpretation::band(0.80).action(), "local alert"); + } + + #[test] + fn circular_diff_wraps() { + assert!((circular_diff_deg(350.0, 10.0) - 20.0).abs() < 1e-9); + assert!((circular_diff_deg(72.0, 252.0) - 180.0).abs() < 1e-9); + } +} diff --git a/examples/sky-monitor/src/brief.rs b/examples/sky-monitor/src/brief.rs new file mode 100644 index 0000000000..c6e17c9f31 --- /dev/null +++ b/examples/sky-monitor/src/brief.rs @@ -0,0 +1,148 @@ +//! Daily sky brief (ADR-199 §21.3). +//! +//! Renders the Oakville-style text block: +//! +//! > **Sky brief — Oakville, 2026-06-09.** 812 aircraft observed; 37 overhead +//! > candidates; 4 unusual tracks. Light rain 14:10–15:30. Most unusual +//! > event: low-altitude eastbound pass at 21:14 (confidence 0.78). + +use crate::anomaly::{AnomalyReport, Interpretation}; +use crate::config::ObserverConfig; +use crate::track::Track; +use crate::weather::{WeatherCondition, WeatherWindow}; +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// Description of the day's most unusual event. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MostUnusual { + pub description: String, + /// The anomaly score doubles as the confidence that this is genuinely + /// unusual (it is a calibrated 0–1 composite). + pub confidence: f64, +} + +/// One day's summary of the sky above the observer. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DailySkyBrief { + pub observer: String, + pub date: NaiveDate, + pub aircraft_observed: usize, + pub overhead_candidates: usize, + /// Tracks scoring above "mildly unusual" (> 0.55). + pub unusual_tracks: usize, + /// Human-readable weather events (non-clear windows / alerts). + pub weather_events: Vec, + pub most_unusual: Option, +} + +impl DailySkyBrief { + /// Build the brief from the day's tracks, anomaly reports, and weather. + pub fn build( + observer: &ObserverConfig, + tracks: &[Track], + reports: &[AnomalyReport], + weather: &[WeatherWindow], + ) -> Self { + let date = tracks + .first() + .map(|t| t.started.date_naive()) + .unwrap_or_else(|| NaiveDate::from_ymd_opt(1970, 1, 1).unwrap()); + let aircraft: std::collections::BTreeSet<&str> = + tracks.iter().map(|t| t.icao24.as_str()).collect(); + let overhead = tracks.iter().filter(|t| t.is_overhead_candidate).count(); + let unusual = reports + .iter() + .filter(|r| r.band > Interpretation::MildlyUnusual) + .count(); + + let mut weather_events = Vec::new(); + let mut rain_run: Option<(usize, usize)> = None; + for (i, w) in weather.iter().enumerate() { + if w.condition != WeatherCondition::Clear && w.condition != WeatherCondition::Cloudy { + rain_run = Some(rain_run.map_or((i, i), |(s, _)| (s, i))); + } else if let Some((s, e)) = rain_run.take() { + weather_events.push(format!( + "{} {}–{} UTC", + weather[s].condition.as_str(), + weather[s].start.format("%H:%M"), + weather[e].end.format("%H:%M"), + )); + } + } + if let Some((s, e)) = rain_run { + weather_events.push(format!( + "{} {}–{} UTC", + weather[s].condition.as_str(), + weather[s].start.format("%H:%M"), + weather[e].end.format("%H:%M"), + )); + } + + let most_unusual = reports + .iter() + .max_by(|a, b| a.score.total_cmp(&b.score)) + .filter(|r| r.score > 0.30) + .map(|r| { + let track = tracks.iter().find(|t| t.track_id == r.track_id); + let when = track + .map(|t| t.closest_approach.format("%H:%M UTC").to_string()) + .unwrap_or_default(); + let alt = track.map(|t| t.mean_altitude_m()).unwrap_or(0.0); + let heading = track.map(|t| t.dominant_heading_deg()).unwrap_or(0.0); + let who = if r.callsign.is_empty() { + format!("icao24 {}", r.icao24) + } else { + r.callsign.clone() + }; + MostUnusual { + description: format!( + "{}-altitude pass by {who} heading {heading:.0}° at {when} ({:.0} m): {}", + if alt < 1_500.0 { "low" } else { "high" }, + alt, + r.reasons.first().cloned().unwrap_or_default() + ), + confidence: r.score, + } + }); + + Self { + observer: observer.name.clone(), + date, + aircraft_observed: aircraft.len(), + overhead_candidates: overhead, + unusual_tracks: unusual, + weather_events, + most_unusual, + } + } +} + +impl fmt::Display for DailySkyBrief { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "Sky brief — {}, {}. {} aircraft observed; {} overhead candidates; {} unusual track{}.", + self.observer, + self.date, + self.aircraft_observed, + self.overhead_candidates, + self.unusual_tracks, + if self.unusual_tracks == 1 { "" } else { "s" }, + )?; + if self.weather_events.is_empty() { + write!(f, " Weather: clear throughout.")?; + } else { + write!(f, " Weather: {}.", self.weather_events.join("; "))?; + } + if let Some(mu) = &self.most_unusual { + write!( + f, + " Most unusual event: {} (confidence {:.2}).", + mu.description, mu.confidence + )?; + } + Ok(()) + } +} diff --git a/examples/sky-monitor/src/config.rs b/examples/sky-monitor/src/config.rs new file mode 100644 index 0000000000..c8b81db2c1 --- /dev/null +++ b/examples/sky-monitor/src/config.rs @@ -0,0 +1,106 @@ +//! Appliance configuration (ADR-199 §30). +//! +//! Defaults reproduce the reference deployment from the ADR configuration +//! sketch: the Oakville node at 43.4675 N, -79.6877 E, 100 m AMSL, with the +//! ADR §15 anomaly weights and the 0.76 local-alert threshold. + +use serde::{Deserialize, Serialize}; + +/// A fixed physical observer (sensor node) on the ground. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ObserverConfig { + /// Stable node name (used as `sensor_id` / Observer node id). + pub name: String, + /// Geodetic latitude in degrees (WGS-84). + pub lat: f64, + /// Geodetic longitude in degrees (WGS-84). + pub lon: f64, + /// Altitude above the WGS-84 ellipsoid, metres. + pub alt_m: f64, +} + +impl Default for ObserverConfig { + fn default() -> Self { + Self { + name: "oakville_node".to_string(), + lat: 43.4675, + lon: -79.6877, + alt_m: 100.0, + } + } +} + +/// Anomaly-scoring configuration (ADR-199 §15 and §30). +/// +/// The six weights mirror the composite formula exactly: +/// +/// ```text +/// anomaly_score = 0.30 * route_deviation +/// + 0.20 * altitude_deviation +/// + 0.15 * time_of_day_rarity +/// + 0.15 * signal_unusualness +/// + 0.10 * cross_sensor_confirmation +/// + 0.10 * novelty_score +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnomalyConfig { + /// Weight of the route (heading corridor) deviation component. + pub w_route_deviation: f64, + /// Weight of the altitude deviation component. + pub w_altitude_deviation: f64, + /// Weight of the time-of-day rarity component. + pub w_time_of_day_rarity: f64, + /// Weight of the signal-strength unusualness component. + pub w_signal_unusualness: f64, + /// Weight of the cross-sensor confirmation component (Phase 5 placeholder). + pub w_cross_sensor_confirmation: f64, + /// Weight of the RuVector novelty component. + pub w_novelty_score: f64, + /// Scores at or above this raise a local alert (ADR band "Strong anomaly"). + pub alert_threshold: f64, + /// Minimum number of prior tracks required before a track is scored. + /// (The ADR mandates a baseline period before alerting, §26.) + pub min_history: usize, +} + +impl Default for AnomalyConfig { + fn default() -> Self { + Self { + w_route_deviation: 0.30, + w_altitude_deviation: 0.20, + w_time_of_day_rarity: 0.15, + w_signal_unusualness: 0.15, + w_cross_sensor_confirmation: 0.10, + w_novelty_score: 0.10, + alert_threshold: 0.76, + min_history: 5, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_observer_is_oakville_node() { + let cfg = ObserverConfig::default(); + assert_eq!(cfg.name, "oakville_node"); + assert!((cfg.lat - 43.4675).abs() < 1e-9); + assert!((cfg.lon + 79.6877).abs() < 1e-9); + assert!((cfg.alt_m - 100.0).abs() < 1e-9); + } + + #[test] + fn anomaly_weights_sum_to_one() { + let c = AnomalyConfig::default(); + let sum = c.w_route_deviation + + c.w_altitude_deviation + + c.w_time_of_day_rarity + + c.w_signal_unusualness + + c.w_cross_sensor_confirmation + + c.w_novelty_score; + assert!((sum - 1.0).abs() < 1e-9); + assert!((c.alert_threshold - 0.76).abs() < 1e-9); + } +} diff --git a/examples/sky-monitor/src/coords.rs b/examples/sky-monitor/src/coords.rs new file mode 100644 index 0000000000..9617c04f1e --- /dev/null +++ b/examples/sky-monitor/src/coords.rs @@ -0,0 +1,246 @@ +//! Observer-relative coordinate model (ADR-199 §10). +//! +//! Pipeline: **WGS-84 geodetic → ECEF → ENU → azimuth / elevation / range**. +//! +//! All math is plain `f64`; formulas are the standard geodesy ones: +//! +//! * Geodetic → ECEF (WGS-84 ellipsoid, semi-major axis `a`, first +//! eccentricity squared `e²`): +//! `N(φ) = a / sqrt(1 − e² sin²φ)`, +//! `x = (N + h)·cosφ·cosλ`, `y = (N + h)·cosφ·sinλ`, +//! `z = (N·(1 − e²) + h)·sinφ`. +//! * ECEF Δ → local East/North/Up tangent plane at the observer: +//! `e = −sinλ·Δx + cosλ·Δy`, +//! `n = −sinφ·cosλ·Δx − sinφ·sinλ·Δy + cosφ·Δz`, +//! `u = cosφ·cosλ·Δx + cosφ·sinλ·Δy + sinφ·Δz`. +//! * Azimuth = `atan2(e, n)` (clockwise from true north), +//! elevation = `atan2(u, hypot(e, n))`, slant range = `‖(e,n,u)‖`. +//! * Bearing = great-circle initial bearing from observer to target: +//! `θ = atan2(sinΔλ·cosφ₂, cosφ₁·sinφ₂ − sinφ₁·cosφ₂·cosΔλ)`. +//! +//! Note that for distant targets at the *same* ellipsoidal altitude, elevation +//! is slightly **negative** because the Earth curves away under the line of +//! sight (≈ −range / 2R radians). + +use serde::{Deserialize, Serialize}; + +/// WGS-84 semi-major axis (metres). +pub const WGS84_A: f64 = 6_378_137.0; +/// WGS-84 flattening. +pub const WGS84_F: f64 = 1.0 / 298.257_223_563; +/// WGS-84 first eccentricity squared, `e² = f·(2 − f)`. +pub const WGS84_E2: f64 = WGS84_F * (2.0 - WGS84_F); + +/// Earth-Centred Earth-Fixed cartesian coordinates (metres). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Ecef { + pub x: f64, + pub y: f64, + pub z: f64, +} + +/// Local East/North/Up tangent-plane coordinates (metres) at an observer. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Enu { + pub east: f64, + pub north: f64, + pub up: f64, +} + +/// Observer-relative spherical frame (ADR-199 §11 `observer_frame`). +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct ObserverFrame { + /// Slant range, metres. + pub range_m: f64, + /// Azimuth, degrees clockwise from true north, in `[0, 360)`. + pub azimuth_deg: f64, + /// Elevation above the local horizon, degrees, in `[-90, 90]`. + pub elevation_deg: f64, + /// Great-circle initial bearing observer → target, degrees, in `[0, 360)`. + pub bearing_deg: f64, +} + +/// Convert WGS-84 geodetic coordinates to ECEF. +pub fn geodetic_to_ecef(lat_deg: f64, lon_deg: f64, alt_m: f64) -> Ecef { + let lat = lat_deg.to_radians(); + let lon = lon_deg.to_radians(); + let (sin_lat, cos_lat) = lat.sin_cos(); + let (sin_lon, cos_lon) = lon.sin_cos(); + // Prime-vertical radius of curvature. + let n = WGS84_A / (1.0 - WGS84_E2 * sin_lat * sin_lat).sqrt(); + Ecef { + x: (n + alt_m) * cos_lat * cos_lon, + y: (n + alt_m) * cos_lat * sin_lon, + z: (n * (1.0 - WGS84_E2) + alt_m) * sin_lat, + } +} + +/// Rotate an ECEF delta (target − observer) into the observer's ENU frame. +pub fn ecef_to_enu(obs_lat_deg: f64, obs_lon_deg: f64, obs: Ecef, target: Ecef) -> Enu { + let lat = obs_lat_deg.to_radians(); + let lon = obs_lon_deg.to_radians(); + let (sin_lat, cos_lat) = lat.sin_cos(); + let (sin_lon, cos_lon) = lon.sin_cos(); + let dx = target.x - obs.x; + let dy = target.y - obs.y; + let dz = target.z - obs.z; + Enu { + east: -sin_lon * dx + cos_lon * dy, + north: -sin_lat * cos_lon * dx - sin_lat * sin_lon * dy + cos_lat * dz, + up: cos_lat * cos_lon * dx + cos_lat * sin_lon * dy + sin_lat * dz, + } +} + +/// Normalize an angle in degrees into `[0, 360)`. +pub fn normalize_deg(deg: f64) -> f64 { + let d = deg % 360.0; + if d < 0.0 { + d + 360.0 + } else { + d + } +} + +/// Great-circle initial bearing from `(lat1, lon1)` to `(lat2, lon2)`, degrees. +pub fn initial_bearing_deg(lat1_deg: f64, lon1_deg: f64, lat2_deg: f64, lon2_deg: f64) -> f64 { + let phi1 = lat1_deg.to_radians(); + let phi2 = lat2_deg.to_radians(); + let dl = (lon2_deg - lon1_deg).to_radians(); + let y = dl.sin() * phi2.cos(); + let x = phi1.cos() * phi2.sin() - phi1.sin() * phi2.cos() * dl.cos(); + normalize_deg(y.atan2(x).to_degrees()) +} + +/// Full projection: target geodetic position → observer-relative frame. +pub fn observer_frame( + obs_lat: f64, + obs_lon: f64, + obs_alt_m: f64, + target_lat: f64, + target_lon: f64, + target_alt_m: f64, +) -> ObserverFrame { + // Same math as `geodetic_to_ecef` → `ecef_to_enu` → `initial_bearing_deg`, + // inlined so each sin/cos is computed exactly once (the helper composition + // recomputes the observer trig three times and the target trig twice). + let lat1 = obs_lat.to_radians(); + let lon1 = obs_lon.to_radians(); + let lat2 = target_lat.to_radians(); + let lon2 = target_lon.to_radians(); + let (sin_lat1, cos_lat1) = lat1.sin_cos(); + let (sin_lon1, cos_lon1) = lon1.sin_cos(); + let (sin_lat2, cos_lat2) = lat2.sin_cos(); + let (sin_lon2, cos_lon2) = lon2.sin_cos(); + + // Geodetic → ECEF for observer and target (WGS-84). + let n1 = WGS84_A / (1.0 - WGS84_E2 * sin_lat1 * sin_lat1).sqrt(); + let ox = (n1 + obs_alt_m) * cos_lat1 * cos_lon1; + let oy = (n1 + obs_alt_m) * cos_lat1 * sin_lon1; + let oz = (n1 * (1.0 - WGS84_E2) + obs_alt_m) * sin_lat1; + let n2 = WGS84_A / (1.0 - WGS84_E2 * sin_lat2 * sin_lat2).sqrt(); + let tx = (n2 + target_alt_m) * cos_lat2 * cos_lon2; + let ty = (n2 + target_alt_m) * cos_lat2 * sin_lon2; + let tz = (n2 * (1.0 - WGS84_E2) + target_alt_m) * sin_lat2; + + // ECEF Δ → ENU at the observer. + let dx = tx - ox; + let dy = ty - oy; + let dz = tz - oz; + let east = -sin_lon1 * dx + cos_lon1 * dy; + let north = -sin_lat1 * cos_lon1 * dx - sin_lat1 * sin_lon1 * dy + cos_lat1 * dz; + let up = cos_lat1 * cos_lon1 * dx + cos_lat1 * sin_lon1 * dy + sin_lat1 * dz; + + let horizontal = east.hypot(north); + let range_m = (horizontal * horizontal + up * up).sqrt(); + let azimuth_deg = if horizontal < 1e-9 { + 0.0 // directly overhead/underfoot: azimuth undefined, report 0 + } else { + normalize_deg(east.atan2(north).to_degrees()) + }; + let elevation_deg = up.atan2(horizontal).to_degrees(); + + // Great-circle initial bearing, reusing the trig above via the angle + // subtraction identities (sin/cos of Δλ from the per-longitude values). + let sin_dl = sin_lon2 * cos_lon1 - cos_lon2 * sin_lon1; + let cos_dl = cos_lon2 * cos_lon1 + sin_lon2 * sin_lon1; + let by = sin_dl * cos_lat2; + let bx = cos_lat1 * sin_lat2 - sin_lat1 * cos_lat2 * cos_dl; + let bearing_deg = normalize_deg(by.atan2(bx).to_degrees()); + + ObserverFrame { + range_m, + azimuth_deg, + elevation_deg, + bearing_deg, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const OBS: (f64, f64, f64) = (43.4675, -79.6877, 100.0); + + #[test] + fn aircraft_due_north_same_altitude() { + // ~50 km due north at the same ellipsoidal altitude. + let dlat = 50_000.0 / 111_132.0; // metres per degree latitude (approx.) + let f = observer_frame(OBS.0, OBS.1, OBS.2, OBS.0 + dlat, OBS.1, OBS.2); + let az = if f.azimuth_deg > 180.0 { + f.azimuth_deg - 360.0 + } else { + f.azimuth_deg + }; + assert!( + az.abs() < 0.5, + "azimuth should be ~0 deg, got {}", + f.azimuth_deg + ); + // Earth curvature drops the target below the horizon: ~ -r/2R rad ≈ -0.22 deg. + assert!( + f.elevation_deg < 0.0 && f.elevation_deg > -0.5, + "expected slightly negative elevation, got {}", + f.elevation_deg + ); + assert!((f.range_m - 50_000.0).abs() < 500.0); + assert!(f.bearing_deg < 0.5 || f.bearing_deg > 359.5); + } + + #[test] + fn aircraft_directly_overhead() { + let f = observer_frame(OBS.0, OBS.1, OBS.2, OBS.0, OBS.1, OBS.2 + 5_000.0); + assert!((f.elevation_deg - 90.0).abs() < 1e-6); + assert!((f.range_m - 5_000.0).abs() < 1.0); + } + + #[test] + fn aircraft_due_east_above_horizon() { + // ~20 km east, 10 km up: azimuth ~90, elevation ~atan(9900/20000) ≈ 26.3 deg. + let dlon = 20_000.0 / (111_320.0 * OBS.0.to_radians().cos()); + let f = observer_frame(OBS.0, OBS.1, OBS.2, OBS.0, OBS.1 + dlon, 10_000.0); + assert!((f.azimuth_deg - 90.0).abs() < 1.0, "az {}", f.azimuth_deg); + assert!( + (f.elevation_deg - 26.3).abs() < 1.5, + "el {}", + f.elevation_deg + ); + assert!( + (f.bearing_deg - 90.0).abs() < 1.0, + "bearing {}", + f.bearing_deg + ); + } + + #[test] + fn ecef_of_equator_prime_meridian() { + let e = geodetic_to_ecef(0.0, 0.0, 0.0); + assert!((e.x - WGS84_A).abs() < 1e-6); + assert!(e.y.abs() < 1e-6 && e.z.abs() < 1e-6); + } + + #[test] + fn normalize_wraps_negative() { + assert!((normalize_deg(-90.0) - 270.0).abs() < 1e-9); + assert!((normalize_deg(725.0) - 5.0).abs() < 1e-9); + } +} diff --git a/examples/sky-monitor/src/embedding.rs b/examples/sky-monitor/src/embedding.rs new file mode 100644 index 0000000000..85c96320cc --- /dev/null +++ b/examples/sky-monitor/src/embedding.rs @@ -0,0 +1,364 @@ +//! Deterministic feature embeddings (ADR-199 §13). +//! +//! These are engineered (not learned) embeddings: every dimension is a +//! documented, normalized feature so that euclidean distance in embedding +//! space is interpretable. Per the ADR table, an **aircraft-track** embedding +//! encodes path shape, speed profile, altitude profile, time of day, and route +//! class; a **weather-window** embedding encodes the precip/wind state. +//! +//! Track and weather embeddings have different dimensions and live in +//! **separate** RuVector collections (one `VectorDB` per modality) — never mix +//! them in one index. +//! +//! Normalization targets `[0, 1]` per dimension (a few can mildly exceed 1 for +//! out-of-envelope inputs, which is fine for distance purposes): +//! +//! | dim | feature | scale | +//! |-----|---------|-------| +//! | 0–2 | mean / min / max altitude | / 12 000 m | +//! | 3 | mean ground speed | / 300 m/s | +//! | 4–5 | dominant heading sin/cos | mapped to `[0,1]` | +//! | 6–7 | time-of-day sin/cos | `0.5 ± 0.25` (half weight vs heading) | +//! | 8 | min slant range | / 50 km | +//! | 9–10| max / mean elevation | / 90° | +//! | 11 | duration | / 1 800 s | +//! | 12–13 | climb / descent point ratio | already 0–1 | +//! | 14 | path straightness | already 0–1 | +//! | 15 | mean abs vertical rate | / 15 m/s | +//! | 16–23 | azimuth-bucket occupancy (8 × 45° buckets) | fractions | +//! | 24 | mean signal | (dBFS + 40) / 40 | +//! | 25 | speed std | / 50 m/s | +//! | 26 | altitude std | / 3 000 m | +//! | 27–28 | start / end slant range | / 50 km | +//! | 29 | sample count | / 600 | +//! | 30 | overhead-candidate flag | 0 or 1 | +//! | 31 | reserved | 0 | + +use crate::track::Track; +use crate::weather::{WeatherCondition, WeatherWindow}; +use chrono::Timelike; + +/// Dimension of aircraft-track embeddings. +pub const TRACK_EMBEDDING_DIM: usize = 32; +/// Dimension of weather-window embeddings (separate collection!). +pub const WEATHER_EMBEDDING_DIM: usize = 8; + +fn clamp01(v: f64) -> f32 { + v.clamp(0.0, 1.0) as f32 +} + +/// Lightweight per-point sample for embedding a track when a full [`Track`] +/// (with `Observation` provenance) is unavailable — e.g. the live browser +/// feed embedded through `sky-monitor-wasm`. Field meanings match +/// [`crate::track::TrackPoint`]; `t_unix` is Unix epoch seconds (UTC). +#[derive(Debug, Clone, Copy, Default)] +pub struct EmbeddingSample { + pub t_unix: f64, + pub lat: f64, + pub lon: f64, + pub alt_m: f64, + pub speed_mps: f64, + pub track_deg: f64, + pub vertical_rate_mps: f64, + pub signal_dbfs: f64, + pub azimuth_deg: f64, + pub elevation_deg: f64, + pub range_m: f64, +} + +/// Compute the 32-dimensional track embedding described in the module docs. +/// Fully deterministic in the track contents. Delegates to +/// [`track_embedding_from_samples`] so the native and live (wasm) ingestion +/// paths share one normalization. +pub fn track_embedding(track: &Track) -> Vec { + let samples: Vec = track + .points + .iter() + .map(|p| EmbeddingSample { + t_unix: p.ts.timestamp_millis() as f64 / 1000.0, + lat: p.lat, + lon: p.lon, + alt_m: p.alt_m, + speed_mps: p.speed_mps, + track_deg: p.track_deg, + vertical_rate_mps: p.vertical_rate_mps, + signal_dbfs: p.signal_dbfs, + azimuth_deg: p.frame.azimuth_deg, + elevation_deg: p.frame.elevation_deg, + range_m: p.frame.range_m, + }) + .collect(); + track_embedding_from_samples(&samples) +} + +/// [`track_embedding`] over raw samples: the same single-pass accumulation +/// and normalization, with the track-level statistics (start time, duration, +/// `min_range_m`, `max_elevation_deg`, overhead-candidate flag) derived from +/// the samples exactly as `Track::from_points` derives them from points. +/// An empty slice embeds to the zero vector. +pub fn track_embedding_from_samples(pts: &[EmbeddingSample]) -> Vec { + let mut e = vec![0.0f32; TRACK_EMBEDDING_DIM]; + if pts.is_empty() { + return e; + } + // Single pass over the points: accumulate every per-point statistic at + // once instead of one full iteration per feature. + let count = pts.len(); + let nf = count as f64; + let mut alt_sum = 0.0f64; + let mut alt_sq = 0.0f64; + let mut min_alt = f64::INFINITY; + let mut max_alt = f64::NEG_INFINITY; + let mut speed_sum = 0.0f64; + let mut speed_sq = 0.0f64; + let mut sig_sum = 0.0f64; + let mut elev_sum = 0.0f64; + let mut vr_abs_sum = 0.0f64; + let mut climb = 0usize; + let mut descent = 0usize; + let mut head_s = 0.0f64; + let mut head_c = 0.0f64; + let mut buckets = [0u32; 8]; + let mut path_m = 0.0f64; + let mut prev_lat = 0.0f64; + let mut prev_lon = 0.0f64; + let mut min_range = f64::INFINITY; + let mut max_el = f64::NEG_INFINITY; + for (i, p) in pts.iter().enumerate() { + alt_sum += p.alt_m; + alt_sq += p.alt_m * p.alt_m; + min_alt = min_alt.min(p.alt_m); + max_alt = max_alt.max(p.alt_m); + speed_sum += p.speed_mps; + speed_sq += p.speed_mps * p.speed_mps; + sig_sum += p.signal_dbfs; + elev_sum += p.elevation_deg; + vr_abs_sum += p.vertical_rate_mps.abs(); + if p.vertical_rate_mps > 2.0 { + climb += 1; + } + if p.vertical_rate_mps < -2.0 { + descent += 1; + } + let r = p.track_deg.to_radians(); + head_s += r.sin(); + head_c += r.cos(); + let b = ((p.azimuth_deg.rem_euclid(360.0)) / 45.0) as usize % 8; + buckets[b] += 1; + min_range = min_range.min(p.range_m); + max_el = max_el.max(p.elevation_deg); + if i > 0 { + // Equirectangular step, identical to Track::path_length_m. + let dy = (p.lat - prev_lat) * 111_132.0; + let dx = (p.lon - prev_lon) * 111_320.0 * ((prev_lat + p.lat) / 2.0).to_radians().cos(); + path_m += dx.hypot(dy); + } + prev_lat = p.lat; + prev_lon = p.lon; + } + let mean = |sum: f64| sum / nf; + let std = |sq: f64, sum: f64| { + let m = sum / nf; + (sq / nf - m * m).max(0.0).sqrt() + }; + + e[0] = clamp01(mean(alt_sum) / 12_000.0); + e[1] = clamp01(min_alt / 12_000.0); + e[2] = clamp01(max_alt / 12_000.0); + e[3] = clamp01(mean(speed_sum) / 300.0); + + // Circular-mean ground track, as Track::dominant_heading_deg. + let h = crate::coords::normalize_deg(head_s.atan2(head_c).to_degrees()).to_radians(); + e[4] = ((h.sin() + 1.0) / 2.0) as f32; + e[5] = ((h.cos() + 1.0) / 2.0) as f32; + + // Time of day on the unit circle (UTC), half-amplitude so route class + // dominates time of day in distance terms. Derived from the first + // sample's epoch seconds (matches `started.hour() + minute()/60`). + let sod = pts[0].t_unix.rem_euclid(86_400.0); + let frac = ((sod / 3_600.0).floor() + ((sod % 3_600.0) / 60.0).floor() / 60.0) / 24.0; + let a = frac * std::f64::consts::TAU; + e[6] = (0.5 + 0.25 * a.sin()) as f32; + e[7] = (0.5 + 0.25 * a.cos()) as f32; + + e[8] = clamp01(min_range / 50_000.0); + e[9] = clamp01(max_el / 90.0); + e[10] = clamp01(mean(elev_sum) / 90.0); + e[11] = clamp01((pts[count - 1].t_unix - pts[0].t_unix) / 1_800.0); + e[12] = clamp01(climb as f64 / nf); + e[13] = clamp01(descent as f64 / nf); + // Net displacement / path length, as Track::straightness. + let straightness = if path_m < 1.0 { + 1.0 + } else { + let (a, b) = (&pts[0], &pts[count - 1]); + let dy = (b.lat - a.lat) * 111_132.0; + let dx = (b.lon - a.lon) * 111_320.0 * ((a.lat + b.lat) / 2.0).to_radians().cos(); + (dx.hypot(dy) / path_m).clamp(0.0, 1.0) + }; + e[14] = clamp01(straightness); + e[15] = clamp01(mean(vr_abs_sum) / 15.0); + + // Coarse azimuth occupancy: fraction of samples seen in each 45° sky + // sector around the observer (route-class signature). + for (b, &hits) in buckets.iter().enumerate() { + e[16 + b] = (f64::from(hits) / nf) as f32; + } + + e[24] = clamp01((mean(sig_sum) + 40.0) / 40.0); + e[25] = clamp01(std(speed_sq, speed_sum) / 50.0); + e[26] = clamp01(std(alt_sq, alt_sum) / 3_000.0); + e[27] = clamp01(pts[0].range_m / 50_000.0); + e[28] = clamp01(pts[count - 1].range_m / 50_000.0); + e[29] = clamp01(count as f64 / 600.0); + // ADR §14 rule 1, exactly as Track::from_points sets the flag. + e[30] = if min_range < crate::track::OVERHEAD_RANGE_M + && max_el > crate::track::OVERHEAD_ELEVATION_DEG + { + 1.0 + } else { + 0.0 + }; + e[31] = 0.0; // reserved + e +} + +/// Weather-window embedding (8 dims, separate collection): +/// `[condition one-hot-ish severity, precip/5, wind/20, alert flag, +/// hour sin/cos (half weight), reserved, reserved]`. +pub fn weather_window_embedding(w: &WeatherWindow) -> Vec { + let severity = match w.condition { + WeatherCondition::Clear => 0.0, + WeatherCondition::Cloudy => 0.25, + WeatherCondition::Fog => 0.5, + WeatherCondition::Rain => 0.6, + WeatherCondition::Snow => 0.7, + WeatherCondition::Thunderstorm => 1.0, + }; + let frac = w.start.hour() as f64 / 24.0; + let a = frac * std::f64::consts::TAU; + vec![ + severity as f32, + clamp01(w.precip_mm_hr / 5.0), + clamp01(w.wind_mps / 20.0), + if w.alert.is_some() { 1.0 } else { 0.0 }, + (0.5 + 0.25 * a.sin()) as f32, + (0.5 + 0.25 * a.cos()) as f32, + 0.0, + 0.0, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::adsb::{default_day_start, generate_scenario, ANOMALOUS_ICAO24}; + use crate::config::ObserverConfig; + use crate::observation::{EntityType, GeoPosition, Motion, Observation}; + use crate::track::{stitch_tracks, TRACK_GAP_SECS}; + + fn tracks() -> Vec { + let cfg = ObserverConfig::default(); + let obs: Vec = generate_scenario(cfg.lat, cfg.lon, 42, default_day_start()) + .iter() + .map(|s| { + Observation::new( + &cfg, + "adsb_synthetic", + EntityType::Aircraft, + s.icao24.clone(), + s.ts, + GeoPosition { + lat: s.lat, + lon: s.lon, + alt_m: s.alt_m, + }, + Motion { + speed_mps: s.speed_mps, + track_deg: s.track_deg, + vertical_rate_mps: s.vertical_rate_mps, + }, + serde_json::json!({ "callsign": s.callsign, "signal_dbfs": s.signal_dbfs }), + 0.95, + ) + }) + .collect(); + stitch_tracks(&cfg, &obs, TRACK_GAP_SECS) + } + + fn dist(a: &[f32], b: &[f32]) -> f32 { + a.iter() + .zip(b) + .map(|(x, y)| (x - y) * (x - y)) + .sum::() + .sqrt() + } + + #[test] + fn embeddings_are_fixed_dim_and_separate_route_classes() { + let ts = tracks(); + let embs: Vec<(String, Vec)> = ts + .iter() + .map(|t| (t.icao24.clone(), track_embedding(t))) + .collect(); + for (_, e) in &embs { + assert_eq!(e.len(), TRACK_EMBEDDING_DIM); + assert!(e.iter().all(|v| (0.0..=1.0001).contains(v))); + } + // Two eastbound corridor flights must be closer to each other than + // either is to the anomalous track. + let east1 = &embs.iter().find(|(i, _)| i == "c01a01").unwrap().1; + let east2 = &embs.iter().find(|(i, _)| i == "a02b02").unwrap().1; + let anom = &embs.iter().find(|(i, _)| i == ANOMALOUS_ICAO24).unwrap().1; + assert!(dist(east1, east2) < dist(east1, anom)); + assert!(dist(east1, east2) < dist(east2, anom)); + } + + #[test] + fn weather_embedding_dim_and_alert_flag() { + let w = crate::weather::generate_weather(42, default_day_start()); + let rain = weather_window_embedding(&w[14]); + assert_eq!(rain.len(), WEATHER_EMBEDDING_DIM); + assert_eq!(rain[3], 1.0, "rain window carries an alert"); + let clear = weather_window_embedding(&w[2]); + assert_eq!(clear[3], 0.0); + } + + #[test] + fn sample_embedding_matches_track_embedding() { + for t in tracks() { + let direct = track_embedding(&t); + let samples: Vec = t + .points + .iter() + .map(|p| EmbeddingSample { + t_unix: p.ts.timestamp_millis() as f64 / 1000.0, + lat: p.lat, + lon: p.lon, + alt_m: p.alt_m, + speed_mps: p.speed_mps, + track_deg: p.track_deg, + vertical_rate_mps: p.vertical_rate_mps, + signal_dbfs: p.signal_dbfs, + azimuth_deg: p.frame.azimuth_deg, + elevation_deg: p.frame.elevation_deg, + range_m: p.frame.range_m, + }) + .collect(); + assert_eq!( + direct, + track_embedding_from_samples(&samples), + "{}", + t.icao24 + ); + } + } + + #[test] + fn empty_samples_embed_to_zero() { + assert_eq!( + track_embedding_from_samples(&[]), + vec![0.0; TRACK_EMBEDDING_DIM] + ); + } +} diff --git a/examples/sky-monitor/src/indexer.rs b/examples/sky-monitor/src/indexer.rs new file mode 100644 index 0000000000..aaf5914ded --- /dev/null +++ b/examples/sky-monitor/src/indexer.rs @@ -0,0 +1,175 @@ +//! RuVector indexer (ADR-199 §19 `ruvector-indexer`, Phase 4). +//! +//! Wraps a `ruvector_core::VectorDB` collection holding **track embeddings +//! only** (weather embeddings would go in a second, separate collection — the +//! dimensions differ on purpose). Provides: +//! +//! * similarity search ("find tracks like this one"), +//! * a **novelty score** — distance of a new track from its nearest prior +//! neighbours, the §15 `novelty_score` component. +//! +//! Euclidean distance is used because every embedding dimension is normalized +//! to a comparable `[0, 1]` scale (see `embedding.rs`); cosine would discard +//! magnitude information that is meaningful here (e.g. altitude level). +//! +//! ## Novelty calibration +//! +//! `novelty = min(1, mean(top-3 prior distances) / NOVELTY_CALIBRATION)`. +//! +//! The constant 1.2 is calibrated on the synthetic corpus so that a repeat +//! corridor flight (mean top-3 distance ≈ 0.3–0.5) scores ≈ 0.25–0.4, while +//! the off-corridor low/slow night track (distance ≳ 1.2 from everything) +//! saturates at 1.0. With fewer than `MIN_NEIGHBOURS` prior tracks the score +//! falls back to a neutral 0.5 (the baseline period, ADR §26). + +use crate::track::Track; +use ruvector_core::types::{DbOptions, DistanceMetric, SearchQuery, VectorEntry}; +use ruvector_core::VectorDB; +use std::collections::HashMap; + +/// See module docs: distance scale at which novelty saturates. +pub const NOVELTY_CALIBRATION: f32 = 1.2; +/// Number of nearest prior neighbours averaged for the novelty score. +pub const NOVELTY_K: usize = 3; +/// Below this many indexed tracks, novelty is the neutral 0.5. +pub const MIN_NEIGHBOURS: usize = 1; + +/// Track-embedding index backed by an in-memory RuVector `VectorDB`. +pub struct TrackIndexer { + db: VectorDB, + len: usize, +} + +impl TrackIndexer { + /// Create an empty index for `dim`-dimensional track embeddings. + /// + /// Uses the flat (exact) index: the demo corpus is small and exactness + /// keeps the acceptance tests deterministic. Swap in `hnsw_config` for + /// large fleets. + pub fn new(dim: usize) -> crate::Result { + let options = DbOptions { + dimensions: dim, + distance_metric: DistanceMetric::Euclidean, + // Ignored by the in-memory backend (ruvector-core is built here + // without the `storage` feature); kept for API completeness. + storage_path: "sky-monitor-tracks.mem".to_string(), + hnsw_config: None, + quantization: None, + }; + Ok(Self { + db: VectorDB::new(options)?, + len: 0, + }) + } + + /// Number of indexed tracks. + pub fn len(&self) -> usize { + self.len + } + + pub fn is_empty(&self) -> bool { + self.len == 0 + } + + /// Insert one track embedding with its provenance metadata + /// (`track_id`, `icao24`, `label` = callsign or icao24). + pub fn insert_track(&mut self, track: &Track, embedding: Vec) -> crate::Result<()> { + let label = if track.callsign.is_empty() { + track.icao24.clone() + } else { + track.callsign.clone() + }; + let mut metadata = HashMap::new(); + metadata.insert("track_id".to_string(), serde_json::json!(track.track_id)); + metadata.insert("icao24".to_string(), serde_json::json!(track.icao24)); + metadata.insert("label".to_string(), serde_json::json!(label)); + metadata.insert( + "overhead".to_string(), + serde_json::json!(track.is_overhead_candidate), + ); + self.db.insert(VectorEntry { + id: Some(track.track_id.clone()), + vector: embedding, + metadata: Some(metadata), + })?; + self.len += 1; + Ok(()) + } + + /// Top-`k` most similar indexed tracks (excluding the query's own + /// `track_id` if it is already indexed), as `(track_id, distance)`. + pub fn similar_tracks( + &self, + embedding: &[f32], + exclude_track_id: Option<&str>, + k: usize, + ) -> crate::Result> { + let results = self.db.search(SearchQuery { + vector: embedding.to_vec(), + k: k + 1, // self may come back first + filter: None, + ef_search: None, + })?; + Ok(results + .into_iter() + .filter(|r| exclude_track_id != Some(r.id.as_str())) + .take(k) + .map(|r| (r.id, r.score)) + .collect()) + } + + /// Novelty of an embedding **relative to the tracks indexed so far** + /// (call before inserting the track itself). Range `[0, 1]`. + pub fn novelty_score(&self, embedding: &[f32]) -> crate::Result { + if self.len < MIN_NEIGHBOURS { + return Ok(0.5); // baseline period: neutral + } + let neighbours = self.similar_tracks(embedding, None, NOVELTY_K)?; + if neighbours.is_empty() { + return Ok(0.5); + } + let mean = neighbours.iter().map(|(_, d)| *d).sum::() / neighbours.len() as f32; + Ok((mean / NOVELTY_CALIBRATION).min(1.0)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::embedding::TRACK_EMBEDDING_DIM; + use crate::pipeline::Pipeline; + + #[test] + fn indexes_and_finds_similar_corridor_tracks() { + let (tracks, embeddings) = Pipeline::default().tracks_and_embeddings().unwrap(); + let mut idx = TrackIndexer::new(TRACK_EMBEDDING_DIM).unwrap(); + for (t, e) in tracks.iter().zip(&embeddings) { + idx.insert_track(t, e.clone()).unwrap(); + } + assert_eq!(idx.len(), tracks.len()); + + // Query with the first eastbound corridor flight: best match (not + // itself) must be another eastbound corridor flight. + let i = tracks.iter().position(|t| t.icao24 == "c01a01").unwrap(); + let hits = idx + .similar_tracks(&embeddings[i], Some(&tracks[i].track_id), 3) + .unwrap(); + assert!(!hits.is_empty()); + let top_icao = &tracks + .iter() + .find(|t| t.track_id == hits[0].0) + .unwrap() + .icao24; + assert!( + ["a02b02", "a03c03", "c04d04"].contains(&top_icao.as_str()), + "expected an eastbound corridor flight, got {top_icao}" + ); + } + + #[test] + fn novelty_is_neutral_when_empty() { + let idx = TrackIndexer::new(TRACK_EMBEDDING_DIM).unwrap(); + let z = vec![0.0f32; TRACK_EMBEDDING_DIM]; + assert!((idx.novelty_score(&z).unwrap() - 0.5).abs() < 1e-6); + } +} diff --git a/examples/sky-monitor/src/lib.rs b/examples/sky-monitor/src/lib.rs new file mode 100644 index 0000000000..9c89f027bf --- /dev/null +++ b/examples/sky-monitor/src/lib.rs @@ -0,0 +1,85 @@ +//! # RuView SkyGraph Appliance — core pipeline (ADR-199, Phases 1–4) +//! +//! A local sky-monitoring appliance core that observes, projects, records, and +//! explains activity above a fixed physical location — driven here by a fully +//! deterministic synthetic ADS-B + weather scenario (no network access). +//! +//! The sky is treated as a continuously changing spatial graph, not a +//! dashboard (ADR-199 §1): +//! +//! ```text +//! synthetic ADS-B (adsb) ──► canonical observations (observation, §11) +//! │ │ WGS-84 → ECEF → ENU → az/el/range (coords, §10) +//! ▼ ▼ +//! track stitching (track) ──► SkyGraph nodes + edges (skygraph, §12) +//! │ ▲ +//! ▼ │ +//! embeddings (embedding, §13) ─► RuVector index (indexer) ─► novelty +//! │ │ +//! ▼ ▼ +//! anomaly scoring (anomaly, §15) ────────────────► daily sky brief (brief, §21.3) +//! ``` +//! +//! [`pipeline::Pipeline`] orchestrates the end-to-end run shared by the demo +//! binary, the acceptance tests, and the criterion benches. + +pub mod adsb; +pub mod anomaly; +pub mod brief; +pub mod config; +pub mod coords; +pub mod embedding; +#[cfg(feature = "appliance")] +pub mod indexer; +pub mod observation; +#[cfg(feature = "appliance")] +pub mod pipeline; +#[cfg(feature = "appliance")] +pub mod skygraph; +pub mod track; +pub mod weather; + +pub use adsb::{parse_dump1090, AircraftState, ANOMALOUS_ICAO24, GA_OVERHEAD_ICAO24}; +pub use anomaly::{ + score_summary, score_track, AnomalyComponents, AnomalyReport, BaselineStats, Interpretation, + TrackSummary, +}; +pub use brief::DailySkyBrief; +pub use config::{AnomalyConfig, ObserverConfig}; +pub use coords::{geodetic_to_ecef, observer_frame, Ecef, Enu, ObserverFrame}; +pub use embedding::{ + track_embedding, track_embedding_from_samples, weather_window_embedding, EmbeddingSample, + TRACK_EMBEDDING_DIM, +}; +#[cfg(feature = "appliance")] +pub use indexer::TrackIndexer; +pub use observation::{EntityType, GeoPosition, Motion, Observation}; +#[cfg(feature = "appliance")] +pub use pipeline::{Pipeline, PipelineReport}; +#[cfg(feature = "appliance")] +pub use skygraph::{SkyGraph, TrackExplanation}; +pub use track::{stitch_tracks, Track, TrackPoint}; +pub use weather::{WeatherCondition, WeatherWindow}; + +/// Crate-level error type unifying the vector store, graph store, and JSON +/// parsing failure modes encountered by the pipeline. +#[derive(Debug, thiserror::Error)] +pub enum SkyError { + /// Error from the `ruvector-core` vector database. + #[cfg(feature = "appliance")] + #[error("vector store error: {0}")] + Vector(#[from] ruvector_core::RuvectorError), + /// Error from the `ruvector-graph` graph database. + #[cfg(feature = "appliance")] + #[error("graph store error: {0}")] + Graph(#[from] ruvector_graph::GraphError), + /// JSON decode error (e.g. malformed dump1090 payload). + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + /// Pipeline-level invariant violation. + #[error("pipeline error: {0}")] + Pipeline(String), +} + +/// Crate-wide result alias. +pub type Result = std::result::Result; diff --git a/examples/sky-monitor/src/main.rs b/examples/sky-monitor/src/main.rs new file mode 100644 index 0000000000..0ca2685ac8 --- /dev/null +++ b/examples/sky-monitor/src/main.rs @@ -0,0 +1,162 @@ +//! Demo binary: run the full ADR-199 Phase 1–4 pipeline over the synthetic +//! Oakville-node scenario and print a live-style report. +//! +//! `--emit-json ` additionally serializes the run for the canvas +//! dashboard (`ui/dashboard`): raw JSON, or — when `` ends in `.js` — a +//! `const SKY_DATA = {...};` script (e.g. `ui/dashboard/sky-demo-data.js`). + +use sky_monitor::{Interpretation, Pipeline, SkyError}; +use std::path::PathBuf; + +/// Parse CLI arguments; only `--emit-json ` is recognized. +fn parse_args() -> sky_monitor::Result> { + let mut emit_json = None; + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--emit-json" => { + let path = args.next().ok_or_else(|| { + SkyError::Pipeline("--emit-json requires a argument".to_string()) + })?; + emit_json = Some(PathBuf::from(path)); + } + other => { + return Err(SkyError::Pipeline(format!( + "unknown argument `{other}` (usage: sky-monitor [--emit-json ])" + ))) + } + } + } + Ok(emit_json) +} + +/// Write the dashboard export: plain JSON, or a `const SKY_DATA = ...;` JS +/// module when the target file name ends in `.js`. +fn emit_json( + pipeline: &Pipeline, + report: &sky_monitor::PipelineReport, + path: &PathBuf, +) -> sky_monitor::Result<()> { + let value = pipeline.demo_export_json(report); + let body = if path.extension().is_some_and(|e| e == "js") { + format!( + "// Generated by `cargo run -p sky-monitor --release -- --emit-json {}`.\n// Deterministic synthetic scenario (seed 42) — do not edit by hand.\nconst SKY_DATA = {value};\n", + path.display() + ) + } else { + serde_json::to_string_pretty(&value)? + }; + std::fs::write(path, body) + .map_err(|e| SkyError::Pipeline(format!("cannot write {}: {e}", path.display())))?; + println!("\n== Dashboard export ==\nwrote {}", path.display()); + Ok(()) +} + +fn main() -> sky_monitor::Result<()> { + let emit = parse_args()?; + let pipeline = Pipeline::default(); + let report = pipeline.run()?; + + println!("RuView SkyGraph Appliance — synthetic demo (ADR-199 Phases 1-4)"); + println!( + "Observer: {} ({:.4}, {:.4}, {:.0} m) | seed {} | {} observations", + pipeline.observer.name, + pipeline.observer.lat, + pipeline.observer.lon, + pipeline.observer.alt_m, + pipeline.seed, + report.observations.len() + ); + + // ---- Track table (observer frame at closest approach) ----------------- + println!("\n== Tracks (observer-relative at closest approach) =="); + println!( + "{:<16} {:<7} {:>9} {:>7} {:>7} {:>8} {:>7} {:>9} overhead", + "track", "call", "range_km", "az_deg", "el_deg", "alt_m", "hdg", "speed_mps" + ); + for t in &report.tracks { + let f = t.closest_frame(); + println!( + "{:<16} {:<7} {:>9.1} {:>7.0} {:>7.1} {:>8.0} {:>7.0} {:>9.0} {}", + t.track_id, + if t.callsign.is_empty() { + "-" + } else { + &t.callsign + }, + t.min_range_m / 1000.0, + f.azimuth_deg, + f.elevation_deg, + t.mean_altitude_m(), + t.dominant_heading_deg(), + t.mean_speed_mps(), + if t.is_overhead_candidate { "yes" } else { "" } + ); + } + + // ---- SkyGraph ---------------------------------------------------------- + let (nodes, edges) = report.skygraph.stats(); + println!("\n== SkyGraph =="); + println!("nodes: {nodes} edges: {edges}"); + println!( + "overhead candidates: {:?}", + report.skygraph.overhead_candidates() + ); + + // ---- Similarity -------------------------------------------------------- + println!("\n== Top similar-track pairs (RuVector, euclidean) =="); + for (a, b, d) in report.similar_pairs.iter().take(5) { + println!(" {a} <-> {b} distance {d:.3}"); + } + + // ---- Anomalies --------------------------------------------------------- + println!("\n== Anomaly scores (ADR-199 §15) =="); + println!( + "{:<16} {:<7} {:>6} {:<16} reasons", + "track", "call", "score", "band" + ); + for r in &report.reports { + println!( + "{:<16} {:<7} {:>6.3} {:<16} {}", + r.track_id, + if r.callsign.is_empty() { + "-" + } else { + &r.callsign + }, + r.score, + r.band.to_string(), + r.reasons.join(" | ") + ); + } + + // ---- Explanation of the most unusual track ----------------------------- + if let Some(worst) = report + .reports + .iter() + .max_by(|a, b| a.score.total_cmp(&b.score)) + .filter(|r| r.band > Interpretation::MildlyUnusual) + { + println!( + "\n== Explain {} ({}, action: {}) ==", + worst.track_id, + worst.band, + worst.band.action() + ); + if let Some(explanation) = report.skygraph.explain(&worst.track_id) { + for line in &explanation.evidence { + println!(" - {line}"); + } + } + } + + // ---- Daily brief -------------------------------------------------------- + println!("\n== Daily sky brief (ADR-199 §21.3) =="); + println!("{}", report.brief); + + // ---- Optional dashboard export ------------------------------------------ + if let Some(path) = emit { + emit_json(&pipeline, &report, &path)?; + } + Ok(()) +} diff --git a/examples/sky-monitor/src/observation.rs b/examples/sky-monitor/src/observation.rs new file mode 100644 index 0000000000..e501032165 --- /dev/null +++ b/examples/sky-monitor/src/observation.rs @@ -0,0 +1,182 @@ +//! Canonical observation schema (ADR-199 §11). +//! +//! Every normalized observation, from any sensor, conforms to one schema so +//! that the SkyGraph, vector index, and assistant all consume a single shape. +//! `raw_ref` links every insight back to evidence; `embedding_ref` links every +//! observation into vector memory. + +use crate::config::ObserverConfig; +use crate::coords::{observer_frame, ObserverFrame}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// The kind of entity an observation describes (ADR-199 §12 node types). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EntityType { + Aircraft, + WeatherCell, + RfEvent, + AudioEvent, + CameraEvent, + Satellite, +} + +impl EntityType { + /// Stable lowercase name (matches the JSON encoding). + pub fn as_str(&self) -> &'static str { + match self { + EntityType::Aircraft => "aircraft", + EntityType::WeatherCell => "weather_cell", + EntityType::RfEvent => "rf_event", + EntityType::AudioEvent => "audio_event", + EntityType::CameraEvent => "camera_event", + EntityType::Satellite => "satellite", + } + } +} + +/// Geodetic position of the observed entity (WGS-84). +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct GeoPosition { + pub lat: f64, + pub lon: f64, + pub alt_m: f64, +} + +/// Motion state of the observed entity. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct Motion { + /// Ground speed, metres per second. + pub speed_mps: f64, + /// Ground track, degrees clockwise from true north. + pub track_deg: f64, + /// Vertical rate, metres per second (positive = climbing). + pub vertical_rate_mps: f64, +} + +/// One normalized observation (ADR-199 §11). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Observation { + /// Unique observation id. + pub observation_id: Uuid, + /// UTC timestamp of the observation. + pub timestamp_utc: DateTime, + /// Producing source, e.g. `adsb_local`, `adsb_synthetic`, `msc_geomet`. + pub source: String, + /// Producing sensor node, e.g. `sky_node_001`. + pub sensor_id: String, + /// Kind of entity observed. + pub entity_type: EntityType, + /// Resolved entity id (icao24 for aircraft, internal id otherwise). + pub entity_id: String, + /// Geodetic location of the entity. + pub location: GeoPosition, + /// Observer-relative frame (range/azimuth/elevation/bearing), computed + /// from the observer configuration via the §10 projection pipeline. + pub observer_frame: ObserverFrame, + /// Motion state. + pub motion: Motion, + /// Free-form sensor attributes (callsign, squawk, signal_dbfs, ...). + pub attributes: serde_json::Value, + /// Confidence in `[0, 1]`. + pub confidence: f64, + /// Link back to raw evidence in the object store (governance rule 1). + pub raw_ref: Option, + /// Link into RuVector memory, set once the observation is embedded. + pub embedding_ref: Option, +} + +impl Observation { + /// Build a normalized observation, computing `observer_frame` from the + /// observer configuration and the entity location. + #[allow(clippy::too_many_arguments)] + pub fn new( + observer: &ObserverConfig, + source: impl Into, + entity_type: EntityType, + entity_id: impl Into, + timestamp_utc: DateTime, + location: GeoPosition, + motion: Motion, + attributes: serde_json::Value, + confidence: f64, + ) -> Self { + let frame = observer_frame( + observer.lat, + observer.lon, + observer.alt_m, + location.lat, + location.lon, + location.alt_m, + ); + Self { + observation_id: Uuid::new_v4(), + timestamp_utc, + source: source.into(), + sensor_id: observer.name.clone(), + entity_type, + entity_id: entity_id.into(), + location, + observer_frame: frame, + motion, + attributes, + confidence, + raw_ref: None, + embedding_ref: None, + } + } + + /// Signal strength attribute (dBFS), if present. + pub fn signal_dbfs(&self) -> Option { + self.attributes.get("signal_dbfs").and_then(|v| v.as_f64()) + } + + /// Callsign attribute, if present. + pub fn callsign(&self) -> Option<&str> { + self.attributes.get("callsign").and_then(|v| v.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + #[test] + fn observation_computes_observer_frame_and_roundtrips() { + let cfg = ObserverConfig::default(); + let obs = Observation::new( + &cfg, + "adsb_synthetic", + EntityType::Aircraft, + "c01a01", + Utc.with_ymd_and_hms(2026, 6, 8, 19, 0, 0).unwrap(), + GeoPosition { + lat: cfg.lat, + lon: cfg.lon, + alt_m: 1_200.0, + }, + Motion { + speed_mps: 210.0, + track_deg: 247.0, + vertical_rate_mps: -3.1, + }, + serde_json::json!({ "callsign": "ACA123", "signal_dbfs": -18.4 }), + 0.92, + ); + // Directly overhead at 1100 m above the observer. + assert!((obs.observer_frame.elevation_deg - 90.0).abs() < 0.1); + assert!((obs.observer_frame.range_m - 1_100.0).abs() < 1.0); + assert_eq!(obs.sensor_id, "oakville_node"); + assert_eq!(obs.callsign(), Some("ACA123")); + assert_eq!(obs.signal_dbfs(), Some(-18.4)); + + let json = serde_json::to_string(&obs).unwrap(); + let back: Observation = serde_json::from_str(&json).unwrap(); + assert_eq!(back.observation_id, obs.observation_id); + assert_eq!(back.entity_type, EntityType::Aircraft); + assert!(json.contains("\"entity_type\":\"aircraft\"")); + } +} diff --git a/examples/sky-monitor/src/pipeline.rs b/examples/sky-monitor/src/pipeline.rs new file mode 100644 index 0000000000..2b8ae63c47 --- /dev/null +++ b/examples/sky-monitor/src/pipeline.rs @@ -0,0 +1,262 @@ +//! End-to-end orchestration (ADR-199 Phases 1–4 in one pass). +//! +//! `scenario → observations → tracks → baseline split → RuVector index → +//! anomaly scores → SkyGraph → daily brief`. The demo binary, the acceptance +//! tests, and the criterion benches all run through [`Pipeline::run`] so they +//! exercise exactly the same path. + +use crate::adsb::{default_day_start, generate_scenario}; +use crate::anomaly::{score_track, AnomalyReport, BaselineStats, Interpretation}; +use crate::brief::DailySkyBrief; +use crate::config::{AnomalyConfig, ObserverConfig}; +use crate::embedding::{track_embedding, TRACK_EMBEDDING_DIM}; +use crate::indexer::TrackIndexer; +use crate::observation::{EntityType, GeoPosition, Motion, Observation}; +use crate::skygraph::SkyGraph; +use crate::track::{stitch_tracks, Track, TRACK_GAP_SECS}; +use crate::weather::{generate_weather, WeatherWindow}; +use chrono::{DateTime, Utc}; + +/// Everything a run produced (kept in memory for inspection / rendering). +pub struct PipelineReport { + pub observations: Vec, + pub tracks: Vec, + /// One anomaly report per scored track (tracks after the baseline split). + pub reports: Vec, + /// Top cross-track similarity pairs `(track_a, track_b, distance)`. + pub similar_pairs: Vec<(String, String, f32)>, + pub skygraph: SkyGraph, + pub weather: Vec, + pub brief: DailySkyBrief, +} + +/// The end-to-end SkyGraph appliance pipeline over the synthetic scenario. +pub struct Pipeline { + pub observer: ObserverConfig, + pub anomaly: AnomalyConfig, + pub seed: u64, + pub day_start: DateTime, +} + +impl Default for Pipeline { + fn default() -> Self { + Self { + observer: ObserverConfig::default(), + anomaly: AnomalyConfig::default(), + seed: 42, + day_start: default_day_start(), + } + } +} + +impl Pipeline { + /// Phase 1: synthetic ADS-B samples → canonical observations (§11). + pub fn observations(&self) -> Vec { + generate_scenario( + self.observer.lat, + self.observer.lon, + self.seed, + self.day_start, + ) + .iter() + .map(|s| { + Observation::new( + &self.observer, + "adsb_synthetic", + EntityType::Aircraft, + s.icao24.clone(), + s.ts, + GeoPosition { + lat: s.lat, + lon: s.lon, + alt_m: s.alt_m, + }, + Motion { + speed_mps: s.speed_mps, + track_deg: s.track_deg, + vertical_rate_mps: s.vertical_rate_mps, + }, + serde_json::json!({ "callsign": s.callsign, "signal_dbfs": s.signal_dbfs }), + 0.95, + ) + }) + .collect() + } + + /// Phases 1–3 shortcut used by tests/benches: stitched tracks (time + /// ordered) plus their embeddings. + pub fn tracks_and_embeddings(&self) -> crate::Result<(Vec, Vec>)> { + let observations = self.observations(); + let tracks = stitch_tracks(&self.observer, &observations, TRACK_GAP_SECS); + let embeddings = tracks.iter().map(track_embedding).collect(); + Ok((tracks, embeddings)) + } + + /// Run the full pipeline. + pub fn run(&self) -> crate::Result { + // Phase 1: observe + normalize. + let observations = self.observations(); + let weather = generate_weather(self.seed, self.day_start); + + // Phase 3: stitch tracks (already time ordered). + let tracks = stitch_tracks(&self.observer, &observations, TRACK_GAP_SECS); + + // Phase 4: index in RuVector and score anomalies. The first + // `min_history` tracks form the unscored baseline; every later track + // is scored against strictly *prior* tracks, then indexed itself. + let mut indexer = TrackIndexer::new(TRACK_EMBEDDING_DIM)?; + let embeddings: Vec> = tracks.iter().map(track_embedding).collect(); + let mut reports = Vec::new(); + let mut nearest_baseline: Vec> = vec![None; tracks.len()]; + for (i, (track, embedding)) in tracks.iter().zip(&embeddings).enumerate() { + if i >= self.anomaly.min_history { + let baseline = BaselineStats::from_tracks(&tracks[..i]); + let novelty = indexer.novelty_score(embedding)? as f64; + // Phase 5 placeholder: no second sensor modality in the + // synthetic scenario, so no cross-sensor confirmation. + let cross_sensor = 0.0; + reports.push(score_track( + &self.anomaly, + track, + &baseline, + novelty, + cross_sensor, + )); + nearest_baseline[i] = indexer + .similar_tracks(embedding, Some(&track.track_id), 1)? + .first() + .map(|(id, _)| id.clone()); + } + indexer.insert_track(track, embedding.clone())?; + } + + // Cross-track similarity pairs (deduplicated, closest first). + let mut similar_pairs: Vec<(String, String, f32)> = Vec::new(); + for (track, embedding) in tracks.iter().zip(&embeddings) { + if let Some((other, d)) = indexer + .similar_tracks(embedding, Some(&track.track_id), 1)? + .first() + { + let (a, b) = if track.track_id < *other { + (track.track_id.clone(), other.clone()) + } else { + (other.clone(), track.track_id.clone()) + }; + if !similar_pairs.iter().any(|(x, y, _)| *x == a && *y == b) { + similar_pairs.push((a, b, *d)); + } + } + } + similar_pairs.sort_by(|a, b| a.2.total_cmp(&b.2)); + + // Phase 3: build the SkyGraph. + let skygraph = SkyGraph::new(&self.observer)?; + for w in &weather { + skygraph.add_weather_window(w)?; + } + for track in &tracks { + skygraph.add_track(track, &weather)?; + } + for (a, b, d) in similar_pairs.iter().take(5) { + skygraph.add_similarity(a, b, *d)?; + } + for report in &reports { + if report.band > Interpretation::MildlyUnusual { + let i = tracks.iter().position(|t| t.track_id == report.track_id); + let baseline = i.and_then(|i| nearest_baseline[i].as_deref()); + skygraph.add_anomaly(report, baseline)?; + } + } + + // Phase 4: daily brief. + let brief = DailySkyBrief::build(&self.observer, &tracks, &reports, &weather); + + Ok(PipelineReport { + observations, + tracks, + reports, + similar_pairs, + skygraph, + weather, + brief, + }) + } + + /// Serialize a run for the canvas dashboard (`ui/dashboard`): observer + /// position plus per-track point series and anomaly verdicts. + /// + /// Shape: `{ observer: {name, lat, lon, alt_m}, day_start, tracks: [{ + /// icao24, callsign, overhead, points: [{t, lat, lon, alt_m}], anomaly: + /// {score, band, reasons} | null }] }` — `t` is Unix epoch seconds and + /// `anomaly` is `null` for the unscored baseline tracks (ADR §26). + pub fn demo_export_json(&self, report: &PipelineReport) -> serde_json::Value { + let round = |v: f64, scale: f64| (v * scale).round() / scale; + let tracks: Vec = report + .tracks + .iter() + .map(|t| { + let anomaly = report + .reports + .iter() + .find(|r| r.track_id == t.track_id) + .map(|r| { + serde_json::json!({ + "score": round(r.score, 1e3), + "band": r.band.to_string(), + "reasons": r.reasons, + }) + }) + .unwrap_or(serde_json::Value::Null); + let points: Vec = t + .points + .iter() + .map(|p| { + serde_json::json!({ + "t": p.ts.timestamp(), + "lat": round(p.lat, 1e6), + "lon": round(p.lon, 1e6), + "alt_m": round(p.alt_m, 10.0), + }) + }) + .collect(); + serde_json::json!({ + "icao24": t.icao24, + "callsign": t.callsign, + "overhead": t.is_overhead_candidate, + "points": points, + "anomaly": anomaly, + }) + }) + .collect(); + serde_json::json!({ + "observer": { + "name": self.observer.name, + "lat": self.observer.lat, + "lon": self.observer.lon, + "alt_m": self.observer.alt_m, + }, + "day_start": self.day_start.to_rfc3339(), + "tracks": tracks, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pipeline_runs_end_to_end() { + let report = Pipeline::default().run().unwrap(); + assert_eq!(report.tracks.len(), 10); + assert_eq!( + report.reports.len(), + 10 - AnomalyConfig::default().min_history + ); + assert!(!report.similar_pairs.is_empty()); + let (nodes, edges) = report.skygraph.stats(); + assert!(nodes > 50, "expected a populated graph, got {nodes} nodes"); + assert!(edges > 80, "expected a populated graph, got {edges} edges"); + assert!(report.brief.aircraft_observed == 10); + } +} diff --git a/examples/sky-monitor/src/skygraph.rs b/examples/sky-monitor/src/skygraph.rs new file mode 100644 index 0000000000..1eeb01c2ac --- /dev/null +++ b/examples/sky-monitor/src/skygraph.rs @@ -0,0 +1,408 @@ +//! SkyGraph (ADR-199 §12), built on `ruvector_graph::GraphDB`. +//! +//! Node labels: `Observer`, `Aircraft`, `Track`, `Observation` (the three +//! evidence samples per track: first / closest approach / last), +//! `WeatherCell`, `TimeWindow`, `Anomaly`. +//! +//! Edge types (ADR §12 vocabulary): +//! * `part_of_track` — Observation → Track, +//! * `observed_by` — Track → Observer, +//! * `during` — Track/WeatherCell/Anomaly → TimeWindow (hourly), +//! * `near` — Track → Observer when closest approach < 10 km, +//! * `correlated_with` — Track → WeatherCell overlapping in time, and +//! Track → Anomaly linking a flagged track to its score, +//! * `similar_to` — Track → Track vector-similarity link, +//! * `anomalous_relative_to` — Anomaly → the baseline Track it deviates from. + +use crate::anomaly::AnomalyReport; +use crate::config::ObserverConfig; +use crate::track::Track; +use crate::weather::WeatherWindow; +use chrono::{DateTime, Duration, Timelike, Utc}; +use ruvector_graph::{EdgeBuilder, GraphDB, Node, NodeBuilder, PropertyValue}; + +/// Structured, citeable explanation of a track (ADR §27 governance rule 1: +/// every insight links back to observations). +#[derive(Debug, Clone)] +pub struct TrackExplanation { + pub track_id: String, + pub aircraft_id: String, + pub callsign: String, + /// Evidence lines, each citing graph nodes / observation ids. + pub evidence: Vec, +} + +/// The sky-as-a-graph store. +pub struct SkyGraph { + graph: GraphDB, + observer_node_id: String, +} + +fn prop_str(node: &Node, key: &str) -> String { + match node.get_property(key) { + Some(PropertyValue::String(s)) => s.clone(), + _ => String::new(), + } +} + +fn prop_i64(node: &Node, key: &str) -> i64 { + match node.get_property(key) { + Some(PropertyValue::Integer(i)) => *i, + _ => 0, + } +} + +fn prop_f64(node: &Node, key: &str) -> f64 { + match node.get_property(key) { + Some(PropertyValue::Float(f)) => *f, + Some(PropertyValue::Integer(i)) => *i as f64, + _ => 0.0, + } +} + +impl SkyGraph { + /// Create the graph with its Observer node. + pub fn new(observer: &ObserverConfig) -> crate::Result { + let graph = GraphDB::new(); + let observer_node_id = format!("observer:{}", observer.name); + graph.create_node( + NodeBuilder::new() + .id(observer_node_id.clone()) + .label("Observer") + .property("name", observer.name.as_str()) + .property("lat", observer.lat) + .property("lon", observer.lon) + .property("alt_m", observer.alt_m) + .build(), + )?; + Ok(Self { + graph, + observer_node_id, + }) + } + + fn time_window_id(ts: DateTime) -> String { + format!("window:{}", ts.format("%Y-%m-%dT%H")) + } + + /// Get-or-create the hourly TimeWindow node containing `ts`. + fn ensure_time_window(&self, ts: DateTime) -> crate::Result { + let id = Self::time_window_id(ts); + if self.graph.get_node(&id).is_none() { + let start = ts + .date_naive() + .and_hms_opt(ts.hour(), 0, 0) + .unwrap() + .and_utc(); + self.graph.create_node( + NodeBuilder::new() + .id(id.clone()) + .label("TimeWindow") + .property("start_epoch", start.timestamp()) + .property("end_epoch", (start + Duration::hours(1)).timestamp()) + .property("start_iso", start.to_rfc3339()) + .build(), + )?; + } + Ok(id) + } + + /// Hourly windows overlapped by `[start, end]`. + fn hours_covering(start: DateTime, end: DateTime) -> Vec> { + let mut t = start + .date_naive() + .and_hms_opt(start.hour(), 0, 0) + .unwrap() + .and_utc(); + let mut out = Vec::new(); + while t <= end { + out.push(t); + t += Duration::hours(1); + } + out + } + + /// Insert a WeatherCell node and its `during` edges. + pub fn add_weather_window(&self, w: &WeatherWindow) -> crate::Result<()> { + self.graph.create_node( + NodeBuilder::new() + .id(w.window_id.clone()) + .label("WeatherCell") + .property("condition", w.condition.as_str()) + .property("wind_mps", w.wind_mps) + .property("precip_mm_hr", w.precip_mm_hr) + .property("alert", w.alert.clone().unwrap_or_default()) + .property("start_epoch", w.start.timestamp()) + .property("end_epoch", w.end.timestamp()) + .build(), + )?; + for h in Self::hours_covering(w.start, w.end - Duration::seconds(1)) { + let win = self.ensure_time_window(h)?; + self.graph + .create_edge(EdgeBuilder::new(w.window_id.clone(), win, "during").build())?; + } + Ok(()) + } + + /// Insert Aircraft + Track + evidence Observation nodes and all rule-layer + /// edges for one stitched track. Weather correlation edges are created + /// against the already-inserted weather windows. + pub fn add_track(&self, track: &Track, weather: &[WeatherWindow]) -> crate::Result { + let aircraft_id = format!("aircraft:{}", track.icao24); + if self.graph.get_node(&aircraft_id).is_none() { + self.graph.create_node( + NodeBuilder::new() + .id(aircraft_id.clone()) + .label("Aircraft") + .property("icao24", track.icao24.as_str()) + .property("callsign", track.callsign.as_str()) + .build(), + )?; + } + let (first_obs, closest_obs, last_obs) = track.evidence_observation_ids(); + let cf = track.closest_frame(); + self.graph.create_node( + NodeBuilder::new() + .id(track.track_id.clone()) + .label("Track") + .property("icao24", track.icao24.as_str()) + .property("callsign", track.callsign.as_str()) + .property("started_epoch", track.started.timestamp()) + .property("ended_epoch", track.ended.timestamp()) + .property("started_iso", track.started.to_rfc3339()) + .property("min_range_m", track.min_range_m) + .property("max_elevation_deg", track.max_elevation_deg) + .property("closest_azimuth_deg", cf.azimuth_deg) + .property("dominant_heading_deg", track.dominant_heading_deg()) + .property("mean_altitude_m", track.mean_altitude_m()) + .property("overhead", track.is_overhead_candidate) + .property("n_points", track.points.len() as i64) + .property("first_observation_id", first_obs.to_string()) + .property("closest_observation_id", closest_obs.to_string()) + .property("last_observation_id", last_obs.to_string()) + .build(), + )?; + // Evidence observation nodes (first / closest / last samples). + for (role, oid) in [ + ("first", first_obs), + ("closest_approach", closest_obs), + ("last", last_obs), + ] { + let node_id = format!("obs:{oid}"); + self.graph.create_node( + NodeBuilder::new() + .id(node_id.clone()) + .label("Observation") + .property("observation_id", oid.to_string()) + .property("role", role) + .build(), + )?; + self.graph.create_edge( + EdgeBuilder::new(node_id, track.track_id.clone(), "part_of_track") + .property("role", role) + .build(), + )?; + } + // Track relationships. + self.graph.create_edge( + EdgeBuilder::new(track.track_id.clone(), aircraft_id, "part_of_track").build(), + )?; + self.graph.create_edge( + EdgeBuilder::new( + track.track_id.clone(), + self.observer_node_id.clone(), + "observed_by", + ) + .property("min_range_m", track.min_range_m) + .build(), + )?; + if track.min_range_m < crate::track::OVERHEAD_RANGE_M { + self.graph.create_edge( + EdgeBuilder::new( + track.track_id.clone(), + self.observer_node_id.clone(), + "near", + ) + .property("range_m", track.min_range_m) + .property("at", track.closest_approach.to_rfc3339()) + .build(), + )?; + } + for h in Self::hours_covering(track.started, track.ended) { + let win = self.ensure_time_window(h)?; + self.graph + .create_edge(EdgeBuilder::new(track.track_id.clone(), win, "during").build())?; + } + for w in weather + .iter() + .filter(|w| w.overlaps(track.started, track.ended)) + { + self.graph.create_edge( + EdgeBuilder::new( + track.track_id.clone(), + w.window_id.clone(), + "correlated_with", + ) + .property("kind", "weather_context") + .build(), + )?; + } + Ok(track.track_id.clone()) + } + + /// Vector-similarity link between two tracks. + pub fn add_similarity( + &self, + from_track: &str, + to_track: &str, + distance: f32, + ) -> crate::Result<()> { + self.graph.create_edge( + EdgeBuilder::new(from_track.to_string(), to_track.to_string(), "similar_to") + .property("distance", distance as f64) + .build(), + )?; + Ok(()) + } + + /// Insert an Anomaly node for a scored track. `baseline_track_id` is the + /// most similar prior track (the baseline the anomaly deviates from). + pub fn add_anomaly( + &self, + report: &AnomalyReport, + baseline_track_id: Option<&str>, + ) -> crate::Result { + let anomaly_id = format!("anomaly:{}", report.track_id); + self.graph.create_node( + NodeBuilder::new() + .id(anomaly_id.clone()) + .label("Anomaly") + .property("track_id", report.track_id.as_str()) + .property("score", report.score) + .property("band", report.band.to_string()) + .property("reasons", report.reasons.join("; ")) + .build(), + )?; + self.graph.create_edge( + EdgeBuilder::new( + report.track_id.clone(), + anomaly_id.clone(), + "correlated_with", + ) + .property("kind", "anomaly_score") + .build(), + )?; + if let Some(baseline) = baseline_track_id { + if self.graph.get_node(baseline).is_some() { + self.graph.create_edge( + EdgeBuilder::new( + anomaly_id.clone(), + baseline.to_string(), + "anomalous_relative_to", + ) + .build(), + )?; + } + } + Ok(anomaly_id) + } + + /// Aircraft active in `[start, end]`: `(icao24, track_id)` pairs. + pub fn aircraft_in_window( + &self, + start: DateTime, + end: DateTime, + ) -> Vec<(String, String)> { + let (s, e) = (start.timestamp(), end.timestamp()); + let mut out: Vec<(String, String)> = self + .graph + .get_nodes_by_label("Track") + .into_iter() + .filter(|n| prop_i64(n, "started_epoch") <= e && prop_i64(n, "ended_epoch") >= s) + .map(|n| (prop_str(&n, "icao24"), n.id)) + .collect(); + out.sort(); + out + } + + /// Track ids satisfying the §14 overhead rule. + pub fn overhead_candidates(&self) -> Vec { + let mut ids: Vec = self + .graph + .get_nodes_by_property("overhead", &PropertyValue::Boolean(true)) + .into_iter() + .map(|n| n.id) + .collect(); + ids.sort(); + ids + } + + /// `(node_count, edge_count)`. + pub fn stats(&self) -> (usize, usize) { + (self.graph.node_count(), self.graph.edge_count()) + } + + /// Structured explanation listing the graph evidence for a track. + pub fn explain(&self, track_id: &str) -> Option { + let node = self.graph.get_node(track_id)?; + let mut evidence = Vec::new(); + evidence.push(format!( + "track {track_id} stitched from {} observations; evidence observation ids: first {}, closest approach {}, last {}", + prop_i64(&node, "n_points"), + prop_str(&node, "first_observation_id"), + prop_str(&node, "closest_observation_id"), + prop_str(&node, "last_observation_id"), + )); + evidence.push(format!( + "geometry: closest approach {:.0} m at azimuth {:.0}°, max elevation {:.1}°, dominant heading {:.0}°, mean altitude {:.0} m", + prop_f64(&node, "min_range_m"), + prop_f64(&node, "closest_azimuth_deg"), + prop_f64(&node, "max_elevation_deg"), + prop_f64(&node, "dominant_heading_deg"), + prop_f64(&node, "mean_altitude_m"), + )); + for edge in self.graph.get_outgoing_edges(&track_id.to_string()) { + match edge.edge_type.as_str() { + "observed_by" => evidence.push(format!("observed_by {}", edge.to)), + "near" => { + evidence.push(format!("near {} (closest approach inside 10 km)", edge.to)) + } + "during" => evidence.push(format!("during {}", edge.to)), + "part_of_track" => evidence.push(format!("flight of {}", edge.to)), + "similar_to" => evidence.push(format!("similar_to {}", edge.to)), + "correlated_with" => { + if let Some(w) = self.graph.get_node(&edge.to) { + if w.has_label("WeatherCell") { + evidence.push(format!( + "correlated_with {} ({}, wind {:.1} m/s)", + edge.to, + prop_str(&w, "condition"), + prop_f64(&w, "wind_mps") + )); + } else if w.has_label("Anomaly") { + evidence.push(format!( + "anomaly score {:.2} ({}): {}", + prop_f64(&w, "score"), + prop_str(&w, "band"), + prop_str(&w, "reasons") + )); + for be in self.graph.get_outgoing_edges(&edge.to) { + if be.edge_type == "anomalous_relative_to" { + evidence + .push(format!("anomalous_relative_to baseline {}", be.to)); + } + } + } + } + } + _ => {} + } + } + Some(TrackExplanation { + track_id: track_id.to_string(), + aircraft_id: prop_str(&node, "icao24"), + callsign: prop_str(&node, "callsign"), + evidence, + }) + } +} diff --git a/examples/sky-monitor/src/track.rs b/examples/sky-monitor/src/track.rs new file mode 100644 index 0000000000..21747486bb --- /dev/null +++ b/examples/sky-monitor/src/track.rs @@ -0,0 +1,346 @@ +//! Track stitching (ADR-199 §19 `skygraph-builder`, Phase 3). +//! +//! Groups per-aircraft observations into [`Track`] segments, splitting on +//! reception gaps, and computes the summary statistics used by the embeddings +//! (§13), the rule layer (§14), and anomaly scoring (§15). + +use crate::config::ObserverConfig; +use crate::coords::ObserverFrame; +use crate::observation::{EntityType, Observation}; +use chrono::{DateTime, Timelike, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use uuid::Uuid; + +/// Gap (seconds) after which a new track segment starts for the same icao24. +pub const TRACK_GAP_SECS: i64 = 60; +/// ADR-199 §14 rule 1: overhead candidate iff range < 10 km AND elevation > 5°. +pub const OVERHEAD_RANGE_M: f64 = 10_000.0; +pub const OVERHEAD_ELEVATION_DEG: f64 = 5.0; + +/// One time-ordered point of a stitched track. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrackPoint { + pub observation_id: Uuid, + pub ts: DateTime, + pub lat: f64, + pub lon: f64, + pub alt_m: f64, + pub speed_mps: f64, + pub track_deg: f64, + pub vertical_rate_mps: f64, + pub signal_dbfs: f64, + pub frame: ObserverFrame, +} + +/// A stitched flight path segment through local airspace. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Track { + pub track_id: String, + pub icao24: String, + pub callsign: String, + pub points: Vec, + pub started: DateTime, + pub ended: DateTime, + /// Minimum slant range to the observer, metres. + pub min_range_m: f64, + /// Time of closest approach. + pub closest_approach: DateTime, + /// Maximum elevation seen, degrees. + pub max_elevation_deg: f64, + /// ADR §14 rule 1 result. + pub is_overhead_candidate: bool, +} + +impl Track { + fn from_points( + icao24: String, + callsign: String, + segment: usize, + points: Vec, + ) -> Self { + let started = points.first().map(|p| p.ts).unwrap_or_else(Utc::now); + let ended = points.last().map(|p| p.ts).unwrap_or(started); + let closest = points + .iter() + .min_by(|a, b| a.frame.range_m.total_cmp(&b.frame.range_m)) + .expect("track has at least one point"); + let min_range_m = closest.frame.range_m; + let closest_approach = closest.ts; + let max_elevation_deg = points + .iter() + .map(|p| p.frame.elevation_deg) + .fold(f64::NEG_INFINITY, f64::max); + let is_overhead_candidate = + min_range_m < OVERHEAD_RANGE_M && max_elevation_deg > OVERHEAD_ELEVATION_DEG; + Self { + track_id: format!("track-{icao24}-{segment}"), + icao24, + callsign, + points, + started, + ended, + min_range_m, + closest_approach, + max_elevation_deg, + is_overhead_candidate, + } + } + + pub fn duration_secs(&self) -> f64 { + (self.ended - self.started).num_milliseconds() as f64 / 1000.0 + } + + pub fn mean_altitude_m(&self) -> f64 { + mean(self.points.iter().map(|p| p.alt_m)) + } + + pub fn altitude_std_m(&self) -> f64 { + std_dev(self.points.iter().map(|p| p.alt_m)) + } + + pub fn mean_speed_mps(&self) -> f64 { + mean(self.points.iter().map(|p| p.speed_mps)) + } + + pub fn speed_std_mps(&self) -> f64 { + std_dev(self.points.iter().map(|p| p.speed_mps)) + } + + pub fn mean_signal_dbfs(&self) -> f64 { + mean(self.points.iter().map(|p| p.signal_dbfs)) + } + + pub fn mean_elevation_deg(&self) -> f64 { + mean(self.points.iter().map(|p| p.frame.elevation_deg)) + } + + /// Circular-mean ground track, degrees in `[0, 360)`. + pub fn dominant_heading_deg(&self) -> f64 { + let (s, c) = self.points.iter().fold((0.0, 0.0), |(s, c), p| { + let r = p.track_deg.to_radians(); + (s + r.sin(), c + r.cos()) + }); + crate::coords::normalize_deg(s.atan2(c).to_degrees()) + } + + /// Heading histogram over `bins` equal azimuth buckets (fractions sum 1). + pub fn heading_histogram(&self, bins: usize) -> Vec { + let mut h = vec![0.0; bins]; + for p in &self.points { + let idx = ((p.track_deg.rem_euclid(360.0) / 360.0) * bins as f64) as usize % bins; + h[idx] += 1.0; + } + let n = self.points.len().max(1) as f64; + h.iter_mut().for_each(|v| *v /= n); + h + } + + /// Total path length, metres (sum of great-circle-approximated steps). + pub fn path_length_m(&self) -> f64 { + self.points + .windows(2) + .map(|w| flat_distance_m(w[0].lat, w[0].lon, w[1].lat, w[1].lon)) + .sum() + } + + /// Net displacement / path length in `[0, 1]` (1 = perfectly straight). + pub fn straightness(&self) -> f64 { + let path = self.path_length_m(); + if path < 1.0 { + return 1.0; + } + let (a, b) = (self.points.first().unwrap(), self.points.last().unwrap()); + (flat_distance_m(a.lat, a.lon, b.lat, b.lon) / path).clamp(0.0, 1.0) + } + + /// Fraction of points climbing faster than +2 m/s. + pub fn climb_ratio(&self) -> f64 { + ratio(&self.points, |p| p.vertical_rate_mps > 2.0) + } + + /// Fraction of points descending faster than −2 m/s. + pub fn descent_ratio(&self) -> f64 { + ratio(&self.points, |p| p.vertical_rate_mps < -2.0) + } + + pub fn mean_abs_vertical_rate_mps(&self) -> f64 { + mean(self.points.iter().map(|p| p.vertical_rate_mps.abs())) + } + + /// UTC hour of the track start (0–23) — used for time-of-day rarity. + pub fn start_hour_utc(&self) -> u32 { + self.started.hour() + } + + /// Observer frame at the closest point of approach. + pub fn closest_frame(&self) -> &ObserverFrame { + &self + .points + .iter() + .min_by(|a, b| a.frame.range_m.total_cmp(&b.frame.range_m)) + .expect("track has at least one point") + .frame + } + + /// First / closest-approach / last observation ids (evidence links). + pub fn evidence_observation_ids(&self) -> (Uuid, Uuid, Uuid) { + let closest = self + .points + .iter() + .min_by(|a, b| a.frame.range_m.total_cmp(&b.frame.range_m)) + .unwrap(); + ( + self.points.first().unwrap().observation_id, + closest.observation_id, + self.points.last().unwrap().observation_id, + ) + } +} + +fn mean(it: impl Iterator) -> f64 { + let (sum, n) = it.fold((0.0, 0usize), |(s, n), v| (s + v, n + 1)); + if n == 0 { + 0.0 + } else { + sum / n as f64 + } +} + +fn std_dev(it: impl Iterator + Clone) -> f64 { + let m = mean(it.clone()); + let (sq, n) = it.fold((0.0, 0usize), |(s, n), v| (s + (v - m) * (v - m), n + 1)); + if n == 0 { + 0.0 + } else { + (sq / n as f64).sqrt() + } +} + +fn ratio(points: &[TrackPoint], pred: impl Fn(&TrackPoint) -> bool) -> f64 { + if points.is_empty() { + return 0.0; + } + points.iter().filter(|p| pred(p)).count() as f64 / points.len() as f64 +} + +/// Equirectangular-approximation ground distance, metres (fine at local scale). +fn flat_distance_m(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 { + let dy = (lat2 - lat1) * 111_132.0; + let dx = (lon2 - lon1) * 111_320.0 * ((lat1 + lat2) / 2.0).to_radians().cos(); + dx.hypot(dy) +} + +/// Stitch aircraft observations into tracks: group by `entity_id` (icao24), +/// order by time, and split whenever consecutive samples are more than +/// `gap_secs` apart. Non-aircraft observations are ignored. +pub fn stitch_tracks( + _observer: &ObserverConfig, + observations: &[Observation], + gap_secs: i64, +) -> Vec { + let mut by_aircraft: BTreeMap> = BTreeMap::new(); + for o in observations { + if o.entity_type == EntityType::Aircraft { + by_aircraft.entry(o.entity_id.clone()).or_default().push(o); + } + } + let mut tracks = Vec::new(); + for (icao24, mut group) in by_aircraft { + group.sort_by_key(|o| o.timestamp_utc); + let mut segment: Vec = Vec::new(); + let mut seg_no = 0usize; + let mut callsign = String::new(); + let mut last_ts: Option> = None; + for o in group { + if let (Some(prev), true) = (last_ts, !segment.is_empty()) { + if (o.timestamp_utc - prev).num_seconds() > gap_secs { + tracks.push(Track::from_points( + icao24.clone(), + callsign.clone(), + seg_no, + std::mem::take(&mut segment), + )); + seg_no += 1; + } + } + if callsign.is_empty() { + callsign = o.callsign().unwrap_or("").to_string(); + } + segment.push(TrackPoint { + observation_id: o.observation_id, + ts: o.timestamp_utc, + lat: o.location.lat, + lon: o.location.lon, + alt_m: o.location.alt_m, + speed_mps: o.motion.speed_mps, + track_deg: o.motion.track_deg, + vertical_rate_mps: o.motion.vertical_rate_mps, + signal_dbfs: o.signal_dbfs().unwrap_or(-30.0), + frame: o.observer_frame, + }); + last_ts = Some(o.timestamp_utc); + } + if !segment.is_empty() { + tracks.push(Track::from_points(icao24, callsign, seg_no, segment)); + } + } + tracks.sort_by_key(|t| t.started); + tracks +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::adsb::{default_day_start, generate_scenario}; + use crate::observation::{GeoPosition, Motion}; + + fn observations() -> Vec { + let cfg = ObserverConfig::default(); + generate_scenario(cfg.lat, cfg.lon, 42, default_day_start()) + .iter() + .map(|s| { + Observation::new( + &cfg, + "adsb_synthetic", + EntityType::Aircraft, + s.icao24.clone(), + s.ts, + GeoPosition { + lat: s.lat, + lon: s.lon, + alt_m: s.alt_m, + }, + Motion { + speed_mps: s.speed_mps, + track_deg: s.track_deg, + vertical_rate_mps: s.vertical_rate_mps, + }, + serde_json::json!({ "callsign": s.callsign, "signal_dbfs": s.signal_dbfs }), + 0.95, + ) + }) + .collect() + } + + #[test] + fn stitches_one_track_per_synthetic_flight() { + let cfg = ObserverConfig::default(); + let tracks = stitch_tracks(&cfg, &observations(), TRACK_GAP_SECS); + assert_eq!(tracks.len(), 10, "10 flights → 10 tracks"); + assert!(tracks.windows(2).all(|w| w[0].started <= w[1].started)); + let ga = tracks + .iter() + .find(|t| t.icao24 == crate::adsb::GA_OVERHEAD_ICAO24) + .unwrap(); + assert!(ga.is_overhead_candidate, "GA pass must satisfy ADR rule 1"); + assert!((ga.dominant_heading_deg() - 88.0).abs() < 3.0); + assert!(ga.straightness() > 0.95); + let corridor = tracks.iter().find(|t| t.icao24 == "c01a01").unwrap(); + assert!( + !corridor.is_overhead_candidate, + "en-route corridor is > 10 km slant range" + ); + assert!(corridor.mean_altitude_m() > 10_000.0); + } +} diff --git a/examples/sky-monitor/src/weather.rs b/examples/sky-monitor/src/weather.rs new file mode 100644 index 0000000000..e7a915c134 --- /dev/null +++ b/examples/sky-monitor/src/weather.rs @@ -0,0 +1,113 @@ +//! Synthetic weather context (ADR-199 §9.3 / Phase 2). +//! +//! Stand-in for the MSC GeoMet collector: produces hourly +//! [`WeatherWindow`]s aligned to the synthetic ADS-B timeline, including the +//! light-rain band the ADR's sample brief mentions (14:10–15:30) and calm +//! clear conditions overnight (so the anomalous 03:10 track has no weather +//! corroboration). + +use crate::adsb::Lcg; +use chrono::{DateTime, Duration, Utc}; +use serde::{Deserialize, Serialize}; + +/// Coarse sky condition for a window. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WeatherCondition { + Clear, + Cloudy, + Rain, + Thunderstorm, + Snow, + Fog, +} + +impl WeatherCondition { + pub fn as_str(&self) -> &'static str { + match self { + WeatherCondition::Clear => "clear", + WeatherCondition::Cloudy => "cloudy", + WeatherCondition::Rain => "rain", + WeatherCondition::Thunderstorm => "thunderstorm", + WeatherCondition::Snow => "snow", + WeatherCondition::Fog => "fog", + } + } +} + +/// One bounded weather window over the observer (a `WeatherCell` node source). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WeatherWindow { + pub window_id: String, + pub start: DateTime, + pub end: DateTime, + pub condition: WeatherCondition, + /// Mean wind speed, m/s. + pub wind_mps: f64, + /// Precipitation rate, mm/h. + pub precip_mm_hr: f64, + /// Official alert text, if any (drives the §14 weather-suppression rule). + pub alert: Option, +} + +impl WeatherWindow { + /// True if this window overlaps `[start, end]`. + pub fn overlaps(&self, start: DateTime, end: DateTime) -> bool { + self.start < end && start < self.end + } +} + +/// Generate 28 hourly windows from `day_start` (covers the next-night anomaly +/// at +27 h). Deterministic for a given seed. +pub fn generate_weather(seed: u64, day_start: DateTime) -> Vec { + let mut rng = Lcg::new(seed ^ 0x5eed_2ea7_dead_beef); + (0..28) + .map(|h| { + let start = day_start + Duration::hours(h); + // Light rain band 14:00-16:00; cloudy shoulder hours; clear otherwise. + let (condition, precip, alert) = match h { + 13 => (WeatherCondition::Cloudy, 0.0, None), + 14 | 15 => ( + WeatherCondition::Rain, + 1.2 + rng.next_f64() * 0.8, + Some("light rain advisory".to_string()), + ), + 16 => (WeatherCondition::Cloudy, 0.1, None), + _ => (WeatherCondition::Clear, 0.0, None), + }; + WeatherWindow { + window_id: format!("weather:{}", start.format("%Y-%m-%dT%H")), + start, + end: start + Duration::hours(1), + condition, + wind_mps: 2.0 + rng.next_f64() * 4.0, + precip_mm_hr: precip, + alert, + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::adsb::default_day_start; + + #[test] + fn weather_timeline_is_deterministic_with_rain_band() { + let a = generate_weather(42, default_day_start()); + let b = generate_weather(42, default_day_start()); + assert_eq!(a.len(), 28); + assert_eq!(a[5].wind_mps.to_bits(), b[5].wind_mps.to_bits()); + assert_eq!(a[14].condition, WeatherCondition::Rain); + assert!(a[14].alert.is_some()); + assert_eq!( + a[27].condition, + WeatherCondition::Clear, + "anomaly hour is clear" + ); + // Overlap math. + assert!(a[14].overlaps(a[14].start, a[14].end)); + assert!(!a[14].overlaps(a[16].start, a[16].end)); + } +} diff --git a/examples/sky-monitor/tests/acceptance.rs b/examples/sky-monitor/tests/acceptance.rs new file mode 100644 index 0000000000..2eeab93ddf --- /dev/null +++ b/examples/sky-monitor/tests/acceptance.rs @@ -0,0 +1,268 @@ +//! Acceptance tests mapped to ADR-199 §31 (system acceptance) and the §22 +//! Phase 1–4 build-plan acceptance columns. + +use chrono::Duration; +use sky_monitor::{ + observer_frame, parse_dump1090, AnomalyConfig, Interpretation, ObserverConfig, Pipeline, + ANOMALOUS_ICAO24, GA_OVERHEAD_ICAO24, +}; + +const EASTBOUND_CORRIDOR: [&str; 4] = ["c01a01", "a02b02", "a03c03", "c04d04"]; +const WESTBOUND_CORRIDOR: [&str; 2] = ["400a05", "39a006"]; + +fn run() -> sky_monitor::PipelineReport { + Pipeline::default().run().expect("pipeline runs") +} + +/// (1) §31.2 — state vectors convert to azimuth/elevation/range. +#[test] +fn acceptance_1_positions_convert_to_az_el_range() { + let cfg = ObserverConfig::default(); + // Synthetic target ~10 km north-east, 5 km up. + let f = observer_frame( + cfg.lat, + cfg.lon, + cfg.alt_m, + cfg.lat + 0.0636, + cfg.lon + 0.0875, + 5_000.0, + ); + assert!( + f.range_m > 9_000.0 && f.range_m < 13_500.0, + "range {}", + f.range_m + ); + assert!( + f.azimuth_deg > 30.0 && f.azimuth_deg < 60.0, + "az {}", + f.azimuth_deg + ); + assert!( + f.elevation_deg > 20.0 && f.elevation_deg < 35.0, + "el {}", + f.elevation_deg + ); + + // And every pipeline observation carries a finite observer frame. + let report = run(); + assert!(report.observations.iter().all(|o| { + o.observer_frame.range_m.is_finite() + && (0.0..360.0).contains(&o.observer_frame.azimuth_deg) + && o.observer_frame.elevation_deg.abs() <= 90.0 + })); +} + +/// (2) §14 rule 1 / §22 Phase 3 — the overhead query returns the low GA pass +/// (and the low anomalous pass) but not the high en-route corridor flights. +#[test] +fn acceptance_2_overhead_query_excludes_en_route() { + let report = run(); + let overhead = report.skygraph.overhead_candidates(); + assert!( + overhead.iter().any(|id| id.contains(GA_OVERHEAD_ICAO24)), + "GA overhead pass missing from {overhead:?}" + ); + for icao in EASTBOUND_CORRIDOR.iter().chain(&WESTBOUND_CORRIDOR) { + assert!( + !overhead.iter().any(|id| id.contains(icao)), + "en-route corridor flight {icao} must not be an overhead candidate" + ); + } +} + +/// (3) §31.6 — "what flew overhead in this period" via the SkyGraph +/// time-window query. +#[test] +fn acceptance_3_aircraft_by_time_window() { + let report = run(); + let pipeline = Pipeline::default(); + // 11:00–12:00 UTC contains exactly the first eastbound corridor flight. + let start = pipeline.day_start + Duration::hours(11); + let in_window = report + .skygraph + .aircraft_in_window(start, start + Duration::hours(1)); + assert_eq!(in_window.len(), 1, "got {in_window:?}"); + assert_eq!(in_window[0].0, "c01a01"); + // The anomaly night window (+27 h) contains exactly the anomalous track. + let night = pipeline.day_start + Duration::hours(27); + let in_night = report + .skygraph + .aircraft_in_window(night, night + Duration::hours(1)); + assert_eq!(in_night.len(), 1, "got {in_night:?}"); + assert_eq!(in_night[0].0, ANOMALOUS_ICAO24); + // An empty pre-dawn window has no aircraft. + let empty = pipeline.day_start + Duration::hours(2); + assert!(report + .skygraph + .aircraft_in_window(empty, empty + Duration::hours(1)) + .is_empty()); +} + +/// (4) §31.8 / §15 — after the baseline period the anomalous track raises a +/// local alert (> 0.76) while normal corridor flights stay ≤ 0.55. +#[test] +fn acceptance_4_anomaly_scoring_separates_corridor_traffic() { + let report = run(); + let cfg = AnomalyConfig::default(); + let anomaly = report + .reports + .iter() + .find(|r| r.icao24 == ANOMALOUS_ICAO24) + .expect("anomalous track is scored (it is last in the day)"); + assert!( + anomaly.score > cfg.alert_threshold, + "anomalous track must exceed the {} alert threshold, got {:.3} ({:?})", + cfg.alert_threshold, + anomaly.score, + anomaly.components + ); + assert!(anomaly.band >= Interpretation::StrongAnomaly); + assert!( + !anomaly.reasons.is_empty(), + "governance rule 2: reasons required" + ); + + let corridor: Vec<_> = report + .reports + .iter() + .filter(|r| { + EASTBOUND_CORRIDOR.contains(&r.icao24.as_str()) + || WESTBOUND_CORRIDOR.contains(&r.icao24.as_str()) + }) + .collect(); + assert!( + !corridor.is_empty(), + "some corridor flights must be scored post-baseline" + ); + for r in corridor { + assert!( + r.score <= 0.55, + "corridor flight {} should stay ≤ 0.55, got {:.3} ({:?})", + r.icao24, + r.score, + r.components + ); + } + // Governance: every report carries at least one reason. + assert!(report.reports.iter().all(|r| !r.reasons.is_empty())); +} + +/// (5) §31.9 / §27 rule 1 — explain() cites observation and track ids. +#[test] +fn acceptance_5_explain_cites_observation_ids() { + let report = run(); + let anomalous = report + .tracks + .iter() + .find(|t| t.icao24 == ANOMALOUS_ICAO24) + .unwrap(); + let explanation = report + .skygraph + .explain(&anomalous.track_id) + .expect("track explainable"); + assert_eq!(explanation.aircraft_id, ANOMALOUS_ICAO24); + let joined = explanation.evidence.join("\n"); + assert!( + joined.contains(&anomalous.track_id), + "must cite the track id" + ); + let (first, closest, last) = anomalous.evidence_observation_ids(); + for oid in [first, closest, last] { + assert!( + joined.contains(&oid.to_string()), + "must cite observation {oid}" + ); + } + assert!( + joined.contains("anomaly score"), + "must surface the anomaly evidence" + ); + assert!( + joined.contains("anomalous_relative_to"), + "must cite the deviated-from baseline" + ); +} + +/// (6) §22 Phase 4 — similarity search returns same-corridor flights before +/// the anomalous track. +#[test] +fn acceptance_6_similarity_prefers_same_corridor() { + let report = run(); + let east: Vec<_> = report + .tracks + .iter() + .filter(|t| EASTBOUND_CORRIDOR.contains(&t.icao24.as_str())) + .collect(); + // Every eastbound corridor flight's best partner in the top similarity + // pairs must be corridor traffic, never the anomalous track. + for (a, b, _) in report.similar_pairs.iter().take(5) { + assert!( + !a.contains(ANOMALOUS_ICAO24) && !b.contains(ANOMALOUS_ICAO24), + "anomalous track must not appear in the closest pairs: {a} <-> {b}" + ); + } + // And explicitly: nearest neighbour of an eastbound flight is eastbound. + let pair = report + .similar_pairs + .iter() + .find(|(a, b, _)| { + east.iter().any(|t| t.track_id == *a) || east.iter().any(|t| t.track_id == *b) + }) + .expect("an eastbound pair exists"); + let both_eastbound = EASTBOUND_CORRIDOR + .iter() + .filter(|i| pair.0.contains(*i) || pair.1.contains(*i)) + .count(); + assert_eq!( + both_eastbound, 2, + "closest eastbound pair must be two eastbound flights: {pair:?}" + ); +} + +/// (7) §31.7 — the daily brief renders with non-zero counts. +#[test] +fn acceptance_7_brief_renders_with_counts() { + let report = run(); + let brief = &report.brief; + assert_eq!(brief.aircraft_observed, 10); + assert!(brief.overhead_candidates > 0); + assert!(brief.unusual_tracks > 0); + assert!( + !brief.weather_events.is_empty(), + "the rain band must be reported" + ); + let text = brief.to_string(); + assert!(text.contains("Sky brief — oakville_node")); + assert!(text.contains("10 aircraft observed")); + assert!(text.contains("Most unusual event"), "brief: {text}"); + let mu = brief.most_unusual.as_ref().unwrap(); + assert!(mu.confidence > 0.55); +} + +/// (8) §9.1 — dump1090-style JSON parses into the same pipeline input type. +#[test] +fn acceptance_8_dump1090_parser() { + let json = r#"{ + "now": 1765219200.0, + "aircraft": [ + { "hex": "c01a01", "flight": "ACA101 ", "alt_baro": 35000, "gs": 460, + "track": 71.8, "baro_rate": 0, "lat": 43.49, "lon": -79.62, "rssi": -17.9 } + ] + }"#; + let states = parse_dump1090(json).unwrap(); + assert_eq!(states.len(), 1); + assert_eq!(states[0].icao24, "c01a01"); + assert_eq!(states[0].callsign, "ACA101"); + assert!((states[0].alt_m - 10_668.0).abs() < 1.0); + // The parsed state projects into the observer frame like any other. + let cfg = ObserverConfig::default(); + let f = observer_frame( + cfg.lat, + cfg.lon, + cfg.alt_m, + states[0].lat, + states[0].lon, + states[0].alt_m, + ); + assert!(f.range_m > 5_000.0 && f.elevation_deg > 30.0); +} diff --git a/examples/sky-monitor/ui/dashboard/README.md b/examples/sky-monitor/ui/dashboard/README.md new file mode 100644 index 0000000000..06d50a840e --- /dev/null +++ b/examples/sky-monitor/ui/dashboard/README.md @@ -0,0 +1,105 @@ +# SkyGraph all-sky dashboard (ADR-199 presentation plane) + +Vanilla JS + Canvas, no build tooling. **Realtime**: polls live ADS-B traffic +(airplanes.live primary, adsb.lol fallback — key-free, CORS-friendly) every +5 s and Open-Meteo weather every 10 min around the fixed observer, and renders +it on a polar all-sky plot: zenith at the centre, horizon at the edge, azimuth +0° = North = up. Aircraft are dots with callsign + altitude labels and fading +trails; between polls the dots glide on smoothed dead reckoning. There is no +embedded scenario — when no ADS-B source is reachable, the dome stays up and +the status line reports offline/retrying. + +**Satellite layer** (`sat-feed.js`): CelesTrak TLEs (ACAO `*`, localStorage +6 h cache per group) propagated per frame with SGP4 in `sky-monitor-wasm`. +The ⚙ drawer selects the group — `visual` (~150 brightest, default), +`stations`, or `starlink` (offered only while the WebGPU layer is on). +**Sun & moon** (`astro.js`) render on the dome; sunlit satellites under a +dark sky (sun < −6°) are flagged **✦ visible now**. + +**Real §15 anomaly scoring with vector novelty** (`score-live.js` + +`novelty.js`): every live track is embedded through the wasm §13 32-dim +`embed_track` (canonical `embedding.rs` normalization; per-point motion +derived in Rust) and scored against a rolling IndexedDB store of past track +embeddings (cap ~5 000, oldest pruned) with the indexer-calibrated `novelty` +(mean top-3 distance / 1.2). The §15 composite then runs in the wasm +`AnomalyScorer` with that real novelty term — dots/trails/rows take the band +color and the details panel shows score, reasons, and the novelty line. + +**Behavior badges** (`behavior.js`, pure functions): holding patterns +(net ≪ path), survey grids (parallel legs + 180° reversals), go-arounds +(descent < 600 m then sustained climb), and formation pairs (< 1 km, matched +vector) — `[HOLD]`/`[GRID]`/`[GO-AROUND]`/`[FORM]` in the table + details. + +**Conflict prediction** (`conflict.js`): pairwise CPA in observer ENU — alert +when predicted separation < 1 km horizontal AND < 300 m vertical within 90 s. +Conflicting pairs get a dashed red line + ⚠ status; the selected aircraft +shows a turn-aware predicted-path cone (heading rate from recent fixes). + +**Satellite pass timeline** (`passes.js`): wasm `predict_passes` steps SGP4 +24 h ahead (30 s grid, sun model in Rust) and the side panel lists the next +naked-eye passes (rise–set local times, max el, direction); a drawer button +arms a Notification ~5 min before each visible pass (permission-gated). + +**Route enrichment** (`route-info.js`): on selection only, the callsign is +looked up at adsbdb (probed 2026-06-10: ACAO `*`; 404 = unknown callsign) — +airline + origin → destination in the details panel, 24 h localStorage cache. + +**Space weather** (`space-wx.js`): NOAA SWPC planetary Kp (probed 2026-06-10: +ACAO `*`), polled 15 min, shown in the weather card with an aurora hint for +43°N at Kp ≥ 7. + +**WebGPU sats (experimental)** (`gpu-sats.js`): a drawer toggle moves the +satellite layer to instanced point sprites on a transparent overlay canvas — +the path that scales to starlink (~7 000 dots). Automatic fallback to +Canvas2D when `navigator.gpu` is missing or init fails; Canvas2D stays the +default. + +**Recorded replay** (`record.js`): live projected points stream into an +IndexedDB ring buffer (~1 h). The footer ⏪ button + scrubber re-render the +dome at any past wall-clock t from that buffer (same drawTrack path); LIVE +returns to now. This replays **real recorded traffic** — the synthetic-day +replay was deliberately removed. + +The **⚙ drawer** (top right) toggles layers (aircraft / satellites / +sun & moon / trails / labels / conflict alerts) and settings (trail length, +TLE group, WebGPU sats, pass alerts); choices persist in localStorage. + +## Serve + +```bash +# from this directory (ES modules need http://, not file://) +python3 -m http.server 8000 +# open http://localhost:8000/ +``` + +## Optional: wasm projection engine + +`sky.js` does the WGS-84 → az/el/range projection in plain JS (mirroring +`src/coords.rs`). If the wasm-pack output exists at `./pkg/`, it is detected +and preferred automatically: + +```bash +# from the repository root +wasm-pack build examples/sky-monitor/wasm --target web --out-dir ../ui/dashboard/pkg +``` + +The header shows which engine is active. Without `./pkg` the dashboard still +renders live traffic on the JS fallback; the satellite layer, §15 scoring, +vector novelty, and pass prediction require the wasm pkg. + +## Tests + +Pure detector logic (behavior, CPA) runs under node: + +```bash +node --test test/behavior.test.mjs test/conflict.test.mjs +``` + +## Module map + +`sky.js` (conductor) · `draw.js` (dome/track/cone primitives) · +`settings.js` (⚙ drawer) · `panels.js` (details + sat table) · +`live-feed.js` / `sat-feed.js` / `space-wx.js` / `route-info.js` (data) · +`novelty.js` / `behavior.js` / `conflict.js` / `passes.js` (intelligence) · +`record.js` (replay) · `gpu-sats.js` (WebGPU) · `project.js` / `astro.js` +(math) · `score-live.js` (§15 bridge). Every file stays under 500 lines. diff --git a/examples/sky-monitor/ui/dashboard/astro.js b/examples/sky-monitor/ui/dashboard/astro.js new file mode 100644 index 0000000000..e38e27c08c --- /dev/null +++ b/examples/sky-monitor/ui/dashboard/astro.js @@ -0,0 +1,78 @@ +// Low-precision sun & moon positions (truncated Meeus series, good to a +// fraction of a degree for the sun and ~1° for the moon — display grade) and +// the cylinder-shadow satellite illumination test. A satellite is naked-eye +// "visible now" when it is above the horizon, sunlit, and the observer's sky +// is dark (sun below -6°, civil twilight). + +import { DEG, geodeticToEcef, normalizeDeg } from "./project.js"; + +const EARTH_R_KM = 6371.0; + +function j2000Days(unixS) { + return unixS / 86400.0 - 10957.5; // days since J2000.0 (2000-01-01 12:00 UTC) +} + +function gmstDeg(unixS) { + return normalizeDeg(280.46061837 + 360.98564736629 * j2000Days(unixS)); +} + +// Equatorial RA/dec (deg) -> observer az/el (deg, az 0 = North). +function raDecToAzEl(raDeg, decDeg, lat, lon, unixS) { + const H = normalizeDeg(gmstDeg(unixS) + lon - raDeg) * DEG; + const phi = lat * DEG, dec = decDeg * DEG; + const el = Math.asin( + Math.sin(phi) * Math.sin(dec) + Math.cos(phi) * Math.cos(dec) * Math.cos(H)); + const az = Math.atan2( + -Math.sin(H), Math.tan(dec) * Math.cos(phi) - Math.sin(phi) * Math.cos(H)); + return [normalizeDeg(az / DEG), el / DEG]; +} + +// Sun: {az, el, dir} where dir is the ECEF unit vector toward the sun +// (via the subsolar point — for satellite shadow tests). +export function sunPosition(unixS, lat, lon) { + const d = j2000Days(unixS); + const L = normalizeDeg(280.460 + 0.9856474 * d); + const g = normalizeDeg(357.528 + 0.9856003 * d) * DEG; + const lambda = (L + 1.915 * Math.sin(g) + 0.020 * Math.sin(2 * g)) * DEG; + const eps = (23.439 - 0.0000004 * d) * DEG; + const ra = normalizeDeg( + Math.atan2(Math.cos(eps) * Math.sin(lambda), Math.cos(lambda)) / DEG); + const dec = Math.asin(Math.sin(eps) * Math.sin(lambda)) / DEG; + const [az, el] = raDecToAzEl(ra, dec, lat, lon, unixS); + let subLon = normalizeDeg(ra - gmstDeg(unixS)); + if (subLon > 180) subLon -= 360; + const v = geodeticToEcef(dec, subLon, 0); + const n = Math.hypot(v[0], v[1], v[2]); + return { az, el, dir: [v[0] / n, v[1] / n, v[2] / n] }; +} + +// Moon: {az, el}. Topocentric parallax (~1°) ignored — display only. +export function moonPosition(unixS, lat, lon) { + const d = j2000Days(unixS); + const L = (218.316 + 13.176396 * d) * DEG; // mean longitude + const M = (134.963 + 13.064993 * d) * DEG; // mean anomaly + const F = (93.272 + 13.229350 * d) * DEG; // argument of latitude + const lambda = L + 6.289 * DEG * Math.sin(M); + const beta = 5.128 * DEG * Math.sin(F); + const eps = 23.439 * DEG; + const ra = normalizeDeg(Math.atan2( + Math.sin(lambda) * Math.cos(eps) - Math.tan(beta) * Math.sin(eps), + Math.cos(lambda)) / DEG); + const dec = Math.asin( + Math.sin(beta) * Math.cos(eps) + + Math.cos(beta) * Math.sin(eps) * Math.sin(lambda)) / DEG; + const [az, el] = raDecToAzEl(ra, dec, lat, lon, unixS); + return { az, el }; +} + +// Cylinder-shadow test: is a satellite at geodetic lat/lon/alt in sunlight? +export function satSunlit(latDeg, lonDeg, altM, sunDir) { + const e = geodeticToEcef(latDeg, lonDeg, altM); + const p = [e[0] / 1000, e[1] / 1000, e[2] / 1000]; // km + const dot = p[0] * sunDir[0] + p[1] * sunDir[1] + p[2] * sunDir[2]; + if (dot > 0) return true; // on the sun side of Earth + const ox = p[0] - dot * sunDir[0]; + const oy = p[1] - dot * sunDir[1]; + const oz = p[2] - dot * sunDir[2]; + return Math.hypot(ox, oy, oz) > EARTH_R_KM; // outside the shadow cylinder +} diff --git a/examples/sky-monitor/ui/dashboard/behavior.js b/examples/sky-monitor/ui/dashboard/behavior.js new file mode 100644 index 0000000000..a3fbdaa565 --- /dev/null +++ b/examples/sky-monitor/ui/dashboard/behavior.js @@ -0,0 +1,164 @@ +// Behavior detectors over rolling live tracks — pure functions on the +// canonical track shape ({points: [{t, lat, lon, alt_m}], vel: {gs_ms, +// trackDeg}}). No DOM, no projection imports: unit-testable under +// `node --test` (see ./test/behavior.test.mjs). +// +// Window-based heuristics tuned for the 5 s ADS-B cadence: +// holding — racetrack/orbit: long path, tiny net displacement +// grid — survey/mapping: parallel legs joined by ~180° reversals +// goaround — approach below ~600 m followed by a sustained climb +// formation — two aircraft < 1 km apart with matched heading + speed + +const M_PER_DEG_LAT = 111132; +const M_PER_DEG_LON = 111320; + +// Display badges for the aircraft table / details panel. +export const BADGES = { + holding: "HOLD", grid: "GRID", goaround: "GO-AROUND", formation: "FORM", +}; + +// Equirectangular ground distance, metres (mirrors track.rs flat_distance_m). +export function flatDistM(aLat, aLon, bLat, bLon) { + const dy = (bLat - aLat) * M_PER_DEG_LAT; + const dx = (bLon - aLon) * M_PER_DEG_LON * + Math.cos((((aLat + bLat) / 2) * Math.PI) / 180); + return Math.hypot(dx, dy); +} + +function bearingDeg(a, b) { + const dy = (b.lat - a.lat) * M_PER_DEG_LAT; + const dx = (b.lon - a.lon) * M_PER_DEG_LON * + Math.cos((((a.lat + b.lat) / 2) * Math.PI) / 180); + return ((Math.atan2(dx, dy) * 180) / Math.PI + 360) % 360; +} + +// Smallest circular difference between two headings, degrees in [0, 180]. +export function circDiff(a, b) { + const d = Math.abs(a - b) % 360; + return d > 180 ? 360 - d : d; +} + +function recent(points, nowT, windowS) { + const t0 = nowT - windowS; + let i = points.length; + while (i > 0 && points[i - 1].t >= t0) i--; + return points.slice(i); +} + +// Latest point with p.t <= t (or null). +function atTime(points, t) { + for (let i = points.length - 1; i >= 0; i--) { + if (points[i].t <= t) return points[i]; + } + return null; +} + +// Holding pattern: over the last ~6 min the aircraft flew a long path that +// went nowhere — path ≥ 4 km with net displacement < 25 % of it. +export function detectHolding(points, nowT) { + const w = recent(points, nowT, 360); + if (w.length < 8) return false; + let path = 0; + for (let i = 1; i < w.length; i++) { + path += flatDistM(w[i - 1].lat, w[i - 1].lon, w[i].lat, w[i].lon); + } + if (path < 4000) return false; + const net = flatDistM(w[0].lat, w[0].lon, w[w.length - 1].lat, w[w.length - 1].lon); + return net / path < 0.25; +} + +// Survey grid: ≥ 4 straight legs (≥ 800 m, ≥ 4 steps each) whose +// consecutive bearings reverse by ~180°, over the last ~20 min — the +// "lawnmower" signature of mapping/survey flights. +export function detectSurveyGrid(points, nowT) { + const w = recent(points, nowT, 1200); + if (w.length < 24) return false; + const legs = []; + let cur = null; + for (let i = 1; i < w.length; i++) { + const d = flatDistM(w[i - 1].lat, w[i - 1].lon, w[i].lat, w[i].lon); + if (d < 15) continue; // stationary / duplicate fix + const brg = bearingDeg(w[i - 1], w[i]); + if (cur && circDiff(brg, cur.mean()) < 25) { + cur.s += Math.sin((brg * Math.PI) / 180); + cur.c += Math.cos((brg * Math.PI) / 180); + cur.n += 1; + cur.len += d; + } else { + if (cur && cur.n >= 4 && cur.len >= 800) legs.push(cur.mean()); + cur = { + s: Math.sin((brg * Math.PI) / 180), + c: Math.cos((brg * Math.PI) / 180), + n: 1, len: d, + mean() { return ((Math.atan2(this.s, this.c) * 180) / Math.PI + 360) % 360; }, + }; + } + } + if (cur && cur.n >= 4 && cur.len >= 800) legs.push(cur.mean()); + if (legs.length < 4) return false; + let reversals = 0; + for (let i = 1; i < legs.length; i++) { + if (circDiff(legs[i], legs[i - 1]) > 155) reversals++; + } + return reversals >= 3; +} + +// Go-around: within the last ~10 min the aircraft descended ≥ 150 m to a +// minimum below 600 m, then climbed ≥ 200 m back out without a track gap. +export function detectGoAround(points, nowT) { + const w = recent(points, nowT, 600); + if (w.length < 10) return false; + let mi = 0; + for (let i = 0; i < w.length; i++) if (w[i].alt_m < w[mi].alt_m) mi = i; + if (w[mi].alt_m > 600 || mi === 0 || mi >= w.length - 3) return false; + let preMax = -Infinity, postMax = -Infinity; + for (let i = 0; i < mi; i++) preMax = Math.max(preMax, w[i].alt_m); + for (let i = mi; i < w.length; i++) postMax = Math.max(postMax, w[i].alt_m); + return preMax - w[mi].alt_m >= 150 && postMax - w[mi].alt_m >= 200; +} + +// Formation: distinct moving aircraft (> 30 m/s) < 1 km apart now AND +// ~30 s ago, headings within 15°, speeds within 15 %, altitudes within +// 600 m. Returns pairs of track objects. +export function detectFormationPairs(tracks, nowT) { + const pairs = []; + const fresh = tracks.filter((tr) => { + const last = tr.points[tr.points.length - 1]; + return last && nowT - last.t < 30 && tr.vel && tr.vel.gs_ms > 30; + }); + for (let i = 0; i < fresh.length; i++) { + for (let j = i + 1; j < fresh.length; j++) { + const a = fresh[i], b = fresh[j]; + if (circDiff(a.vel.trackDeg, b.vel.trackDeg) > 15) continue; + const gsMax = Math.max(a.vel.gs_ms, b.vel.gs_ms); + if (Math.abs(a.vel.gs_ms - b.vel.gs_ms) / gsMax > 0.15) continue; + const al = a.points[a.points.length - 1], bl = b.points[b.points.length - 1]; + if (Math.abs(al.alt_m - bl.alt_m) > 600) continue; + if (flatDistM(al.lat, al.lon, bl.lat, bl.lon) > 1000) continue; + const a30 = atTime(a.points, nowT - 30), b30 = atTime(b.points, nowT - 30); + if (a30 && b30 && flatDistM(a30.lat, a30.lon, b30.lat, b30.lon) > 1200) continue; + pairs.push([a, b]); + } + } + return pairs; +} + +// Annotate every track in place: tr.behaviors = ["holding", ...] and +// tr.badges = "HOLD·FORM" (consumed by live-feed.js syncLiveTable and the +// details panel). Holding suppresses grid (both are turn-heavy). +export function detectBehaviors(tracks, nowT) { + const inFormation = new Set(); + for (const [a, b] of detectFormationPairs(tracks, nowT)) { + inFormation.add(a); + inFormation.add(b); + } + for (const tr of tracks) { + const found = []; + if (detectHolding(tr.points, nowT)) found.push("holding"); + else if (detectSurveyGrid(tr.points, nowT)) found.push("grid"); + if (detectGoAround(tr.points, nowT)) found.push("goaround"); + if (inFormation.has(tr)) found.push("formation"); + tr.behaviors = found; + tr.badges = found.map((k) => BADGES[k]).join("·"); + } +} diff --git a/examples/sky-monitor/ui/dashboard/conflict.js b/examples/sky-monitor/ui/dashboard/conflict.js new file mode 100644 index 0000000000..516763d26f --- /dev/null +++ b/examples/sky-monitor/ui/dashboard/conflict.js @@ -0,0 +1,123 @@ +// Conflict prediction: pairwise closest point of approach (CPA) in the +// observer's local ENU frame from current position + velocity, plus a +// turn-aware short-term predicted path ("cone") for the selected aircraft. +// Pure math, no DOM — unit-testable under `node --test` +// (see ./test/conflict.test.mjs). +// +// Alert criterion (drawn on the dome + ⚠ in the status line): predicted +// separation < 1 km horizontally AND < 300 m vertically within 90 s. + +const DEG = Math.PI / 180; +export const CPA_HORIZON_S = 90; +export const CPA_H_LIMIT_M = 1000; +export const CPA_V_LIMIT_M = 300; +const FRESH_S = 30; // ignore tracks without a fix in the last 30 s + +// Observer-frame ENU (metres) of a projected point {az, el, range}. +export function enuOf(p) { + const az = p.az * DEG, el = p.el * DEG; + const ch = Math.cos(el) * p.range; + return { e: ch * Math.sin(az), n: ch * Math.cos(az), u: p.range * Math.sin(el) }; +} + +// ENU velocity (m/s) from a feed velocity snapshot {gs_ms, trackDeg, vrate_ms}. +export function velEnu(vel) { + const b = vel.trackDeg * DEG; + return { e: vel.gs_ms * Math.sin(b), n: vel.gs_ms * Math.cos(b), u: vel.vrate_ms || 0 }; +} + +// Closest point of approach of two constant-velocity states within +// [0, horizonS]: returns {t, dh, dv} (time s, horizontal m, vertical m). +export function cpa(pa, va, pb, vb, horizonS = CPA_HORIZON_S) { + const px = pb.e - pa.e, py = pb.n - pa.n, pz = pb.u - pa.u; + const vx = vb.e - va.e, vy = vb.n - va.n, vz = vb.u - va.u; + const v2 = vx * vx + vy * vy + vz * vz; + let t = v2 < 1e-9 ? 0 : -(px * vx + py * vy + pz * vz) / v2; + t = Math.max(0, Math.min(horizonS, t)); + return { + t, + dh: Math.hypot(px + vx * t, py + vy * t), + dv: Math.abs(pz + vz * t), + }; +} + +// All conflicting pairs among live tracks. Each track needs a projected +// last point (az/el/range) and a velocity snapshot. Returns +// [{a, b, t, dh, dv}] sorted by soonest CPA. +export function detectConflicts(tracks, nowT, horizonS = CPA_HORIZON_S) { + const states = []; + for (const tr of tracks) { + const last = tr.points[tr.points.length - 1]; + if (!last || last.az === undefined || !tr.vel) continue; + if (nowT - last.t > FRESH_S) continue; + states.push({ tr, p: enuOf(last), v: velEnu(tr.vel) }); + } + const out = []; + for (let i = 0; i < states.length; i++) { + for (let j = i + 1; j < states.length; j++) { + const r = cpa(states[i].p, states[i].v, states[j].p, states[j].v, horizonS); + if (r.dh < CPA_H_LIMIT_M && r.dv < CPA_V_LIMIT_M) { + out.push({ a: states[i].tr, b: states[j].tr, t: r.t, dh: r.dh, dv: r.dv }); + } + } + } + out.sort((x, y) => x.t - y.t); + return out; +} + +// Mean signed heading rate (deg/s) over the recent path: successive step +// bearings (steps > 30 m so noise doesn't dominate) differenced over time. +export function headingRateDegS(points, nowT, windowS = 90) { + const t0 = nowT - windowS; + let prev = null, prevBrg = null, sum = 0, n = 0; + for (const p of points) { + if (p.t < t0) continue; + if (prev) { + const dy = (p.lat - prev.lat) * 111132; + const dx = (p.lon - prev.lon) * 111320 * + Math.cos((((prev.lat + p.lat) / 2) * Math.PI) / 180); + if (Math.hypot(dx, dy) > 30 && p.t > prev.t) { + const brg = ((Math.atan2(dx, dy) / DEG) + 360) % 360; + if (prevBrg !== null) { + const d = ((brg - prevBrg + 540) % 360) - 180; // signed turn + sum += d / (p.t - prev.t); + n++; + } + prevBrg = brg; + } + } + prev = p; + } + return n ? sum / n : 0; +} + +// Turn-aware dead reckoning: advance from {lat, lon, alt_m} with velocity +// `vel`, heading drifting at rateDegS. Returns [{lat, lon, alt_m}, ...]. +export function predictPath(start, vel, rateDegS, secs = 90, stepS = 5) { + const out = []; + let { lat, lon } = start, alt = start.alt_m, hdg = vel.trackDeg; + for (let t = stepS; t <= secs; t += stepS) { + hdg += rateDegS * stepS; + const b = hdg * DEG, d = vel.gs_ms * stepS; + lat += (d * Math.cos(b)) / 111132; + lon += (d * Math.sin(b)) / (111320 * Math.cos((lat * Math.PI) / 180)); + alt += (vel.vrate_ms || 0) * stepS; + out.push({ lat, lon, alt_m: alt }); + } + return out; +} + +// Prediction cone for one track: centre path at the measured heading rate, +// left/right edges at rate ± spread (spread grows with the measured rate so +// straight flight gets a narrow cone, turns a wide one). Null without data. +export function predictCone(tr, nowT, secs = 90) { + const last = tr.points[tr.points.length - 1]; + if (!last || last.az === undefined || !tr.vel || tr.vel.gs_ms < 20) return null; + const rate = headingRateDegS(tr.points, nowT); + const spread = 0.3 + Math.abs(rate) * 0.5; // deg/s + return { + center: predictPath(last, tr.vel, rate, secs), + left: predictPath(last, tr.vel, rate - spread, secs), + right: predictPath(last, tr.vel, rate + spread, secs), + }; +} diff --git a/examples/sky-monitor/ui/dashboard/draw.js b/examples/sky-monitor/ui/dashboard/draw.js new file mode 100644 index 0000000000..f73b1e3124 --- /dev/null +++ b/examples/sky-monitor/ui/dashboard/draw.js @@ -0,0 +1,164 @@ +// Canvas2D rendering primitives for the all-sky dome — extracted from +// sky.js (which stays the app conductor). Pure drawing over already +// projected az/el points; the only import is the polar screen mapping. + +import { polarScreenXY } from "./project.js"; + +export const BAND_COLORS = { + "normal": "#3ddc84", + "mildly unusual": "#e8d44d", + "interesting": "#ff9f43", + "strong anomaly": "#ff5252", + "rare": "#d05aff", +}; +export const LIVE_COLOR = "#5aa9ff"; // unscored live tracks +export const SAT_COLOR = "#cfd8ea"; +export const SAT_VISIBLE_COLOR = "#ffe08a"; // sunlit satellite, dark sky +export const CONFLICT_COLOR = "#ff5252"; +export const LINGER_SECS = 20; // dot stays this long after the last sample +export const KT = 0.514444; // m/s per knot + +// Last point index with p.t <= t (binary search; points are ordered by t). +export function indexAt(tr, t) { + if (t < tr.t0) return -1; + let lo = 0, hi = tr.points.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (tr.points[mid].t <= t) lo = mid; else hi = mid - 1; + } + return lo; +} + +export function drawSkyDome(ctx, w, h) { + const cx = w / 2, cy = h / 2; + const R = Math.min(w, h) / 2; + // Elevation rings at 0 / 30 / 60 degrees. + for (const el of [0, 30, 60]) { + const r = ((90 - el) / 90) * R; + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.strokeStyle = el === 0 ? "#27345c" : "#1a2542"; + ctx.lineWidth = el === 0 ? 1.5 : 1; + ctx.stroke(); + ctx.fillStyle = "#3d4d78"; + ctx.font = "10px monospace"; + ctx.fillText(`${el}°`, cx + 4, cy - r + 12); + } + // Cross hairs + compass labels (N up, E right, S down, W left). + ctx.strokeStyle = "#16203c"; + ctx.beginPath(); + ctx.moveTo(cx - R, cy); ctx.lineTo(cx + R, cy); + ctx.moveTo(cx, cy - R); ctx.lineTo(cx, cy + R); + ctx.stroke(); + ctx.fillStyle = "#7e90bd"; + ctx.font = "bold 13px monospace"; + ctx.textAlign = "center"; + ctx.fillText("N", cx, cy - R + 16); + ctx.fillText("S", cx, cy + R - 8); + ctx.fillText("E", cx + R - 10, cy + 4); + ctx.fillText("W", cx - R + 10, cy + 4); + ctx.textAlign = "left"; +} + +// Draw one aircraft track at timeline t. `cfg` carries {trails, labels, +// trailLen} (the ⚙ drawer settings). Returns whether the dot is visible. +export function drawTrack(ctx, tr, t, w, h, selected, cfg) { + const i = indexAt(tr, t); + if (i < 0 || t > tr.t1 + LINGER_SECS) return false; + // Fading trail. + if (cfg.trails) { + ctx.lineWidth = 1.5; + for (let j = Math.max(1, i - cfg.trailLen); j <= i; j++) { + const a = tr.points[j - 1], b = tr.points[j]; + const [x1, y1] = polarScreenXY(a.az, a.el, w, h); + const [x2, y2, vis] = polarScreenXY(b.az, b.el, w, h); + if (!vis && b.el < -2) continue; + const age = (i - j) / cfg.trailLen; + ctx.strokeStyle = tr.color; + ctx.globalAlpha = 0.55 * (1 - age); + ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); + } + ctx.globalAlpha = 1; + } + // Current dot — the smoothed dead-reckoned ghost glides between polls. + let p = tr.points[i]; + let gone = t > tr.t1; // lingering after last sample + if (gone && tr._ghost) { p = tr._ghost; gone = false; } + const [x, y, visible] = polarScreenXY(p.az, p.el, w, h); + if (!visible) return false; + ctx.globalAlpha = gone ? Math.max(0, 1 - (t - tr.t1) / LINGER_SECS) : 1; + ctx.fillStyle = tr.color; + if (tr.category === "A7") { + // Rotorcraft: small cross instead of a dot. + ctx.fillRect(x - 5, y - 1.2, 10, 2.4); + ctx.fillRect(x - 1.2, y - 5, 2.4, 10); + } else { + const r = selected ? 5 : tr.category === "A5" ? 4.6 : 3.5; // A5 = heavy + ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); + } + if (tr.emergency) { + // Emergency squawk: double red ring. + ctx.strokeStyle = "#ff5252"; + ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.arc(x, y, 9, 0, Math.PI * 2); ctx.stroke(); + ctx.beginPath(); ctx.arc(x, y, 14, 0, Math.PI * 2); ctx.stroke(); + } + if (selected) { + ctx.strokeStyle = tr.color; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.arc(x, y, 13, 0, Math.PI * 2); ctx.stroke(); + } + if (cfg.labels) { + const vr = tr.vel ? tr.vel.vrate_ms : 0; + const arrow = vr > 1.5 ? "↑" : vr < -1.5 ? "↓" : ""; + ctx.fillStyle = "#c7d2e8"; + ctx.font = "11px monospace"; + ctx.fillText(`${tr.label}${arrow} ${Math.round(p.alt_m)}m`, x + 12, y - 6); + } + ctx.globalAlpha = 1; + return true; +} + +// Dashed red line between a conflicting pair (current display positions). +export function drawConflictLine(ctx, pa, pb, w, h, label) { + const [x1, y1, v1] = polarScreenXY(pa.az, pa.el, w, h); + const [x2, y2, v2] = polarScreenXY(pb.az, pb.el, w, h); + if (!v1 && !v2) return; + ctx.save(); + ctx.strokeStyle = CONFLICT_COLOR; + ctx.setLineDash([6, 4]); + ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = CONFLICT_COLOR; + ctx.font = "11px monospace"; + ctx.fillText(`⚠ ${label}`, (x1 + x2) / 2 + 8, (y1 + y2) / 2 - 4); + ctx.restore(); +} + +// Turn-aware predicted-path cone for the selected aircraft: dashed edges, +// solid centre line, over already projected {az, el} arrays. +export function drawCone(ctx, cone, w, h, color) { + const stroke = (pts, dash) => { + ctx.setLineDash(dash); + ctx.beginPath(); + let started = false; + for (const p of pts) { + if (p.el < -2) continue; + const [x, y] = polarScreenXY(p.az, p.el, w, h); + if (started) ctx.lineTo(x, y); + else { ctx.moveTo(x, y); started = true; } + } + ctx.stroke(); + }; + ctx.save(); + ctx.strokeStyle = color; + ctx.lineWidth = 1; + ctx.globalAlpha = 0.55; + stroke(cone.left, [3, 3]); + stroke(cone.right, [3, 3]); + ctx.globalAlpha = 0.85; + stroke(cone.center, []); + ctx.setLineDash([]); + ctx.restore(); +} diff --git a/examples/sky-monitor/ui/dashboard/gpu-sats.js b/examples/sky-monitor/ui/dashboard/gpu-sats.js new file mode 100644 index 0000000000..f7f6fabd72 --- /dev/null +++ b/examples/sky-monitor/ui/dashboard/gpu-sats.js @@ -0,0 +1,163 @@ +// Experimental WebGPU renderer for the SATELLITE layer only (the layer that +// actually scales — starlink is ~7 000 dots). Instanced point sprites on a +// transparent overlay canvas above the Canvas2D dome; aircraft, trails, +// sun/moon and the dome itself stay on Canvas2D, which remains the default +// and the automatic fallback whenever navigator.gpu is absent or init +// fails (headless browsers, older GPUs, lost devices). +// +// Instance layout (Float32Array, 4 per sat): [x_px, y_px, half_size_px, +// visibility] where visibility 1 tints the sprite the "sunlit against dark +// sky" gold of the Canvas2D path. + +const SHADER = /* wgsl */ ` +struct VSOut { + @builtin(position) pos: vec4f, + @location(0) color: vec4f, + @location(1) uv: vec2f, +}; + +@group(0) @binding(0) var viewport: vec2f; + +@vertex +fn vs(@builtin(vertex_index) vi: u32, @location(0) inst: vec4f) -> VSOut { + var corners = array( + vec2f(-1.0, -1.0), vec2f(1.0, -1.0), vec2f(-1.0, 1.0), vec2f(1.0, 1.0)); + let c = corners[vi]; + let px = inst.xy + c * inst.z; + let ndc = vec2f(px.x / viewport.x * 2.0 - 1.0, 1.0 - px.y / viewport.y * 2.0); + var out: VSOut; + out.pos = vec4f(ndc, 0.0, 1.0); + out.uv = c; + // SAT_COLOR #cfd8ea vs SAT_VISIBLE_COLOR #ffe08a (see draw.js). + out.color = mix(vec4f(0.81, 0.85, 0.92, 0.85), vec4f(1.0, 0.88, 0.54, 1.0), inst.w); + return out; +} + +@fragment +fn fs(in: VSOut) -> @location(0) vec4f { + let d = length(in.uv); + if (d > 1.0) { discard; } + let a = in.color.a * (1.0 - d * d); + return vec4f(in.color.rgb * a, a); // premultiplied +} +`; + +export class GpuSats { + static supported() { + return typeof navigator !== "undefined" && !!navigator.gpu; + } + + constructor() { + this.device = null; + this.ctx = null; + this.canvas = null; + this.capacity = 0; + } + + // True on success; false (never throws) when WebGPU is unavailable — + // the caller falls back to Canvas2D. + async init(canvas) { + try { + if (!GpuSats.supported()) return false; + const adapter = await navigator.gpu.requestAdapter(); + if (!adapter) return false; + this.device = await adapter.requestDevice(); + this.ctx = canvas.getContext("webgpu"); + if (!this.ctx) return false; + this.format = navigator.gpu.getPreferredCanvasFormat(); + this.ctx.configure({ + device: this.device, format: this.format, alphaMode: "premultiplied", + }); + const module = this.device.createShaderModule({ code: SHADER }); + this.pipeline = this.device.createRenderPipeline({ + layout: "auto", + vertex: { + module, entryPoint: "vs", + buffers: [{ + arrayStride: 16, stepMode: "instance", + attributes: [{ shaderLocation: 0, offset: 0, format: "float32x4" }], + }], + }, + fragment: { + module, entryPoint: "fs", + targets: [{ + format: this.format, + blend: { + color: { srcFactor: "one", dstFactor: "one-minus-src-alpha" }, + alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha" }, + }, + }], + }, + primitive: { topology: "triangle-strip" }, + }); + this.uniform = this.device.createBuffer({ + size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + this.bindGroup = this.device.createBindGroup({ + layout: this.pipeline.getBindGroupLayout(0), + entries: [{ binding: 0, resource: { buffer: this.uniform } }], + }); + this.canvas = canvas; + return true; + } catch (_e) { + this.dispose(); + return false; + } + } + + _instanceBuffer(byteLen) { + if (!this.instBuf || this.capacity < byteLen) { + this.instBuf?.destroy?.(); + this.capacity = Math.max(byteLen, 4096); + this.instBuf = this.device.createBuffer({ + size: this.capacity, + usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, + }); + } + return this.instBuf; + } + + // Draw `n` instances from a Float32Array of [x, y, half, vis] tuples + // (already in CSS pixels for a w×h canvas). Never throws. + draw(instances, n, w, h, dpr) { + if (!this.device || !this.ctx) return; + try { + const pw = Math.max(1, Math.round(w * dpr)), ph = Math.max(1, Math.round(h * dpr)); + if (this.canvas.width !== pw || this.canvas.height !== ph) { + this.canvas.width = pw; + this.canvas.height = ph; + } + this.device.queue.writeBuffer(this.uniform, 0, new Float32Array([w, h])); + const byteLen = n * 16; + const buf = this._instanceBuffer(byteLen); + if (n) this.device.queue.writeBuffer(buf, 0, instances, 0, n * 4); + const enc = this.device.createCommandEncoder(); + const pass = enc.beginRenderPass({ + colorAttachments: [{ + view: this.ctx.getCurrentTexture().createView(), + clearValue: { r: 0, g: 0, b: 0, a: 0 }, + loadOp: "clear", storeOp: "store", + }], + }); + if (n) { + pass.setPipeline(this.pipeline); + pass.setBindGroup(0, this.bindGroup); + pass.setVertexBuffer(0, buf, 0, byteLen); + pass.draw(4, n); + } + pass.end(); + this.device.queue.submit([enc.finish()]); + } catch (_e) { /* lost device etc. — caller may dispose */ } + } + + dispose() { + try { this.instBuf?.destroy?.(); this.uniform?.destroy?.(); this.device?.destroy?.(); } + catch (_e) { /* already gone */ } + this.device = null; + this.ctx = null; + if (this.canvas) { + this.canvas.width = 1; // clear the overlay + this.canvas.height = 1; + } + } +} diff --git a/examples/sky-monitor/ui/dashboard/index.html b/examples/sky-monitor/ui/dashboard/index.html new file mode 100644 index 0000000000..2c01ffcc0c --- /dev/null +++ b/examples/sky-monitor/ui/dashboard/index.html @@ -0,0 +1,172 @@ + + + + + +RuView SkyGraph — realtime all-sky dashboard (ADR-199) + + + +
+

RuView SkyGraph — realtime all-sky view

+ observer: — + + LIVE · connecting… + + projection: JS (loading…) + +
+
+
+ + +
+ +
+
+ + + + +
+
+

Layers

+ + + + + + +

Settings

+ + + + + + +
+ Observer: oakville_node · ADS-B poll 5 s (airplanes.live) · + weather 10 min (Open-Meteo) · Kp 15 min (NOAA SWPC) · + TLEs 6 h (CelesTrak) · routes on selection (adsbdb, 24 h cache) · + replay buffer ~1 h (IndexedDB). Settings persist in this browser. +
+
+ + + + diff --git a/examples/sky-monitor/ui/dashboard/live-feed.js b/examples/sky-monitor/ui/dashboard/live-feed.js new file mode 100644 index 0000000000..c89cb5619e --- /dev/null +++ b/examples/sky-monitor/ui/dashboard/live-feed.js @@ -0,0 +1,278 @@ +// RuView SkyGraph live data layer (ADR-199 — the dashboard's only data source). +// +// Polls public, key-free ADS-B + weather APIs and maintains rolling tracks in +// the canonical track shape ({icao24, callsign, points: [{t, lat, lon, +// alt_m}], anomaly, overhead}) consumed by sky.js, which projects and renders +// them on the all-sky plot in realtime. +// +// Source survey (probed 2026-06-09 with Origin: http://localhost:8000): +// airplanes.live /v2/point -> 200, Access-Control-Allow-Origin: * PRIMARY +// adsb.lol /v2/lat/.. -> 200, same readsb shape but NO ACAO header +// observed; kept as a long-shot fallback only +// OpenSky states/all -> ACAO locked to opensky-network.org REJECTED +// open-meteo /v1/forecast-> 200, ACAO: * WEATHER + +const FT_TO_M = 0.3048; +const KT_TO_MS = 0.514444; + +export const ADSB_POLL_MS = 5000; // 12 req/min — well inside anon limits +export const WX_POLL_MS = 10 * 60e3; // Open-Meteo current block updates ~15 min +const RADIUS_NM = 40; // search radius around the observer +const MAX_POINTS = 720; // per-track cap (~96 min @ 8 s cadence) +const STALE_SECS = 120; // drop tracks unseen for this long +const FETCH_TIMEOUT_MS = 6000; +const FTMIN_TO_MS = 0.00508; // ft/min -> m/s (vertical rate) +const SMOOTH_TAU = 0.35; // s — display smoothing time constant +const PREDICT_MAX_S = 20; // dead-reckon horizon (= dot linger) + +const ADSB_SOURCES = [ + { name: "airplanes.live", + url: (o) => `https://api.airplanes.live/v2/point/${o.lat}/${o.lon}/${RADIUS_NM}` }, + { name: "adsb.lol", + url: (o) => `https://api.adsb.lol/v2/lat/${o.lat}/lon/${o.lon}/dist/${RADIUS_NM}` }, +]; + +// WMO weather interpretation codes -> short text (Open-Meteo `weather_code`). +const WMO = { + 0: "clear", 1: "mostly clear", 2: "partly cloudy", 3: "overcast", + 45: "fog", 48: "rime fog", 51: "drizzle", 53: "drizzle", 55: "drizzle", + 61: "rain", 63: "rain", 65: "heavy rain", 66: "freezing rain", 67: "freezing rain", + 71: "snow", 73: "snow", 75: "heavy snow", 77: "snow grains", + 80: "showers", 81: "showers", 82: "heavy showers", 85: "snow showers", + 86: "snow showers", 95: "thunderstorm", 96: "thunderstorm + hail", 99: "thunderstorm + hail", +}; + +function fetchJson(url) { + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS); + return fetch(url, { signal: ctl.signal, headers: { Accept: "application/json" } }) + .then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }) + .finally(() => clearTimeout(timer)); +} + +export class LiveFeed { + constructor(obs, onUpdate) { + this.obs = obs; + this.onUpdate = onUpdate || (() => {}); + this.byIcao = new Map(); // icao24 -> rolling track (canonical shape) + this.weather = null; // latest Open-Meteo `current` block + this.sourceIdx = 0; + this.source = ADSB_SOURCES[0].name; + this.lastOkAt = 0; // epoch secs of last successful ADS-B poll + this.failStreak = 0; + this.running = false; + this._timers = []; + } + + start() { + if (this.running) return; + this.running = true; + this._pollAdsb(); + this._pollWx(); + this._timers.push(setInterval(() => this._pollAdsb(), ADSB_POLL_MS)); + this._timers.push(setInterval(() => this._pollWx(), WX_POLL_MS)); + } + + stop() { + this.running = false; + this._timers.forEach(clearInterval); + this._timers = []; + } + + get trackList() { return [...this.byIcao.values()]; } + + statusText() { + if (!this.lastOkAt) { + return this.failStreak + ? `LIVE · offline — no ADS-B source reachable (retrying every ${ADSB_POLL_MS / 1000}s)` + : "LIVE · connecting…"; + } + const age = Math.round(Date.now() / 1000 - this.lastOkAt); + const stale = age > 30 ? ` · stale ${age}s` : ""; + return `LIVE · ${this.source} · ${this.byIcao.size} aircraft${stale}`; + } + + weatherText() { + const w = this.weather; + if (!w) return ""; + const dir = String(Math.round(w.wind_direction_10m)).padStart(3, "0"); + const precip = w.precipitation > 0 ? ` · precip ${w.precipitation} mm` : ""; + return `wx ${w.temperature_2m}°C · wind ${Math.round(w.wind_speed_10m)} kn @ ${dir}°` + + ` · cloud ${w.cloud_cover}% · ${WMO[w.weather_code] ?? `wmo ${w.weather_code}`}${precip}`; + } + + // Detail lines for the side-panel weather card (no selection active). + // METAR (aviationweather.gov) was probed 2026-06-10: no ACAO header, so + // browsers cannot fetch it directly — Open-Meteo carries the extras. + weatherLines() { + const w = this.weather; + if (!w) return ["weather: waiting for Open-Meteo…"]; + const dir = String(Math.round(w.wind_direction_10m)).padStart(3, "0"); + return [ + `temperature ${w.temperature_2m} °C · humidity ${w.relative_humidity_2m}%`, + `wind ${Math.round(w.wind_speed_10m)} kn @ ${dir}° · gusts ${Math.round(w.wind_gusts_10m)} kn`, + `pressure ${Math.round(w.surface_pressure)} hPa · cloud ${w.cloud_cover}%`, + `${WMO[w.weather_code] ?? `wmo ${w.weather_code}`} · precip ${w.precipitation} mm`, + ]; + } + + async _pollAdsb() { + for (let k = 0; k < ADSB_SOURCES.length; k++) { + const idx = (this.sourceIdx + k) % ADSB_SOURCES.length; + const src = ADSB_SOURCES[idx]; + try { + const body = await fetchJson(src.url(this.obs)); + if (!Array.isArray(body.ac)) throw new Error("unexpected shape"); + this.sourceIdx = idx; + this.source = src.name; + this.failStreak = 0; + this.lastOkAt = Date.now() / 1000; + this._ingest(body.ac, this.lastOkAt); + this.onUpdate(this); + return; + } catch (_e) { /* CORS / timeout / shape — try the next source */ } + } + this.failStreak += 1; + this._prune(Date.now() / 1000); // age out tracks while offline too + this.onUpdate(this); // surface offline status; canvas keeps last dots + } + + _ingest(acList, nowSec) { + for (const ac of acList) { + if (typeof ac.lat !== "number" || typeof ac.lon !== "number") continue; // no position + const icao = String(ac.hex || "").replace("~", "").toLowerCase(); + if (!icao) continue; + let altFt = ac.alt_geom ?? ac.alt_baro; + if (altFt === "ground") altFt = 0; + if (typeof altFt !== "number" || !isFinite(altFt)) continue; + const t = nowSec - (ac.seen_pos ?? ac.seen ?? 0); // Unix epoch seconds + let tr = this.byIcao.get(icao); + if (!tr) { + tr = { icao24: icao, callsign: null, points: [], anomaly: null, overhead: false, live: true }; + this.byIcao.set(icao, tr); + } + const cs = (ac.flight || "").trim(); + if (cs) tr.callsign = cs; + // Enrichment carried by readsb: type / registration / squawk / + // wake category / receiver signal — shown in the table + details. + if (ac.t) tr.type = ac.t; + if (ac.r) tr.reg = ac.r; + if (ac.squawk) tr.squawk = ac.squawk; + if (ac.category) tr.category = ac.category; + if (typeof ac.rssi === "number") tr.rssi = ac.rssi; + tr.emergency = + ac.emergency && ac.emergency !== "none" ? ac.emergency + : ["7500", "7600", "7700"].includes(ac.squawk) ? `squawk ${ac.squawk}` + : null; + const last = tr.points[tr.points.length - 1]; + if (!last || t > last.t + 0.5) { + tr.points.push({ t, lat: ac.lat, lon: ac.lon, alt_m: altFt * FT_TO_M }); + if (tr.points.length > MAX_POINTS) tr.points.splice(0, tr.points.length - MAX_POINTS); + } + // Velocity snapshot for between-poll dead reckoning in the renderer. + if (typeof ac.gs === "number" && typeof ac.track === "number") { + const vr = typeof ac.geom_rate === "number" ? ac.geom_rate + : typeof ac.baro_rate === "number" ? ac.baro_rate : 0; + tr.vel = { t, gs_ms: ac.gs * KT_TO_MS, trackDeg: ac.track, vrate_ms: vr * FTMIN_TO_MS }; + } + } + this._prune(nowSec); + } + + _prune(nowSec) { + for (const [icao, tr] of this.byIcao) { + const last = tr.points[tr.points.length - 1]; + if (!last || nowSec - last.t > STALE_SECS) this.byIcao.delete(icao); + } + } + + async _pollWx() { + const url = `https://api.open-meteo.com/v1/forecast?latitude=${this.obs.lat}&longitude=${this.obs.lon}` + + "¤t=temperature_2m,relative_humidity_2m,surface_pressure,wind_speed_10m," + + "wind_direction_10m,wind_gusts_10m,cloud_cover,precipitation,weather_code" + + "&wind_speed_unit=kn"; + try { + this.weather = (await fetchJson(url)).current || null; + } catch (_e) { /* keep last reading; header simply stays as-is */ } + this.onUpdate(this); + } +} + +// Dead-reckon a display position `t - p.t` seconds past the last sample +// (flat-earth step is fine for <=20 s at airliner speeds; display only). +export function deadReckon(p, vel, t) { + const dt = t - p.t; + if (!vel || !(dt > 0) || dt > PREDICT_MAX_S) return null; + const d = vel.gs_ms * dt; + const brg = (vel.trackDeg * Math.PI) / 180; + const lat = p.lat + (d * Math.cos(brg)) / 111320; + const lon = p.lon + (d * Math.sin(brg)) / (111320 * Math.cos((p.lat * Math.PI) / 180)); + return { t, lat, lon, alt_m: p.alt_m + (vel.vrate_ms || 0) * dt }; +} + +// Smoothed display position for `tr` at wall-clock `tNow`: dead-reckoned +// target, eased exponentially (SMOOTH_TAU) so dots glide at frame rate and +// absorb the correction when a fresh sample lands instead of snapping. +export function displayPoint(tr, tNow) { + const last = tr.points[tr.points.length - 1]; + if (!last || tNow - last.t > PREDICT_MAX_S) { tr._disp = null; return null; } + const target = deadReckon(last, tr.vel, tNow) || last; + const prev = tr._disp; + if (!prev || !(tNow > tr._dispT)) { + tr._disp = { t: tNow, lat: target.lat, lon: target.lon, alt_m: target.alt_m }; + tr._dispT = tNow; + return tr._disp; + } + const a = 1 - Math.exp(-(tNow - tr._dispT) / SMOOTH_TAU); + prev.lat += (target.lat - prev.lat) * a; + prev.lon += (target.lon - prev.lon) * a; + prev.alt_m += (target.alt_m - prev.alt_m) * a; + prev.t = tNow; + tr._dispT = tNow; + return prev; +} + +// Incrementally sync the live track table (call / alt / hdg / range / age): +// rebuild rows only when the track set changes, otherwise update cells in +// place — keeps hover/selection stable while the numbers stay realtime. +// Callsigns are untrusted API data, so they go through textContent, never +// innerHTML. +export function syncLiveTable(feed, tbody, liveRows, onSelect) { + const list = feed.trackList; + for (const tr of list) tr.label = tr.callsign || tr.icao24; + list.sort((a, b) => a.label.localeCompare(b.label)); + const sig = list.map((tr) => tr.icao24).join(","); + if (sig !== tbody._liveSig) { + tbody._liveSig = sig; + tbody.innerHTML = ""; + liveRows.clear(); + for (const tr of list) { + const row = document.createElement("tr"); + row.className = "track-row"; + row.innerHTML = ""; + row.cells[0].textContent = tr.label; + row.addEventListener("click", () => onSelect(tr)); + tbody.appendChild(row); + liveRows.set(tr, { + row, nameTd: row.cells[0], altTd: row.cells[1], hdgTd: row.cells[2], + rngTd: row.cells[3], ageTd: row.cells[4], + }); + } + } + const now = Date.now() / 1000; + for (const tr of list) { + const e = liveRows.get(tr); + if (!e) continue; + const last = tr.points[tr.points.length - 1]; + const p = tr._disp || last; + const vr = tr.vel ? tr.vel.vrate_ms : 0; + e.nameTd.textContent = + (tr.emergency ? "⚠ " : "") + tr.label + (tr.badges ? ` [${tr.badges}]` : ""); + e.nameTd.style.color = tr.emergency ? "#ff5252" : tr.anomaly ? tr.color : ""; + e.altTd.textContent = + String(Math.round(p.alt_m)) + (vr > 1.5 ? " ↑" : vr < -1.5 ? " ↓" : ""); + e.hdgTd.textContent = tr.vel ? `${Math.round(tr.vel.trackDeg)}°` : "—"; + e.rngTd.textContent = last.range !== undefined ? (last.range / 1000).toFixed(1) : "—"; + e.ageTd.textContent = String(Math.max(0, Math.round(now - last.t))); + } +} diff --git a/examples/sky-monitor/ui/dashboard/novelty.js b/examples/sky-monitor/ui/dashboard/novelty.js new file mode 100644 index 0000000000..d674ea4ef0 --- /dev/null +++ b/examples/sky-monitor/ui/dashboard/novelty.js @@ -0,0 +1,112 @@ +// Real §15 vector novelty (full §13 embeddings) — the browser counterpart of +// src/indexer.rs. Each live track is embedded through wasm `embed_track` +// (canonical 32-dim §13 embedding; per-point motion derived in Rust by +// finite differences) and scored with wasm `novelty` (mean top-3 euclidean +// distance / the 1.2 indexer calibration; neutral 0.5 with no priors). +// +// Past embeddings persist in an IndexedDB rolling store (cap ~5 000, oldest +// pruned) so novelty survives reloads: a corridor flight seen an hour ago +// keeps scoring familiar now. Brute-force distance is fine at this scale. +// If IndexedDB is unavailable (private mode) the store runs RAM-only. + +const DB_NAME = "skygraph-novelty-v1"; +const STORE = "embeddings"; +const DIM = 32; +const MIN_FIXES = 4; // need ≥4 projected points to embed +const SELF_EXCLUDE_S = 3600; // ignore own records newer than this +const PERSIST_MIN_S = 20; // per-track snapshot cadence into the store + +const idb = (req) => new Promise((res, rej) => { + req.onsuccess = () => res(req.result); + req.onerror = () => rej(req.error); +}); + +// Flatten a live track's projected points into the wasm embed_track shape: +// [t, lat, lon, alt_m, az_deg, el_deg, range_m] per point. +export function trackFlatPoints(tr) { + const pts = tr.points.filter((p) => p.az !== undefined); + const flat = new Float64Array(pts.length * 7); + pts.forEach((p, i) => { + const o = i * 7; + flat[o] = p.t; flat[o + 1] = p.lat; flat[o + 2] = p.lon; flat[o + 3] = p.alt_m; + flat[o + 4] = p.az; flat[o + 5] = p.el; flat[o + 6] = p.range; + }); + return flat; +} + +export class NoveltyStore { + constructor(cap = 5000) { + this.cap = cap; + this.records = []; // [{at, icao24, emb: Float32Array}] oldest first + this.db = null; + } + + async open() { + try { + const req = indexedDB.open(DB_NAME, 1); + req.onupgradeneeded = () => + req.result.createObjectStore(STORE, { autoIncrement: true }); + this.db = await idb(req); + const all = await idb(this.db.transaction(STORE).objectStore(STORE).getAll()); + this.records = all.map((r) => ({ + at: r.at, icao24: r.icao24, emb: new Float32Array(r.emb), + })); + } catch (_e) { this.db = null; /* RAM-only fallback */ } + return this; + } + + size() { return this.records.length; } + + // Score novelty for every track (sets tr.novelty + tr._emb), THEN append + // this poll's embeddings — novelty is always relative to the past. + update(wasm, tracks, nowT) { + if (!wasm?.embedTrack) return; + for (const tr of tracks) { + const flat = trackFlatPoints(tr); + if (flat.length < 7 * MIN_FIXES) { tr.novelty = null; continue; } + try { + tr._emb = wasm.embedTrack(flat, typeof tr.rssi === "number" ? tr.rssi : -20); + tr.novelty = wasm.noveltyScore(tr._emb, this._pastFor(tr.icao24, nowT)); + } catch (_e) { tr.novelty = null; } + } + this._append(tracks, nowT); + } + + // Flattened prior embeddings, excluding this aircraft's own recent + // snapshots (an aircraft is never novel relative to itself mid-flight — + // mirrors the indexer's exclude-own-track_id rule). + _pastFor(icao24, nowT) { + const keep = this.records.filter( + (r) => r.icao24 !== icao24 || nowT - r.at > SELF_EXCLUDE_S); + const flat = new Float32Array(keep.length * DIM); + keep.forEach((r, i) => flat.set(r.emb, i * DIM)); + return flat; + } + + _append(tracks, nowT) { + const added = []; + for (const tr of tracks) { + if (!tr._emb) continue; + if (tr._embSavedAt && nowT - tr._embSavedAt < PERSIST_MIN_S) continue; + tr._embSavedAt = nowT; + this.records.push({ at: nowT, icao24: tr.icao24, emb: tr._emb }); + added.push({ at: nowT, icao24: tr.icao24, emb: Array.from(tr._emb) }); + } + const extra = this.records.length - this.cap; + if (extra > 0) this.records.splice(0, extra); + if (!this.db || !added.length) return; + try { + const os = this.db.transaction(STORE, "readwrite").objectStore(STORE); + for (const rec of added) os.add(rec); + os.count().onsuccess = (ev) => { // prune oldest beyond cap + let drop = ev.target.result - this.cap; + if (drop > 0) { + os.openCursor().onsuccess = (e2) => { + const cur = e2.target.result; + if (cur && drop-- > 0) { cur.delete(); cur.continue(); } + }; + } + }; + } catch (_e) { /* quota / private mode — RAM store keeps working */ } + } +} diff --git a/examples/sky-monitor/ui/dashboard/panels.js b/examples/sky-monitor/ui/dashboard/panels.js new file mode 100644 index 0000000000..24da1dacff --- /dev/null +++ b/examples/sky-monitor/ui/dashboard/panels.js @@ -0,0 +1,114 @@ +// Side-panel renderers (details card + satellite table) — extracted from +// sky.js. Every remote string goes through esc()/textContent; numbers are +// formatted locally. + +import { KT, SAT_COLOR, SAT_VISIBLE_COLOR } from "./draw.js"; +import { routeLines } from "./route-info.js"; + +const BEHAVIOR_TEXT = { + holding: "holding pattern", grid: "survey-grid pattern", + goaround: "go-around", formation: "formation flight", +}; + +const esc = (x) => String(x).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`); +const line = (text, color) => + `
${text}
`; + +// v: {details, selected, selectedSat, satsAbove, satNames, sun, feed, +// spaceWx, noveltySize, conflicts, requestRoute} +export function renderDetails(v) { + const { details, sun } = v; + if (v.selectedSat >= 0) { + const s = v.satsAbove.find((q) => q.i === v.selectedSat); + const who = + `
${esc(v.satNames[v.selectedSat])} (satellite — CelesTrak)
`; + const c = s?.visibleNow ? SAT_VISIBLE_COLOR : SAT_COLOR; + const lines = s ? [ + `position: az ${Math.round(s.az)}° · el ${s.el.toFixed(1)}° · range ${(s.range / 1000).toFixed(0)} km`, + `orbit altitude ${(s.alt / 1000).toFixed(0)} km · SGP4 propagation in sky-monitor-wasm`, + s.visibleNow ? "✦ visible now — sunlit against a dark sky" + : sun.el < -6 ? "in Earth's shadow — not naked-eye visible" + : "sky too bright for naked-eye visibility", + ] : ["below the horizon"]; + details.innerHTML = who + lines.map((l) => line(l, c)).join(""); + return; + } + if (!v.selected) { + // Weather card while nothing is selected (Open-Meteo + NOAA SWPC Kp). + const who = + '
conditions — Open-Meteo + NOAA SWPC (select a row for object details)
'; + const sunLine = `sun el ${sun.el.toFixed(1)}° · ` + + (sun.el > 0 ? "day" : sun.el > -6 ? "civil twilight" : "dark sky"); + details.innerHTML = who + + [...v.feed.weatherLines(), ...v.spaceWx.lines(), sunLine] + .map((l) => line(l, "#3d4d78")).join(""); + return; + } + const tr = v.selected; + const last = tr.points[tr.points.length - 1]; + const age = Math.max(0, Math.round(Date.now() / 1000 - last.t)); + const who = `
${esc(tr.label)} (icao24 ${esc(tr.icao24)})
`; + const c = tr.color; + const lines = []; + if (tr.emergency) lines.push(`⚠ EMERGENCY: ${esc(tr.emergency)}`); + for (const cf of v.conflicts) { + if (cf.a !== tr && cf.b !== tr) continue; + const other = cf.a === tr ? cf.b : cf.a; + lines.push(`⚠ CPA with ${esc(other.label || other.icao24)} in ${Math.round(cf.t)} s — ` + + `${Math.round(cf.dh)} m horizontal · ${Math.round(cf.dv)} m vertical`); + } + lines.push( + [tr.type && `type ${esc(tr.type)}`, tr.reg && `reg ${esc(tr.reg)}`, + tr.squawk && `squawk ${esc(tr.squawk)}`, tr.category && `cat ${esc(tr.category)}`] + .filter(Boolean).join(" · ") || "no airframe metadata yet", + `position: az ${Math.round(last.az)}° · el ${last.el.toFixed(1)}° · range ${(last.range / 1000).toFixed(1)} km`, + `altitude ${Math.round(last.alt_m)} m` + + (tr.vel ? ` · gs ${Math.round(tr.vel.gs_ms / KT)} kn · hdg ${Math.round(tr.vel.trackDeg)}°` + + ` · v/s ${(tr.vel.vrate_ms || 0).toFixed(1)} m/s` : ""), + `${tr.points.length} samples · last seen ${age} s ago`, + ); + if (tr.behaviors?.length) { + lines.push("behavior: " + tr.behaviors.map((k) => BEHAVIOR_TEXT[k] || k).join(" · ")); + } + if (tr.callsign) { + // adsbdb route enrichment — fetched once per selection, 24 h cache. + if (tr._route === undefined) { + v.requestRoute(tr); + lines.push("route: looking up…"); + } else { + lines.push(...routeLines(tr._route).map(esc)); + } + } + if (tr.anomaly) { + lines.push(`§15 score ${tr.anomaly.score.toFixed(3)} — ${esc(tr.anomaly.band)}`); + for (const r of tr.anomaly.reasons) lines.push(esc(r)); + } else { + lines.push("unscored — needs ≥6 concurrent tracks for a live §15 baseline"); + } + if (typeof tr.novelty === "number") { + lines.push(`vector novelty ${tr.novelty.toFixed(2)} — §13 embedding vs ` + + `${v.noveltySize} stored tracks (IndexedDB)`); + } + details.innerHTML = who + + lines.map((l) => line(l, tr.emergency ? "#ff5252" : c)).join(""); +} + +// Satellite table (name / el / az / range / alt), highest elevation first. +// v: {satTbody, satsAbove, satNames, selectedSat}; onSelect(i). +export function renderSatTable(v, onSelect) { + const list = [...v.satsAbove].sort((a, b) => b.el - a.el).slice(0, 80); + v.satTbody.innerHTML = ""; + for (const s of list) { + const row = document.createElement("tr"); + row.className = "track-row" + (s.i === v.selectedSat ? " active" : ""); + row.innerHTML = ""; + row.cells[0].textContent = (s.visibleNow ? "✦ " : "") + v.satNames[s.i]; + if (s.visibleNow) row.cells[0].style.color = SAT_VISIBLE_COLOR; + row.cells[1].textContent = `${s.el.toFixed(1)}°`; + row.cells[2].textContent = `${Math.round(s.az)}°`; + row.cells[3].textContent = (s.range / 1000).toFixed(0); + row.cells[4].textContent = (s.alt / 1000).toFixed(0); + row.addEventListener("click", () => onSelect(s.i)); + v.satTbody.appendChild(row); + } +} diff --git a/examples/sky-monitor/ui/dashboard/passes.js b/examples/sky-monitor/ui/dashboard/passes.js new file mode 100644 index 0000000000..69e9ac6a3e --- /dev/null +++ b/examples/sky-monitor/ui/dashboard/passes.js @@ -0,0 +1,104 @@ +// Satellite pass timeline: 24 h pass prediction through the wasm +// SatPropagator.predict_passes (SGP4 stepped at 30 s in Rust, with a +// low-precision sun model so each pass carries a naked-eye "visible" +// flag — sunlit satellite against a dark observer sky). The side panel +// lists the next visible passes; the ⚙ drawer can arm a Notification-API +// alert 5 minutes before each visible pass (permission-gated). + +const RECOMPUTE_S = 6 * 3600; // refresh horizon twice per TLE cache life +const MIN_LIST_EL = 10; // ignore grazing passes below 10° max el +const ALERT_LEAD_S = 300; + +export function compassDir(az) { + const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]; + return dirs[Math.round(((az % 360) + 360) % 360 / 45) % 8]; +} + +export function fmtLocal(t) { + return new Date(t * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +} + +export class PassPlanner { + constructor(prop, names) { + this.prop = prop; + this.names = names; + this.passes = []; + this.computedAt = 0; + this.alertsOn = false; + this._alerted = new Set(); + } + + // One synchronous wasm call (~150 sats × 24 h @ 30 s ≈ 0.4 M SGP4 steps, + // well under a second) — run after TLE load and then every 6 h, never + // per frame. + compute(nowT) { + const out = this.prop.predict_passes(nowT, 24, 30); + const ps = []; + for (let i = 0; i + 6 < out.length; i += 7) { + ps.push({ + sat: out[i], rise: out[i + 1], culm: out[i + 2], set: out[i + 3], + maxEl: out[i + 4], azCulm: out[i + 5], visible: out[i + 6] > 0.5, + name: this.names[out[i]] || `sat ${out[i]}`, + }); + } + ps.sort((a, b) => a.rise - b.rise); + this.passes = ps; + this.computedAt = nowT; + } + + upcomingVisible(nowT, n = 10) { + if (this.computedAt && nowT - this.computedAt > RECOMPUTE_S) this.compute(nowT); + return this.passes + .filter((p) => p.visible && p.set > nowT && p.maxEl >= MIN_LIST_EL) + .slice(0, n); + } + + // Render the "Upcoming passes" panel (textContent only — TLE names are + // remote data). + renderInto(container, nowT) { + const list = this.upcomingVisible(nowT); + container.innerHTML = ""; + if (!list.length) { + const d = document.createElement("div"); + d.className = "reason"; + d.textContent = this.computedAt + ? "no naked-eye passes in the next 24 h" + : "predicting passes…"; + container.appendChild(d); + return; + } + for (const p of list) { + const d = document.createElement("div"); + d.className = "reason pass"; + const when = p.rise <= nowT ? "NOW" : `${fmtLocal(p.rise)}–${fmtLocal(p.set)}`; + d.textContent = + `✦ ${p.name} · ${when} · max ${Math.round(p.maxEl)}° ${compassDir(p.azCulm)}`; + container.appendChild(d); + } + } + + // Permission-gated browser notification ~5 min before each visible pass. + async enableAlerts() { + if (!("Notification" in window)) return false; + const perm = await Notification.requestPermission(); + this.alertsOn = perm === "granted"; + return this.alertsOn; + } + + maybeNotify(nowT) { + if (!this.alertsOn) return; + for (const p of this.upcomingVisible(nowT)) { + const lead = p.rise - nowT; + const key = `${p.name}@${Math.round(p.rise)}`; + if (lead > 0 && lead <= ALERT_LEAD_S && !this._alerted.has(key)) { + this._alerted.add(key); + try { + new Notification(`✦ ${p.name} pass in ${Math.round(lead / 60)} min`, { + body: `rises ${fmtLocal(p.rise)}, max ${Math.round(p.maxEl)}° ` + + `${compassDir(p.azCulm)}, sets ${fmtLocal(p.set)}`, + }); + } catch (_e) { /* notification construction can throw on some platforms */ } + } + } + } +} diff --git a/examples/sky-monitor/ui/dashboard/project.js b/examples/sky-monitor/ui/dashboard/project.js new file mode 100644 index 0000000000..0f9edbc891 --- /dev/null +++ b/examples/sky-monitor/ui/dashboard/project.js @@ -0,0 +1,77 @@ +// Projection helpers shared by sky.js and astro.js — JS mirror of +// examples/sky-monitor/src/coords.rs (WGS-84 geodetic -> ECEF -> ENU -> +// az/el/range) plus the polar all-sky screen mapping (wasm/src/screen.rs) +// and the optional wasm engine loader. + +export const DEG = Math.PI / 180.0; + +const WGS84_A = 6378137.0; +const WGS84_F = 1.0 / 298.257223563; +const WGS84_E2 = WGS84_F * (2.0 - WGS84_F); + +export function geodeticToEcef(latDeg, lonDeg, altM) { + const lat = latDeg * DEG, lon = lonDeg * DEG; + const sLat = Math.sin(lat), cLat = Math.cos(lat); + const sLon = Math.sin(lon), cLon = Math.cos(lon); + const n = WGS84_A / Math.sqrt(1.0 - WGS84_E2 * sLat * sLat); // prime vertical + return [ + (n + altM) * cLat * cLon, + (n + altM) * cLat * sLon, + (n * (1.0 - WGS84_E2) + altM) * sLat, + ]; +} + +export function normalizeDeg(d) { + const r = d % 360.0; + return r < 0.0 ? r + 360.0 : r; +} + +// Full WGS-84 -> observer az/el/range projection (coords.rs observer_frame). +export function observerFrameJs(obs, obsEcef, lat, lon, altM) { + const t = geodeticToEcef(lat, lon, altM); + const dx = t[0] - obsEcef[0], dy = t[1] - obsEcef[1], dz = t[2] - obsEcef[2]; + const la = obs.lat * DEG, lo = obs.lon * DEG; + const sLat = Math.sin(la), cLat = Math.cos(la); + const sLon = Math.sin(lo), cLon = Math.cos(lo); + const e = -sLon * dx + cLon * dy; + const n = -sLat * cLon * dx - sLat * sLon * dy + cLat * dz; + const u = cLat * cLon * dx + cLat * sLon * dy + sLat * dz; + const horizontal = Math.hypot(e, n); + const range = Math.hypot(horizontal, u); + const az = horizontal < 1e-9 ? 0.0 : normalizeDeg(Math.atan2(e, n) / DEG); + const el = Math.atan2(u, horizontal) / DEG; + return [az, el, range]; +} + +// Polar "fisheye" all-sky mapping: zenith at the centre, horizon on the +// inscribed circle, azimuth 0 = North = up. +export function polarScreenXY(azDeg, elDeg, width, height) { + const cx = width / 2, cy = height / 2; + const radius = Math.min(width, height) / 2; + const el = Math.max(-90, Math.min(90, elDeg)); + const r = ((90 - el) / 90) * radius; + const az = azDeg * DEG; + return [cx + r * Math.sin(az), cy - r * Math.cos(az), elDeg >= 0]; +} + +// Optional wasm engine (preferred when ./pkg exists): batched projection, +// SGP4 satellite propagation, and the §15 anomaly scorer. +export async function loadWasmEngine(obs) { + try { + const mod = await import("./pkg/sky_monitor_wasm.js"); + await mod.default(); // init wasm + const projector = new mod.SkyProjector(obs.lat, obs.lon, obs.alt_m); + return { + projectBatch: (flat) => projector.project_batch(flat), + SatPropagator: mod.SatPropagator, + AnomalyScorer: mod.AnomalyScorer, + // §13/§15: canonical 32-dim track embedding + indexer-calibrated + // novelty (mean top-3 distance / 1.2) — see wasm/src/embed.rs. + embedTrack: (flat, rssi) => mod.embed_track(flat, rssi), + noveltyScore: (emb, past) => mod.novelty(emb, past), + version: mod.version(), + }; + } catch (_e) { + return null; // pkg not built — JS fallback stays active + } +} diff --git a/examples/sky-monitor/ui/dashboard/record.js b/examples/sky-monitor/ui/dashboard/record.js new file mode 100644 index 0000000000..57e122a197 --- /dev/null +++ b/examples/sky-monitor/ui/dashboard/record.js @@ -0,0 +1,115 @@ +// Recorded replay of REAL traffic (the synthetic-day replay was deliberately +// removed — this only ever replays what the live feed actually saw). +// +// Every poll, freshly projected aircraft points are appended to an +// IndexedDB ring buffer capped at ~1 h. The footer scrubber re-renders the +// dome at a past wall-clock t from this buffer through the exact same +// drawTrack/indexAt path — recorded points carry t plus az/el/range, so no +// re-projection is needed. LIVE returns to the wall clock. + +const DB_NAME = "skygraph-replay-v1"; +const STORE = "points"; +const WINDOW_S = 3600; +const PRUNE_EVERY_S = 60; + +const idb = (req) => new Promise((res, rej) => { + req.onsuccess = () => res(req.result); + req.onerror = () => rej(req.error); +}); + +export class Recorder { + constructor() { this.db = null; this._lastPrune = 0; } + + async open() { + try { + const req = indexedDB.open(DB_NAME, 1); + req.onupgradeneeded = () => { + const os = req.result.createObjectStore(STORE, { autoIncrement: true }); + os.createIndex("t", "t"); + }; + this.db = await idb(req); + } catch (_e) { this.db = null; /* private mode — replay unavailable */ } + return this; + } + + get available() { return !!this.db; } + + // Append every projected point newer than the track's last recorded t. + record(tracks, nowT) { + if (!this.db) return; + const rows = []; + for (const tr of tracks) { + for (const p of tr.points) { + if (p.az === undefined || (tr._recT !== undefined && p.t <= tr._recT)) continue; + rows.push({ + t: p.t, icao24: tr.icao24, label: tr.label || tr.icao24, + category: tr.category || null, emergency: tr.emergency || null, + lat: p.lat, lon: p.lon, alt_m: p.alt_m, + az: p.az, el: p.el, range: p.range, + }); + } + const last = tr.points[tr.points.length - 1]; + if (last) tr._recT = last.t; + } + if (!rows.length) return; + try { + const os = this.db.transaction(STORE, "readwrite").objectStore(STORE); + for (const r of rows) os.add(r); + } catch (_e) { /* quota — stop growing, replay keeps what it has */ } + if (nowT - this._lastPrune > PRUNE_EVERY_S) { + this._lastPrune = nowT; + this._prune(nowT - WINDOW_S); + } + } + + _prune(beforeT) { + try { + const idx = this.db.transaction(STORE, "readwrite") + .objectStore(STORE).index("t"); + idx.openCursor(IDBKeyRange.upperBound(beforeT, true)).onsuccess = (e) => { + const cur = e.target.result; + if (cur) { cur.delete(); cur.continue(); } + }; + } catch (_e) { /* best effort */ } + } + + // Earliest recorded t (or null when the buffer is empty). + async earliestT() { + if (!this.db) return null; + try { + const idx = this.db.transaction(STORE).objectStore(STORE).index("t"); + const cur = await idb(idx.openCursor()); + return cur ? cur.value.t : null; + } catch (_e) { return null; } + } + + // Load the whole buffer and group it into renderable track shapes + // ({icao24, label, category, emergency, points[{t,lat,lon,alt_m,az,el, + // range}], t0, t1}) — drawTrack consumes these directly. + async loadTracks() { + if (!this.db) return []; + let rows = []; + try { + rows = await idb(this.db.transaction(STORE).objectStore(STORE).getAll()); + } catch (_e) { return []; } + const by = new Map(); + for (const r of rows) { + let tr = by.get(r.icao24); + if (!tr) { + tr = { icao24: r.icao24, label: r.label, category: r.category, + emergency: null, points: [], replay: true }; + by.set(r.icao24, tr); + } + tr.label = r.label; + tr.points.push({ t: r.t, lat: r.lat, lon: r.lon, alt_m: r.alt_m, + az: r.az, el: r.el, range: r.range }); + } + const out = [...by.values()]; + for (const tr of out) { + tr.points.sort((a, b) => a.t - b.t); + tr.t0 = tr.points[0].t; + tr.t1 = tr.points[tr.points.length - 1].t; + } + return out; + } +} diff --git a/examples/sky-monitor/ui/dashboard/route-info.js b/examples/sky-monitor/ui/dashboard/route-info.js new file mode 100644 index 0000000000..7ecdd2a556 --- /dev/null +++ b/examples/sky-monitor/ui/dashboard/route-info.js @@ -0,0 +1,87 @@ +// Route enrichment: adsbdb callsign lookup, fetched only when an aircraft +// is selected (never bulk — one keyless API call per new callsign per day). +// +// Source survey (probed 2026-06-10 with a real request, +// Origin: http://localhost:8000): +// GET api.adsbdb.com/v0/callsign/ACA123 +// -> 200, access-control-allow-origin: * (usable from the browser) +// GET api.adsbdb.com/v0/callsign/ZZZ9X9 +// -> 404 {"response":"unknown callsign"} (cached as a miss) +// +// Hits and misses cache in localStorage for 24 h; network/CORS errors are +// not cached so a flaky connection can retry on the next selection. + +const CACHE_KEY = "skygraph-routes-v1"; +const TTL_MS = 24 * 3600e3; +const MAX_CACHE = 300; +const FETCH_TIMEOUT_MS = 6000; + +const pending = new Map(); // callsign -> in-flight promise + +function loadCache() { + try { return JSON.parse(localStorage.getItem(CACHE_KEY) || "{}"); } + catch (_e) { return {}; } +} + +function saveCache(cache) { + const keys = Object.keys(cache); + if (keys.length > MAX_CACHE) { + keys.sort((a, b) => cache[a].at - cache[b].at) + .slice(0, keys.length - MAX_CACHE) + .forEach((k) => delete cache[k]); + } + try { localStorage.setItem(CACHE_KEY, JSON.stringify(cache)); } catch (_e) { /* quota */ } +} + +function pick(body) { + const fr = body?.response?.flightroute; + if (!fr) return null; + const ap = (a) => a ? { iata: a.iata_code || "", name: a.municipality || a.name || "" } : null; + return { + airline: fr.airline?.name || null, + origin: ap(fr.origin), + destination: ap(fr.destination), + }; +} + +// Resolve {airline, origin, destination} | null (no route known) for a +// callsign. Never rejects — resolves null on any failure. +export async function routeFor(callsign) { + const cs = String(callsign || "").trim().toUpperCase(); + if (!cs) return null; + const cache = loadCache(); + const hit = cache[cs]; + if (hit && Date.now() - hit.at < TTL_MS) return hit.route; + if (pending.has(cs)) return pending.get(cs); + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS); + const p = fetch(`https://api.adsbdb.com/v0/callsign/${encodeURIComponent(cs)}`, + { signal: ctl.signal, headers: { Accept: "application/json" } }) + .then((r) => { + if (r.status === 404) return null; // unknown callsign — cache the miss + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json().then(pick); + }) + .then((route) => { + const c = loadCache(); + c[cs] = { at: Date.now(), route }; + saveCache(c); + return route; + }) + .catch(() => null) // network/CORS — graceful skip, not cached + .finally(() => { clearTimeout(timer); pending.delete(cs); }); + pending.set(cs, p); + return p; +} + +// Display lines for the details panel. +export function routeLines(route) { + if (!route) return ["route: not in adsbdb"]; + const lines = []; + if (route.airline) lines.push(`airline: ${route.airline}`); + if (route.origin && route.destination) { + lines.push(`route: ${route.origin.iata} ${route.origin.name} → ` + + `${route.destination.iata} ${route.destination.name}`); + } + return lines.length ? lines : ["route: not in adsbdb"]; +} diff --git a/examples/sky-monitor/ui/dashboard/sat-feed.js b/examples/sky-monitor/ui/dashboard/sat-feed.js new file mode 100644 index 0000000000..5be4dd33bd --- /dev/null +++ b/examples/sky-monitor/ui/dashboard/sat-feed.js @@ -0,0 +1,68 @@ +// RuView SkyGraph satellite layer data: TLE fetch + cache, per CelesTrak +// group. +// +// Source survey (probed 2026-06-10 with Origin: http://localhost:8000): +// celestrak.org gp.php?GROUP=visual&FORMAT=tle -> 200, ACAO: * PRIMARY +// +// Groups offered in the ⚙ drawer: +// visual (~160 brightest objects — default, Canvas2D-friendly) +// stations (crewed stations + visitors, a couple dozen) +// starlink (~7 000+ — only offered while the WebGPU sat layer is active; +// "active" would be bigger still and is deliberately not offered) +// +// TLEs change slowly, so responses cache in localStorage for 6 h per group +// to stay polite to CelesTrak. Propagation happens in sky-monitor-wasm +// (`SatPropagator`, SGP4) — without the wasm pkg the satellite layer simply +// stays off. + +export const TLE_GROUPS = ["visual", "stations", "starlink"]; +const TLE_URL = (g) => + `https://celestrak.org/NORAD/elements/gp.php?GROUP=${encodeURIComponent(g)}&FORMAT=tle`; +const CACHE_KEY = (g) => `skygraph-tle-${g}-v1`; +const TLE_TTL_MS = 6 * 3600e3; +const FETCH_TIMEOUT_MS = 15000; + +// Parse 3-line TLE text (name / line 1 / line 2) into [{name, l1, l2}]. +export function parseTle(text) { + const lines = text.split(/\r?\n/).map((l) => l.trimEnd()).filter((l) => l.length); + const sats = []; + let i = 0; + while (i + 2 < lines.length + 1) { + if (lines[i + 1]?.startsWith("1 ") && lines[i + 2]?.startsWith("2 ")) { + sats.push({ name: lines[i].trim(), l1: lines[i + 1], l2: lines[i + 2] }); + i += 3; + } else { + i += 1; + } + } + return sats; +} + +// Load TLEs for a group: fresh cache -> network -> stale cache. Returns +// `{sats, source}` or null when no TLEs are available at all. +export async function loadTles(group = "visual") { + if (!TLE_GROUPS.includes(group)) group = "visual"; + let cached = null; + try { + cached = JSON.parse(localStorage.getItem(CACHE_KEY(group)) || "null"); + } catch (_e) { /* corrupt cache — refetch */ } + if (cached?.sats?.length && Date.now() - cached.at < TLE_TTL_MS) { + return { sats: cached.sats, source: `cache (${group})` }; + } + try { + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS); + const r = await fetch(TLE_URL(group), { signal: ctl.signal }) + .finally(() => clearTimeout(timer)); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const sats = parseTle(await r.text()); + if (!sats.length) throw new Error("no TLEs in response"); + try { + localStorage.setItem(CACHE_KEY(group), JSON.stringify({ at: Date.now(), sats })); + } catch (_e) { /* quota (starlink is ~2 MB) — run uncached */ } + return { sats, source: `celestrak ${group}` }; + } catch (_e) { + if (cached?.sats?.length) return { sats: cached.sats, source: `stale cache (${group})` }; + return null; + } +} diff --git a/examples/sky-monitor/ui/dashboard/score-live.js b/examples/sky-monitor/ui/dashboard/score-live.js new file mode 100644 index 0000000000..6e3065905f --- /dev/null +++ b/examples/sky-monitor/ui/dashboard/score-live.js @@ -0,0 +1,47 @@ +// Live §15 anomaly scoring through the wasm AnomalyScorer (the documented +// ADR-199 follow-up). Each live track is summarized into the exact +// TrackSummary shape (src/anomaly.rs) and scored against a baseline built +// from the *other* current tracks — a browser approximation of §26 +// ("baseline before alerting"): nothing is scored until at least +// MIN_BASELINE other tracks exist. Vector novelty comes from ./novelty.js +// (wasm §13 embeddings against the IndexedDB store); cross-sensor +// confirmation stays 0 in the browser (no second modality). + +const MIN_BASELINE = 5; +const DEFAULT_RSSI_DBFS = -20; // when the feed carries no rssi field + +export function summarize(tr) { + let altSum = 0, minRange = Infinity, maxEl = -90; + for (const p of tr.points) { + altSum += p.alt_m; + if (p.range < minRange) minRange = p.range; + if (p.el > maxEl) maxEl = p.el; + } + return { + icao24: tr.icao24, + callsign: tr.callsign || "", + mean_alt_m: altSum / tr.points.length, + dominant_heading_deg: tr.vel ? tr.vel.trackDeg : 0, + start_hour: new Date(tr.t0 * 1000).getUTCHours(), + mean_signal_dbfs: typeof tr.rssi === "number" ? tr.rssi : DEFAULT_RSSI_DBFS, + min_range_m: isFinite(minRange) ? minRange : 0, + max_elevation_deg: maxEl, + }; +} + +// Score every track in place (tr.anomaly = {score, band, reasons} | null). +export function scoreAll(scorer, tracks) { + if (!scorer || tracks.length < MIN_BASELINE + 1) { + for (const tr of tracks) tr.anomaly = null; + return; + } + const summaries = tracks.map(summarize); + for (let i = 0; i < tracks.length; i++) { + try { + scorer.baseline_from(summaries.filter((_, j) => j !== i)); + tracks[i].anomaly = scorer.score(summaries[i], tracks[i].novelty ?? 0); + } catch (_e) { + tracks[i].anomaly = null; + } + } +} diff --git a/examples/sky-monitor/ui/dashboard/settings.js b/examples/sky-monitor/ui/dashboard/settings.js new file mode 100644 index 0000000000..27f0c00fa5 --- /dev/null +++ b/examples/sky-monitor/ui/dashboard/settings.js @@ -0,0 +1,88 @@ +// ⚙ drawer: persisted layer/setting state (localStorage) + control wiring. +// New v2 keys: conflicts (CPA layer), webgpuSats (experimental satellite +// renderer), tleGroup (CelesTrak group — starlink gated on WebGPU). + +export const SETTINGS_KEY = "skygraph-settings-v1"; +const DEFAULTS = { + aircraft: true, satellites: true, sunmoon: true, trails: true, labels: true, + conflicts: true, trailLen: 150, webgpuSats: false, tleGroup: "visual", +}; + +export const CFG = (() => { + try { return { ...DEFAULTS, ...JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}") }; } + catch (_e) { return { ...DEFAULTS }; } +})(); + +export const saveSettings = () => { + try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(CFG)); } catch (_e) { /* quota */ } +}; + +// Wire all drawer controls. handlers: +// onWebgpu(enabled) -> Promise (false = init failed, fall back) +// onTleGroup(group) (reload the satellite layer) +// onPassAlerts() -> Promise (Notification permission result) +export function initDrawer(handlers) { + const drawer = document.getElementById("drawer"); + document.getElementById("gear") + .addEventListener("click", () => drawer.classList.toggle("open")); + + for (const key of ["aircraft", "satellites", "sunmoon", "trails", "labels", "conflicts"]) { + const box = document.getElementById(`opt-${key}`); + box.checked = CFG[key]; + box.addEventListener("change", () => { CFG[key] = box.checked; saveSettings(); }); + } + + const trailLen = document.getElementById("opt-trail-len"); + const trailOut = document.getElementById("opt-trail-out"); + trailLen.value = String(CFG.trailLen); + trailOut.textContent = String(CFG.trailLen); + trailLen.addEventListener("input", () => { + CFG.trailLen = Number(trailLen.value); + trailOut.textContent = trailLen.value; + saveSettings(); + }); + + // TLE group select — starlink is only offered while WebGPU is active + // ("active" is bigger still and deliberately not offered at all). + const sel = document.getElementById("opt-tle-group"); + const syncTleOptions = () => { + const starlink = sel.querySelector('option[value="starlink"]'); + starlink.disabled = !CFG.webgpuSats; + if (starlink.disabled && CFG.tleGroup === "starlink") { + CFG.tleGroup = "visual"; + sel.value = "visual"; + saveSettings(); + handlers.onTleGroup("visual"); + } + }; + sel.value = CFG.tleGroup; + sel.addEventListener("change", () => { + CFG.tleGroup = sel.value; + saveSettings(); + handlers.onTleGroup(sel.value); + }); + + // WebGPU toggle with automatic Canvas2D fallback on init failure. + const gpuBox = document.getElementById("opt-webgpu"); + gpuBox.checked = CFG.webgpuSats; + gpuBox.addEventListener("change", async () => { + if (gpuBox.checked && !(await handlers.onWebgpu(true))) { + gpuBox.checked = false; // no WebGPU here — stay on Canvas2D + } else if (!gpuBox.checked) { + await handlers.onWebgpu(false); + } + CFG.webgpuSats = gpuBox.checked; + saveSettings(); + syncTleOptions(); + }); + + // Pass alerts (Notification permission is user-gesture gated). + const alertBtn = document.getElementById("opt-pass-alerts"); + alertBtn.addEventListener("click", async () => { + const on = await handlers.onPassAlerts(); + alertBtn.textContent = on ? "Pass alerts: ON" : "Pass alerts: unavailable"; + }); + + syncTleOptions(); + return { syncTleOptions }; +} diff --git a/examples/sky-monitor/ui/dashboard/sky.js b/examples/sky-monitor/ui/dashboard/sky.js new file mode 100644 index 0000000000..dce49e1285 --- /dev/null +++ b/examples/sky-monitor/ui/dashboard/sky.js @@ -0,0 +1,428 @@ +// RuView SkyGraph dashboard (ADR-199 presentation plane) — realtime, with +// recorded replay of real traffic. +// +// Live ADS-B + Open-Meteo (./live-feed.js), satellites (./sat-feed.js TLEs + +// wasm SGP4, optional WebGPU sprite layer ./gpu-sats.js), sun & moon +// (./astro.js), §15 anomaly scoring with REAL §13 vector novelty +// (./score-live.js + ./novelty.js + IndexedDB), behavior badges +// (./behavior.js), CPA conflict prediction (./conflict.js), satellite pass +// timeline (./passes.js), adsbdb route enrichment (./route-info.js), NOAA +// space weather (./space-wx.js) and an IndexedDB ring-buffer replay of the +// last hour of real traffic (./record.js). Offline, the dome stays up and +// the status line reports retrying. Rendering primitives live in ./draw.js, +// the ⚙ drawer in ./settings.js, the side panel in ./panels.js. + +import { geodeticToEcef, loadWasmEngine, observerFrameJs, polarScreenXY } from "./project.js"; +import { LiveFeed, displayPoint, syncLiveTable } from "./live-feed.js"; +import { moonPosition, satSunlit, sunPosition } from "./astro.js"; +import { scoreAll } from "./score-live.js"; +import { + BAND_COLORS, drawConflictLine, drawCone, drawSkyDome, drawTrack, + LIVE_COLOR, SAT_COLOR, SAT_VISIBLE_COLOR, +} from "./draw.js"; +import { CFG, initDrawer, saveSettings } from "./settings.js"; +import { renderDetails, renderSatTable } from "./panels.js"; +import { NoveltyStore } from "./novelty.js"; +import { detectBehaviors } from "./behavior.js"; +import { detectConflicts, predictCone } from "./conflict.js"; +import { PassPlanner } from "./passes.js"; +import { routeFor } from "./route-info.js"; +import { SpaceWeather } from "./space-wx.js"; +import { Recorder } from "./record.js"; +import { GpuSats } from "./gpu-sats.js"; +import { loadTles } from "./sat-feed.js"; + +// Reference observer (matches src/config.rs ObserverConfig defaults). +const OBSERVER = { name: "oakville_node", lat: 43.4675, lon: -79.6877, alt_m: 100.0 }; + +async function main() { + const canvas = document.getElementById("sky"); + const gpuCanvas = document.getElementById("sky-gpu"); + const ctx = canvas.getContext("2d"); + const clock = document.getElementById("clock"); + const wxLabel = document.getElementById("wx"); + const liveStatus = document.getElementById("live-status"); + const satStatus = document.getElementById("sat-status"); + const tbody = document.querySelector("#track-table tbody"); + const satTbody = document.querySelector("#sat-table tbody"); + const details = document.getElementById("details"); + const passList = document.getElementById("pass-list"); + document.getElementById("observer-label").textContent = + `observer: ${OBSERVER.name} (${OBSERVER.lat.toFixed(4)}, ${OBSERVER.lon.toFixed(4)}, ${OBSERVER.alt_m} m)`; + + // Prefer wasm when ./pkg is present (projection + SGP4 + scoring + §13). + const obsEcef = geodeticToEcef(OBSERVER.lat, OBSERVER.lon, OBSERVER.alt_m); + const wasm = await loadWasmEngine(OBSERVER); + const scorer = wasm?.AnomalyScorer ? new wasm.AnomalyScorer() : null; + document.getElementById("engine").textContent = wasm + ? `projection: wasm (sky-monitor-wasm ${wasm.version})` + : "projection: JS fallback (build ./pkg for wasm)"; + + let t = Date.now() / 1000; // displayed timeline (wall clock, or replay t) + let sun = sunPosition(t, OBSERVER.lat, OBSERVER.lon); + let selected = null; // selected aircraft track + let selectedSat = -1; // selected satellite index (exclusive with above) + let lastStatusSec = 0; + let lastPassRender = 0; + let conflicts = []; + const liveRows = new Map(); + + // Stores: §13/§15 novelty embeddings + the replay ring buffer. + const novelty = await new NoveltyStore().open(); + const recorder = await new Recorder().open(); + const spaceWx = new SpaceWeather(() => showDetails()); + spaceWx.start(); + + // --- Satellite layer (wasm SGP4; stays off without ./pkg) ------------------- + let satProp = null, satNames = [], satsAbove = [], passes = null; + let satGen = 0; + async function loadSats(group) { + if (!wasm?.SatPropagator) { + satStatus.textContent = "sats: off (build ./pkg for wasm SGP4)"; + return; + } + const gen = ++satGen; + satProp = null; passes = null; satNames = []; selectedSat = -1; + satStatus.textContent = `sats: loading TLEs (${group})…`; + try { + const tle = await loadTles(group); + if (gen !== satGen) return; // superseded by a newer group switch + if (!tle) { satStatus.textContent = "sats: offline — no TLE source"; return; } + const prop = new wasm.SatPropagator(OBSERVER.lat, OBSERVER.lon, OBSERVER.alt_m); + let n = 0; + for (const s of tle.sats) if (prop.add_tle(s.name, s.l1, s.l2)) n++; + satNames = Array.from({ length: n }, (_, i) => prop.name(i)); + satProp = prop; + satStatus.textContent = `sats: ${n} TLEs (${tle.source})`; + // 24 h pass horizon: one wasm call, then a 6 h refresh inside + // upcomingVisible(). Skipped for starlink (pass lists make no sense + // for a 7 000-sat mesh and the prediction would take seconds). + if (group !== "starlink") { + passes = new PassPlanner(prop, satNames); + passes.compute(Date.now() / 1000); + } + passes ? passes.renderInto(passList, Date.now() / 1000) + : (passList.innerHTML = '
pass list off for starlink
'); + } catch (_e) { + if (gen === satGen) satStatus.textContent = "sats: unavailable"; + } + } + + // --- WebGPU satellite layer (experimental, auto-fallback) ------------------- + let gpu = null; + async function setWebgpu(on) { + if (!on) { + gpu?.dispose(); + gpu = null; + return true; + } + const g = new GpuSats(); + if (await g.init(gpuCanvas)) { gpu = g; return true; } + satStatus.textContent = "sats: WebGPU unavailable — Canvas2D fallback"; + return false; + } + + const drawerCtl = initDrawer({ + onWebgpu: setWebgpu, + onTleGroup: (g) => loadSats(g), + onPassAlerts: async () => (passes ? passes.enableAlerts() : false), + }); + if (CFG.webgpuSats) { + setWebgpu(true).then((ok) => { + if (!ok) { + CFG.webgpuSats = false; + saveSettings(); + document.getElementById("opt-webgpu").checked = false; + drawerCtl.syncTleOptions(); + } + }); + } + loadSats(CFG.tleGroup); + + // Propagate + draw satellites at timeline t. Canvas2D diamonds by + // default; with the WebGPU toggle the same projected positions go to the + // instanced sprite overlay instead (labels stay off there — point cloud). + let gpuInst = new Float32Array(4096); + function drawSats(w, h, dpr) { + const out = satProp.positions(t); + const dark = sun.el < -6; // civil twilight or darker + const above = []; + let gpuN = 0; + if (gpu && gpuInst.length < (out.length / 6) * 4) { + gpuInst = new Float32Array((out.length / 6) * 4); + } + for (let i = 0; i * 6 < out.length; i++) { + const el = out[i * 6 + 4]; + if (!isFinite(el) || el <= 0) continue; + const az = out[i * 6 + 3]; + const visibleNow = + dark && satSunlit(out[i * 6], out[i * 6 + 1], out[i * 6 + 2], sun.dir); + const [x, y] = polarScreenXY(az, el, w, h); + const sel = i === selectedSat; + if (gpu) { + const o = gpuN * 4; + gpuInst[o] = x; gpuInst[o + 1] = y; + gpuInst[o + 2] = sel ? 5 : visibleNow ? 4 : 2.8; + gpuInst[o + 3] = visibleNow ? 1 : 0; + gpuN++; + } else { + const half = sel ? 4 : visibleNow ? 3.5 : 2.5; + ctx.fillStyle = sel ? "#ffffff" : visibleNow ? SAT_VISIBLE_COLOR : SAT_COLOR; + ctx.save(); ctx.translate(x, y); ctx.rotate(Math.PI / 4); + ctx.fillRect(-half, -half, half * 2, half * 2); + ctx.restore(); + if (CFG.labels) { + ctx.fillStyle = visibleNow ? SAT_VISIBLE_COLOR : "#8b97b8"; + ctx.font = "10px monospace"; + ctx.fillText(satNames[i], x + 9, y + 3); + } + } + above.push({ i, az, el, range: out[i * 6 + 5], alt: out[i * 6 + 2], visibleNow }); + } + if (gpu) gpu.draw(gpuInst, gpuN, w, h, dpr); + return above; + } + + function drawSunMoon(w, h) { + if (sun.el > -0.8) { + const [x, y] = polarScreenXY(sun.az, sun.el, w, h); + ctx.fillStyle = "#ffd75e"; + ctx.beginPath(); ctx.arc(x, y, 7, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = "rgba(255, 215, 94, 0.35)"; + ctx.lineWidth = 4; + ctx.beginPath(); ctx.arc(x, y, 11, 0, Math.PI * 2); ctx.stroke(); + if (CFG.labels) { ctx.fillStyle = "#d9b84d"; ctx.font = "10px monospace"; ctx.fillText("sun", x + 14, y + 3); } + } + const moon = moonPosition(t, OBSERVER.lat, OBSERVER.lon); + if (moon.el > -0.8) { + const [x, y] = polarScreenXY(moon.az, moon.el, w, h); + ctx.fillStyle = "#dde4f2"; + ctx.beginPath(); ctx.arc(x, y, 5.5, 0, Math.PI * 2); ctx.fill(); + if (CFG.labels) { ctx.fillStyle = "#9aa6c4"; ctx.font = "10px monospace"; ctx.fillText("moon", x + 12, y + 3); } + } + } + + // Project aircraft points that arrived since the last poll. + function projectNew(tr) { + const fresh = tr.points.filter((p) => p.az === undefined); + if (fresh.length && wasm) { + const flat = new Float64Array(fresh.length * 3); + fresh.forEach((p, i) => { flat[i * 3] = p.lat; flat[i * 3 + 1] = p.lon; flat[i * 3 + 2] = p.alt_m; }); + const out = wasm.projectBatch(flat); // [az, el, range, bearing] * N + fresh.forEach((p, i) => { p.az = out[i * 4]; p.el = out[i * 4 + 1]; p.range = out[i * 4 + 2]; }); + } else { + for (const p of fresh) { + const [az, el, range] = observerFrameJs(OBSERVER, obsEcef, p.lat, p.lon, p.alt_m); + p.az = az; p.el = el; p.range = range; + } + } + tr.t0 = tr.points[0].t; + tr.t1 = tr.points[tr.points.length - 1].t; + tr.label = tr.callsign || tr.icao24; + } + + // Smoothed dead-reckoned display position, re-projected each frame. + function reckonGhost(tr, tNow) { + const g = displayPoint(tr, tNow); + if (g) { + const [az, el] = observerFrameJs(OBSERVER, obsEcef, g.lat, g.lon, g.alt_m); + g.az = az; g.el = el; + } + tr._ghost = g; + } + + // Project a prediction cone's lat/lon paths into az/el for drawing. + function projectCone(cone) { + const proj = (pts) => pts.map((p) => { + const [az, el] = observerFrameJs(OBSERVER, obsEcef, p.lat, p.lon, p.alt_m); + return { az, el }; + }); + return { center: proj(cone.center), left: proj(cone.left), right: proj(cone.right) }; + } + + // adsbdb lookup on selection only (24 h localStorage cache inside). + function requestRoute(tr) { + if (!tr.callsign || tr._routePending) return; + tr._routePending = true; + routeFor(tr.callsign).then((r) => { + tr._route = r; + if (selected === tr) showDetails(); + }); + } + + function showDetails() { + renderDetails({ + details, selected, selectedSat, satsAbove, satNames, sun, + feed, spaceWx, noveltySize: novelty.size(), conflicts, requestRoute, + }); + } + + function syncSatTable() { + renderSatTable({ satTbody, satsAbove, satNames, selectedSat }, (i) => { + selectedSat = selectedSat === i ? -1 : i; + selected = null; // aircraft and satellite selection are exclusive + showDetails(); + }); + } + + function syncTable() { + syncLiveTable(feed, tbody, liveRows, (tr) => { + selected = selected === tr ? null : tr; + selectedSat = -1; // aircraft and satellite selection are exclusive + showDetails(); + }); + } + + // --- Feed -------------------------------------------------------------------- + let emergencyPrefix = ""; + function statusLine(f) { + const cpa = conflicts.length ? `⚠ CPA alert (${conflicts.length} pair${conflicts.length > 1 ? "s" : ""}) · ` : ""; + return emergencyPrefix + cpa + f.statusText(); + } + + function onFeedUpdate(f) { + const nowT = Date.now() / 1000; + for (const tr of f.trackList) projectNew(tr); + novelty.update(wasm, f.trackList, nowT); // §13 embed + §15 novelty (tr.novelty) + scoreAll(scorer, f.trackList); // §15 via wasm, novelty-bearing + detectBehaviors(f.trackList, nowT); // HOLD / GRID / GO-AROUND / FORM + conflicts = CFG.conflicts ? detectConflicts(f.trackList, nowT) : []; + recorder.record(f.trackList, nowT); // replay ring buffer (~1 h) + let emergency = null; + for (const tr of f.trackList) { + tr.color = tr.anomaly ? BAND_COLORS[tr.anomaly.band] || LIVE_COLOR : LIVE_COLOR; + if (tr.emergency && !emergency) emergency = tr; + } + emergencyPrefix = emergency + ? `⚠ ${emergency.emergency} ${emergency.callsign || emergency.icao24} · ` : ""; + liveStatus.textContent = statusLine(f); + wxLabel.textContent = f.weatherText(); + if (selected && !f.byIcao.has(selected.icao24)) { + selected = null; // selected track aged out + } + syncTable(); + showDetails(); + } + + const feed = new LiveFeed(OBSERVER, onFeedUpdate); + feed.start(); + liveStatus.textContent = feed.statusText(); + + // --- Replay (footer scrubber over the recorded ring buffer) ------------------- + const replay = { active: false, t: 0, tracks: [] }; + const replayBtn = document.getElementById("replay-toggle"); + const scrub = document.getElementById("replay-scrub"); + const liveBtn = document.getElementById("replay-live"); + + async function enterReplay() { + const t0 = await recorder.earliestT(); + if (t0 === null) { + replayBtn.textContent = "⏪ nothing recorded yet"; + setTimeout(() => { replayBtn.textContent = "⏪ replay"; }, 1800); + return; + } + replay.tracks = await recorder.loadTracks(); + for (const tr of replay.tracks) tr.color = LIVE_COLOR; + scrub.min = String(Math.ceil(t0)); + scrub.max = String(Math.floor(Date.now() / 1000)); + scrub.value = scrub.max; + replay.t = Number(scrub.value); + replay.active = true; + selected = null; + document.body.classList.add("replaying"); + replayBtn.textContent = "⏪ recording continues…"; + } + + function exitReplay() { + replay.active = false; + replay.tracks = []; + replayBtn.textContent = "⏪ replay"; + document.body.classList.remove("replaying"); + } + + replayBtn.addEventListener("click", () => (replay.active ? exitReplay() : enterReplay())); + liveBtn.addEventListener("click", exitReplay); + scrub.addEventListener("input", () => { replay.t = Number(scrub.value); }); + + // --- Render loop --------------------------------------------------------------- + function render() { + const dpr = window.devicePixelRatio || 1; + const w = canvas.clientWidth, h = canvas.clientHeight; + if (canvas.width !== w * dpr || canvas.height !== h * dpr) { + canvas.width = w * dpr; canvas.height = h * dpr; + } + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, w, h); + drawSkyDome(ctx, w, h); + if (CFG.sunmoon) drawSunMoon(w, h); + const tracks = replay.active ? replay.tracks : feed.trackList; + if (CFG.aircraft) { + for (const tr of tracks) { + if (!tr.points.length || tr.points[tr.points.length - 1].az === undefined) continue; + if (!replay.active) reckonGhost(tr, t); + const visible = drawTrack(ctx, tr, t, w, h, tr === selected, CFG); + if (!replay.active) { + const entry = liveRows.get(tr); + if (entry) { + entry.row.classList.toggle("live", visible); + entry.row.classList.toggle("active", tr === selected); + } + } + } + } + if (!replay.active && CFG.conflicts) { + for (const c of conflicts) { + const pa = c.a._ghost || c.a.points[c.a.points.length - 1]; + const pb = c.b._ghost || c.b.points[c.b.points.length - 1]; + if (pa?.az !== undefined && pb?.az !== undefined) { + drawConflictLine(ctx, pa, pb, w, h, `${Math.round(c.dh)} m in ${Math.round(c.t)} s`); + } + } + if (selected) { + const cone = predictCone(selected, t); + if (cone) drawCone(ctx, projectCone(cone), w, h, selected.color || LIVE_COLOR); + } + } + if (satProp && CFG.satellites) { + satsAbove = drawSats(w, h, dpr); + } else { + satsAbove = []; + if (gpu) gpu.draw(gpuInst, 0, w, h, dpr); // clear the overlay + } + const wallSec = Math.floor(Date.now() / 1000); + if (wallSec !== lastStatusSec) { + lastStatusSec = wallSec; // 1 Hz: sun, status, tables, details, passes + sun = sunPosition(t, OBSERVER.lat, OBSERVER.lon); + const vis = satsAbove.filter((s) => s.visibleNow).length; + if (satProp) { + satStatus.textContent = `sats: ${satNames.length} TLEs · ${satsAbove.length} overhead` + + (vis ? ` · ${vis} ✦ visible` : "") + (gpu ? " · WebGPU" : ""); + } + liveStatus.textContent = statusLine(feed); + syncTable(); + syncSatTable(); + showDetails(); + if (passes) { + passes.maybeNotify(wallSec); + if (wallSec - lastPassRender >= 30) { + lastPassRender = wallSec; + passes.renderInto(passList, wallSec); + } + } + } + clock.textContent = + new Date(t * 1000).toISOString().replace("T", " ").slice(0, 19) + + (replay.active ? " UTC · REPLAY" : " UTC · LIVE"); + } + + function tick() { + t = replay.active ? replay.t : Date.now() / 1000; + render(); + requestAnimationFrame(tick); + } + + window.addEventListener("resize", render); + requestAnimationFrame(tick); +} + +main(); diff --git a/examples/sky-monitor/ui/dashboard/space-wx.js b/examples/sky-monitor/ui/dashboard/space-wx.js new file mode 100644 index 0000000000..5de3ee7b98 --- /dev/null +++ b/examples/sky-monitor/ui/dashboard/space-wx.js @@ -0,0 +1,75 @@ +// Space weather: NOAA SWPC planetary K index for the weather card. +// +// Source survey (probed 2026-06-10 with Origin: http://localhost:8000): +// services.swpc.noaa.gov/json/planetary_k_index_1m.json +// -> 200, Access-Control-Allow-Origin: * (usable from the browser) +// (Same CORS posture as the METAR note in live-feed.js — aviationweather.gov +// is blocked, SWPC is open.) +// +// The 1-minute product regenerates server-side every minute but Kp is a +// 3-hourly planetary index — polling every 15 min is plenty. Any failure +// keeps the last reading; offline the card simply omits the Kp line. + +const URL = "https://services.swpc.noaa.gov/json/planetary_k_index_1m.json"; +const POLL_MS = 15 * 60e3; +const FETCH_TIMEOUT_MS = 8000; +const AURORA_KP = 7; // Kp ≥ 7 pushes the auroral oval to ~43°N (observer lat) + +export class SpaceWeather { + constructor(onUpdate) { + this.onUpdate = onUpdate || (() => {}); + this.kp = null; + this.at = null; + this._timer = null; + } + + start() { + if (this._timer) return; + this._poll(); + this._timer = setInterval(() => this._poll(), POLL_MS); + } + + stop() { + clearInterval(this._timer); + this._timer = null; + } + + async _poll() { + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS); + try { + const r = await fetch(URL, { signal: ctl.signal, headers: { Accept: "application/json" } }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const rows = await r.json(); + const last = Array.isArray(rows) && rows.length ? rows[rows.length - 1] : null; + if (last && isFinite(Number(last.estimated_kp))) { + this.kp = Number(last.estimated_kp); + this.at = last.time_tag; + this.onUpdate(this); + } + } catch (_e) { /* graceful skip — keep last reading */ } + finally { clearTimeout(timer); } + } + + level() { + const k = this.kp; + if (k === null) return ""; + if (k < 4) return "quiet"; + if (k < 5) return "active"; + if (k < 6) return "G1 minor storm"; + if (k < 7) return "G2 moderate storm"; + if (k < 8) return "G3 strong storm"; + if (k < 9) return "G4 severe storm"; + return "G5 extreme storm"; + } + + // Lines for the weather card (details panel, nothing selected). + lines() { + if (this.kp === null) return []; + const out = [`geomagnetic Kp ${this.kp.toFixed(2)} — ${this.level()} · NOAA SWPC`]; + if (this.kp >= AURORA_KP) { + out.push("⚠ aurora possible at this latitude (43°N) — check the northern horizon"); + } + return out; + } +} diff --git a/examples/sky-monitor/ui/dashboard/test/behavior.test.mjs b/examples/sky-monitor/ui/dashboard/test/behavior.test.mjs new file mode 100644 index 0000000000..273cd27309 --- /dev/null +++ b/examples/sky-monitor/ui/dashboard/test/behavior.test.mjs @@ -0,0 +1,100 @@ +// node --test — synthetic-input tests for the pure behavior detectors. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + detectHolding, detectSurveyGrid, detectGoAround, detectFormationPairs, + detectBehaviors, +} from "../behavior.js"; + +const NOW = 1_781_200_000; + +// Circular orbit: radius ~2 km around a fix, one lap ~4 min. +function orbitPoints() { + const pts = []; + for (let i = 0; i < 60; i++) { + const a = (i / 48) * 2 * Math.PI; // 1.25 laps + pts.push({ + t: NOW - 300 + i * 5, + lat: 43.5 + 0.018 * Math.sin(a), + lon: -79.7 + 0.025 * Math.cos(a), + alt_m: 1500, + }); + } + return pts; +} + +function straightPoints(speedDegPerStep = 0.005) { + const pts = []; + for (let i = 0; i < 60; i++) { + pts.push({ t: NOW - 300 + i * 5, lat: 43.2 + i * speedDegPerStep, lon: -79.7, alt_m: 9000 }); + } + return pts; +} + +// Lawnmower: 5 east/west legs of ~3.6 km joined by quick row steps. +function gridPoints() { + const pts = []; + let t = NOW - 1100; + for (let leg = 0; leg < 5; leg++) { + for (let i = 0; i <= 8; i++) { + const f = leg % 2 === 0 ? i / 8 : 1 - i / 8; + pts.push({ t, lat: 43.4 + leg * 0.004, lon: -79.8 + f * 0.045, alt_m: 900 }); + t += 20; + } + } + return pts; +} + +function goAroundPoints() { + const pts = []; + let t = NOW - 500; + for (let i = 0; i < 20; i++) { // approach 1200 → 300 m + pts.push({ t, lat: 43.3 + i * 0.002, lon: -79.6, alt_m: 1200 - i * 47 }); + t += 10; + } + for (let i = 0; i < 15; i++) { // climb-out 300 → 1050 m + pts.push({ t, lat: 43.34 + i * 0.002, lon: -79.6, alt_m: 300 + i * 50 }); + t += 10; + } + return pts; +} + +test("holding triggers on an orbit, not on straight flight", () => { + assert.equal(detectHolding(orbitPoints(), NOW), true); + assert.equal(detectHolding(straightPoints(), NOW), false); +}); + +test("survey grid triggers on lawnmower legs, not on an orbit", () => { + assert.equal(detectSurveyGrid(gridPoints(), NOW), true); + assert.equal(detectSurveyGrid(straightPoints(), NOW), false); +}); + +test("go-around triggers on descend-then-climb, not on cruise", () => { + assert.equal(detectGoAround(goAroundPoints(), NOW), true); + assert.equal(detectGoAround(straightPoints(), NOW), false); +}); + +test("formation pairs need proximity + matched velocity", () => { + const mk = (lat, lon, gs, hdg) => ({ + icao24: `${lat}${lon}`, + points: [ + { t: NOW - 30, lat: lat - 0.004, lon, alt_m: 3000 }, + { t: NOW - 2, lat, lon, alt_m: 3000 }, + ], + vel: { gs_ms: gs, trackDeg: hdg, vrate_ms: 0 }, + }); + const a = mk(43.5, -79.7, 120, 10); + const b = mk(43.503, -79.7, 118, 12); // ~330 m north, same vector + const c = mk(43.8, -79.2, 120, 10); // far away + const d = mk(43.5005, -79.701, 120, 190); // close but opposite heading + const pairs = detectFormationPairs([a, b, c, d], NOW); + assert.equal(pairs.length, 1); + assert.ok(pairs[0].includes(a) && pairs[0].includes(b)); +}); + +test("detectBehaviors annotates badges in place", () => { + const tr = { icao24: "abc", points: orbitPoints(), vel: { gs_ms: 80, trackDeg: 0, vrate_ms: 0 } }; + detectBehaviors([tr], NOW); + assert.deepEqual(tr.behaviors, ["holding"]); + assert.equal(tr.badges, "HOLD"); +}); diff --git a/examples/sky-monitor/ui/dashboard/test/conflict.test.mjs b/examples/sky-monitor/ui/dashboard/test/conflict.test.mjs new file mode 100644 index 0000000000..078a65cfa0 --- /dev/null +++ b/examples/sky-monitor/ui/dashboard/test/conflict.test.mjs @@ -0,0 +1,84 @@ +// node --test — synthetic-input tests for CPA conflict prediction. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + cpa, enuOf, velEnu, detectConflicts, headingRateDegS, predictPath, + predictCone, +} from "../conflict.js"; + +const NOW = 1_781_200_000; + +test("cpa of a head-on pair is at the midpoint time, zero separation", () => { + // 10 km apart on the E axis, closing at 100 m/s each → meet in 50 s. + const r = cpa( + { e: -5000, n: 0, u: 1000 }, { e: 100, n: 0, u: 0 }, + { e: 5000, n: 0, u: 1000 }, { e: -100, n: 0, u: 0 }, + ); + assert.ok(Math.abs(r.t - 50) < 1e-6, `t ${r.t}`); + assert.ok(r.dh < 1e-6 && r.dv < 1e-6); +}); + +test("cpa clamps to the horizon for slowly converging pairs", () => { + const r = cpa( + { e: -50000, n: 0, u: 0 }, { e: 10, n: 0, u: 0 }, + { e: 50000, n: 0, u: 0 }, { e: -10, n: 0, u: 0 }, + ); + assert.equal(r.t, 90); // never reaches CPA inside 90 s + assert.ok(r.dh > 90_000); +}); + +test("detectConflicts flags a converging pair and skips a diverging one", () => { + const mk = (az, el, range, gs, hdg) => ({ + icao24: `${az}-${hdg}`, + points: [{ t: NOW - 2, lat: 43.5, lon: -79.7, alt_m: 1500, az, el, range }], + vel: { gs_ms: gs, trackDeg: hdg, vrate_ms: 0 }, + }); + // One due E at 6 km flying W, one due W at 6 km flying E, same level + // → meet overhead in ~30 s at 200 m/s each. + const a = mk(90, 14, 6200, 200, 270); + const b = mk(270, 14, 6200, 200, 90); + // High-altitude crosser nowhere near them. + const c = mk(0, 60, 11000, 200, 0); + const conflicts = detectConflicts([a, b, c], NOW); + assert.equal(conflicts.length, 1); + assert.ok(conflicts[0].dh < 1000 && conflicts[0].dv < 300); + assert.ok(conflicts[0].t > 10 && conflicts[0].t < 60, `t ${conflicts[0].t}`); + // Reverse the headings → diverging, no conflict. + a.vel.trackDeg = 90; + b.vel.trackDeg = 270; + assert.equal(detectConflicts([a, b, c], NOW).length, 0); +}); + +test("headingRateDegS measures a steady turn", () => { + // 3°/s right turn at ~100 m/s for 60 s. + const pts = []; + let lat = 43.5, lon = -79.7, hdg = 0; + for (let i = 0; i < 13; i++) { + pts.push({ t: NOW - 60 + i * 5, lat, lon, alt_m: 1000 }); + hdg += 15; // 3°/s × 5 s + lat += (500 * Math.cos((hdg * Math.PI) / 180)) / 111132; + lon += (500 * Math.sin((hdg * Math.PI) / 180)) / + (111320 * Math.cos((lat * Math.PI) / 180)); + } + const rate = headingRateDegS(pts, NOW); + assert.ok(Math.abs(rate - 3) < 0.8, `rate ${rate}`); +}); + +test("predictPath curves with the heading rate; cone brackets the centre", () => { + const start = { lat: 43.5, lon: -79.7, alt_m: 1000 }; + const vel = { gs_ms: 100, trackDeg: 0, vrate_ms: 0 }; + const straight = predictPath(start, vel, 0, 60, 5); + const curved = predictPath(start, vel, 3, 60, 5); + // Straight north: longitude unchanged; curved: drifts east. + assert.ok(Math.abs(straight[straight.length - 1].lon - start.lon) < 1e-9); + assert.ok(curved[curved.length - 1].lon > start.lon + 0.01); + + const tr = { + points: [{ t: NOW - 2, lat: 43.5, lon: -79.7, alt_m: 1000, az: 10, el: 20, range: 5000 }], + vel, + }; + const cone = predictCone(tr, NOW, 60); + assert.ok(cone && cone.center.length && cone.left.length === cone.center.length); + // Left edge ends west of right edge for a northbound aircraft. + assert.ok(cone.left[cone.left.length - 1].lon < cone.right[cone.right.length - 1].lon); +}); diff --git a/examples/sky-monitor/wasm/Cargo.toml b/examples/sky-monitor/wasm/Cargo.toml new file mode 100644 index 0000000000..aae6ea766f --- /dev/null +++ b/examples/sky-monitor/wasm/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "sky-monitor-wasm" +version = "0.1.0" +edition = "2021" +publish = false +license = "MIT" +description = "Browser-facing WASM projection engine + anomaly scorer for the RuView SkyGraph dashboard (ADR-199 presentation plane). Wraps the pure (non-appliance) subset of sky-monitor." + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +# Pure subset only: coords / track / anomaly / adsb — no ruvector-core / +# ruvector-graph (those stay behind the native-only `appliance` feature). +sky-monitor = { path = "..", default-features = false } + +wasm-bindgen = { workspace = true } +js-sys = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde-wasm-bindgen = "0.6" + +# Satellite layer: SGP4 propagation from TLEs (pure math, wasm-friendly); +# chrono only converts the TLE epoch to Unix time. +sgp4 = "2" +chrono = { version = "0.4", default-features = false } + +# wasm-opt is a size optimization only; skip it so `wasm-pack build` works +# in offline/air-gapped environments (the appliance target) where the +# binaryen download is unavailable. Run wasm-opt manually if size matters. +[package.metadata.wasm-pack.profile.release] +wasm-opt = false + diff --git a/examples/sky-monitor/wasm/src/embed.rs b/examples/sky-monitor/wasm/src/embed.rs new file mode 100644 index 0000000000..d5270f3c7c --- /dev/null +++ b/examples/sky-monitor/wasm/src/embed.rs @@ -0,0 +1,174 @@ +//! §13 track embedding + §15 vector novelty for the browser (ADR-199). +//! +//! The live dashboard feed carries `[t, lat, lon, alt_m, azimuth_deg, +//! elevation_deg, range_m]` per point plus one receiver `rssi` per track; +//! per-point speed / heading / vertical rate are derived here by finite +//! differences before calling the canonical +//! [`track_embedding_from_samples`](sky_monitor::embedding) so browser +//! embeddings share the exact native §13 normalization. +//! +//! [`novelty`] mirrors the native indexer calibration (`src/indexer.rs`): +//! `min(1, mean(top-3 prior distances) / 1.2)`, neutral `0.5` with no priors. +//! Brute-force distance is intentional — the browser store caps at ~5 000 +//! embeddings, far below where an index would pay off. + +use sky_monitor::embedding::{track_embedding_from_samples, EmbeddingSample, TRACK_EMBEDDING_DIM}; +use wasm_bindgen::prelude::*; + +/// Distance scale at which novelty saturates (`indexer::NOVELTY_CALIBRATION`). +const NOVELTY_CALIBRATION: f32 = 1.2; +/// Nearest prior neighbours averaged (`indexer::NOVELTY_K`). +const NOVELTY_K: usize = 3; +/// Below this many stored embeddings novelty is the neutral 0.5 +/// (`indexer::MIN_NEIGHBOURS` — the §26 baseline period). +const MIN_NEIGHBOURS: usize = 1; + +/// Fields per live point: `[t, lat, lon, alt_m, az_deg, el_deg, range_m]`. +const FIELDS: usize = 7; + +/// Expand flat live points into [`EmbeddingSample`]s, deriving motion by +/// finite differences (each point gets the velocity of the step arriving at +/// it; the first point copies the second's — same convention as the live +/// feed's dead-reckoning, which only knows instantaneous velocity). +fn samples_from_flat(points: &[f64], rssi_dbfs: f64) -> Vec { + let n = points.len() / FIELDS; + let mut out = Vec::with_capacity(n); + for i in 0..n { + let p = &points[i * FIELDS..(i + 1) * FIELDS]; + out.push(EmbeddingSample { + t_unix: p[0], + lat: p[1], + lon: p[2], + alt_m: p[3], + speed_mps: 0.0, + track_deg: 0.0, + vertical_rate_mps: 0.0, + signal_dbfs: rssi_dbfs, + azimuth_deg: p[4], + elevation_deg: p[5], + range_m: p[6], + }); + } + for i in 1..out.len() { + let (a, b) = (out[i - 1], out[i]); + let dt = b.t_unix - a.t_unix; + if dt <= 0.0 { + out[i].speed_mps = a.speed_mps; + out[i].track_deg = a.track_deg; + out[i].vertical_rate_mps = a.vertical_rate_mps; + continue; + } + // Equirectangular step, identical to Track::path_length_m. + let dy = (b.lat - a.lat) * 111_132.0; + let dx = (b.lon - a.lon) * 111_320.0 * ((a.lat + b.lat) / 2.0).to_radians().cos(); + out[i].speed_mps = dx.hypot(dy) / dt; + out[i].track_deg = if dx == 0.0 && dy == 0.0 { + a.track_deg + } else { + dx.atan2(dy).to_degrees().rem_euclid(360.0) + }; + out[i].vertical_rate_mps = (b.alt_m - a.alt_m) / dt; + } + if out.len() > 1 { + out[0].speed_mps = out[1].speed_mps; + out[0].track_deg = out[1].track_deg; + out[0].vertical_rate_mps = out[1].vertical_rate_mps; + } + out +} + +/// Embed one live track: `points` is a `Float64Array` of +/// `[t_unix, lat, lon, alt_m, azimuth_deg, elevation_deg, range_m]` per +/// sample (time-ordered), `rssi_dbfs` the track's receiver signal (use −20 +/// when the feed carries none). Returns the 32-dim §13 embedding +/// (`Float32Array`), every dimension normalized to `[0, 1]`. +#[wasm_bindgen] +pub fn embed_track(points: &[f64], rssi_dbfs: f64) -> Vec { + track_embedding_from_samples(&samples_from_flat(points, rssi_dbfs)) +} + +/// §15 `novelty_score` of `query` against `past` — a flattened +/// `Float32Array` of concatenated 32-dim prior embeddings. Mirrors +/// `TrackIndexer::novelty_score`: mean euclidean distance to the top-3 +/// nearest priors, divided by the 1.2 calibration constant, clamped to 1; +/// neutral 0.5 when no priors exist. +#[wasm_bindgen] +pub fn novelty(query: &[f32], past: &[f32]) -> f32 { + let n = past.len() / TRACK_EMBEDDING_DIM; + if n < MIN_NEIGHBOURS || query.len() < TRACK_EMBEDDING_DIM { + return 0.5; + } + let mut best = [f32::INFINITY; NOVELTY_K]; + for i in 0..n { + let v = &past[i * TRACK_EMBEDDING_DIM..(i + 1) * TRACK_EMBEDDING_DIM]; + let mut d = query + .iter() + .zip(v) + .map(|(a, b)| (a - b) * (a - b)) + .sum::() + .sqrt(); + for slot in best.iter_mut() { + if d < *slot { + std::mem::swap(slot, &mut d); + } + } + } + let mut sum = 0.0f32; + let mut used = 0usize; + for d in best { + if d.is_finite() { + sum += d; + used += 1; + } + } + if used == 0 { + return 0.5; + } + (sum / used as f32 / NOVELTY_CALIBRATION).min(1.0) +} + +#[cfg(test)] +mod tests { + use super::*; + + // 55 s of straight northbound flight at ~77.8 m/s, level 1 000 m. + fn flat_points() -> Vec { + let mut v = Vec::new(); + for i in 0..12 { + let t = 1_781_100_000.0 + f64::from(i) * 5.0; + let lat = 43.0 + f64::from(i) * 0.0035; // ≈ 389 m per 5 s step + v.extend_from_slice(&[t, lat, -79.0, 1_000.0, 0.0, 45.0, 10_000.0]); + } + v + } + + #[test] + fn embeds_live_points_with_derived_motion() { + let e = embed_track(&flat_points(), -20.0); + assert_eq!(e.len(), TRACK_EMBEDDING_DIM); + // Northbound: heading ≈ 0° → sin dim ≈ 0.5, cos dim ≈ 1.0. + assert!((e[4] - 0.5).abs() < 0.05, "heading sin {}", e[4]); + assert!(e[5] > 0.95, "heading cos {}", e[5]); + assert!(e[14] > 0.99, "straightness {}", e[14]); + // Derived speed ≈ 77.8 m/s → /300 ≈ 0.26. + assert!((e[3] - 0.26).abs() < 0.03, "speed {}", e[3]); + // Level flight: no climb/descent fractions. + assert_eq!(e[12], 0.0); + assert_eq!(e[13], 0.0); + } + + #[test] + fn novelty_neutral_empty_and_zero_for_repeats() { + let e = embed_track(&flat_points(), -20.0); + assert!((novelty(&e, &[]) - 0.5).abs() < 1e-6); + let past: Vec = e.iter().chain(e.iter()).chain(e.iter()).copied().collect(); + assert!(novelty(&e, &past) < 1e-6); + } + + #[test] + fn novelty_saturates_for_distant_embeddings() { + let q = vec![0.0f32; TRACK_EMBEDDING_DIM]; + let past = vec![0.5f32; TRACK_EMBEDDING_DIM]; // distance ≈ 2.83 ≫ 1.2 + assert!((novelty(&q, &past) - 1.0).abs() < 1e-6); + } +} diff --git a/examples/sky-monitor/wasm/src/lib.rs b/examples/sky-monitor/wasm/src/lib.rs new file mode 100644 index 0000000000..e2442d0147 --- /dev/null +++ b/examples/sky-monitor/wasm/src/lib.rs @@ -0,0 +1,182 @@ +//! # sky-monitor-wasm — browser projection engine for the SkyGraph dashboard +//! +//! ADR-199 presentation plane ("dashboard first"): the heavy stores +//! (RuVector `VectorDB`, SkyGraph `GraphDB`) stay native behind the +//! `appliance` feature of `sky-monitor`; this crate wraps the **pure** subset +//! (`coords`, `anomaly`, `adsb`) with `wasm-bindgen` so the Canvas dashboard +//! can do exact §10 projection math and §15 anomaly scoring in the browser. +//! +//! Exposed API: +//! +//! * [`SkyProjector`] — WGS-84 → observer-relative az/el/range/bearing, single +//! and batched (`Float64Array` in/out, for fast trail rendering), plus the +//! polar "fisheye" all-sky screen mapping ([`screen::polar_screen_xy`]). +//! * [`AnomalyScorer`] — baseline + scoring over JSON track summaries, reusing +//! the core `anomaly` module (`BaselineStats::from_summaries` / +//! `score_summary`) so browser scores match the native pipeline exactly. +//! * [`embed::embed_track`] / [`embed::novelty`] — §13 32-dim track +//! embeddings from live points and the §15 vector-novelty score (mirrors +//! the native indexer calibration), for the browser novelty store. +//! * [`SatPropagator`] — SGP4 satellite propagation from TLEs +//! ([`sat`]: TEME → GMST-rotated ECEF → geodetic → observer az/el/range). +//! * [`parse_dump1090_json`] — the core dump1090 `aircraft.json` parser, for +//! live feeds proxied into the browser. + +use sky_monitor::anomaly::{score_summary, BaselineStats, Interpretation, TrackSummary}; +use sky_monitor::config::AnomalyConfig; +use sky_monitor::coords::observer_frame; +use wasm_bindgen::prelude::*; + +pub mod embed; +pub mod sat; +pub mod screen; + +pub use sat::SatPropagator; + +fn js_err(e: impl std::fmt::Display) -> JsValue { + JsValue::from_str(&e.to_string()) +} + +/// `{x, y, visible}` result of the all-sky screen mapping. +#[derive(serde::Serialize)] +struct ScreenPos { + x: f64, + y: f64, + visible: bool, +} + +/// `{score, band, reasons}` result of [`AnomalyScorer::score`]. +#[derive(serde::Serialize)] +struct ScoreResult { + score: f64, + band: String, + reasons: Vec, +} + +/// Fixed observer projecting WGS-84 targets into its local sky +/// (ADR-199 §10: geodetic → ECEF → ENU → azimuth/elevation/range/bearing). +#[wasm_bindgen] +pub struct SkyProjector { + lat: f64, + lon: f64, + alt_m: f64, +} + +#[wasm_bindgen] +impl SkyProjector { + /// New projector at the observer's geodetic position. + #[wasm_bindgen(constructor)] + pub fn new(lat: f64, lon: f64, alt_m: f64) -> SkyProjector { + SkyProjector { lat, lon, alt_m } + } + + /// Project one target; returns `{range_m, azimuth_deg, elevation_deg, + /// bearing_deg}`. + pub fn project(&self, lat: f64, lon: f64, alt_m: f64) -> Result { + let frame = observer_frame(self.lat, self.lon, self.alt_m, lat, lon, alt_m); + serde_wasm_bindgen::to_value(&frame).map_err(js_err) + } + + /// Batched projection for trail rendering: input is a `Float64Array` of + /// `[lat, lon, alt_m]` triplets; output is a `Float64Array` of + /// `[azimuth_deg, elevation_deg, range_m, bearing_deg]` quadruplets, one + /// per input triplet (a trailing partial triplet is ignored). + pub fn project_batch(&self, coords: &[f64]) -> Vec { + let n = coords.len() / 3; + let mut out = Vec::with_capacity(n * 4); + for c in coords.chunks_exact(3) { + let f = observer_frame(self.lat, self.lon, self.alt_m, c[0], c[1], c[2]); + out.extend_from_slice(&[f.azimuth_deg, f.elevation_deg, f.range_m, f.bearing_deg]); + } + out + } + + /// Map an az/el direction onto a `width`×`height` canvas using the polar + /// "fisheye" all-sky projection: zenith (el = 90°) at the canvas centre, + /// horizon (el = 0°) on the inscribed-circle edge, azimuth 0° = North = + /// straight up. Returns `{x, y, visible}` (`visible` = above horizon). + pub fn screen_position( + &self, + azimuth_deg: f64, + elevation_deg: f64, + width: f64, + height: f64, + ) -> Result { + let (x, y, visible) = screen::polar_screen_xy(azimuth_deg, elevation_deg, width, height); + serde_wasm_bindgen::to_value(&ScreenPos { x, y, visible }).map_err(js_err) + } +} + +/// §15 anomaly scorer over track summaries, sharing the exact native scoring +/// path (`BaselineStats::from_summaries` + `score_summary`). +/// +/// `Default` gives the ADR-199 §15 weights (`AnomalyConfig::default()`) and +/// an empty baseline. +#[derive(Default)] +#[wasm_bindgen] +pub struct AnomalyScorer { + cfg: AnomalyConfig, + baseline: BaselineStats, +} + +#[wasm_bindgen] +impl AnomalyScorer { + /// New scorer with the ADR-199 §15 default weights and an empty baseline. + #[wasm_bindgen(constructor)] + pub fn new() -> AnomalyScorer { + AnomalyScorer::default() + } + + /// Build the baseline from an array of track summaries: + /// `[{icao24, callsign, mean_alt_m, dominant_heading_deg, start_hour, + /// mean_signal_dbfs, min_range_m, max_elevation_deg}, ...]`. + /// Returns the number of baseline tracks ingested. + pub fn baseline_from(&mut self, tracks_json: JsValue) -> Result { + let summaries: Vec = + serde_wasm_bindgen::from_value(tracks_json).map_err(js_err)?; + self.baseline = BaselineStats::from_summaries(&summaries); + Ok(summaries.len()) + } + + /// Score one track summary against the baseline; `novelty` in `[0, 1]` + /// (vector novelty from the native indexer, or 0 when unavailable). + /// Returns `{score, band, reasons}`. + pub fn score(&self, track_json: JsValue, novelty: f64) -> Result { + let summary: TrackSummary = serde_wasm_bindgen::from_value(track_json).map_err(js_err)?; + // No second sensor modality in the browser: cross_sensor = 0. + let report = score_summary(&self.cfg, &summary, &self.baseline, novelty, 0.0); + serde_wasm_bindgen::to_value(&ScoreResult { + score: report.score, + band: report.band.to_string(), + reasons: report.reasons, + }) + .map_err(js_err) + } + + /// Number of tracks in the current baseline. + pub fn baseline_len(&self) -> usize { + self.baseline.n_tracks + } +} + +/// Parse a dump1090-style `aircraft.json` payload (live RTL-SDR feed) into an +/// array of aircraft state objects, using the same core parser as the native +/// pipeline. Entries without a position fix are skipped. +#[wasm_bindgen] +pub fn parse_dump1090_json(json: &str) -> Result { + let states = sky_monitor::parse_dump1090(json).map_err(js_err)?; + serde_wasm_bindgen::to_value(&states).map_err(js_err) +} + +/// ADR-199 §15 interpretation band for a composite score: +/// `normal | mildly unusual | interesting | strong anomaly | rare`. +#[wasm_bindgen] +pub fn band_for(score: f64) -> String { + Interpretation::band(score).to_string() +} + +/// Crate version (for the dashboard footer / cache busting). +#[wasm_bindgen] +pub fn version() -> String { + env!("CARGO_PKG_VERSION").to_string() +} diff --git a/examples/sky-monitor/wasm/src/sat.rs b/examples/sky-monitor/wasm/src/sat.rs new file mode 100644 index 0000000000..3a611c6dab --- /dev/null +++ b/examples/sky-monitor/wasm/src/sat.rs @@ -0,0 +1,368 @@ +//! SGP4 satellite layer for the dashboard: TLE → TEME → geodetic → observer +//! az/el/range, feeding the same all-sky projection as aircraft. +//! +//! TEME → pseudo-ECEF uses the IAU-1982 GMST rotation (polar motion and +//! equation-of-equinoxes ignored); ECEF → geodetic uses Bowring's closed-form +//! method. Display-grade accuracy (dots on a dome), not ephemeris-grade. + +use sky_monitor::coords::{geodetic_to_ecef, observer_frame}; +use wasm_bindgen::prelude::*; + +const WGS84_A_KM: f64 = 6378.137; +const WGS84_F: f64 = 1.0 / 298.257_223_563; + +/// IAU-1982 Greenwich mean sidereal time for a Unix timestamp, radians. +fn gmst_rad(unix_s: f64) -> f64 { + let d = unix_s / 86_400.0 - 10_957.5; // days since J2000.0 (2000-01-01 12:00 UTC) + let deg = (280.460_618_37 + 360.985_647_366_29 * d) % 360.0; + (if deg < 0.0 { deg + 360.0 } else { deg }).to_radians() +} + +/// TEME position (km) at `unix_s` → geodetic `(lat_deg, lon_deg, alt_m)`. +fn teme_to_geodetic(pos_km: &[f64; 3], unix_s: f64) -> (f64, f64, f64) { + // TEME → pseudo-ECEF: rotate by GMST around Z. + let (s, c) = gmst_rad(unix_s).sin_cos(); + let x = c * pos_km[0] + s * pos_km[1]; + let y = -s * pos_km[0] + c * pos_km[1]; + let z = pos_km[2]; + // ECEF → geodetic (Bowring). + let a = WGS84_A_KM; + let b = a * (1.0 - WGS84_F); + let e2 = WGS84_F * (2.0 - WGS84_F); + let ep2 = (a * a - b * b) / (b * b); + let p = x.hypot(y); + let theta = (z * a).atan2(p * b); + let lat = (z + ep2 * b * theta.sin().powi(3)).atan2(p - e2 * a * theta.cos().powi(3)); + let n = a / (1.0 - e2 * lat.sin().powi(2)).sqrt(); + let alt_km = p / lat.cos() - n; + (lat.to_degrees(), y.atan2(x).to_degrees(), alt_km * 1000.0) +} + +const EARTH_R_KM: f64 = 6371.0; +/// Sun elevation below which the observer's sky counts as dark (civil +/// twilight) for naked-eye satellite visibility — matches astro.js. +const DARK_SUN_EL_DEG: f64 = -6.0; + +/// Low-precision solar ECEF unit direction via the subsolar point +/// (truncated Meeus, mirrors ui/dashboard/astro.js `sunPosition`). +/// Good to a fraction of a degree — display / pass-flagging grade. +fn sun_dir_ecef(unix_s: f64) -> [f64; 3] { + let d = unix_s / 86_400.0 - 10_957.5; // days since J2000.0 + let l = (280.460 + 0.985_647_4 * d).rem_euclid(360.0); + let g = ((357.528 + 0.985_600_3 * d).rem_euclid(360.0)).to_radians(); + let lambda = (l + 1.915 * g.sin() + 0.020 * (2.0 * g).sin()).to_radians(); + let eps = (23.439 - 0.000_000_4 * d).to_radians(); + let ra = (eps.cos() * lambda.sin()) + .atan2(lambda.cos()) + .to_degrees() + .rem_euclid(360.0); + let dec = (eps.sin() * lambda.sin()).asin().to_degrees(); + let mut sub_lon = (ra - gmst_rad(unix_s).to_degrees()).rem_euclid(360.0); + if sub_lon > 180.0 { + sub_lon -= 360.0; + } + let e = geodetic_to_ecef(dec, sub_lon, 0.0); + let n = (e.x * e.x + e.y * e.y + e.z * e.z).sqrt(); + [e.x / n, e.y / n, e.z / n] +} + +/// Sun elevation (degrees) above the local geodetic horizon at a point. +fn sun_elevation_deg(lat_deg: f64, lon_deg: f64, dir: &[f64; 3]) -> f64 { + let (sl, cl) = lat_deg.to_radians().sin_cos(); + let (so, co) = lon_deg.to_radians().sin_cos(); + (cl * co * dir[0] + cl * so * dir[1] + sl * dir[2]) + .asin() + .to_degrees() +} + +/// Cylinder-shadow illumination test (mirrors astro.js `satSunlit`). +fn sat_sunlit(lat_deg: f64, lon_deg: f64, alt_m: f64, dir: &[f64; 3]) -> bool { + let e = geodetic_to_ecef(lat_deg, lon_deg, alt_m); + let p = [e.x / 1000.0, e.y / 1000.0, e.z / 1000.0]; // km + let dot = p[0] * dir[0] + p[1] * dir[1] + p[2] * dir[2]; + if dot > 0.0 { + return true; // on the sun side of Earth + } + let o = [ + p[0] - dot * dir[0], + p[1] - dot * dir[1], + p[2] - dot * dir[2], + ]; + (o[0] * o[0] + o[1] * o[1] + o[2] * o[2]).sqrt() > EARTH_R_KM +} + +struct Sat { + name: String, + epoch_unix: f64, + constants: sgp4::Constants, +} + +/// SGP4 propagator over a set of TLEs, projecting each satellite into a fixed +/// observer's sky (same §10 observer frame as aircraft). +#[wasm_bindgen] +pub struct SatPropagator { + lat: f64, + lon: f64, + alt_m: f64, + sats: Vec, +} + +#[wasm_bindgen] +impl SatPropagator { + /// New propagator for the observer's geodetic position. + #[wasm_bindgen(constructor)] + pub fn new(lat: f64, lon: f64, alt_m: f64) -> SatPropagator { + SatPropagator { + lat, + lon, + alt_m, + sats: Vec::new(), + } + } + + /// Add one TLE; returns `false` (and skips it) on parse/init failure. + pub fn add_tle(&mut self, name: &str, line1: &str, line2: &str) -> bool { + let Ok(elements) = sgp4::Elements::from_tle( + Some(name.trim().to_string()), + line1.as_bytes(), + line2.as_bytes(), + ) else { + return false; + }; + let Ok(constants) = sgp4::Constants::from_elements(&elements) else { + return false; + }; + let epoch_unix = elements.datetime.and_utc().timestamp_millis() as f64 / 1000.0; + self.sats.push(Sat { + name: name.trim().to_string(), + epoch_unix, + constants, + }); + true + } + + /// Number of loaded satellites. + pub fn count(&self) -> usize { + self.sats.len() + } + + /// Name of satellite `i` (insertion order, as passed to `add_tle`). + pub fn name(&self, i: usize) -> String { + self.sats.get(i).map(|s| s.name.clone()).unwrap_or_default() + } + + /// Propagate every satellite to Unix time `unix_s` and project it into + /// the observer's sky. Returns a `Float64Array` of + /// `[lat_deg, lon_deg, alt_m, azimuth_deg, elevation_deg, range_m]` per + /// satellite (insertion order); a satellite that fails to propagate + /// (e.g. decayed) yields six `NaN`s. + pub fn positions(&self, unix_s: f64) -> Vec { + let mut out = Vec::with_capacity(self.sats.len() * 6); + for sat in &self.sats { + let minutes = (unix_s - sat.epoch_unix) / 60.0; + match sat.constants.propagate(sgp4::MinutesSinceEpoch(minutes)) { + Ok(pred) => { + let (lat, lon, alt_m) = teme_to_geodetic(&pred.position, unix_s); + let f = observer_frame(self.lat, self.lon, self.alt_m, lat, lon, alt_m); + out.extend_from_slice(&[ + lat, + lon, + alt_m, + f.azimuth_deg, + f.elevation_deg, + f.range_m, + ]); + } + Err(_) => out.extend_from_slice(&[f64::NAN; 6]), + } + } + out + } + + /// Predict horizon-to-horizon passes for every loaded satellite, + /// stepping SGP4 from `start_unix` over `hours` in `step_s`-second + /// samples (use ~30 s; rise/set instants are linearly interpolated + /// between samples). + /// + /// Returns a `Float64Array` of 7-tuples, one per pass: + /// `[sat_index, t_rise, t_culminate, t_set, max_elevation_deg, + /// culmination_azimuth_deg, visible]`. `visible` is `1.0` when the + /// satellite is sunlit against a dark observer sky (sun below −6°) at + /// any sampled point of the pass — the same naked-eye criterion the + /// dashboard's astro.js applies to the live layer. A pass still in + /// progress at the window end is truncated there; satellites that fail + /// to propagate (e.g. decayed) simply yield no passes. + pub fn predict_passes(&self, start_unix: f64, hours: f64, step_s: f64) -> Vec { + struct Open { + rise: f64, + max_el: f64, + az_culm: f64, + t_culm: f64, + visible: bool, + } + let step_s = if step_s > 0.0 { step_s } else { 30.0 }; + let steps = ((hours.max(0.0) * 3_600.0 / step_s).ceil() as usize).max(1); + // The sun moves identically for every satellite: precompute its + // direction and the observer's "dark sky" flag once per sample. + let sun: Vec<([f64; 3], bool)> = (0..=steps) + .map(|k| { + let dir = sun_dir_ecef(start_unix + k as f64 * step_s); + let dark = sun_elevation_deg(self.lat, self.lon, &dir) < DARK_SUN_EL_DEG; + (dir, dark) + }) + .collect(); + let mut out = Vec::new(); + for (si, sat) in self.sats.iter().enumerate() { + let mut open: Option = None; + let mut prev_el = f64::NEG_INFINITY; + let mut prev_t = start_unix; + // `sun` has exactly `steps + 1` samples, one per time step. + for (k, (dir, dark)) in sun.iter().enumerate() { + let t = start_unix + k as f64 * step_s; + let minutes = (t - sat.epoch_unix) / 60.0; + let geo = sat + .constants + .propagate(sgp4::MinutesSinceEpoch(minutes)) + .ok() + .map(|p| teme_to_geodetic(&p.position, t)); + let (lat, lon, alt_m, az, el) = match geo { + Some((lat, lon, alt_m)) => { + let f = observer_frame(self.lat, self.lon, self.alt_m, lat, lon, alt_m); + (lat, lon, alt_m, f.azimuth_deg, f.elevation_deg) + } + None => (0.0, 0.0, 0.0, 0.0, f64::NEG_INFINITY), + }; + if el > 0.0 { + let p = open.get_or_insert_with(|| Open { + // Interpolate the horizon crossing when the previous + // sample was a real below-horizon elevation. + rise: if prev_el.is_finite() && prev_el <= 0.0 { + prev_t + step_s * (-prev_el) / (el - prev_el) + } else { + t + }, + max_el: f64::NEG_INFINITY, + az_culm: az, + t_culm: t, + visible: false, + }); + if el > p.max_el { + p.max_el = el; + p.az_culm = az; + p.t_culm = t; + } + if *dark && sat_sunlit(lat, lon, alt_m, dir) { + p.visible = true; + } + if k == steps { + if let Some(p) = open.take() { + out.extend_from_slice(&[ + si as f64, + p.rise, + p.t_culm, + t, + p.max_el, + p.az_culm, + if p.visible { 1.0 } else { 0.0 }, + ]); + } + } + } else if let Some(p) = open.take() { + let set = if el.is_finite() { + prev_t + step_s * prev_el / (prev_el - el) + } else { + prev_t + }; + out.extend_from_slice(&[ + si as f64, + p.rise, + p.t_culm, + set, + p.max_el, + p.az_culm, + if p.visible { 1.0 } else { 0.0 }, + ]); + } + prev_el = el; + prev_t = t; + } + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Real TLE from the CelesTrak `visual` group (fetched 2026-06-10). + // Perigee ≈ 460 km, apogee ≈ 1250 km, inclination 30.36°. + const L1: &str = "1 00694U 63047A 26161.07228530 .00001358 00000+0 15205-3 0 9994"; + const L2: &str = "2 00694 30.3551 39.1817 0545986 174.7629 185.8982 14.12503204144697"; + + #[test] + fn propagates_atlas_centaur_to_plausible_geodetic() { + let mut sp = SatPropagator::new(43.4675, -79.6877, 100.0); + assert!(sp.add_tle("ATLAS CENTAUR 2", L1, L2)); + // At the TLE's own epoch the orbit must respect its inclination and + // LEO altitude band. + let epoch_unix = sp.sats[0].epoch_unix; + let out = sp.positions(epoch_unix); + assert_eq!(out.len(), 6); + let (lat, lon, alt_m) = (out[0], out[1], out[2]); + assert!(lat.abs() <= 30.5, "lat {lat}"); + assert!((-180.0..=180.0).contains(&lon), "lon {lon}"); + assert!((300_000.0..=1_400_000.0).contains(&alt_m), "alt {alt_m}"); + assert!((-90.0..=90.0).contains(&out[4]), "el {}", out[4]); + assert!(out[5] > 300_000.0, "range {}", out[5]); + } + + #[test] + fn rejects_garbage_tle() { + let mut sp = SatPropagator::new(0.0, 0.0, 0.0); + assert!(!sp.add_tle("junk", "not a tle", "also not")); + assert_eq!(sp.count(), 0); + } + + #[test] + fn gmst_reference_value_at_j2000() { + // 2000-01-01 12:00 UTC: GMST ≈ 280.4606°. + let g = gmst_rad(946_728_000.0).to_degrees(); + assert!((g - 280.4606).abs() < 0.01, "gmst {g}"); + } + #[test] + fn sun_elevation_sane_at_toronto_noon_and_night() { + // 2026-06-10 17:00 UTC ≈ solar noon in Toronto: high sun. + let dir = sun_dir_ecef(1_781_110_800.0); + let el = sun_elevation_deg(43.4675, -79.6877, &dir); + assert!((60.0..80.0).contains(&el), "noon el {el}"); + // 2026-06-10 05:00 UTC ≈ 1 a.m. local: deep night. + let dir = sun_dir_ecef(1_781_067_600.0); + let el = sun_elevation_deg(43.4675, -79.6877, &dir); + assert!(el < -10.0, "night el {el}"); + } + + #[test] + fn predicts_ordered_passes_over_24h() { + let mut sp = SatPropagator::new(43.4675, -79.6877, 100.0); + assert!(sp.add_tle("ATLAS CENTAUR 2", L1, L2)); + let start = sp.sats[0].epoch_unix; + let out = sp.predict_passes(start, 24.0, 30.0); + assert_eq!(out.len() % 7, 0, "7 numbers per pass"); + assert!(!out.is_empty(), "LEO sat must pass at least once in 24 h"); + for p in out.chunks_exact(7) { + assert_eq!(p[0], 0.0, "single sat index"); + assert!( + p[1] <= p[2] && p[2] <= p[3], + "rise {} culm {} set {}", + p[1], + p[2], + p[3] + ); + assert!(p[3] - p[1] < 3_600.0, "LEO pass under an hour"); + assert!(p[4] > 0.0 && p[4] <= 90.0, "max el {}", p[4]); + assert!((0.0..360.0).contains(&p[5]), "az {}", p[5]); + assert!(p[6] == 0.0 || p[6] == 1.0, "visible flag {}", p[6]); + } + } +} diff --git a/examples/sky-monitor/wasm/src/screen.rs b/examples/sky-monitor/wasm/src/screen.rs new file mode 100644 index 0000000000..a15dcb2f65 --- /dev/null +++ b/examples/sky-monitor/wasm/src/screen.rs @@ -0,0 +1,104 @@ +//! Polar "fisheye" all-sky screen mapping (pure math, natively testable). +//! +//! The whole sky dome is flattened onto a disc inscribed in the canvas: +//! +//! * zenith (elevation 90°) → canvas centre, +//! * horizon (elevation 0°) → edge of the inscribed circle +//! (radius = `min(width, height) / 2`), +//! * azimuth 0° = North = straight **up**, 90° = East = right, 180° = South = +//! down, 270° = West = left (i.e. the view looking straight up, with the +//! compass laid out as on a map). +//! +//! Radius grows linearly with zenith angle: `r = (90 − el) / 90 · R`. +//! Below-horizon directions (el < 0) land **outside** the disc and are marked +//! not visible — trails can still be drawn fading off the edge. + +/// Map azimuth/elevation (degrees) onto a `width`×`height` canvas. +/// Returns `(x, y, visible)`; `visible` is true iff the target is at or above +/// the horizon. Elevation is clamped to `[-90, 90]` for the radius math. +pub fn polar_screen_xy( + azimuth_deg: f64, + elevation_deg: f64, + width: f64, + height: f64, +) -> (f64, f64, bool) { + let cx = width / 2.0; + let cy = height / 2.0; + let radius = width.min(height) / 2.0; + let el = elevation_deg.clamp(-90.0, 90.0); + let r = (90.0 - el) / 90.0 * radius; + let az = azimuth_deg.to_radians(); + // Screen y grows downward, so North (az 0) maps to cy − r. + (cx + r * az.sin(), cy - r * az.cos(), elevation_deg >= 0.0) +} + +#[cfg(test)] +mod tests { + use super::polar_screen_xy; + + const W: f64 = 800.0; + const H: f64 = 600.0; + const EPS: f64 = 1e-9; + + /// Inscribed-circle radius for the 800×600 test canvas. + const R: f64 = 300.0; + + #[test] + fn zenith_maps_to_canvas_centre() { + let (x, y, visible) = polar_screen_xy(123.0, 90.0, W, H); + assert!((x - 400.0).abs() < EPS, "x {x}"); + assert!((y - 300.0).abs() < EPS, "y {y}"); + assert!(visible); + } + + #[test] + fn horizon_north_maps_to_top_edge_centre() { + // az 0 (North), el 0 → straight up from centre by the full radius. + let (x, y, visible) = polar_screen_xy(0.0, 0.0, W, H); + assert!((x - 400.0).abs() < EPS, "x {x}"); + assert!((y - (300.0 - R)).abs() < EPS, "y {y}"); + assert!(visible); + } + + #[test] + fn horizon_east_south_west_map_to_compass_points() { + let (x, y, _) = polar_screen_xy(90.0, 0.0, W, H); // East → right + assert!( + (x - (400.0 + R)).abs() < 1e-6 && (y - 300.0).abs() < 1e-6, + "E ({x},{y})" + ); + let (x, y, _) = polar_screen_xy(180.0, 0.0, W, H); // South → down + assert!( + (x - 400.0).abs() < 1e-6 && (y - (300.0 + R)).abs() < 1e-6, + "S ({x},{y})" + ); + let (x, y, _) = polar_screen_xy(270.0, 0.0, W, H); // West → left + assert!( + (x - (400.0 - R)).abs() < 1e-6 && (y - 300.0).abs() < 1e-6, + "W ({x},{y})" + ); + } + + #[test] + fn elevation_scales_radius_linearly() { + // el 45 → half the radius; el 30 → two thirds. + let (_, y, _) = polar_screen_xy(0.0, 45.0, W, H); + assert!((y - (300.0 - R / 2.0)).abs() < EPS, "y {y}"); + let (_, y, _) = polar_screen_xy(0.0, 30.0, W, H); + assert!((y - (300.0 - R * 2.0 / 3.0)).abs() < 1e-6, "y {y}"); + } + + #[test] + fn below_horizon_is_outside_disc_and_invisible() { + let (x, y, visible) = polar_screen_xy(90.0, -10.0, W, H); + assert!(!visible); + let r = ((x - 400.0).powi(2) + (y - 300.0).powi(2)).sqrt(); + assert!( + r > R, + "below-horizon point must land outside the disc, r {r}" + ); + // Clamp: el −90 stays finite (2R). + let (x, y, visible) = polar_screen_xy(0.0, -90.0, W, H); + assert!(!visible && x.is_finite() && y.is_finite()); + } +}