From 905f4622f9b14270f942005835888dc0d7d85cf6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 21:09:11 +0000 Subject: [PATCH 1/8] docs(adr): ADR-199 Sky Monitor and SkyGraph appliance Architecture decision record for the RuView SkyGraph appliance: a local sky monitoring system that treats the sky as a continuously changing spatial graph. Covers ADS-B ingestion (dump1090 + OpenSky fallback), MSC GeoMet weather, observer-frame coordinate model, canonical observation schema, SkyGraph node/edge model, RuVector embedding and novelty usage, rule layer, composite anomaly scoring, privacy and security governance, storage tiers, phased build plan, and acceptance tests. Companion implementation lands in examples/sky-monitor/. https://claude.ai/code/session_013Nh9Naw8gim75DGY9LBvK7 --- .../ADR-199-sky-monitor-skygraph-appliance.md | 639 ++++++++++++++++++ 1 file changed, 639 insertions(+) create mode 100644 docs/adr/ADR-199-sky-monitor-skygraph-appliance.md 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. From 143a541f3357677e914c8895cdc819c91be87d43 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 21:30:01 +0000 Subject: [PATCH 2/8] feat(examples): sky-monitor SkyGraph appliance core (ADR-199 Phases 1-4) New workspace example crate implementing the RuView SkyGraph appliance pipeline on synthetic ADS-B data: - WGS-84 -> ECEF -> ENU observer-frame projection (az/el/range/bearing) - canonical observation schema (ADR-199 s11) with serde - deterministic synthetic ADS-B scenario + dump1090 JSON parser - track stitching with circular-stats summaries and overhead rule - SkyGraph on ruvector-graph GraphDB (s12 node/edge vocabulary, time-window queries, citeable explain()) - 32-dim track embeddings indexed in ruvector-core VectorDB with similarity search and calibrated novelty scoring - composite anomaly score per ADR-199 s15 with mandatory reasons - daily sky brief, end-to-end pipeline, demo binary - 27 tests (19 unit + 8 ADR acceptance), criterion benchmarks https://claude.ai/code/session_013Nh9Naw8gim75DGY9LBvK7 --- Cargo.lock | 14 + Cargo.toml | 2 + examples/sky-monitor/Cargo.toml | 33 ++ examples/sky-monitor/README.md | 115 +++++++ examples/sky-monitor/benches/sky_bench.rs | 116 +++++++ examples/sky-monitor/src/adsb.rs | 258 ++++++++++++++++ examples/sky-monitor/src/anomaly.rs | 285 +++++++++++++++++ examples/sky-monitor/src/brief.rs | 145 +++++++++ examples/sky-monitor/src/config.rs | 106 +++++++ examples/sky-monitor/src/coords.rs | 194 ++++++++++++ examples/sky-monitor/src/embedding.rs | 187 ++++++++++++ examples/sky-monitor/src/indexer.rs | 159 ++++++++++ examples/sky-monitor/src/lib.rs | 71 +++++ examples/sky-monitor/src/main.rs | 91 ++++++ examples/sky-monitor/src/observation.rs | 174 +++++++++++ examples/sky-monitor/src/pipeline.rs | 183 +++++++++++ examples/sky-monitor/src/skygraph.rs | 353 ++++++++++++++++++++++ examples/sky-monitor/src/track.rs | 318 +++++++++++++++++++ examples/sky-monitor/src/weather.rs | 109 +++++++ examples/sky-monitor/tests/acceptance.rs | 208 +++++++++++++ 20 files changed, 3121 insertions(+) create mode 100644 examples/sky-monitor/Cargo.toml create mode 100644 examples/sky-monitor/README.md create mode 100644 examples/sky-monitor/benches/sky_bench.rs create mode 100644 examples/sky-monitor/src/adsb.rs create mode 100644 examples/sky-monitor/src/anomaly.rs create mode 100644 examples/sky-monitor/src/brief.rs create mode 100644 examples/sky-monitor/src/config.rs create mode 100644 examples/sky-monitor/src/coords.rs create mode 100644 examples/sky-monitor/src/embedding.rs create mode 100644 examples/sky-monitor/src/indexer.rs create mode 100644 examples/sky-monitor/src/lib.rs create mode 100644 examples/sky-monitor/src/main.rs create mode 100644 examples/sky-monitor/src/observation.rs create mode 100644 examples/sky-monitor/src/pipeline.rs create mode 100644 examples/sky-monitor/src/skygraph.rs create mode 100644 examples/sky-monitor/src/track.rs create mode 100644 examples/sky-monitor/src/weather.rs create mode 100644 examples/sky-monitor/tests/acceptance.rs diff --git a/Cargo.lock b/Cargo.lock index 47bb4492c5..fb8db04696 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11636,6 +11636,20 @@ 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 = "slab" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index d2464666e7..a7357d65fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -171,6 +171,8 @@ 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", # Spectral graph sparsification "crates/ruvector-sparsifier", "crates/ruvector-sparsifier-wasm", diff --git a/examples/sky-monitor/Cargo.toml b/examples/sky-monitor/Cargo.toml new file mode 100644 index 0000000000..a4a4227481 --- /dev/null +++ b/examples/sky-monitor/Cargo.toml @@ -0,0 +1,33 @@ +[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" + +[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"] } +# 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" } + +serde = { workspace = true } +serde_json = { workspace = true } +chrono = { workspace = true } +uuid = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +criterion = { workspace = true } + +[lib] +bench = false + +[[bench]] +name = "sky_bench" +harness = false diff --git a/examples/sky-monitor/README.md b/examples/sky-monitor/README.md new file mode 100644 index 0000000000..438a743beb --- /dev/null +++ b/examples/sky-monitor/README.md @@ -0,0 +1,115 @@ +# 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 + +# 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 +``` + +## 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 WASM projection engine +and Canvas dashboard (separate `examples/sky-monitor/wasm` work), 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..39051d5b4e --- /dev/null +++ b/examples/sky-monitor/benches/sky_bench.rs @@ -0,0 +1,116 @@ +//! 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..7126b517a8 --- /dev/null +++ b/examples/sky-monitor/src/adsb.rs @@ -0,0 +1,258 @@ +//! 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..abeb068a28 --- /dev/null +++ b/examples/sky-monitor/src/anomaly.rs @@ -0,0 +1,285 @@ +//! 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; + +/// 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 { + let n = prior.len(); + if n == 0 { + return Self::default(); + } + let alts: Vec = prior.iter().map(|t| t.mean_altitude_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_utc() 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 { + 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_altitude_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_utc() 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_altitude_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_utc() + )); + } + 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_altitude_m() + )); + } + + AnomalyReport { + track_id: track.track_id.clone(), + 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..67366a6f87 --- /dev/null +++ b/examples/sky-monitor/src/brief.rs @@ -0,0 +1,145 @@ +//! 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..c441972d99 --- /dev/null +++ b/examples/sky-monitor/src/coords.rs @@ -0,0 +1,194 @@ +//! 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 { + let obs_ecef = geodetic_to_ecef(obs_lat, obs_lon, obs_alt_m); + let tgt_ecef = geodetic_to_ecef(target_lat, target_lon, target_alt_m); + let enu = ecef_to_enu(obs_lat, obs_lon, obs_ecef, tgt_ecef); + let horizontal = enu.east.hypot(enu.north); + let range_m = (horizontal * horizontal + enu.up * enu.up).sqrt(); + let azimuth_deg = if horizontal < 1e-9 { + 0.0 // directly overhead/underfoot: azimuth undefined, report 0 + } else { + normalize_deg(enu.east.atan2(enu.north).to_degrees()) + }; + let elevation_deg = enu.up.atan2(horizontal).to_degrees(); + ObserverFrame { + range_m, + azimuth_deg, + elevation_deg, + bearing_deg: initial_bearing_deg(obs_lat, obs_lon, target_lat, target_lon), + } +} + +#[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..c57ded333b --- /dev/null +++ b/examples/sky-monitor/src/embedding.rs @@ -0,0 +1,187 @@ +//! 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 +} + +/// Compute the 32-dimensional track embedding described in the module docs. +/// Fully deterministic in the track contents. +pub fn track_embedding(track: &Track) -> Vec { + let mut e = vec![0.0f32; TRACK_EMBEDDING_DIM]; + let alts: Vec = track.points.iter().map(|p| p.alt_m).collect(); + let min_alt = alts.iter().copied().fold(f64::INFINITY, f64::min); + let max_alt = alts.iter().copied().fold(f64::NEG_INFINITY, f64::max); + e[0] = clamp01(track.mean_altitude_m() / 12_000.0); + e[1] = clamp01(min_alt / 12_000.0); + e[2] = clamp01(max_alt / 12_000.0); + e[3] = clamp01(track.mean_speed_mps() / 300.0); + + let h = track.dominant_heading_deg().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. + let frac = (track.started.hour() as f64 + track.started.minute() as f64 / 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(track.min_range_m / 50_000.0); + e[9] = clamp01(track.max_elevation_deg / 90.0); + e[10] = clamp01(track.mean_elevation_deg() / 90.0); + e[11] = clamp01(track.duration_secs() / 1_800.0); + e[12] = clamp01(track.climb_ratio()); + e[13] = clamp01(track.descent_ratio()); + e[14] = clamp01(track.straightness()); + e[15] = clamp01(track.mean_abs_vertical_rate_mps() / 15.0); + + // Coarse azimuth occupancy: fraction of samples seen in each 45° sky + // sector around the observer (route-class signature). + let n = track.points.len().max(1) as f64; + for p in &track.points { + let b = ((p.frame.azimuth_deg.rem_euclid(360.0)) / 45.0) as usize % 8; + e[16 + b] += (1.0 / n) as f32; + } + + e[24] = clamp01((track.mean_signal_dbfs() + 40.0) / 40.0); + e[25] = clamp01(track.speed_std_mps() / 50.0); + e[26] = clamp01(track.altitude_std_m() / 3_000.0); + e[27] = clamp01(track.points.first().map(|p| p.frame.range_m).unwrap_or(0.0) / 50_000.0); + e[28] = clamp01(track.points.last().map(|p| p.frame.range_m).unwrap_or(0.0) / 50_000.0); + e[29] = clamp01(track.points.len() as f64 / 600.0); + e[30] = if track.is_overhead_candidate { 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); + } +} diff --git a/examples/sky-monitor/src/indexer.rs b/examples/sky-monitor/src/indexer.rs new file mode 100644 index 0000000000..63293f2072 --- /dev/null +++ b/examples/sky-monitor/src/indexer.rs @@ -0,0 +1,159 @@ +//! 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..dcc149b740 --- /dev/null +++ b/examples/sky-monitor/src/lib.rs @@ -0,0 +1,71 @@ +//! # 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; +pub mod indexer; +pub mod observation; +pub mod pipeline; +pub mod skygraph; +pub mod track; +pub mod weather; + +pub use adsb::{parse_dump1090, AircraftState, ANOMALOUS_ICAO24, GA_OVERHEAD_ICAO24}; +pub use anomaly::{AnomalyComponents, AnomalyReport, BaselineStats, Interpretation}; +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, weather_window_embedding, TRACK_EMBEDDING_DIM}; +pub use indexer::TrackIndexer; +pub use observation::{EntityType, GeoPosition, Motion, Observation}; +pub use pipeline::{Pipeline, PipelineReport}; +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. + #[error("vector store error: {0}")] + Vector(#[from] ruvector_core::RuvectorError), + /// Error from the `ruvector-graph` graph database. + #[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..e9f4087fb5 --- /dev/null +++ b/examples/sky-monitor/src/main.rs @@ -0,0 +1,91 @@ +//! Demo binary: run the full ADR-199 Phase 1–4 pipeline over the synthetic +//! Oakville-node scenario and print a live-style report. + +use sky_monitor::{Interpretation, Pipeline}; + +fn main() -> sky_monitor::Result<()> { + 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); + Ok(()) +} diff --git a/examples/sky-monitor/src/observation.rs b/examples/sky-monitor/src/observation.rs new file mode 100644 index 0000000000..30ab46da86 --- /dev/null +++ b/examples/sky-monitor/src/observation.rs @@ -0,0 +1,174 @@ +//! 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..1b852a94b7 --- /dev/null +++ b/examples/sky-monitor/src/pipeline.rs @@ -0,0 +1,183 @@ +//! 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, + }) + } +} + +#[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..2685fca5b8 --- /dev/null +++ b/examples/sky-monitor/src/skygraph.rs @@ -0,0 +1,353 @@ +//! 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..a88bcb80d1 --- /dev/null +++ b/examples/sky-monitor/src/track.rs @@ -0,0 +1,318 @@ +//! 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..d0c8138287 --- /dev/null +++ b/examples/sky-monitor/src/weather.rs @@ -0,0 +1,109 @@ +//! 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..e0880f48c4 --- /dev/null +++ b/examples/sky-monitor/tests/acceptance.rs @@ -0,0 +1,208 @@ +//! 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); +} From 7976ce1a30407cecc0c2fffa106698667a697fc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 21:46:42 +0000 Subject: [PATCH 3/8] feat(examples): sky-monitor WASM projection engine, canvas dashboard, perf tuning Presentation plane for the ADR-199 SkyGraph appliance (dashboard-first decision) plus measured hot-path optimizations: - feature-gate sky-monitor: default 'appliance' feature carries ruvector-core/ruvector-graph; --no-default-features yields a wasm32-compatible subset (coords, observation, adsb, track, weather, embedding, anomaly, brief) - new sky-monitor-wasm crate (wasm-bindgen): SkyProjector with single and Float64Array batch projection, polar all-sky screen mapping, AnomalyScorer sharing the exact native scorer via new TrackSummary adapter, dump1090 JSON parser binding; 5 native unit tests - canvas dashboard (ui/dashboard): polar sky plot with elevation rings, fading trails, overhead highlights, band-colored anomaly badges, track table with reasons, replay scrubber; JS projection fallback with automatic wasm-pack pkg detection; demo data generated via new --emit-json flag on the demo binary - perf: observer_frame inlined to single sin_cos per angle; track_embedding single-pass accumulation; anomaly baseline reuse Validation: 27/27 sky-monitor tests, 5/5 sky-monitor-wasm tests, wasm32-unknown-unknown builds clean for both, clippy clean, node --check on dashboard JS. https://claude.ai/code/session_013Nh9Naw8gim75DGY9LBvK7 --- Cargo.lock | 12 + Cargo.toml | 2 + examples/sky-monitor/Cargo.toml | 25 +- examples/sky-monitor/README.md | 61 ++- examples/sky-monitor/src/anomaly.rs | 122 +++++- examples/sky-monitor/src/coords.rs | 52 ++- examples/sky-monitor/src/embedding.rs | 111 ++++-- examples/sky-monitor/src/lib.rs | 13 +- examples/sky-monitor/src/main.rs | 53 ++- examples/sky-monitor/src/pipeline.rs | 58 +++ examples/sky-monitor/ui/dashboard/README.md | 40 ++ examples/sky-monitor/ui/dashboard/index.html | 104 ++++++ .../sky-monitor/ui/dashboard/sky-demo-data.js | 3 + examples/sky-monitor/ui/dashboard/sky.js | 347 ++++++++++++++++++ examples/sky-monitor/wasm/Cargo.toml | 21 ++ examples/sky-monitor/wasm/src/lib.rs | 174 +++++++++ examples/sky-monitor/wasm/src/screen.rs | 92 +++++ 17 files changed, 1239 insertions(+), 51 deletions(-) create mode 100644 examples/sky-monitor/ui/dashboard/README.md create mode 100644 examples/sky-monitor/ui/dashboard/index.html create mode 100644 examples/sky-monitor/ui/dashboard/sky-demo-data.js create mode 100644 examples/sky-monitor/ui/dashboard/sky.js create mode 100644 examples/sky-monitor/wasm/Cargo.toml create mode 100644 examples/sky-monitor/wasm/src/lib.rs create mode 100644 examples/sky-monitor/wasm/src/screen.rs diff --git a/Cargo.lock b/Cargo.lock index fb8db04696..d8cd1d38b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11650,6 +11650,18 @@ dependencies = [ "uuid", ] +[[package]] +name = "sky-monitor-wasm" +version = "0.1.0" +dependencies = [ + "js-sys", + "serde", + "serde-wasm-bindgen", + "serde_json", + "sky-monitor", + "wasm-bindgen", +] + [[package]] name = "slab" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index a7357d65fd..d5e5d6ffde 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -173,6 +173,8 @@ members = [ "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/examples/sky-monitor/Cargo.toml b/examples/sky-monitor/Cargo.toml index a4a4227481..cb289f5cf2 100644 --- a/examples/sky-monitor/Cargo.toml +++ b/examples/sky-monitor/Cargo.toml @@ -6,15 +6,26 @@ 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"] } +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" } +ruvector-graph = { path = "../../crates/ruvector-graph", optional = true } serde = { workspace = true } serde_json = { workspace = true } @@ -28,6 +39,16 @@ 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 index 438a743beb..cf56e4713c 100644 --- a/examples/sky-monitor/README.md +++ b/examples/sky-monitor/README.md @@ -36,6 +36,9 @@ the door open for real RTL-SDR data. Vectors live in # demo (from the repository root) cargo run -p sky-monitor --release +# demo + dashboard data export (writes `const SKY_DATA = {...};` for .js paths) +cargo run -p sky-monitor --release -- --emit-json examples/sky-monitor/ui/dashboard/sky-demo-data.js + # acceptance + unit tests (mapped to ADR-199 §31 / §22) cargo test -p sky-monitor @@ -44,6 +47,58 @@ 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. +* `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`): an +all-sky polar plot (elevation rings at 0/30/60°, compass labels) showing +aircraft as labelled dots with fading trails, overhead-candidate highlight +rings, anomaly badges colored by band, a side panel with the live track table +and §15 anomaly reasons, and a replay scrubber over the embedded deterministic +scenario (`sky-demo-data.js`, regenerated via `--emit-json` above). +Projection runs in JS by default and switches to `sky-monitor-wasm` +automatically when the wasm-pack output is present at `ui/dashboard/pkg/`. + +```bash +cd examples/sky-monitor/ui/dashboard && python3 -m http.server 8000 +# open http://localhost:8000/ +``` + ## Sample output (trimmed) ```text @@ -110,6 +165,6 @@ 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 WASM projection engine -and Canvas dashboard (separate `examples/sky-monitor/wasm` work), retention / -hash-chained raw archive, and the NL assistant service. +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/src/anomaly.rs b/examples/sky-monitor/src/anomaly.rs index abeb068a28..2bc6b0fced 100644 --- a/examples/sky-monitor/src/anomaly.rs +++ b/examples/sky-monitor/src/anomaly.rs @@ -27,6 +27,68 @@ const ROUTE_SATURATION_DEG: f64 = 60.0; 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 { @@ -44,12 +106,18 @@ pub struct BaselineStats { 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_altitude_m()).collect(); - let sigs: Vec = prior.iter().map(|t| t.mean_signal_dbfs()).collect(); + 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() @@ -58,10 +126,10 @@ impl BaselineStats { let sm = mean(&sigs); let mut hours = [0u32; 24]; for t in prior { - hours[t.start_hour_utc() as usize] += 1; + hours[(t.start_hour % 24) as usize] += 1; } Self { - corridor_headings: prior.iter().map(|t| t.dominant_heading_deg()).collect(), + 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, @@ -164,7 +232,33 @@ pub fn score_track( novelty: f64, cross_sensor: f64, ) -> AnomalyReport { - let heading = track.dominant_heading_deg(); + 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() @@ -176,19 +270,19 @@ pub fn score_track( 0.5 // no baseline corridors yet }; - let alt_z = (track.mean_altitude_m() - baseline.altitude_mean_m).abs() / baseline.altitude_std_m.max(1.0); + 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_utc() as i64; + 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 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 { @@ -215,21 +309,19 @@ pub fn score_track( 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_altitude_m(), - baseline.altitude_mean_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_utc() + 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 + track.mean_signal_dbfs, baseline.signal_mean_dbfs )); } if components.cross_sensor_confirmation > 0.5 { @@ -247,12 +339,12 @@ pub fn score_track( if reasons.is_empty() { reasons.push(format!( "within normal envelope: heading {heading:.0}°, altitude {:.0} m, score {score:.2}", - track.mean_altitude_m() + track.mean_alt_m )); } AnomalyReport { - track_id: track.track_id.clone(), + track_id: track_id.to_string(), icao24: track.icao24.clone(), callsign: track.callsign.clone(), score, diff --git a/examples/sky-monitor/src/coords.rs b/examples/sky-monitor/src/coords.rs index c441972d99..628349531d 100644 --- a/examples/sky-monitor/src/coords.rs +++ b/examples/sky-monitor/src/coords.rs @@ -120,22 +120,58 @@ pub fn observer_frame( target_lon: f64, target_alt_m: f64, ) -> ObserverFrame { - let obs_ecef = geodetic_to_ecef(obs_lat, obs_lon, obs_alt_m); - let tgt_ecef = geodetic_to_ecef(target_lat, target_lon, target_alt_m); - let enu = ecef_to_enu(obs_lat, obs_lon, obs_ecef, tgt_ecef); - let horizontal = enu.east.hypot(enu.north); - let range_m = (horizontal * horizontal + enu.up * enu.up).sqrt(); + // 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(enu.east.atan2(enu.north).to_degrees()) + normalize_deg(east.atan2(north).to_degrees()) }; - let elevation_deg = enu.up.atan2(horizontal).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: initial_bearing_deg(obs_lat, obs_lon, target_lat, target_lon), + bearing_deg, } } diff --git a/examples/sky-monitor/src/embedding.rs b/examples/sky-monitor/src/embedding.rs index c57ded333b..c2641df4fb 100644 --- a/examples/sky-monitor/src/embedding.rs +++ b/examples/sky-monitor/src/embedding.rs @@ -51,15 +51,76 @@ fn clamp01(v: f64) -> f32 { /// Fully deterministic in the track contents. pub fn track_embedding(track: &Track) -> Vec { let mut e = vec![0.0f32; TRACK_EMBEDDING_DIM]; - let alts: Vec = track.points.iter().map(|p| p.alt_m).collect(); - let min_alt = alts.iter().copied().fold(f64::INFINITY, f64::min); - let max_alt = alts.iter().copied().fold(f64::NEG_INFINITY, f64::max); - e[0] = clamp01(track.mean_altitude_m() / 12_000.0); + // Single pass over the points: accumulate every per-point statistic at + // once instead of one full iteration per feature (the Track stat methods + // would walk the point list ~14 times and allocate a temp Vec). + let pts = &track.points; + 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; + 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.frame.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.frame.azimuth_deg.rem_euclid(360.0)) / 45.0) as usize % 8; + buckets[b] += 1; + 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| if count == 0 { 0.0 } else { sum / nf }; + let std = |sq: f64, sum: f64| { + if count == 0 { + 0.0 + } else { + 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(track.mean_speed_mps() / 300.0); + e[3] = clamp01(mean(speed_sum) / 300.0); - let h = track.dominant_heading_deg().to_radians(); + // 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; @@ -72,27 +133,35 @@ pub fn track_embedding(track: &Track) -> Vec { e[8] = clamp01(track.min_range_m / 50_000.0); e[9] = clamp01(track.max_elevation_deg / 90.0); - e[10] = clamp01(track.mean_elevation_deg() / 90.0); + e[10] = clamp01(mean(elev_sum) / 90.0); e[11] = clamp01(track.duration_secs() / 1_800.0); - e[12] = clamp01(track.climb_ratio()); - e[13] = clamp01(track.descent_ratio()); - e[14] = clamp01(track.straightness()); - e[15] = clamp01(track.mean_abs_vertical_rate_mps() / 15.0); + e[12] = clamp01(if count == 0 { 0.0 } else { climb as f64 / nf }); + e[13] = clamp01(if count == 0 { 0.0 } else { 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.first().unwrap(), pts.last().unwrap()); + 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). - let n = track.points.len().max(1) as f64; - for p in &track.points { - let b = ((p.frame.azimuth_deg.rem_euclid(360.0)) / 45.0) as usize % 8; - e[16 + b] += (1.0 / n) as f32; + let n = count.max(1) as f64; + for (b, &hits) in buckets.iter().enumerate() { + e[16 + b] = (f64::from(hits) / n) as f32; } - e[24] = clamp01((track.mean_signal_dbfs() + 40.0) / 40.0); - e[25] = clamp01(track.speed_std_mps() / 50.0); - e[26] = clamp01(track.altitude_std_m() / 3_000.0); - e[27] = clamp01(track.points.first().map(|p| p.frame.range_m).unwrap_or(0.0) / 50_000.0); - e[28] = clamp01(track.points.last().map(|p| p.frame.range_m).unwrap_or(0.0) / 50_000.0); - e[29] = clamp01(track.points.len() as f64 / 600.0); + 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.first().map(|p| p.frame.range_m).unwrap_or(0.0) / 50_000.0); + e[28] = clamp01(pts.last().map(|p| p.frame.range_m).unwrap_or(0.0) / 50_000.0); + e[29] = clamp01(count as f64 / 600.0); e[30] = if track.is_overhead_candidate { 1.0 } else { 0.0 }; e[31] = 0.0; // reserved e diff --git a/examples/sky-monitor/src/lib.rs b/examples/sky-monitor/src/lib.rs index dcc149b740..908796e05b 100644 --- a/examples/sky-monitor/src/lib.rs +++ b/examples/sky-monitor/src/lib.rs @@ -29,22 +29,31 @@ 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::{AnomalyComponents, AnomalyReport, BaselineStats, Interpretation}; +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, weather_window_embedding, 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}; @@ -54,9 +63,11 @@ pub use weather::{WeatherCondition, WeatherWindow}; #[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). diff --git a/examples/sky-monitor/src/main.rs b/examples/sky-monitor/src/main.rs index e9f4087fb5..37c5b35db3 100644 --- a/examples/sky-monitor/src/main.rs +++ b/examples/sky-monitor/src/main.rs @@ -1,9 +1,55 @@ //! 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}; +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()?; @@ -87,5 +133,10 @@ fn main() -> sky_monitor::Result<()> { // ---- 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/pipeline.rs b/examples/sky-monitor/src/pipeline.rs index 1b852a94b7..92887be4fa 100644 --- a/examples/sky-monitor/src/pipeline.rs +++ b/examples/sky-monitor/src/pipeline.rs @@ -163,6 +163,64 @@ impl Pipeline { 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)] diff --git a/examples/sky-monitor/ui/dashboard/README.md b/examples/sky-monitor/ui/dashboard/README.md new file mode 100644 index 0000000000..eb33b2ec91 --- /dev/null +++ b/examples/sky-monitor/ui/dashboard/README.md @@ -0,0 +1,40 @@ +# SkyGraph all-sky dashboard (ADR-199 presentation plane) + +Vanilla JS + Canvas, no build tooling. Renders the embedded deterministic +scenario (`sky-demo-data.js`) 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; overhead candidates get a blue highlight +ring; anomaly badges are colored by band (normal / mildly unusual / +interesting / strong anomaly / rare, gray = unscored baseline). The side panel +lists tracks (click to select + jump the replay) and shows the §15 anomaly +reasons; the footer scrubber replays the synthetic day at 60×. + +## Serve + +```bash +# from this directory (ES modules need http://, not file://) +python3 -m http.server 8000 +# open http://localhost:8000/ +``` + +## Regenerate the demo data + +```bash +# from the repository root +cargo run -p sky-monitor --release -- --emit-json examples/sky-monitor/ui/dashboard/sky-demo-data.js +``` + +## 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 (`SkyProjector.project_batch`): + +```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 (`projection: wasm …` vs +`projection: JS fallback`). Without `./pkg` the dashboard is fully functional +on the JS fallback. diff --git a/examples/sky-monitor/ui/dashboard/index.html b/examples/sky-monitor/ui/dashboard/index.html new file mode 100644 index 0000000000..91103b44b9 --- /dev/null +++ b/examples/sky-monitor/ui/dashboard/index.html @@ -0,0 +1,104 @@ + + + + + +RuView SkyGraph — all-sky dashboard (ADR-199) + + + +
+

RuView SkyGraph — all-sky view

+ observer: — + projection: JS (loading…) +
+
+
+ +
+
+ + + +
+ + + + diff --git a/examples/sky-monitor/ui/dashboard/sky-demo-data.js b/examples/sky-monitor/ui/dashboard/sky-demo-data.js new file mode 100644 index 0000000000..b0c2319b69 --- /dev/null +++ b/examples/sky-monitor/ui/dashboard/sky-demo-data.js @@ -0,0 +1,3 @@ +// Generated by `cargo run -p sky-monitor --release -- --emit-json /home/user/RuVector/examples/sky-monitor/ui/dashboard/sky-demo-data.js`. +// Deterministic synthetic scenario (seed 42) — do not edit by hand. +const SKY_DATA = {"day_start":"2026-06-08T00:00:00+00:00","observer":{"alt_m":100.0,"lat":43.4675,"lon":-79.6877,"name":"oakville_node"},"tracks":[{"anomaly":null,"callsign":"ACA101","icao24":"c01a01","overhead":false,"points":[{"alt_m":10602.6,"lat":43.320289,"lon":-79.990474,"t":1780916700},{"alt_m":10599.5,"lat":43.320946,"lon":-79.987696,"t":1780916701},{"alt_m":10596.5,"lat":43.321602,"lon":-79.984918,"t":1780916702},{"alt_m":10598.4,"lat":43.322258,"lon":-79.98214,"t":1780916703},{"alt_m":10596.9,"lat":43.322914,"lon":-79.979362,"t":1780916704},{"alt_m":10598.3,"lat":43.32357,"lon":-79.976584,"t":1780916705},{"alt_m":10602.9,"lat":43.324227,"lon":-79.973806,"t":1780916706},{"alt_m":10608.7,"lat":43.324883,"lon":-79.971028,"t":1780916707},{"alt_m":10608.7,"lat":43.325539,"lon":-79.968249,"t":1780916708},{"alt_m":10594.4,"lat":43.326195,"lon":-79.965471,"t":1780916709},{"alt_m":10600.2,"lat":43.326852,"lon":-79.962693,"t":1780916710},{"alt_m":10590.9,"lat":43.327508,"lon":-79.959915,"t":1780916711},{"alt_m":10605.1,"lat":43.328164,"lon":-79.957137,"t":1780916712},{"alt_m":10595.2,"lat":43.32882,"lon":-79.954359,"t":1780916713},{"alt_m":10607.6,"lat":43.329477,"lon":-79.951581,"t":1780916714},{"alt_m":10597.3,"lat":43.330133,"lon":-79.948803,"t":1780916715},{"alt_m":10597.1,"lat":43.330789,"lon":-79.946025,"t":1780916716},{"alt_m":10604.1,"lat":43.331445,"lon":-79.943247,"t":1780916717},{"alt_m":10608.1,"lat":43.332101,"lon":-79.940468,"t":1780916718},{"alt_m":10604.6,"lat":43.332758,"lon":-79.93769,"t":1780916719},{"alt_m":10597.3,"lat":43.333414,"lon":-79.934912,"t":1780916720},{"alt_m":10603.0,"lat":43.33407,"lon":-79.932134,"t":1780916721},{"alt_m":10603.4,"lat":43.334726,"lon":-79.929356,"t":1780916722},{"alt_m":10608.6,"lat":43.335383,"lon":-79.926578,"t":1780916723},{"alt_m":10592.3,"lat":43.336039,"lon":-79.9238,"t":1780916724},{"alt_m":10596.8,"lat":43.336695,"lon":-79.921022,"t":1780916725},{"alt_m":10602.4,"lat":43.337351,"lon":-79.918244,"t":1780916726},{"alt_m":10590.7,"lat":43.338008,"lon":-79.915465,"t":1780916727},{"alt_m":10609.7,"lat":43.338664,"lon":-79.912687,"t":1780916728},{"alt_m":10594.3,"lat":43.33932,"lon":-79.909909,"t":1780916729},{"alt_m":10590.4,"lat":43.339976,"lon":-79.907131,"t":1780916730},{"alt_m":10606.0,"lat":43.340632,"lon":-79.904353,"t":1780916731},{"alt_m":10592.6,"lat":43.341289,"lon":-79.901575,"t":1780916732},{"alt_m":10602.6,"lat":43.341945,"lon":-79.898797,"t":1780916733},{"alt_m":10607.9,"lat":43.342601,"lon":-79.896019,"t":1780916734},{"alt_m":10600.3,"lat":43.343257,"lon":-79.893241,"t":1780916735},{"alt_m":10594.7,"lat":43.343914,"lon":-79.890462,"t":1780916736},{"alt_m":10607.8,"lat":43.34457,"lon":-79.887684,"t":1780916737},{"alt_m":10599.2,"lat":43.345226,"lon":-79.884906,"t":1780916738},{"alt_m":10590.2,"lat":43.345882,"lon":-79.882128,"t":1780916739},{"alt_m":10607.4,"lat":43.346539,"lon":-79.87935,"t":1780916740},{"alt_m":10601.7,"lat":43.347195,"lon":-79.876572,"t":1780916741},{"alt_m":10604.7,"lat":43.347851,"lon":-79.873794,"t":1780916742},{"alt_m":10592.0,"lat":43.348507,"lon":-79.871016,"t":1780916743},{"alt_m":10591.4,"lat":43.349163,"lon":-79.868238,"t":1780916744},{"alt_m":10595.1,"lat":43.34982,"lon":-79.865459,"t":1780916745},{"alt_m":10608.7,"lat":43.350476,"lon":-79.862681,"t":1780916746},{"alt_m":10605.1,"lat":43.351132,"lon":-79.859903,"t":1780916747},{"alt_m":10609.3,"lat":43.351788,"lon":-79.857125,"t":1780916748},{"alt_m":10595.3,"lat":43.352445,"lon":-79.854347,"t":1780916749},{"alt_m":10603.4,"lat":43.353101,"lon":-79.851569,"t":1780916750},{"alt_m":10595.7,"lat":43.353757,"lon":-79.848791,"t":1780916751},{"alt_m":10593.8,"lat":43.354413,"lon":-79.846013,"t":1780916752},{"alt_m":10602.7,"lat":43.355069,"lon":-79.843235,"t":1780916753},{"alt_m":10591.7,"lat":43.355726,"lon":-79.840456,"t":1780916754},{"alt_m":10604.1,"lat":43.356382,"lon":-79.837678,"t":1780916755},{"alt_m":10607.4,"lat":43.357038,"lon":-79.8349,"t":1780916756},{"alt_m":10605.2,"lat":43.357694,"lon":-79.832122,"t":1780916757},{"alt_m":10602.8,"lat":43.358351,"lon":-79.829344,"t":1780916758},{"alt_m":10600.5,"lat":43.359007,"lon":-79.826566,"t":1780916759},{"alt_m":10594.3,"lat":43.359663,"lon":-79.823788,"t":1780916760},{"alt_m":10599.2,"lat":43.360319,"lon":-79.82101,"t":1780916761},{"alt_m":10601.9,"lat":43.360976,"lon":-79.818232,"t":1780916762},{"alt_m":10591.5,"lat":43.361632,"lon":-79.815453,"t":1780916763},{"alt_m":10608.0,"lat":43.362288,"lon":-79.812675,"t":1780916764},{"alt_m":10607.9,"lat":43.362944,"lon":-79.809897,"t":1780916765},{"alt_m":10595.1,"lat":43.3636,"lon":-79.807119,"t":1780916766},{"alt_m":10609.7,"lat":43.364257,"lon":-79.804341,"t":1780916767},{"alt_m":10600.9,"lat":43.364913,"lon":-79.801563,"t":1780916768},{"alt_m":10608.5,"lat":43.365569,"lon":-79.798785,"t":1780916769},{"alt_m":10595.5,"lat":43.366225,"lon":-79.796007,"t":1780916770},{"alt_m":10607.0,"lat":43.366882,"lon":-79.793229,"t":1780916771},{"alt_m":10590.6,"lat":43.367538,"lon":-79.790451,"t":1780916772},{"alt_m":10592.9,"lat":43.368194,"lon":-79.787672,"t":1780916773},{"alt_m":10602.2,"lat":43.36885,"lon":-79.784894,"t":1780916774},{"alt_m":10593.2,"lat":43.369507,"lon":-79.782116,"t":1780916775},{"alt_m":10597.0,"lat":43.370163,"lon":-79.779338,"t":1780916776},{"alt_m":10604.2,"lat":43.370819,"lon":-79.77656,"t":1780916777},{"alt_m":10591.2,"lat":43.371475,"lon":-79.773782,"t":1780916778},{"alt_m":10605.0,"lat":43.372131,"lon":-79.771004,"t":1780916779},{"alt_m":10608.7,"lat":43.372788,"lon":-79.768226,"t":1780916780},{"alt_m":10592.4,"lat":43.373444,"lon":-79.765448,"t":1780916781},{"alt_m":10603.9,"lat":43.3741,"lon":-79.762669,"t":1780916782},{"alt_m":10609.5,"lat":43.374756,"lon":-79.759891,"t":1780916783},{"alt_m":10597.6,"lat":43.375413,"lon":-79.757113,"t":1780916784},{"alt_m":10592.9,"lat":43.376069,"lon":-79.754335,"t":1780916785},{"alt_m":10597.7,"lat":43.376725,"lon":-79.751557,"t":1780916786},{"alt_m":10599.1,"lat":43.377381,"lon":-79.748779,"t":1780916787},{"alt_m":10603.6,"lat":43.378037,"lon":-79.746001,"t":1780916788},{"alt_m":10605.9,"lat":43.378694,"lon":-79.743223,"t":1780916789},{"alt_m":10590.6,"lat":43.37935,"lon":-79.740445,"t":1780916790},{"alt_m":10607.1,"lat":43.380006,"lon":-79.737666,"t":1780916791},{"alt_m":10590.5,"lat":43.380662,"lon":-79.734888,"t":1780916792},{"alt_m":10602.4,"lat":43.381319,"lon":-79.73211,"t":1780916793},{"alt_m":10595.2,"lat":43.381975,"lon":-79.729332,"t":1780916794},{"alt_m":10591.9,"lat":43.382631,"lon":-79.726554,"t":1780916795},{"alt_m":10606.2,"lat":43.383287,"lon":-79.723776,"t":1780916796},{"alt_m":10602.5,"lat":43.383944,"lon":-79.720998,"t":1780916797},{"alt_m":10600.0,"lat":43.3846,"lon":-79.71822,"t":1780916798},{"alt_m":10598.7,"lat":43.385256,"lon":-79.715442,"t":1780916799},{"alt_m":10590.1,"lat":43.385912,"lon":-79.712663,"t":1780916800},{"alt_m":10600.0,"lat":43.386568,"lon":-79.709885,"t":1780916801},{"alt_m":10609.9,"lat":43.387225,"lon":-79.707107,"t":1780916802},{"alt_m":10596.2,"lat":43.387881,"lon":-79.704329,"t":1780916803},{"alt_m":10595.5,"lat":43.388537,"lon":-79.701551,"t":1780916804},{"alt_m":10598.2,"lat":43.389193,"lon":-79.698773,"t":1780916805},{"alt_m":10609.4,"lat":43.38985,"lon":-79.695995,"t":1780916806},{"alt_m":10606.3,"lat":43.390506,"lon":-79.693217,"t":1780916807},{"alt_m":10590.5,"lat":43.391162,"lon":-79.690439,"t":1780916808},{"alt_m":10607.2,"lat":43.391818,"lon":-79.68766,"t":1780916809},{"alt_m":10608.6,"lat":43.392475,"lon":-79.684882,"t":1780916810},{"alt_m":10593.8,"lat":43.393131,"lon":-79.682104,"t":1780916811},{"alt_m":10597.6,"lat":43.393787,"lon":-79.679326,"t":1780916812},{"alt_m":10594.3,"lat":43.394443,"lon":-79.676548,"t":1780916813},{"alt_m":10602.5,"lat":43.395099,"lon":-79.67377,"t":1780916814},{"alt_m":10605.2,"lat":43.395756,"lon":-79.670992,"t":1780916815},{"alt_m":10609.9,"lat":43.396412,"lon":-79.668214,"t":1780916816},{"alt_m":10600.6,"lat":43.397068,"lon":-79.665436,"t":1780916817},{"alt_m":10608.5,"lat":43.397724,"lon":-79.662657,"t":1780916818},{"alt_m":10594.6,"lat":43.398381,"lon":-79.659879,"t":1780916819},{"alt_m":10591.0,"lat":43.399037,"lon":-79.657101,"t":1780916820},{"alt_m":10599.6,"lat":43.399693,"lon":-79.654323,"t":1780916821},{"alt_m":10591.2,"lat":43.400349,"lon":-79.651545,"t":1780916822},{"alt_m":10592.2,"lat":43.401005,"lon":-79.648767,"t":1780916823},{"alt_m":10601.0,"lat":43.401662,"lon":-79.645989,"t":1780916824},{"alt_m":10602.5,"lat":43.402318,"lon":-79.643211,"t":1780916825},{"alt_m":10593.2,"lat":43.402974,"lon":-79.640433,"t":1780916826},{"alt_m":10590.8,"lat":43.40363,"lon":-79.637655,"t":1780916827},{"alt_m":10603.9,"lat":43.404287,"lon":-79.634876,"t":1780916828},{"alt_m":10608.9,"lat":43.404943,"lon":-79.632098,"t":1780916829},{"alt_m":10591.7,"lat":43.405599,"lon":-79.62932,"t":1780916830},{"alt_m":10591.2,"lat":43.406255,"lon":-79.626542,"t":1780916831},{"alt_m":10601.4,"lat":43.406912,"lon":-79.623764,"t":1780916832},{"alt_m":10597.0,"lat":43.407568,"lon":-79.620986,"t":1780916833},{"alt_m":10593.0,"lat":43.408224,"lon":-79.618208,"t":1780916834},{"alt_m":10604.5,"lat":43.40888,"lon":-79.61543,"t":1780916835},{"alt_m":10590.7,"lat":43.409536,"lon":-79.612652,"t":1780916836},{"alt_m":10607.6,"lat":43.410193,"lon":-79.609873,"t":1780916837},{"alt_m":10607.2,"lat":43.410849,"lon":-79.607095,"t":1780916838},{"alt_m":10608.6,"lat":43.411505,"lon":-79.604317,"t":1780916839},{"alt_m":10608.4,"lat":43.412161,"lon":-79.601539,"t":1780916840},{"alt_m":10607.0,"lat":43.412818,"lon":-79.598761,"t":1780916841},{"alt_m":10593.2,"lat":43.413474,"lon":-79.595983,"t":1780916842},{"alt_m":10599.5,"lat":43.41413,"lon":-79.593205,"t":1780916843},{"alt_m":10594.9,"lat":43.414786,"lon":-79.590427,"t":1780916844},{"alt_m":10600.9,"lat":43.415443,"lon":-79.587649,"t":1780916845},{"alt_m":10609.6,"lat":43.416099,"lon":-79.58487,"t":1780916846},{"alt_m":10596.0,"lat":43.416755,"lon":-79.582092,"t":1780916847},{"alt_m":10597.2,"lat":43.417411,"lon":-79.579314,"t":1780916848},{"alt_m":10605.2,"lat":43.418067,"lon":-79.576536,"t":1780916849},{"alt_m":10597.0,"lat":43.418724,"lon":-79.573758,"t":1780916850},{"alt_m":10600.6,"lat":43.41938,"lon":-79.57098,"t":1780916851},{"alt_m":10601.8,"lat":43.420036,"lon":-79.568202,"t":1780916852},{"alt_m":10609.0,"lat":43.420692,"lon":-79.565424,"t":1780916853},{"alt_m":10599.5,"lat":43.421349,"lon":-79.562646,"t":1780916854},{"alt_m":10605.3,"lat":43.422005,"lon":-79.559867,"t":1780916855},{"alt_m":10595.0,"lat":43.422661,"lon":-79.557089,"t":1780916856},{"alt_m":10599.8,"lat":43.423317,"lon":-79.554311,"t":1780916857},{"alt_m":10596.7,"lat":43.423973,"lon":-79.551533,"t":1780916858},{"alt_m":10605.4,"lat":43.42463,"lon":-79.548755,"t":1780916859},{"alt_m":10597.9,"lat":43.425286,"lon":-79.545977,"t":1780916860},{"alt_m":10596.9,"lat":43.425942,"lon":-79.543199,"t":1780916861},{"alt_m":10602.7,"lat":43.426598,"lon":-79.540421,"t":1780916862},{"alt_m":10596.5,"lat":43.427255,"lon":-79.537643,"t":1780916863},{"alt_m":10602.6,"lat":43.427911,"lon":-79.534864,"t":1780916864},{"alt_m":10598.1,"lat":43.428567,"lon":-79.532086,"t":1780916865},{"alt_m":10605.7,"lat":43.429223,"lon":-79.529308,"t":1780916866},{"alt_m":10590.6,"lat":43.42988,"lon":-79.52653,"t":1780916867},{"alt_m":10596.2,"lat":43.430536,"lon":-79.523752,"t":1780916868},{"alt_m":10602.9,"lat":43.431192,"lon":-79.520974,"t":1780916869},{"alt_m":10601.2,"lat":43.431848,"lon":-79.518196,"t":1780916870},{"alt_m":10594.9,"lat":43.432504,"lon":-79.515418,"t":1780916871},{"alt_m":10593.8,"lat":43.433161,"lon":-79.51264,"t":1780916872},{"alt_m":10595.0,"lat":43.433817,"lon":-79.509861,"t":1780916873},{"alt_m":10595.7,"lat":43.434473,"lon":-79.507083,"t":1780916874},{"alt_m":10591.1,"lat":43.435129,"lon":-79.504305,"t":1780916875},{"alt_m":10609.0,"lat":43.435786,"lon":-79.501527,"t":1780916876},{"alt_m":10603.9,"lat":43.436442,"lon":-79.498749,"t":1780916877},{"alt_m":10590.4,"lat":43.437098,"lon":-79.495971,"t":1780916878},{"alt_m":10594.6,"lat":43.437754,"lon":-79.493193,"t":1780916879},{"alt_m":10605.9,"lat":43.438411,"lon":-79.490415,"t":1780916880},{"alt_m":10597.1,"lat":43.439067,"lon":-79.487637,"t":1780916881},{"alt_m":10605.1,"lat":43.439723,"lon":-79.484859,"t":1780916882},{"alt_m":10593.0,"lat":43.440379,"lon":-79.48208,"t":1780916883},{"alt_m":10606.4,"lat":43.441035,"lon":-79.479302,"t":1780916884},{"alt_m":10595.5,"lat":43.441692,"lon":-79.476524,"t":1780916885},{"alt_m":10592.4,"lat":43.442348,"lon":-79.473746,"t":1780916886},{"alt_m":10594.4,"lat":43.443004,"lon":-79.470968,"t":1780916887},{"alt_m":10605.5,"lat":43.44366,"lon":-79.46819,"t":1780916888},{"alt_m":10608.2,"lat":43.444317,"lon":-79.465412,"t":1780916889},{"alt_m":10603.2,"lat":43.444973,"lon":-79.462634,"t":1780916890},{"alt_m":10595.8,"lat":43.445629,"lon":-79.459856,"t":1780916891},{"alt_m":10594.9,"lat":43.446285,"lon":-79.457077,"t":1780916892},{"alt_m":10609.5,"lat":43.446941,"lon":-79.454299,"t":1780916893},{"alt_m":10592.1,"lat":43.447598,"lon":-79.451521,"t":1780916894},{"alt_m":10597.6,"lat":43.448254,"lon":-79.448743,"t":1780916895},{"alt_m":10603.8,"lat":43.44891,"lon":-79.445965,"t":1780916896},{"alt_m":10605.3,"lat":43.449566,"lon":-79.443187,"t":1780916897},{"alt_m":10598.3,"lat":43.450223,"lon":-79.440409,"t":1780916898},{"alt_m":10596.9,"lat":43.450879,"lon":-79.437631,"t":1780916899},{"alt_m":10591.5,"lat":43.451535,"lon":-79.434853,"t":1780916900},{"alt_m":10604.2,"lat":43.452191,"lon":-79.432074,"t":1780916901},{"alt_m":10599.4,"lat":43.452848,"lon":-79.429296,"t":1780916902},{"alt_m":10595.2,"lat":43.453504,"lon":-79.426518,"t":1780916903},{"alt_m":10596.4,"lat":43.45416,"lon":-79.42374,"t":1780916904},{"alt_m":10597.8,"lat":43.454816,"lon":-79.420962,"t":1780916905},{"alt_m":10590.5,"lat":43.455472,"lon":-79.418184,"t":1780916906},{"alt_m":10599.5,"lat":43.456129,"lon":-79.415406,"t":1780916907},{"alt_m":10599.5,"lat":43.456785,"lon":-79.412628,"t":1780916908},{"alt_m":10591.3,"lat":43.457441,"lon":-79.40985,"t":1780916909},{"alt_m":10597.7,"lat":43.458097,"lon":-79.407071,"t":1780916910},{"alt_m":10595.2,"lat":43.458754,"lon":-79.404293,"t":1780916911},{"alt_m":10601.2,"lat":43.45941,"lon":-79.401515,"t":1780916912},{"alt_m":10608.8,"lat":43.460066,"lon":-79.398737,"t":1780916913},{"alt_m":10605.6,"lat":43.460722,"lon":-79.395959,"t":1780916914},{"alt_m":10591.8,"lat":43.461379,"lon":-79.393181,"t":1780916915},{"alt_m":10595.0,"lat":43.462035,"lon":-79.390403,"t":1780916916},{"alt_m":10599.6,"lat":43.462691,"lon":-79.387625,"t":1780916917},{"alt_m":10598.9,"lat":43.463347,"lon":-79.384847,"t":1780916918},{"alt_m":10591.1,"lat":43.464003,"lon":-79.382068,"t":1780916919},{"alt_m":10598.8,"lat":43.46466,"lon":-79.37929,"t":1780916920},{"alt_m":10605.8,"lat":43.465316,"lon":-79.376512,"t":1780916921},{"alt_m":10593.4,"lat":43.465972,"lon":-79.373734,"t":1780916922},{"alt_m":10600.1,"lat":43.466628,"lon":-79.370956,"t":1780916923},{"alt_m":10593.2,"lat":43.467285,"lon":-79.368178,"t":1780916924},{"alt_m":10607.9,"lat":43.467941,"lon":-79.3654,"t":1780916925},{"alt_m":10594.0,"lat":43.468597,"lon":-79.362622,"t":1780916926},{"alt_m":10604.7,"lat":43.469253,"lon":-79.359844,"t":1780916927},{"alt_m":10600.1,"lat":43.46991,"lon":-79.357066,"t":1780916928},{"alt_m":10591.0,"lat":43.470566,"lon":-79.354287,"t":1780916929},{"alt_m":10593.8,"lat":43.471222,"lon":-79.351509,"t":1780916930},{"alt_m":10605.1,"lat":43.471878,"lon":-79.348731,"t":1780916931},{"alt_m":10603.2,"lat":43.472534,"lon":-79.345953,"t":1780916932},{"alt_m":10606.5,"lat":43.473191,"lon":-79.343175,"t":1780916933},{"alt_m":10602.5,"lat":43.473847,"lon":-79.340397,"t":1780916934},{"alt_m":10590.9,"lat":43.474503,"lon":-79.337619,"t":1780916935},{"alt_m":10597.5,"lat":43.475159,"lon":-79.334841,"t":1780916936},{"alt_m":10597.9,"lat":43.475816,"lon":-79.332063,"t":1780916937},{"alt_m":10605.9,"lat":43.476472,"lon":-79.329284,"t":1780916938},{"alt_m":10599.0,"lat":43.477128,"lon":-79.326506,"t":1780916939}]},{"anomaly":null,"callsign":"BAW505","icao24":"400a05","overhead":false,"points":[{"alt_m":11200.0,"lat":43.457999,"lon":-79.327379,"t":1780920600},{"alt_m":11190.7,"lat":43.457365,"lon":-79.330063,"t":1780920601},{"alt_m":11200.2,"lat":43.456731,"lon":-79.332747,"t":1780920602},{"alt_m":11208.2,"lat":43.456097,"lon":-79.335431,"t":1780920603},{"alt_m":11200.5,"lat":43.455463,"lon":-79.338115,"t":1780920604},{"alt_m":11193.3,"lat":43.454829,"lon":-79.340799,"t":1780920605},{"alt_m":11200.8,"lat":43.454195,"lon":-79.343483,"t":1780920606},{"alt_m":11200.5,"lat":43.453561,"lon":-79.346167,"t":1780920607},{"alt_m":11202.7,"lat":43.452927,"lon":-79.348851,"t":1780920608},{"alt_m":11199.5,"lat":43.452293,"lon":-79.351535,"t":1780920609},{"alt_m":11205.9,"lat":43.451659,"lon":-79.354219,"t":1780920610},{"alt_m":11196.7,"lat":43.451025,"lon":-79.356903,"t":1780920611},{"alt_m":11195.3,"lat":43.450391,"lon":-79.359587,"t":1780920612},{"alt_m":11200.3,"lat":43.449757,"lon":-79.36227,"t":1780920613},{"alt_m":11209.2,"lat":43.449123,"lon":-79.364954,"t":1780920614},{"alt_m":11190.0,"lat":43.448489,"lon":-79.367638,"t":1780920615},{"alt_m":11194.0,"lat":43.447855,"lon":-79.370322,"t":1780920616},{"alt_m":11195.3,"lat":43.447221,"lon":-79.373006,"t":1780920617},{"alt_m":11206.5,"lat":43.446587,"lon":-79.37569,"t":1780920618},{"alt_m":11203.2,"lat":43.445953,"lon":-79.378374,"t":1780920619},{"alt_m":11203.8,"lat":43.445319,"lon":-79.381058,"t":1780920620},{"alt_m":11194.2,"lat":43.444685,"lon":-79.383742,"t":1780920621},{"alt_m":11208.1,"lat":43.444051,"lon":-79.386426,"t":1780920622},{"alt_m":11191.7,"lat":43.443417,"lon":-79.38911,"t":1780920623},{"alt_m":11209.0,"lat":43.442783,"lon":-79.391794,"t":1780920624},{"alt_m":11197.7,"lat":43.442149,"lon":-79.394478,"t":1780920625},{"alt_m":11193.1,"lat":43.441515,"lon":-79.397162,"t":1780920626},{"alt_m":11204.3,"lat":43.440881,"lon":-79.399846,"t":1780920627},{"alt_m":11204.8,"lat":43.440248,"lon":-79.40253,"t":1780920628},{"alt_m":11201.2,"lat":43.439614,"lon":-79.405213,"t":1780920629},{"alt_m":11200.0,"lat":43.43898,"lon":-79.407897,"t":1780920630},{"alt_m":11196.3,"lat":43.438346,"lon":-79.410581,"t":1780920631},{"alt_m":11197.3,"lat":43.437712,"lon":-79.413265,"t":1780920632},{"alt_m":11191.3,"lat":43.437078,"lon":-79.415949,"t":1780920633},{"alt_m":11191.1,"lat":43.436444,"lon":-79.418633,"t":1780920634},{"alt_m":11195.0,"lat":43.43581,"lon":-79.421317,"t":1780920635},{"alt_m":11195.4,"lat":43.435176,"lon":-79.424001,"t":1780920636},{"alt_m":11199.6,"lat":43.434542,"lon":-79.426685,"t":1780920637},{"alt_m":11205.8,"lat":43.433908,"lon":-79.429369,"t":1780920638},{"alt_m":11192.7,"lat":43.433274,"lon":-79.432053,"t":1780920639},{"alt_m":11196.9,"lat":43.43264,"lon":-79.434737,"t":1780920640},{"alt_m":11195.3,"lat":43.432006,"lon":-79.437421,"t":1780920641},{"alt_m":11208.7,"lat":43.431372,"lon":-79.440105,"t":1780920642},{"alt_m":11208.8,"lat":43.430738,"lon":-79.442789,"t":1780920643},{"alt_m":11204.7,"lat":43.430104,"lon":-79.445472,"t":1780920644},{"alt_m":11205.9,"lat":43.42947,"lon":-79.448156,"t":1780920645},{"alt_m":11190.3,"lat":43.428836,"lon":-79.45084,"t":1780920646},{"alt_m":11207.3,"lat":43.428202,"lon":-79.453524,"t":1780920647},{"alt_m":11197.9,"lat":43.427568,"lon":-79.456208,"t":1780920648},{"alt_m":11204.5,"lat":43.426934,"lon":-79.458892,"t":1780920649},{"alt_m":11191.0,"lat":43.4263,"lon":-79.461576,"t":1780920650},{"alt_m":11198.3,"lat":43.425666,"lon":-79.46426,"t":1780920651},{"alt_m":11195.6,"lat":43.425032,"lon":-79.466944,"t":1780920652},{"alt_m":11206.3,"lat":43.424398,"lon":-79.469628,"t":1780920653},{"alt_m":11204.1,"lat":43.423764,"lon":-79.472312,"t":1780920654},{"alt_m":11202.3,"lat":43.42313,"lon":-79.474996,"t":1780920655},{"alt_m":11193.0,"lat":43.422496,"lon":-79.47768,"t":1780920656},{"alt_m":11199.5,"lat":43.421862,"lon":-79.480364,"t":1780920657},{"alt_m":11206.8,"lat":43.421228,"lon":-79.483048,"t":1780920658},{"alt_m":11209.0,"lat":43.420594,"lon":-79.485732,"t":1780920659},{"alt_m":11193.1,"lat":43.41996,"lon":-79.488415,"t":1780920660},{"alt_m":11200.7,"lat":43.419326,"lon":-79.491099,"t":1780920661},{"alt_m":11203.3,"lat":43.418692,"lon":-79.493783,"t":1780920662},{"alt_m":11200.7,"lat":43.418058,"lon":-79.496467,"t":1780920663},{"alt_m":11199.8,"lat":43.417424,"lon":-79.499151,"t":1780920664},{"alt_m":11205.7,"lat":43.41679,"lon":-79.501835,"t":1780920665},{"alt_m":11209.4,"lat":43.416156,"lon":-79.504519,"t":1780920666},{"alt_m":11201.4,"lat":43.415522,"lon":-79.507203,"t":1780920667},{"alt_m":11197.0,"lat":43.414888,"lon":-79.509887,"t":1780920668},{"alt_m":11193.1,"lat":43.414254,"lon":-79.512571,"t":1780920669},{"alt_m":11198.6,"lat":43.41362,"lon":-79.515255,"t":1780920670},{"alt_m":11191.1,"lat":43.412986,"lon":-79.517939,"t":1780920671},{"alt_m":11191.8,"lat":43.412352,"lon":-79.520623,"t":1780920672},{"alt_m":11207.5,"lat":43.411718,"lon":-79.523307,"t":1780920673},{"alt_m":11202.3,"lat":43.411084,"lon":-79.525991,"t":1780920674},{"alt_m":11209.7,"lat":43.41045,"lon":-79.528674,"t":1780920675},{"alt_m":11206.6,"lat":43.409816,"lon":-79.531358,"t":1780920676},{"alt_m":11206.4,"lat":43.409182,"lon":-79.534042,"t":1780920677},{"alt_m":11191.3,"lat":43.408548,"lon":-79.536726,"t":1780920678},{"alt_m":11206.7,"lat":43.407914,"lon":-79.53941,"t":1780920679},{"alt_m":11200.0,"lat":43.40728,"lon":-79.542094,"t":1780920680},{"alt_m":11194.8,"lat":43.406646,"lon":-79.544778,"t":1780920681},{"alt_m":11208.3,"lat":43.406012,"lon":-79.547462,"t":1780920682},{"alt_m":11203.9,"lat":43.405378,"lon":-79.550146,"t":1780920683},{"alt_m":11195.0,"lat":43.404744,"lon":-79.55283,"t":1780920684},{"alt_m":11203.9,"lat":43.40411,"lon":-79.555514,"t":1780920685},{"alt_m":11208.9,"lat":43.403476,"lon":-79.558198,"t":1780920686},{"alt_m":11205.1,"lat":43.402842,"lon":-79.560882,"t":1780920687},{"alt_m":11207.9,"lat":43.402208,"lon":-79.563566,"t":1780920688},{"alt_m":11209.6,"lat":43.401574,"lon":-79.56625,"t":1780920689},{"alt_m":11198.6,"lat":43.400941,"lon":-79.568934,"t":1780920690},{"alt_m":11205.9,"lat":43.400307,"lon":-79.571617,"t":1780920691},{"alt_m":11207.7,"lat":43.399673,"lon":-79.574301,"t":1780920692},{"alt_m":11194.5,"lat":43.399039,"lon":-79.576985,"t":1780920693},{"alt_m":11204.1,"lat":43.398405,"lon":-79.579669,"t":1780920694},{"alt_m":11194.5,"lat":43.397771,"lon":-79.582353,"t":1780920695},{"alt_m":11205.4,"lat":43.397137,"lon":-79.585037,"t":1780920696},{"alt_m":11196.8,"lat":43.396503,"lon":-79.587721,"t":1780920697},{"alt_m":11204.1,"lat":43.395869,"lon":-79.590405,"t":1780920698},{"alt_m":11203.7,"lat":43.395235,"lon":-79.593089,"t":1780920699},{"alt_m":11202.3,"lat":43.394601,"lon":-79.595773,"t":1780920700},{"alt_m":11192.6,"lat":43.393967,"lon":-79.598457,"t":1780920701},{"alt_m":11208.5,"lat":43.393333,"lon":-79.601141,"t":1780920702},{"alt_m":11206.2,"lat":43.392699,"lon":-79.603825,"t":1780920703},{"alt_m":11198.5,"lat":43.392065,"lon":-79.606509,"t":1780920704},{"alt_m":11192.8,"lat":43.391431,"lon":-79.609193,"t":1780920705},{"alt_m":11192.6,"lat":43.390797,"lon":-79.611876,"t":1780920706},{"alt_m":11190.1,"lat":43.390163,"lon":-79.61456,"t":1780920707},{"alt_m":11198.6,"lat":43.389529,"lon":-79.617244,"t":1780920708},{"alt_m":11201.0,"lat":43.388895,"lon":-79.619928,"t":1780920709},{"alt_m":11196.8,"lat":43.388261,"lon":-79.622612,"t":1780920710},{"alt_m":11206.4,"lat":43.387627,"lon":-79.625296,"t":1780920711},{"alt_m":11195.2,"lat":43.386993,"lon":-79.62798,"t":1780920712},{"alt_m":11190.3,"lat":43.386359,"lon":-79.630664,"t":1780920713},{"alt_m":11207.6,"lat":43.385725,"lon":-79.633348,"t":1780920714},{"alt_m":11204.7,"lat":43.385091,"lon":-79.636032,"t":1780920715},{"alt_m":11200.7,"lat":43.384457,"lon":-79.638716,"t":1780920716},{"alt_m":11194.2,"lat":43.383823,"lon":-79.6414,"t":1780920717},{"alt_m":11190.4,"lat":43.383189,"lon":-79.644084,"t":1780920718},{"alt_m":11200.2,"lat":43.382555,"lon":-79.646768,"t":1780920719},{"alt_m":11190.9,"lat":43.381921,"lon":-79.649452,"t":1780920720},{"alt_m":11208.7,"lat":43.381287,"lon":-79.652136,"t":1780920721},{"alt_m":11198.7,"lat":43.380653,"lon":-79.654819,"t":1780920722},{"alt_m":11207.2,"lat":43.380019,"lon":-79.657503,"t":1780920723},{"alt_m":11202.4,"lat":43.379385,"lon":-79.660187,"t":1780920724},{"alt_m":11191.4,"lat":43.378751,"lon":-79.662871,"t":1780920725},{"alt_m":11206.8,"lat":43.378117,"lon":-79.665555,"t":1780920726},{"alt_m":11208.4,"lat":43.377483,"lon":-79.668239,"t":1780920727},{"alt_m":11191.8,"lat":43.376849,"lon":-79.670923,"t":1780920728},{"alt_m":11207.9,"lat":43.376215,"lon":-79.673607,"t":1780920729},{"alt_m":11199.0,"lat":43.375581,"lon":-79.676291,"t":1780920730},{"alt_m":11204.9,"lat":43.374947,"lon":-79.678975,"t":1780920731},{"alt_m":11195.6,"lat":43.374313,"lon":-79.681659,"t":1780920732},{"alt_m":11207.0,"lat":43.373679,"lon":-79.684343,"t":1780920733},{"alt_m":11193.5,"lat":43.373045,"lon":-79.687027,"t":1780920734},{"alt_m":11203.9,"lat":43.372411,"lon":-79.689711,"t":1780920735},{"alt_m":11209.7,"lat":43.371777,"lon":-79.692395,"t":1780920736},{"alt_m":11196.3,"lat":43.371143,"lon":-79.695079,"t":1780920737},{"alt_m":11200.3,"lat":43.370509,"lon":-79.697762,"t":1780920738},{"alt_m":11195.7,"lat":43.369875,"lon":-79.700446,"t":1780920739},{"alt_m":11207.0,"lat":43.369241,"lon":-79.70313,"t":1780920740},{"alt_m":11201.6,"lat":43.368607,"lon":-79.705814,"t":1780920741},{"alt_m":11194.0,"lat":43.367973,"lon":-79.708498,"t":1780920742},{"alt_m":11194.8,"lat":43.367339,"lon":-79.711182,"t":1780920743},{"alt_m":11202.2,"lat":43.366705,"lon":-79.713866,"t":1780920744},{"alt_m":11202.0,"lat":43.366071,"lon":-79.71655,"t":1780920745},{"alt_m":11197.9,"lat":43.365437,"lon":-79.719234,"t":1780920746},{"alt_m":11199.5,"lat":43.364803,"lon":-79.721918,"t":1780920747},{"alt_m":11191.2,"lat":43.364169,"lon":-79.724602,"t":1780920748},{"alt_m":11210.0,"lat":43.363535,"lon":-79.727286,"t":1780920749},{"alt_m":11196.1,"lat":43.362901,"lon":-79.72997,"t":1780920750},{"alt_m":11203.9,"lat":43.362268,"lon":-79.732654,"t":1780920751},{"alt_m":11197.0,"lat":43.361634,"lon":-79.735338,"t":1780920752},{"alt_m":11193.8,"lat":43.361,"lon":-79.738021,"t":1780920753},{"alt_m":11203.5,"lat":43.360366,"lon":-79.740705,"t":1780920754},{"alt_m":11203.1,"lat":43.359732,"lon":-79.743389,"t":1780920755},{"alt_m":11207.6,"lat":43.359098,"lon":-79.746073,"t":1780920756},{"alt_m":11208.2,"lat":43.358464,"lon":-79.748757,"t":1780920757},{"alt_m":11201.3,"lat":43.35783,"lon":-79.751441,"t":1780920758},{"alt_m":11193.2,"lat":43.357196,"lon":-79.754125,"t":1780920759},{"alt_m":11200.2,"lat":43.356562,"lon":-79.756809,"t":1780920760},{"alt_m":11195.4,"lat":43.355928,"lon":-79.759493,"t":1780920761},{"alt_m":11209.4,"lat":43.355294,"lon":-79.762177,"t":1780920762},{"alt_m":11190.6,"lat":43.35466,"lon":-79.764861,"t":1780920763},{"alt_m":11201.8,"lat":43.354026,"lon":-79.767545,"t":1780920764},{"alt_m":11194.5,"lat":43.353392,"lon":-79.770229,"t":1780920765},{"alt_m":11195.8,"lat":43.352758,"lon":-79.772913,"t":1780920766},{"alt_m":11192.9,"lat":43.352124,"lon":-79.775597,"t":1780920767},{"alt_m":11200.9,"lat":43.35149,"lon":-79.778281,"t":1780920768},{"alt_m":11201.4,"lat":43.350856,"lon":-79.780964,"t":1780920769},{"alt_m":11191.1,"lat":43.350222,"lon":-79.783648,"t":1780920770},{"alt_m":11199.5,"lat":43.349588,"lon":-79.786332,"t":1780920771},{"alt_m":11196.1,"lat":43.348954,"lon":-79.789016,"t":1780920772},{"alt_m":11204.0,"lat":43.34832,"lon":-79.7917,"t":1780920773},{"alt_m":11197.5,"lat":43.347686,"lon":-79.794384,"t":1780920774},{"alt_m":11195.5,"lat":43.347052,"lon":-79.797068,"t":1780920775},{"alt_m":11195.7,"lat":43.346418,"lon":-79.799752,"t":1780920776},{"alt_m":11198.4,"lat":43.345784,"lon":-79.802436,"t":1780920777},{"alt_m":11197.9,"lat":43.34515,"lon":-79.80512,"t":1780920778},{"alt_m":11194.6,"lat":43.344516,"lon":-79.807804,"t":1780920779},{"alt_m":11206.7,"lat":43.343882,"lon":-79.810488,"t":1780920780},{"alt_m":11199.2,"lat":43.343248,"lon":-79.813172,"t":1780920781},{"alt_m":11191.6,"lat":43.342614,"lon":-79.815856,"t":1780920782},{"alt_m":11205.4,"lat":43.34198,"lon":-79.81854,"t":1780920783},{"alt_m":11194.6,"lat":43.341346,"lon":-79.821223,"t":1780920784},{"alt_m":11198.9,"lat":43.340712,"lon":-79.823907,"t":1780920785},{"alt_m":11204.6,"lat":43.340078,"lon":-79.826591,"t":1780920786},{"alt_m":11207.8,"lat":43.339444,"lon":-79.829275,"t":1780920787},{"alt_m":11200.2,"lat":43.33881,"lon":-79.831959,"t":1780920788},{"alt_m":11198.5,"lat":43.338176,"lon":-79.834643,"t":1780920789},{"alt_m":11204.7,"lat":43.337542,"lon":-79.837327,"t":1780920790},{"alt_m":11206.3,"lat":43.336908,"lon":-79.840011,"t":1780920791},{"alt_m":11194.0,"lat":43.336274,"lon":-79.842695,"t":1780920792},{"alt_m":11196.8,"lat":43.33564,"lon":-79.845379,"t":1780920793},{"alt_m":11205.9,"lat":43.335006,"lon":-79.848063,"t":1780920794},{"alt_m":11202.6,"lat":43.334372,"lon":-79.850747,"t":1780920795},{"alt_m":11192.6,"lat":43.333738,"lon":-79.853431,"t":1780920796},{"alt_m":11194.4,"lat":43.333104,"lon":-79.856115,"t":1780920797},{"alt_m":11192.2,"lat":43.33247,"lon":-79.858799,"t":1780920798},{"alt_m":11194.7,"lat":43.331836,"lon":-79.861483,"t":1780920799},{"alt_m":11202.5,"lat":43.331202,"lon":-79.864166,"t":1780920800},{"alt_m":11203.4,"lat":43.330568,"lon":-79.86685,"t":1780920801},{"alt_m":11204.9,"lat":43.329934,"lon":-79.869534,"t":1780920802},{"alt_m":11202.3,"lat":43.3293,"lon":-79.872218,"t":1780920803},{"alt_m":11201.5,"lat":43.328666,"lon":-79.874902,"t":1780920804},{"alt_m":11198.8,"lat":43.328032,"lon":-79.877586,"t":1780920805},{"alt_m":11202.5,"lat":43.327398,"lon":-79.88027,"t":1780920806},{"alt_m":11206.2,"lat":43.326764,"lon":-79.882954,"t":1780920807},{"alt_m":11197.9,"lat":43.32613,"lon":-79.885638,"t":1780920808},{"alt_m":11197.5,"lat":43.325496,"lon":-79.888322,"t":1780920809},{"alt_m":11208.2,"lat":43.324862,"lon":-79.891006,"t":1780920810},{"alt_m":11196.0,"lat":43.324228,"lon":-79.89369,"t":1780920811},{"alt_m":11201.0,"lat":43.323595,"lon":-79.896374,"t":1780920812},{"alt_m":11201.2,"lat":43.322961,"lon":-79.899058,"t":1780920813},{"alt_m":11192.8,"lat":43.322327,"lon":-79.901742,"t":1780920814},{"alt_m":11191.6,"lat":43.321693,"lon":-79.904425,"t":1780920815},{"alt_m":11203.0,"lat":43.321059,"lon":-79.907109,"t":1780920816},{"alt_m":11204.4,"lat":43.320425,"lon":-79.909793,"t":1780920817},{"alt_m":11206.9,"lat":43.319791,"lon":-79.912477,"t":1780920818},{"alt_m":11207.0,"lat":43.319157,"lon":-79.915161,"t":1780920819},{"alt_m":11190.7,"lat":43.318523,"lon":-79.917845,"t":1780920820},{"alt_m":11199.8,"lat":43.317889,"lon":-79.920529,"t":1780920821},{"alt_m":11190.5,"lat":43.317255,"lon":-79.923213,"t":1780920822},{"alt_m":11196.0,"lat":43.316621,"lon":-79.925897,"t":1780920823},{"alt_m":11203.0,"lat":43.315987,"lon":-79.928581,"t":1780920824},{"alt_m":11191.9,"lat":43.315353,"lon":-79.931265,"t":1780920825},{"alt_m":11207.1,"lat":43.314719,"lon":-79.933949,"t":1780920826},{"alt_m":11191.9,"lat":43.314085,"lon":-79.936633,"t":1780920827},{"alt_m":11206.9,"lat":43.313451,"lon":-79.939317,"t":1780920828},{"alt_m":11195.5,"lat":43.312817,"lon":-79.942001,"t":1780920829},{"alt_m":11193.9,"lat":43.312183,"lon":-79.944685,"t":1780920830},{"alt_m":11191.8,"lat":43.311549,"lon":-79.947368,"t":1780920831},{"alt_m":11202.6,"lat":43.310915,"lon":-79.950052,"t":1780920832},{"alt_m":11197.6,"lat":43.310281,"lon":-79.952736,"t":1780920833},{"alt_m":11208.3,"lat":43.309647,"lon":-79.95542,"t":1780920834},{"alt_m":11201.7,"lat":43.309013,"lon":-79.958104,"t":1780920835},{"alt_m":11190.4,"lat":43.308379,"lon":-79.960788,"t":1780920836},{"alt_m":11195.2,"lat":43.307745,"lon":-79.963472,"t":1780920837},{"alt_m":11206.8,"lat":43.307111,"lon":-79.966156,"t":1780920838},{"alt_m":11199.1,"lat":43.306477,"lon":-79.96884,"t":1780920839}]},{"anomaly":null,"callsign":"DAL202","icao24":"a02b02","overhead":false,"points":[{"alt_m":10791.9,"lat":43.450348,"lon":-80.039409,"t":1780926000},{"alt_m":10792.4,"lat":43.450923,"lon":-80.036649,"t":1780926001},{"alt_m":10798.4,"lat":43.451499,"lon":-80.033889,"t":1780926002},{"alt_m":10801.2,"lat":43.452074,"lon":-80.031128,"t":1780926003},{"alt_m":10792.3,"lat":43.452649,"lon":-80.028368,"t":1780926004},{"alt_m":10801.2,"lat":43.453225,"lon":-80.025608,"t":1780926005},{"alt_m":10801.5,"lat":43.4538,"lon":-80.022847,"t":1780926006},{"alt_m":10805.5,"lat":43.454376,"lon":-80.020087,"t":1780926007},{"alt_m":10794.0,"lat":43.454951,"lon":-80.017327,"t":1780926008},{"alt_m":10800.5,"lat":43.455526,"lon":-80.014566,"t":1780926009},{"alt_m":10802.6,"lat":43.456102,"lon":-80.011806,"t":1780926010},{"alt_m":10791.8,"lat":43.456677,"lon":-80.009046,"t":1780926011},{"alt_m":10808.4,"lat":43.457253,"lon":-80.006285,"t":1780926012},{"alt_m":10797.1,"lat":43.457828,"lon":-80.003525,"t":1780926013},{"alt_m":10807.8,"lat":43.458404,"lon":-80.000765,"t":1780926014},{"alt_m":10792.4,"lat":43.458979,"lon":-79.998004,"t":1780926015},{"alt_m":10790.3,"lat":43.459554,"lon":-79.995244,"t":1780926016},{"alt_m":10807.2,"lat":43.46013,"lon":-79.992484,"t":1780926017},{"alt_m":10805.2,"lat":43.460705,"lon":-79.989723,"t":1780926018},{"alt_m":10794.0,"lat":43.461281,"lon":-79.986963,"t":1780926019},{"alt_m":10790.9,"lat":43.461856,"lon":-79.984203,"t":1780926020},{"alt_m":10800.8,"lat":43.462432,"lon":-79.981443,"t":1780926021},{"alt_m":10793.2,"lat":43.463007,"lon":-79.978682,"t":1780926022},{"alt_m":10793.5,"lat":43.463582,"lon":-79.975922,"t":1780926023},{"alt_m":10792.6,"lat":43.464158,"lon":-79.973162,"t":1780926024},{"alt_m":10808.9,"lat":43.464733,"lon":-79.970401,"t":1780926025},{"alt_m":10801.7,"lat":43.465309,"lon":-79.967641,"t":1780926026},{"alt_m":10797.4,"lat":43.465884,"lon":-79.964881,"t":1780926027},{"alt_m":10796.9,"lat":43.466459,"lon":-79.96212,"t":1780926028},{"alt_m":10791.6,"lat":43.467035,"lon":-79.95936,"t":1780926029},{"alt_m":10792.1,"lat":43.46761,"lon":-79.9566,"t":1780926030},{"alt_m":10803.0,"lat":43.468186,"lon":-79.953839,"t":1780926031},{"alt_m":10799.5,"lat":43.468761,"lon":-79.951079,"t":1780926032},{"alt_m":10790.5,"lat":43.469337,"lon":-79.948319,"t":1780926033},{"alt_m":10800.1,"lat":43.469912,"lon":-79.945558,"t":1780926034},{"alt_m":10804.3,"lat":43.470487,"lon":-79.942798,"t":1780926035},{"alt_m":10795.9,"lat":43.471063,"lon":-79.940038,"t":1780926036},{"alt_m":10798.8,"lat":43.471638,"lon":-79.937277,"t":1780926037},{"alt_m":10808.4,"lat":43.472214,"lon":-79.934517,"t":1780926038},{"alt_m":10801.0,"lat":43.472789,"lon":-79.931757,"t":1780926039},{"alt_m":10801.9,"lat":43.473365,"lon":-79.928996,"t":1780926040},{"alt_m":10791.0,"lat":43.47394,"lon":-79.926236,"t":1780926041},{"alt_m":10807.1,"lat":43.474515,"lon":-79.923476,"t":1780926042},{"alt_m":10800.1,"lat":43.475091,"lon":-79.920715,"t":1780926043},{"alt_m":10801.4,"lat":43.475666,"lon":-79.917955,"t":1780926044},{"alt_m":10795.2,"lat":43.476242,"lon":-79.915195,"t":1780926045},{"alt_m":10801.3,"lat":43.476817,"lon":-79.912434,"t":1780926046},{"alt_m":10794.2,"lat":43.477393,"lon":-79.909674,"t":1780926047},{"alt_m":10805.1,"lat":43.477968,"lon":-79.906914,"t":1780926048},{"alt_m":10797.9,"lat":43.478543,"lon":-79.904153,"t":1780926049},{"alt_m":10805.9,"lat":43.479119,"lon":-79.901393,"t":1780926050},{"alt_m":10796.9,"lat":43.479694,"lon":-79.898633,"t":1780926051},{"alt_m":10793.2,"lat":43.48027,"lon":-79.895872,"t":1780926052},{"alt_m":10809.0,"lat":43.480845,"lon":-79.893112,"t":1780926053},{"alt_m":10801.8,"lat":43.48142,"lon":-79.890352,"t":1780926054},{"alt_m":10802.9,"lat":43.481996,"lon":-79.887591,"t":1780926055},{"alt_m":10798.4,"lat":43.482571,"lon":-79.884831,"t":1780926056},{"alt_m":10797.6,"lat":43.483147,"lon":-79.882071,"t":1780926057},{"alt_m":10804.8,"lat":43.483722,"lon":-79.87931,"t":1780926058},{"alt_m":10803.4,"lat":43.484298,"lon":-79.87655,"t":1780926059},{"alt_m":10805.2,"lat":43.484873,"lon":-79.87379,"t":1780926060},{"alt_m":10800.4,"lat":43.485448,"lon":-79.871029,"t":1780926061},{"alt_m":10791.5,"lat":43.486024,"lon":-79.868269,"t":1780926062},{"alt_m":10801.8,"lat":43.486599,"lon":-79.865509,"t":1780926063},{"alt_m":10805.4,"lat":43.487175,"lon":-79.862748,"t":1780926064},{"alt_m":10790.2,"lat":43.48775,"lon":-79.859988,"t":1780926065},{"alt_m":10792.8,"lat":43.488326,"lon":-79.857228,"t":1780926066},{"alt_m":10790.5,"lat":43.488901,"lon":-79.854467,"t":1780926067},{"alt_m":10803.6,"lat":43.489476,"lon":-79.851707,"t":1780926068},{"alt_m":10790.2,"lat":43.490052,"lon":-79.848947,"t":1780926069},{"alt_m":10796.3,"lat":43.490627,"lon":-79.846186,"t":1780926070},{"alt_m":10790.2,"lat":43.491203,"lon":-79.843426,"t":1780926071},{"alt_m":10799.9,"lat":43.491778,"lon":-79.840666,"t":1780926072},{"alt_m":10801.3,"lat":43.492354,"lon":-79.837905,"t":1780926073},{"alt_m":10792.2,"lat":43.492929,"lon":-79.835145,"t":1780926074},{"alt_m":10804.3,"lat":43.493504,"lon":-79.832385,"t":1780926075},{"alt_m":10792.0,"lat":43.49408,"lon":-79.829625,"t":1780926076},{"alt_m":10808.0,"lat":43.494655,"lon":-79.826864,"t":1780926077},{"alt_m":10806.6,"lat":43.495231,"lon":-79.824104,"t":1780926078},{"alt_m":10808.8,"lat":43.495806,"lon":-79.821344,"t":1780926079},{"alt_m":10796.6,"lat":43.496381,"lon":-79.818583,"t":1780926080},{"alt_m":10796.2,"lat":43.496957,"lon":-79.815823,"t":1780926081},{"alt_m":10807.1,"lat":43.497532,"lon":-79.813063,"t":1780926082},{"alt_m":10796.8,"lat":43.498108,"lon":-79.810302,"t":1780926083},{"alt_m":10801.0,"lat":43.498683,"lon":-79.807542,"t":1780926084},{"alt_m":10801.6,"lat":43.499259,"lon":-79.804782,"t":1780926085},{"alt_m":10797.7,"lat":43.499834,"lon":-79.802021,"t":1780926086},{"alt_m":10805.5,"lat":43.500409,"lon":-79.799261,"t":1780926087},{"alt_m":10806.5,"lat":43.500985,"lon":-79.796501,"t":1780926088},{"alt_m":10793.0,"lat":43.50156,"lon":-79.79374,"t":1780926089},{"alt_m":10796.7,"lat":43.502136,"lon":-79.79098,"t":1780926090},{"alt_m":10801.6,"lat":43.502711,"lon":-79.78822,"t":1780926091},{"alt_m":10805.7,"lat":43.503287,"lon":-79.785459,"t":1780926092},{"alt_m":10798.9,"lat":43.503862,"lon":-79.782699,"t":1780926093},{"alt_m":10797.9,"lat":43.504437,"lon":-79.779939,"t":1780926094},{"alt_m":10799.1,"lat":43.505013,"lon":-79.777178,"t":1780926095},{"alt_m":10808.7,"lat":43.505588,"lon":-79.774418,"t":1780926096},{"alt_m":10791.8,"lat":43.506164,"lon":-79.771658,"t":1780926097},{"alt_m":10796.3,"lat":43.506739,"lon":-79.768897,"t":1780926098},{"alt_m":10809.3,"lat":43.507314,"lon":-79.766137,"t":1780926099},{"alt_m":10791.5,"lat":43.50789,"lon":-79.763377,"t":1780926100},{"alt_m":10807.6,"lat":43.508465,"lon":-79.760616,"t":1780926101},{"alt_m":10800.6,"lat":43.509041,"lon":-79.757856,"t":1780926102},{"alt_m":10800.9,"lat":43.509616,"lon":-79.755096,"t":1780926103},{"alt_m":10796.0,"lat":43.510192,"lon":-79.752335,"t":1780926104},{"alt_m":10790.1,"lat":43.510767,"lon":-79.749575,"t":1780926105},{"alt_m":10801.5,"lat":43.511342,"lon":-79.746815,"t":1780926106},{"alt_m":10791.9,"lat":43.511918,"lon":-79.744054,"t":1780926107},{"alt_m":10800.5,"lat":43.512493,"lon":-79.741294,"t":1780926108},{"alt_m":10791.1,"lat":43.513069,"lon":-79.738534,"t":1780926109},{"alt_m":10808.9,"lat":43.513644,"lon":-79.735773,"t":1780926110},{"alt_m":10804.0,"lat":43.51422,"lon":-79.733013,"t":1780926111},{"alt_m":10802.4,"lat":43.514795,"lon":-79.730253,"t":1780926112},{"alt_m":10796.4,"lat":43.51537,"lon":-79.727492,"t":1780926113},{"alt_m":10804.4,"lat":43.515946,"lon":-79.724732,"t":1780926114},{"alt_m":10803.6,"lat":43.516521,"lon":-79.721972,"t":1780926115},{"alt_m":10798.9,"lat":43.517097,"lon":-79.719211,"t":1780926116},{"alt_m":10796.1,"lat":43.517672,"lon":-79.716451,"t":1780926117},{"alt_m":10805.2,"lat":43.518248,"lon":-79.713691,"t":1780926118},{"alt_m":10803.2,"lat":43.518823,"lon":-79.71093,"t":1780926119},{"alt_m":10796.4,"lat":43.519398,"lon":-79.70817,"t":1780926120},{"alt_m":10797.6,"lat":43.519974,"lon":-79.70541,"t":1780926121},{"alt_m":10792.8,"lat":43.520549,"lon":-79.702649,"t":1780926122},{"alt_m":10790.1,"lat":43.521125,"lon":-79.699889,"t":1780926123},{"alt_m":10798.8,"lat":43.5217,"lon":-79.697129,"t":1780926124},{"alt_m":10806.8,"lat":43.522275,"lon":-79.694368,"t":1780926125},{"alt_m":10807.6,"lat":43.522851,"lon":-79.691608,"t":1780926126},{"alt_m":10801.5,"lat":43.523426,"lon":-79.688848,"t":1780926127},{"alt_m":10804.3,"lat":43.524002,"lon":-79.686087,"t":1780926128},{"alt_m":10790.7,"lat":43.524577,"lon":-79.683327,"t":1780926129},{"alt_m":10795.4,"lat":43.525153,"lon":-79.680567,"t":1780926130},{"alt_m":10796.7,"lat":43.525728,"lon":-79.677807,"t":1780926131},{"alt_m":10790.3,"lat":43.526303,"lon":-79.675046,"t":1780926132},{"alt_m":10805.0,"lat":43.526879,"lon":-79.672286,"t":1780926133},{"alt_m":10807.4,"lat":43.527454,"lon":-79.669526,"t":1780926134},{"alt_m":10796.5,"lat":43.52803,"lon":-79.666765,"t":1780926135},{"alt_m":10795.5,"lat":43.528605,"lon":-79.664005,"t":1780926136},{"alt_m":10801.8,"lat":43.529181,"lon":-79.661245,"t":1780926137},{"alt_m":10791.1,"lat":43.529756,"lon":-79.658484,"t":1780926138},{"alt_m":10797.7,"lat":43.530331,"lon":-79.655724,"t":1780926139},{"alt_m":10803.4,"lat":43.530907,"lon":-79.652964,"t":1780926140},{"alt_m":10791.1,"lat":43.531482,"lon":-79.650203,"t":1780926141},{"alt_m":10804.5,"lat":43.532058,"lon":-79.647443,"t":1780926142},{"alt_m":10793.1,"lat":43.532633,"lon":-79.644683,"t":1780926143},{"alt_m":10798.0,"lat":43.533209,"lon":-79.641922,"t":1780926144},{"alt_m":10794.3,"lat":43.533784,"lon":-79.639162,"t":1780926145},{"alt_m":10802.6,"lat":43.534359,"lon":-79.636402,"t":1780926146},{"alt_m":10791.0,"lat":43.534935,"lon":-79.633641,"t":1780926147},{"alt_m":10795.6,"lat":43.53551,"lon":-79.630881,"t":1780926148},{"alt_m":10807.5,"lat":43.536086,"lon":-79.628121,"t":1780926149},{"alt_m":10805.6,"lat":43.536661,"lon":-79.62536,"t":1780926150},{"alt_m":10800.4,"lat":43.537236,"lon":-79.6226,"t":1780926151},{"alt_m":10796.4,"lat":43.537812,"lon":-79.61984,"t":1780926152},{"alt_m":10794.0,"lat":43.538387,"lon":-79.617079,"t":1780926153},{"alt_m":10793.7,"lat":43.538963,"lon":-79.614319,"t":1780926154},{"alt_m":10793.1,"lat":43.539538,"lon":-79.611559,"t":1780926155},{"alt_m":10809.8,"lat":43.540114,"lon":-79.608798,"t":1780926156},{"alt_m":10798.1,"lat":43.540689,"lon":-79.606038,"t":1780926157},{"alt_m":10808.7,"lat":43.541264,"lon":-79.603278,"t":1780926158},{"alt_m":10791.8,"lat":43.54184,"lon":-79.600517,"t":1780926159},{"alt_m":10802.7,"lat":43.542415,"lon":-79.597757,"t":1780926160},{"alt_m":10790.5,"lat":43.542991,"lon":-79.594997,"t":1780926161},{"alt_m":10807.0,"lat":43.543566,"lon":-79.592236,"t":1780926162},{"alt_m":10790.8,"lat":43.544142,"lon":-79.589476,"t":1780926163},{"alt_m":10791.0,"lat":43.544717,"lon":-79.586716,"t":1780926164},{"alt_m":10796.9,"lat":43.545292,"lon":-79.583955,"t":1780926165},{"alt_m":10796.1,"lat":43.545868,"lon":-79.581195,"t":1780926166},{"alt_m":10791.8,"lat":43.546443,"lon":-79.578435,"t":1780926167},{"alt_m":10797.4,"lat":43.547019,"lon":-79.575674,"t":1780926168},{"alt_m":10801.6,"lat":43.547594,"lon":-79.572914,"t":1780926169},{"alt_m":10804.0,"lat":43.54817,"lon":-79.570154,"t":1780926170},{"alt_m":10793.2,"lat":43.548745,"lon":-79.567393,"t":1780926171},{"alt_m":10791.8,"lat":43.54932,"lon":-79.564633,"t":1780926172},{"alt_m":10807.5,"lat":43.549896,"lon":-79.561873,"t":1780926173},{"alt_m":10809.0,"lat":43.550471,"lon":-79.559112,"t":1780926174},{"alt_m":10804.0,"lat":43.551047,"lon":-79.556352,"t":1780926175},{"alt_m":10802.7,"lat":43.551622,"lon":-79.553592,"t":1780926176},{"alt_m":10799.1,"lat":43.552197,"lon":-79.550831,"t":1780926177},{"alt_m":10806.1,"lat":43.552773,"lon":-79.548071,"t":1780926178},{"alt_m":10799.0,"lat":43.553348,"lon":-79.545311,"t":1780926179},{"alt_m":10805.5,"lat":43.553924,"lon":-79.54255,"t":1780926180},{"alt_m":10791.4,"lat":43.554499,"lon":-79.53979,"t":1780926181},{"alt_m":10806.6,"lat":43.555075,"lon":-79.53703,"t":1780926182},{"alt_m":10805.8,"lat":43.55565,"lon":-79.534269,"t":1780926183},{"alt_m":10803.3,"lat":43.556225,"lon":-79.531509,"t":1780926184},{"alt_m":10794.2,"lat":43.556801,"lon":-79.528749,"t":1780926185},{"alt_m":10804.4,"lat":43.557376,"lon":-79.525989,"t":1780926186},{"alt_m":10801.3,"lat":43.557952,"lon":-79.523228,"t":1780926187},{"alt_m":10793.7,"lat":43.558527,"lon":-79.520468,"t":1780926188},{"alt_m":10792.0,"lat":43.559103,"lon":-79.517708,"t":1780926189},{"alt_m":10800.8,"lat":43.559678,"lon":-79.514947,"t":1780926190},{"alt_m":10798.5,"lat":43.560253,"lon":-79.512187,"t":1780926191},{"alt_m":10804.7,"lat":43.560829,"lon":-79.509427,"t":1780926192},{"alt_m":10807.7,"lat":43.561404,"lon":-79.506666,"t":1780926193},{"alt_m":10799.1,"lat":43.56198,"lon":-79.503906,"t":1780926194},{"alt_m":10796.0,"lat":43.562555,"lon":-79.501146,"t":1780926195},{"alt_m":10805.6,"lat":43.56313,"lon":-79.498385,"t":1780926196},{"alt_m":10791.5,"lat":43.563706,"lon":-79.495625,"t":1780926197},{"alt_m":10803.4,"lat":43.564281,"lon":-79.492865,"t":1780926198},{"alt_m":10794.1,"lat":43.564857,"lon":-79.490104,"t":1780926199},{"alt_m":10800.4,"lat":43.565432,"lon":-79.487344,"t":1780926200},{"alt_m":10804.3,"lat":43.566008,"lon":-79.484584,"t":1780926201},{"alt_m":10807.3,"lat":43.566583,"lon":-79.481823,"t":1780926202},{"alt_m":10807.7,"lat":43.567158,"lon":-79.479063,"t":1780926203},{"alt_m":10809.4,"lat":43.567734,"lon":-79.476303,"t":1780926204},{"alt_m":10807.1,"lat":43.568309,"lon":-79.473542,"t":1780926205},{"alt_m":10797.3,"lat":43.568885,"lon":-79.470782,"t":1780926206},{"alt_m":10797.9,"lat":43.56946,"lon":-79.468022,"t":1780926207},{"alt_m":10804.2,"lat":43.570036,"lon":-79.465261,"t":1780926208},{"alt_m":10798.1,"lat":43.570611,"lon":-79.462501,"t":1780926209},{"alt_m":10800.5,"lat":43.571186,"lon":-79.459741,"t":1780926210},{"alt_m":10808.0,"lat":43.571762,"lon":-79.45698,"t":1780926211},{"alt_m":10808.2,"lat":43.572337,"lon":-79.45422,"t":1780926212},{"alt_m":10802.1,"lat":43.572913,"lon":-79.45146,"t":1780926213},{"alt_m":10801.0,"lat":43.573488,"lon":-79.448699,"t":1780926214},{"alt_m":10805.4,"lat":43.574064,"lon":-79.445939,"t":1780926215},{"alt_m":10807.2,"lat":43.574639,"lon":-79.443179,"t":1780926216},{"alt_m":10799.0,"lat":43.575214,"lon":-79.440418,"t":1780926217},{"alt_m":10800.0,"lat":43.57579,"lon":-79.437658,"t":1780926218},{"alt_m":10804.2,"lat":43.576365,"lon":-79.434898,"t":1780926219},{"alt_m":10803.6,"lat":43.576941,"lon":-79.432137,"t":1780926220},{"alt_m":10804.0,"lat":43.577516,"lon":-79.429377,"t":1780926221},{"alt_m":10803.6,"lat":43.578091,"lon":-79.426617,"t":1780926222},{"alt_m":10793.3,"lat":43.578667,"lon":-79.423856,"t":1780926223},{"alt_m":10809.1,"lat":43.579242,"lon":-79.421096,"t":1780926224},{"alt_m":10802.1,"lat":43.579818,"lon":-79.418336,"t":1780926225},{"alt_m":10808.2,"lat":43.580393,"lon":-79.415575,"t":1780926226},{"alt_m":10804.5,"lat":43.580969,"lon":-79.412815,"t":1780926227},{"alt_m":10806.2,"lat":43.581544,"lon":-79.410055,"t":1780926228},{"alt_m":10799.2,"lat":43.582119,"lon":-79.407294,"t":1780926229},{"alt_m":10795.8,"lat":43.582695,"lon":-79.404534,"t":1780926230},{"alt_m":10805.2,"lat":43.58327,"lon":-79.401774,"t":1780926231},{"alt_m":10808.5,"lat":43.583846,"lon":-79.399013,"t":1780926232},{"alt_m":10809.3,"lat":43.584421,"lon":-79.396253,"t":1780926233},{"alt_m":10794.2,"lat":43.584997,"lon":-79.393493,"t":1780926234},{"alt_m":10806.3,"lat":43.585572,"lon":-79.390732,"t":1780926235},{"alt_m":10792.2,"lat":43.586147,"lon":-79.387972,"t":1780926236},{"alt_m":10794.3,"lat":43.586723,"lon":-79.385212,"t":1780926237},{"alt_m":10798.3,"lat":43.587298,"lon":-79.382451,"t":1780926238},{"alt_m":10802.8,"lat":43.587874,"lon":-79.379691,"t":1780926239}]},{"anomaly":null,"callsign":"JZA707","icao24":"c07e07","overhead":true,"points":[{"alt_m":4795.2,"lat":43.272916,"lon":-79.767379,"t":1780928100},{"alt_m":4790.5,"lat":43.274022,"lon":-79.766428,"t":1780928101},{"alt_m":4782.2,"lat":43.275129,"lon":-79.765477,"t":1780928102},{"alt_m":4771.3,"lat":43.276235,"lon":-79.764526,"t":1780928103},{"alt_m":4764.5,"lat":43.277342,"lon":-79.763575,"t":1780928104},{"alt_m":4759.2,"lat":43.278448,"lon":-79.762624,"t":1780928105},{"alt_m":4756.1,"lat":43.279554,"lon":-79.761673,"t":1780928106},{"alt_m":4750.4,"lat":43.280661,"lon":-79.760722,"t":1780928107},{"alt_m":4738.6,"lat":43.281767,"lon":-79.759771,"t":1780928108},{"alt_m":4734.4,"lat":43.282874,"lon":-79.75882,"t":1780928109},{"alt_m":4719.6,"lat":43.28398,"lon":-79.757869,"t":1780928110},{"alt_m":4710.9,"lat":43.285087,"lon":-79.756918,"t":1780928111},{"alt_m":4709.1,"lat":43.286193,"lon":-79.755967,"t":1780928112},{"alt_m":4710.6,"lat":43.2873,"lon":-79.755015,"t":1780928113},{"alt_m":4693.6,"lat":43.288406,"lon":-79.754064,"t":1780928114},{"alt_m":4695.3,"lat":43.289513,"lon":-79.753113,"t":1780928115},{"alt_m":4689.0,"lat":43.290619,"lon":-79.752162,"t":1780928116},{"alt_m":4679.2,"lat":43.291726,"lon":-79.751211,"t":1780928117},{"alt_m":4668.5,"lat":43.292832,"lon":-79.75026,"t":1780928118},{"alt_m":4661.2,"lat":43.293939,"lon":-79.749309,"t":1780928119},{"alt_m":4653.4,"lat":43.295045,"lon":-79.748358,"t":1780928120},{"alt_m":4642.8,"lat":43.296152,"lon":-79.747407,"t":1780928121},{"alt_m":4628.5,"lat":43.297258,"lon":-79.746456,"t":1780928122},{"alt_m":4618.5,"lat":43.298365,"lon":-79.745505,"t":1780928123},{"alt_m":4615.2,"lat":43.299471,"lon":-79.744554,"t":1780928124},{"alt_m":4604.7,"lat":43.300578,"lon":-79.743603,"t":1780928125},{"alt_m":4609.8,"lat":43.301684,"lon":-79.742652,"t":1780928126},{"alt_m":4594.7,"lat":43.302791,"lon":-79.741701,"t":1780928127},{"alt_m":4582.8,"lat":43.303897,"lon":-79.74075,"t":1780928128},{"alt_m":4573.3,"lat":43.305004,"lon":-79.739798,"t":1780928129},{"alt_m":4576.6,"lat":43.30611,"lon":-79.738847,"t":1780928130},{"alt_m":4561.9,"lat":43.307217,"lon":-79.737896,"t":1780928131},{"alt_m":4559.7,"lat":43.308323,"lon":-79.736945,"t":1780928132},{"alt_m":4556.8,"lat":43.30943,"lon":-79.735994,"t":1780928133},{"alt_m":4542.9,"lat":43.310536,"lon":-79.735043,"t":1780928134},{"alt_m":4543.4,"lat":43.311643,"lon":-79.734092,"t":1780928135},{"alt_m":4530.9,"lat":43.312749,"lon":-79.733141,"t":1780928136},{"alt_m":4513.1,"lat":43.313856,"lon":-79.73219,"t":1780928137},{"alt_m":4524.5,"lat":43.314962,"lon":-79.731239,"t":1780928138},{"alt_m":4508.0,"lat":43.316069,"lon":-79.730288,"t":1780928139},{"alt_m":4504.3,"lat":43.317175,"lon":-79.729337,"t":1780928140},{"alt_m":4498.6,"lat":43.318282,"lon":-79.728386,"t":1780928141},{"alt_m":4476.6,"lat":43.319388,"lon":-79.727435,"t":1780928142},{"alt_m":4471.9,"lat":43.320495,"lon":-79.726484,"t":1780928143},{"alt_m":4480.0,"lat":43.321601,"lon":-79.725533,"t":1780928144},{"alt_m":4460.8,"lat":43.322708,"lon":-79.724581,"t":1780928145},{"alt_m":4449.6,"lat":43.323814,"lon":-79.72363,"t":1780928146},{"alt_m":4447.8,"lat":43.324921,"lon":-79.722679,"t":1780928147},{"alt_m":4443.7,"lat":43.326027,"lon":-79.721728,"t":1780928148},{"alt_m":4428.1,"lat":43.327134,"lon":-79.720777,"t":1780928149},{"alt_m":4418.5,"lat":43.32824,"lon":-79.719826,"t":1780928150},{"alt_m":4419.4,"lat":43.329347,"lon":-79.718875,"t":1780928151},{"alt_m":4401.2,"lat":43.330453,"lon":-79.717924,"t":1780928152},{"alt_m":4401.0,"lat":43.33156,"lon":-79.716973,"t":1780928153},{"alt_m":4389.9,"lat":43.332666,"lon":-79.716022,"t":1780928154},{"alt_m":4379.4,"lat":43.333773,"lon":-79.715071,"t":1780928155},{"alt_m":4385.2,"lat":43.334879,"lon":-79.71412,"t":1780928156},{"alt_m":4378.1,"lat":43.335986,"lon":-79.713169,"t":1780928157},{"alt_m":4358.4,"lat":43.337092,"lon":-79.712218,"t":1780928158},{"alt_m":4352.9,"lat":43.338199,"lon":-79.711267,"t":1780928159},{"alt_m":4345.4,"lat":43.339305,"lon":-79.710316,"t":1780928160},{"alt_m":4337.8,"lat":43.340412,"lon":-79.709364,"t":1780928161},{"alt_m":4340.6,"lat":43.341518,"lon":-79.708413,"t":1780928162},{"alt_m":4334.5,"lat":43.342625,"lon":-79.707462,"t":1780928163},{"alt_m":4317.2,"lat":43.343731,"lon":-79.706511,"t":1780928164},{"alt_m":4318.3,"lat":43.344838,"lon":-79.70556,"t":1780928165},{"alt_m":4301.2,"lat":43.345944,"lon":-79.704609,"t":1780928166},{"alt_m":4296.3,"lat":43.347051,"lon":-79.703658,"t":1780928167},{"alt_m":4299.0,"lat":43.348157,"lon":-79.702707,"t":1780928168},{"alt_m":4277.0,"lat":43.349264,"lon":-79.701756,"t":1780928169},{"alt_m":4277.0,"lat":43.35037,"lon":-79.700805,"t":1780928170},{"alt_m":4274.3,"lat":43.351477,"lon":-79.699854,"t":1780928171},{"alt_m":4261.3,"lat":43.352583,"lon":-79.698903,"t":1780928172},{"alt_m":4248.8,"lat":43.35369,"lon":-79.697952,"t":1780928173},{"alt_m":4250.1,"lat":43.354796,"lon":-79.697001,"t":1780928174},{"alt_m":4235.2,"lat":43.355903,"lon":-79.69605,"t":1780928175},{"alt_m":4230.4,"lat":43.357009,"lon":-79.695099,"t":1780928176},{"alt_m":4214.9,"lat":43.358116,"lon":-79.694147,"t":1780928177},{"alt_m":4207.0,"lat":43.359222,"lon":-79.693196,"t":1780928178},{"alt_m":4200.2,"lat":43.360329,"lon":-79.692245,"t":1780928179},{"alt_m":4208.6,"lat":43.361435,"lon":-79.691294,"t":1780928180},{"alt_m":4183.3,"lat":43.362542,"lon":-79.690343,"t":1780928181},{"alt_m":4194.8,"lat":43.363648,"lon":-79.689392,"t":1780928182},{"alt_m":4181.2,"lat":43.364755,"lon":-79.688441,"t":1780928183},{"alt_m":4168.8,"lat":43.365861,"lon":-79.68749,"t":1780928184},{"alt_m":4167.4,"lat":43.366968,"lon":-79.686539,"t":1780928185},{"alt_m":4163.1,"lat":43.368074,"lon":-79.685588,"t":1780928186},{"alt_m":4151.5,"lat":43.369181,"lon":-79.684637,"t":1780928187},{"alt_m":4149.8,"lat":43.370287,"lon":-79.683686,"t":1780928188},{"alt_m":4132.9,"lat":43.371394,"lon":-79.682735,"t":1780928189},{"alt_m":4130.1,"lat":43.3725,"lon":-79.681784,"t":1780928190},{"alt_m":4119.2,"lat":43.373607,"lon":-79.680833,"t":1780928191},{"alt_m":4110.8,"lat":43.374713,"lon":-79.679882,"t":1780928192},{"alt_m":4103.4,"lat":43.37582,"lon":-79.678931,"t":1780928193},{"alt_m":4101.0,"lat":43.376926,"lon":-79.677979,"t":1780928194},{"alt_m":4089.4,"lat":43.378033,"lon":-79.677028,"t":1780928195},{"alt_m":4086.0,"lat":43.379139,"lon":-79.676077,"t":1780928196},{"alt_m":4064.2,"lat":43.380246,"lon":-79.675126,"t":1780928197},{"alt_m":4068.0,"lat":43.381352,"lon":-79.674175,"t":1780928198},{"alt_m":4063.1,"lat":43.382459,"lon":-79.673224,"t":1780928199},{"alt_m":4041.5,"lat":43.383565,"lon":-79.672273,"t":1780928200},{"alt_m":4042.8,"lat":43.384671,"lon":-79.671322,"t":1780928201},{"alt_m":4042.8,"lat":43.385778,"lon":-79.670371,"t":1780928202},{"alt_m":4018.9,"lat":43.386884,"lon":-79.66942,"t":1780928203},{"alt_m":4013.7,"lat":43.387991,"lon":-79.668469,"t":1780928204},{"alt_m":4010.8,"lat":43.389097,"lon":-79.667518,"t":1780928205},{"alt_m":4009.1,"lat":43.390204,"lon":-79.666567,"t":1780928206},{"alt_m":3993.0,"lat":43.39131,"lon":-79.665616,"t":1780928207},{"alt_m":3988.3,"lat":43.392417,"lon":-79.664665,"t":1780928208},{"alt_m":3976.2,"lat":43.393523,"lon":-79.663714,"t":1780928209},{"alt_m":3969.4,"lat":43.39463,"lon":-79.662762,"t":1780928210},{"alt_m":3969.9,"lat":43.395736,"lon":-79.661811,"t":1780928211},{"alt_m":3953.1,"lat":43.396843,"lon":-79.66086,"t":1780928212},{"alt_m":3958.3,"lat":43.397949,"lon":-79.659909,"t":1780928213},{"alt_m":3949.4,"lat":43.399056,"lon":-79.658958,"t":1780928214},{"alt_m":3931.3,"lat":43.400162,"lon":-79.658007,"t":1780928215},{"alt_m":3932.8,"lat":43.401269,"lon":-79.657056,"t":1780928216},{"alt_m":3923.1,"lat":43.402375,"lon":-79.656105,"t":1780928217},{"alt_m":3916.7,"lat":43.403482,"lon":-79.655154,"t":1780928218},{"alt_m":3910.9,"lat":43.404588,"lon":-79.654203,"t":1780928219},{"alt_m":3902.7,"lat":43.405695,"lon":-79.653252,"t":1780928220},{"alt_m":3900.7,"lat":43.406801,"lon":-79.652301,"t":1780928221},{"alt_m":3890.7,"lat":43.407908,"lon":-79.65135,"t":1780928222},{"alt_m":3870.3,"lat":43.409014,"lon":-79.650399,"t":1780928223},{"alt_m":3866.4,"lat":43.410121,"lon":-79.649448,"t":1780928224},{"alt_m":3855.9,"lat":43.411227,"lon":-79.648497,"t":1780928225},{"alt_m":3855.8,"lat":43.412334,"lon":-79.647545,"t":1780928226},{"alt_m":3852.6,"lat":43.41344,"lon":-79.646594,"t":1780928227},{"alt_m":3841.8,"lat":43.414547,"lon":-79.645643,"t":1780928228},{"alt_m":3839.0,"lat":43.415653,"lon":-79.644692,"t":1780928229},{"alt_m":3828.1,"lat":43.41676,"lon":-79.643741,"t":1780928230},{"alt_m":3826.2,"lat":43.417866,"lon":-79.64279,"t":1780928231},{"alt_m":3817.0,"lat":43.418973,"lon":-79.641839,"t":1780928232},{"alt_m":3794.9,"lat":43.420079,"lon":-79.640888,"t":1780928233},{"alt_m":3802.9,"lat":43.421186,"lon":-79.639937,"t":1780928234},{"alt_m":3782.2,"lat":43.422292,"lon":-79.638986,"t":1780928235},{"alt_m":3783.0,"lat":43.423399,"lon":-79.638035,"t":1780928236},{"alt_m":3770.4,"lat":43.424505,"lon":-79.637084,"t":1780928237},{"alt_m":3773.3,"lat":43.425612,"lon":-79.636133,"t":1780928238},{"alt_m":3760.4,"lat":43.426718,"lon":-79.635182,"t":1780928239},{"alt_m":3748.2,"lat":43.427825,"lon":-79.634231,"t":1780928240},{"alt_m":3752.3,"lat":43.428931,"lon":-79.63328,"t":1780928241},{"alt_m":3729.5,"lat":43.430038,"lon":-79.632328,"t":1780928242},{"alt_m":3718.7,"lat":43.431144,"lon":-79.631377,"t":1780928243},{"alt_m":3711.7,"lat":43.432251,"lon":-79.630426,"t":1780928244},{"alt_m":3710.1,"lat":43.433357,"lon":-79.629475,"t":1780928245},{"alt_m":3699.5,"lat":43.434464,"lon":-79.628524,"t":1780928246},{"alt_m":3695.3,"lat":43.43557,"lon":-79.627573,"t":1780928247},{"alt_m":3697.7,"lat":43.436677,"lon":-79.626622,"t":1780928248},{"alt_m":3682.5,"lat":43.437783,"lon":-79.625671,"t":1780928249},{"alt_m":3667.1,"lat":43.43889,"lon":-79.62472,"t":1780928250},{"alt_m":3662.5,"lat":43.439996,"lon":-79.623769,"t":1780928251},{"alt_m":3653.4,"lat":43.441103,"lon":-79.622818,"t":1780928252},{"alt_m":3650.7,"lat":43.442209,"lon":-79.621867,"t":1780928253},{"alt_m":3638.4,"lat":43.443316,"lon":-79.620916,"t":1780928254},{"alt_m":3638.0,"lat":43.444422,"lon":-79.619965,"t":1780928255},{"alt_m":3632.0,"lat":43.445529,"lon":-79.619014,"t":1780928256},{"alt_m":3626.3,"lat":43.446635,"lon":-79.618063,"t":1780928257},{"alt_m":3624.5,"lat":43.447742,"lon":-79.617112,"t":1780928258},{"alt_m":3608.6,"lat":43.448848,"lon":-79.61616,"t":1780928259},{"alt_m":3595.2,"lat":43.449955,"lon":-79.615209,"t":1780928260},{"alt_m":3598.9,"lat":43.451061,"lon":-79.614258,"t":1780928261},{"alt_m":3590.2,"lat":43.452168,"lon":-79.613307,"t":1780928262},{"alt_m":3575.2,"lat":43.453274,"lon":-79.612356,"t":1780928263},{"alt_m":3567.4,"lat":43.454381,"lon":-79.611405,"t":1780928264},{"alt_m":3566.5,"lat":43.455487,"lon":-79.610454,"t":1780928265},{"alt_m":3555.6,"lat":43.456594,"lon":-79.609503,"t":1780928266},{"alt_m":3543.6,"lat":43.4577,"lon":-79.608552,"t":1780928267},{"alt_m":3544.5,"lat":43.458807,"lon":-79.607601,"t":1780928268},{"alt_m":3529.4,"lat":43.459913,"lon":-79.60665,"t":1780928269},{"alt_m":3516.6,"lat":43.46102,"lon":-79.605699,"t":1780928270},{"alt_m":3511.0,"lat":43.462126,"lon":-79.604748,"t":1780928271},{"alt_m":3519.2,"lat":43.463233,"lon":-79.603797,"t":1780928272},{"alt_m":3512.1,"lat":43.464339,"lon":-79.602846,"t":1780928273},{"alt_m":3498.9,"lat":43.465446,"lon":-79.601895,"t":1780928274},{"alt_m":3482.2,"lat":43.466552,"lon":-79.600943,"t":1780928275},{"alt_m":3489.1,"lat":43.467659,"lon":-79.599992,"t":1780928276},{"alt_m":3479.5,"lat":43.468765,"lon":-79.599041,"t":1780928277},{"alt_m":3459.5,"lat":43.469872,"lon":-79.59809,"t":1780928278},{"alt_m":3467.2,"lat":43.470978,"lon":-79.597139,"t":1780928279},{"alt_m":3441.5,"lat":43.472085,"lon":-79.596188,"t":1780928280},{"alt_m":3451.4,"lat":43.473191,"lon":-79.595237,"t":1780928281},{"alt_m":3433.7,"lat":43.474298,"lon":-79.594286,"t":1780928282},{"alt_m":3419.2,"lat":43.475404,"lon":-79.593335,"t":1780928283},{"alt_m":3423.6,"lat":43.476511,"lon":-79.592384,"t":1780928284},{"alt_m":3408.5,"lat":43.477617,"lon":-79.591433,"t":1780928285},{"alt_m":3401.7,"lat":43.478724,"lon":-79.590482,"t":1780928286},{"alt_m":3398.3,"lat":43.47983,"lon":-79.589531,"t":1780928287},{"alt_m":3392.8,"lat":43.480937,"lon":-79.58858,"t":1780928288},{"alt_m":3386.1,"lat":43.482043,"lon":-79.587629,"t":1780928289},{"alt_m":3380.5,"lat":43.48315,"lon":-79.586678,"t":1780928290},{"alt_m":3365.1,"lat":43.484256,"lon":-79.585726,"t":1780928291},{"alt_m":3355.3,"lat":43.485363,"lon":-79.584775,"t":1780928292},{"alt_m":3347.0,"lat":43.486469,"lon":-79.583824,"t":1780928293},{"alt_m":3335.3,"lat":43.487576,"lon":-79.582873,"t":1780928294},{"alt_m":3341.9,"lat":43.488682,"lon":-79.581922,"t":1780928295},{"alt_m":3330.8,"lat":43.489788,"lon":-79.580971,"t":1780928296},{"alt_m":3326.8,"lat":43.490895,"lon":-79.58002,"t":1780928297},{"alt_m":3309.7,"lat":43.492001,"lon":-79.579069,"t":1780928298},{"alt_m":3312.7,"lat":43.493108,"lon":-79.578118,"t":1780928299},{"alt_m":3307.4,"lat":43.494214,"lon":-79.577167,"t":1780928300},{"alt_m":3300.6,"lat":43.495321,"lon":-79.576216,"t":1780928301},{"alt_m":3276.8,"lat":43.496427,"lon":-79.575265,"t":1780928302},{"alt_m":3277.9,"lat":43.497534,"lon":-79.574314,"t":1780928303},{"alt_m":3265.0,"lat":43.49864,"lon":-79.573363,"t":1780928304},{"alt_m":3256.5,"lat":43.499747,"lon":-79.572412,"t":1780928305},{"alt_m":3247.2,"lat":43.500853,"lon":-79.571461,"t":1780928306},{"alt_m":3239.2,"lat":43.50196,"lon":-79.570509,"t":1780928307},{"alt_m":3231.0,"lat":43.503066,"lon":-79.569558,"t":1780928308},{"alt_m":3232.6,"lat":43.504173,"lon":-79.568607,"t":1780928309},{"alt_m":3217.9,"lat":43.505279,"lon":-79.567656,"t":1780928310},{"alt_m":3225.3,"lat":43.506386,"lon":-79.566705,"t":1780928311},{"alt_m":3205.6,"lat":43.507492,"lon":-79.565754,"t":1780928312},{"alt_m":3209.3,"lat":43.508599,"lon":-79.564803,"t":1780928313},{"alt_m":3199.1,"lat":43.509705,"lon":-79.563852,"t":1780928314},{"alt_m":3195.6,"lat":43.510812,"lon":-79.562901,"t":1780928315},{"alt_m":3178.3,"lat":43.511918,"lon":-79.56195,"t":1780928316},{"alt_m":3180.9,"lat":43.513025,"lon":-79.560999,"t":1780928317},{"alt_m":3166.0,"lat":43.514131,"lon":-79.560048,"t":1780928318},{"alt_m":3161.0,"lat":43.515238,"lon":-79.559097,"t":1780928319},{"alt_m":3150.1,"lat":43.516344,"lon":-79.558146,"t":1780928320},{"alt_m":3133.8,"lat":43.517451,"lon":-79.557195,"t":1780928321},{"alt_m":3126.0,"lat":43.518557,"lon":-79.556244,"t":1780928322},{"alt_m":3118.6,"lat":43.519664,"lon":-79.555292,"t":1780928323},{"alt_m":3118.6,"lat":43.52077,"lon":-79.554341,"t":1780928324},{"alt_m":3122.3,"lat":43.521877,"lon":-79.55339,"t":1780928325},{"alt_m":3097.4,"lat":43.522983,"lon":-79.552439,"t":1780928326},{"alt_m":3092.0,"lat":43.52409,"lon":-79.551488,"t":1780928327},{"alt_m":3086.6,"lat":43.525196,"lon":-79.550537,"t":1780928328},{"alt_m":3076.7,"lat":43.526303,"lon":-79.549586,"t":1780928329},{"alt_m":3078.1,"lat":43.527409,"lon":-79.548635,"t":1780928330},{"alt_m":3058.9,"lat":43.528516,"lon":-79.547684,"t":1780928331},{"alt_m":3054.5,"lat":43.529622,"lon":-79.546733,"t":1780928332},{"alt_m":3057.8,"lat":43.530729,"lon":-79.545782,"t":1780928333},{"alt_m":3037.1,"lat":43.531835,"lon":-79.544831,"t":1780928334},{"alt_m":3039.9,"lat":43.532942,"lon":-79.54388,"t":1780928335},{"alt_m":3032.7,"lat":43.534048,"lon":-79.542929,"t":1780928336},{"alt_m":3027.4,"lat":43.535155,"lon":-79.541978,"t":1780928337},{"alt_m":3005.5,"lat":43.536261,"lon":-79.541027,"t":1780928338},{"alt_m":3004.8,"lat":43.537368,"lon":-79.540076,"t":1780928339},{"alt_m":2997.4,"lat":43.538474,"lon":-79.539124,"t":1780928340},{"alt_m":2988.1,"lat":43.539581,"lon":-79.538173,"t":1780928341},{"alt_m":2978.1,"lat":43.540687,"lon":-79.537222,"t":1780928342},{"alt_m":2974.7,"lat":43.541794,"lon":-79.536271,"t":1780928343},{"alt_m":2965.6,"lat":43.5429,"lon":-79.53532,"t":1780928344},{"alt_m":2953.9,"lat":43.544007,"lon":-79.534369,"t":1780928345},{"alt_m":2948.6,"lat":43.545113,"lon":-79.533418,"t":1780928346},{"alt_m":2949.2,"lat":43.54622,"lon":-79.532467,"t":1780928347},{"alt_m":2941.8,"lat":43.547326,"lon":-79.531516,"t":1780928348},{"alt_m":2935.5,"lat":43.548433,"lon":-79.530565,"t":1780928349},{"alt_m":2921.9,"lat":43.549539,"lon":-79.529614,"t":1780928350},{"alt_m":2913.4,"lat":43.550646,"lon":-79.528663,"t":1780928351},{"alt_m":2910.9,"lat":43.551752,"lon":-79.527712,"t":1780928352},{"alt_m":2905.6,"lat":43.552859,"lon":-79.526761,"t":1780928353},{"alt_m":2892.9,"lat":43.553965,"lon":-79.52581,"t":1780928354},{"alt_m":2894.6,"lat":43.555072,"lon":-79.524859,"t":1780928355},{"alt_m":2889.9,"lat":43.556178,"lon":-79.523907,"t":1780928356},{"alt_m":2882.1,"lat":43.557285,"lon":-79.522956,"t":1780928357},{"alt_m":2862.4,"lat":43.558391,"lon":-79.522005,"t":1780928358},{"alt_m":2862.7,"lat":43.559498,"lon":-79.521054,"t":1780928359},{"alt_m":2851.3,"lat":43.560604,"lon":-79.520103,"t":1780928360},{"alt_m":2848.3,"lat":43.561711,"lon":-79.519152,"t":1780928361},{"alt_m":2836.9,"lat":43.562817,"lon":-79.518201,"t":1780928362},{"alt_m":2829.7,"lat":43.563924,"lon":-79.51725,"t":1780928363},{"alt_m":2820.2,"lat":43.56503,"lon":-79.516299,"t":1780928364},{"alt_m":2812.1,"lat":43.566137,"lon":-79.515348,"t":1780928365},{"alt_m":2798.0,"lat":43.567243,"lon":-79.514397,"t":1780928366},{"alt_m":2790.4,"lat":43.56835,"lon":-79.513446,"t":1780928367},{"alt_m":2799.7,"lat":43.569456,"lon":-79.512495,"t":1780928368},{"alt_m":2789.9,"lat":43.570563,"lon":-79.511544,"t":1780928369},{"alt_m":2772.5,"lat":43.571669,"lon":-79.510593,"t":1780928370},{"alt_m":2759.3,"lat":43.572776,"lon":-79.509642,"t":1780928371},{"alt_m":2762.2,"lat":43.573882,"lon":-79.50869,"t":1780928372},{"alt_m":2760.1,"lat":43.574989,"lon":-79.507739,"t":1780928373},{"alt_m":2747.4,"lat":43.576095,"lon":-79.506788,"t":1780928374},{"alt_m":2731.8,"lat":43.577202,"lon":-79.505837,"t":1780928375},{"alt_m":2724.8,"lat":43.578308,"lon":-79.504886,"t":1780928376},{"alt_m":2717.3,"lat":43.579415,"lon":-79.503935,"t":1780928377},{"alt_m":2707.5,"lat":43.580521,"lon":-79.502984,"t":1780928378},{"alt_m":2715.3,"lat":43.581628,"lon":-79.502033,"t":1780928379},{"alt_m":2697.4,"lat":43.582734,"lon":-79.501082,"t":1780928380},{"alt_m":2690.1,"lat":43.583841,"lon":-79.500131,"t":1780928381},{"alt_m":2677.2,"lat":43.584947,"lon":-79.49918,"t":1780928382},{"alt_m":2672.0,"lat":43.586054,"lon":-79.498229,"t":1780928383},{"alt_m":2673.8,"lat":43.58716,"lon":-79.497278,"t":1780928384},{"alt_m":2661.6,"lat":43.588267,"lon":-79.496327,"t":1780928385},{"alt_m":2650.4,"lat":43.589373,"lon":-79.495376,"t":1780928386},{"alt_m":2652.8,"lat":43.59048,"lon":-79.494425,"t":1780928387},{"alt_m":2643.6,"lat":43.591586,"lon":-79.493473,"t":1780928388},{"alt_m":2626.0,"lat":43.592693,"lon":-79.492522,"t":1780928389},{"alt_m":2620.0,"lat":43.593799,"lon":-79.491571,"t":1780928390},{"alt_m":2622.1,"lat":43.594905,"lon":-79.49062,"t":1780928391},{"alt_m":2608.3,"lat":43.596012,"lon":-79.489669,"t":1780928392},{"alt_m":2611.4,"lat":43.597118,"lon":-79.488718,"t":1780928393},{"alt_m":2603.1,"lat":43.598225,"lon":-79.487767,"t":1780928394},{"alt_m":2589.0,"lat":43.599331,"lon":-79.486816,"t":1780928395},{"alt_m":2573.0,"lat":43.600438,"lon":-79.485865,"t":1780928396},{"alt_m":2580.0,"lat":43.601544,"lon":-79.484914,"t":1780928397},{"alt_m":2575.0,"lat":43.602651,"lon":-79.483963,"t":1780928398},{"alt_m":2553.5,"lat":43.603757,"lon":-79.483012,"t":1780928399}]},{"anomaly":null,"callsign":"UAL303","icao24":"a03c03","overhead":false,"points":[{"alt_m":10699.9,"lat":43.281735,"lon":-79.973584,"t":1780932000},{"alt_m":10690.8,"lat":43.282432,"lon":-79.970799,"t":1780932001},{"alt_m":10704.5,"lat":43.283129,"lon":-79.968014,"t":1780932002},{"alt_m":10706.8,"lat":43.283827,"lon":-79.965228,"t":1780932003},{"alt_m":10704.2,"lat":43.284524,"lon":-79.962443,"t":1780932004},{"alt_m":10704.7,"lat":43.285221,"lon":-79.959658,"t":1780932005},{"alt_m":10703.5,"lat":43.285918,"lon":-79.956872,"t":1780932006},{"alt_m":10704.5,"lat":43.286616,"lon":-79.954087,"t":1780932007},{"alt_m":10693.9,"lat":43.287313,"lon":-79.951301,"t":1780932008},{"alt_m":10703.0,"lat":43.28801,"lon":-79.948516,"t":1780932009},{"alt_m":10697.7,"lat":43.288707,"lon":-79.945731,"t":1780932010},{"alt_m":10705.0,"lat":43.289404,"lon":-79.942945,"t":1780932011},{"alt_m":10706.4,"lat":43.290102,"lon":-79.94016,"t":1780932012},{"alt_m":10706.4,"lat":43.290799,"lon":-79.937375,"t":1780932013},{"alt_m":10703.8,"lat":43.291496,"lon":-79.934589,"t":1780932014},{"alt_m":10698.8,"lat":43.292193,"lon":-79.931804,"t":1780932015},{"alt_m":10705.4,"lat":43.292891,"lon":-79.929019,"t":1780932016},{"alt_m":10702.4,"lat":43.293588,"lon":-79.926233,"t":1780932017},{"alt_m":10704.8,"lat":43.294285,"lon":-79.923448,"t":1780932018},{"alt_m":10709.0,"lat":43.294982,"lon":-79.920663,"t":1780932019},{"alt_m":10694.7,"lat":43.29568,"lon":-79.917877,"t":1780932020},{"alt_m":10694.0,"lat":43.296377,"lon":-79.915092,"t":1780932021},{"alt_m":10701.6,"lat":43.297074,"lon":-79.912307,"t":1780932022},{"alt_m":10707.3,"lat":43.297771,"lon":-79.909521,"t":1780932023},{"alt_m":10697.8,"lat":43.298469,"lon":-79.906736,"t":1780932024},{"alt_m":10705.6,"lat":43.299166,"lon":-79.903951,"t":1780932025},{"alt_m":10704.3,"lat":43.299863,"lon":-79.901165,"t":1780932026},{"alt_m":10695.9,"lat":43.30056,"lon":-79.89838,"t":1780932027},{"alt_m":10696.5,"lat":43.301257,"lon":-79.895595,"t":1780932028},{"alt_m":10690.2,"lat":43.301955,"lon":-79.892809,"t":1780932029},{"alt_m":10703.6,"lat":43.302652,"lon":-79.890024,"t":1780932030},{"alt_m":10702.2,"lat":43.303349,"lon":-79.887239,"t":1780932031},{"alt_m":10706.9,"lat":43.304046,"lon":-79.884453,"t":1780932032},{"alt_m":10696.8,"lat":43.304744,"lon":-79.881668,"t":1780932033},{"alt_m":10709.8,"lat":43.305441,"lon":-79.878883,"t":1780932034},{"alt_m":10691.9,"lat":43.306138,"lon":-79.876097,"t":1780932035},{"alt_m":10691.1,"lat":43.306835,"lon":-79.873312,"t":1780932036},{"alt_m":10696.9,"lat":43.307533,"lon":-79.870527,"t":1780932037},{"alt_m":10707.8,"lat":43.30823,"lon":-79.867741,"t":1780932038},{"alt_m":10706.3,"lat":43.308927,"lon":-79.864956,"t":1780932039},{"alt_m":10705.1,"lat":43.309624,"lon":-79.862171,"t":1780932040},{"alt_m":10703.8,"lat":43.310322,"lon":-79.859385,"t":1780932041},{"alt_m":10697.5,"lat":43.311019,"lon":-79.8566,"t":1780932042},{"alt_m":10705.5,"lat":43.311716,"lon":-79.853815,"t":1780932043},{"alt_m":10690.1,"lat":43.312413,"lon":-79.851029,"t":1780932044},{"alt_m":10690.8,"lat":43.31311,"lon":-79.848244,"t":1780932045},{"alt_m":10701.3,"lat":43.313808,"lon":-79.845459,"t":1780932046},{"alt_m":10690.4,"lat":43.314505,"lon":-79.842673,"t":1780932047},{"alt_m":10690.6,"lat":43.315202,"lon":-79.839888,"t":1780932048},{"alt_m":10696.2,"lat":43.315899,"lon":-79.837103,"t":1780932049},{"alt_m":10704.6,"lat":43.316597,"lon":-79.834317,"t":1780932050},{"alt_m":10695.3,"lat":43.317294,"lon":-79.831532,"t":1780932051},{"alt_m":10700.8,"lat":43.317991,"lon":-79.828747,"t":1780932052},{"alt_m":10697.5,"lat":43.318688,"lon":-79.825961,"t":1780932053},{"alt_m":10704.9,"lat":43.319386,"lon":-79.823176,"t":1780932054},{"alt_m":10708.8,"lat":43.320083,"lon":-79.820391,"t":1780932055},{"alt_m":10697.8,"lat":43.32078,"lon":-79.817605,"t":1780932056},{"alt_m":10690.4,"lat":43.321477,"lon":-79.81482,"t":1780932057},{"alt_m":10709.6,"lat":43.322175,"lon":-79.812035,"t":1780932058},{"alt_m":10691.7,"lat":43.322872,"lon":-79.809249,"t":1780932059},{"alt_m":10695.6,"lat":43.323569,"lon":-79.806464,"t":1780932060},{"alt_m":10709.8,"lat":43.324266,"lon":-79.803679,"t":1780932061},{"alt_m":10699.3,"lat":43.324964,"lon":-79.800893,"t":1780932062},{"alt_m":10691.3,"lat":43.325661,"lon":-79.798108,"t":1780932063},{"alt_m":10702.1,"lat":43.326358,"lon":-79.795323,"t":1780932064},{"alt_m":10705.3,"lat":43.327055,"lon":-79.792537,"t":1780932065},{"alt_m":10702.4,"lat":43.327752,"lon":-79.789752,"t":1780932066},{"alt_m":10694.1,"lat":43.32845,"lon":-79.786967,"t":1780932067},{"alt_m":10697.0,"lat":43.329147,"lon":-79.784181,"t":1780932068},{"alt_m":10701.5,"lat":43.329844,"lon":-79.781396,"t":1780932069},{"alt_m":10707.7,"lat":43.330541,"lon":-79.778611,"t":1780932070},{"alt_m":10696.6,"lat":43.331239,"lon":-79.775825,"t":1780932071},{"alt_m":10694.0,"lat":43.331936,"lon":-79.77304,"t":1780932072},{"alt_m":10694.2,"lat":43.332633,"lon":-79.770254,"t":1780932073},{"alt_m":10699.5,"lat":43.33333,"lon":-79.767469,"t":1780932074},{"alt_m":10709.8,"lat":43.334028,"lon":-79.764684,"t":1780932075},{"alt_m":10705.3,"lat":43.334725,"lon":-79.761898,"t":1780932076},{"alt_m":10698.6,"lat":43.335422,"lon":-79.759113,"t":1780932077},{"alt_m":10690.5,"lat":43.336119,"lon":-79.756328,"t":1780932078},{"alt_m":10692.1,"lat":43.336817,"lon":-79.753542,"t":1780932079},{"alt_m":10707.4,"lat":43.337514,"lon":-79.750757,"t":1780932080},{"alt_m":10694.3,"lat":43.338211,"lon":-79.747972,"t":1780932081},{"alt_m":10694.1,"lat":43.338908,"lon":-79.745186,"t":1780932082},{"alt_m":10693.8,"lat":43.339605,"lon":-79.742401,"t":1780932083},{"alt_m":10704.9,"lat":43.340303,"lon":-79.739616,"t":1780932084},{"alt_m":10696.2,"lat":43.341,"lon":-79.73683,"t":1780932085},{"alt_m":10691.0,"lat":43.341697,"lon":-79.734045,"t":1780932086},{"alt_m":10696.2,"lat":43.342394,"lon":-79.73126,"t":1780932087},{"alt_m":10696.7,"lat":43.343092,"lon":-79.728474,"t":1780932088},{"alt_m":10703.4,"lat":43.343789,"lon":-79.725689,"t":1780932089},{"alt_m":10709.3,"lat":43.344486,"lon":-79.722904,"t":1780932090},{"alt_m":10709.2,"lat":43.345183,"lon":-79.720118,"t":1780932091},{"alt_m":10694.2,"lat":43.345881,"lon":-79.717333,"t":1780932092},{"alt_m":10700.3,"lat":43.346578,"lon":-79.714548,"t":1780932093},{"alt_m":10691.2,"lat":43.347275,"lon":-79.711762,"t":1780932094},{"alt_m":10710.0,"lat":43.347972,"lon":-79.708977,"t":1780932095},{"alt_m":10693.6,"lat":43.34867,"lon":-79.706192,"t":1780932096},{"alt_m":10702.6,"lat":43.349367,"lon":-79.703406,"t":1780932097},{"alt_m":10703.7,"lat":43.350064,"lon":-79.700621,"t":1780932098},{"alt_m":10705.4,"lat":43.350761,"lon":-79.697836,"t":1780932099},{"alt_m":10702.0,"lat":43.351458,"lon":-79.69505,"t":1780932100},{"alt_m":10690.2,"lat":43.352156,"lon":-79.692265,"t":1780932101},{"alt_m":10706.7,"lat":43.352853,"lon":-79.68948,"t":1780932102},{"alt_m":10698.2,"lat":43.35355,"lon":-79.686694,"t":1780932103},{"alt_m":10707.1,"lat":43.354247,"lon":-79.683909,"t":1780932104},{"alt_m":10706.4,"lat":43.354945,"lon":-79.681124,"t":1780932105},{"alt_m":10691.8,"lat":43.355642,"lon":-79.678338,"t":1780932106},{"alt_m":10700.6,"lat":43.356339,"lon":-79.675553,"t":1780932107},{"alt_m":10691.9,"lat":43.357036,"lon":-79.672768,"t":1780932108},{"alt_m":10695.6,"lat":43.357734,"lon":-79.669982,"t":1780932109},{"alt_m":10691.5,"lat":43.358431,"lon":-79.667197,"t":1780932110},{"alt_m":10693.4,"lat":43.359128,"lon":-79.664412,"t":1780932111},{"alt_m":10696.8,"lat":43.359825,"lon":-79.661626,"t":1780932112},{"alt_m":10704.7,"lat":43.360523,"lon":-79.658841,"t":1780932113},{"alt_m":10690.6,"lat":43.36122,"lon":-79.656056,"t":1780932114},{"alt_m":10691.3,"lat":43.361917,"lon":-79.65327,"t":1780932115},{"alt_m":10690.8,"lat":43.362614,"lon":-79.650485,"t":1780932116},{"alt_m":10701.4,"lat":43.363311,"lon":-79.6477,"t":1780932117},{"alt_m":10708.4,"lat":43.364009,"lon":-79.644914,"t":1780932118},{"alt_m":10696.7,"lat":43.364706,"lon":-79.642129,"t":1780932119},{"alt_m":10696.0,"lat":43.365403,"lon":-79.639344,"t":1780932120},{"alt_m":10700.5,"lat":43.3661,"lon":-79.636558,"t":1780932121},{"alt_m":10694.7,"lat":43.366798,"lon":-79.633773,"t":1780932122},{"alt_m":10690.0,"lat":43.367495,"lon":-79.630988,"t":1780932123},{"alt_m":10690.3,"lat":43.368192,"lon":-79.628202,"t":1780932124},{"alt_m":10704.8,"lat":43.368889,"lon":-79.625417,"t":1780932125},{"alt_m":10704.5,"lat":43.369587,"lon":-79.622632,"t":1780932126},{"alt_m":10701.6,"lat":43.370284,"lon":-79.619846,"t":1780932127},{"alt_m":10699.4,"lat":43.370981,"lon":-79.617061,"t":1780932128},{"alt_m":10702.3,"lat":43.371678,"lon":-79.614276,"t":1780932129},{"alt_m":10691.0,"lat":43.372376,"lon":-79.61149,"t":1780932130},{"alt_m":10709.6,"lat":43.373073,"lon":-79.608705,"t":1780932131},{"alt_m":10693.1,"lat":43.37377,"lon":-79.60592,"t":1780932132},{"alt_m":10692.0,"lat":43.374467,"lon":-79.603134,"t":1780932133},{"alt_m":10705.1,"lat":43.375164,"lon":-79.600349,"t":1780932134},{"alt_m":10707.3,"lat":43.375862,"lon":-79.597564,"t":1780932135},{"alt_m":10703.6,"lat":43.376559,"lon":-79.594778,"t":1780932136},{"alt_m":10702.2,"lat":43.377256,"lon":-79.591993,"t":1780932137},{"alt_m":10693.4,"lat":43.377953,"lon":-79.589207,"t":1780932138},{"alt_m":10706.1,"lat":43.378651,"lon":-79.586422,"t":1780932139},{"alt_m":10704.3,"lat":43.379348,"lon":-79.583637,"t":1780932140},{"alt_m":10702.9,"lat":43.380045,"lon":-79.580851,"t":1780932141},{"alt_m":10704.6,"lat":43.380742,"lon":-79.578066,"t":1780932142},{"alt_m":10694.7,"lat":43.38144,"lon":-79.575281,"t":1780932143},{"alt_m":10696.8,"lat":43.382137,"lon":-79.572495,"t":1780932144},{"alt_m":10696.9,"lat":43.382834,"lon":-79.56971,"t":1780932145},{"alt_m":10694.6,"lat":43.383531,"lon":-79.566925,"t":1780932146},{"alt_m":10696.9,"lat":43.384229,"lon":-79.564139,"t":1780932147},{"alt_m":10694.6,"lat":43.384926,"lon":-79.561354,"t":1780932148},{"alt_m":10706.6,"lat":43.385623,"lon":-79.558569,"t":1780932149},{"alt_m":10705.9,"lat":43.38632,"lon":-79.555783,"t":1780932150},{"alt_m":10693.0,"lat":43.387018,"lon":-79.552998,"t":1780932151},{"alt_m":10701.4,"lat":43.387715,"lon":-79.550213,"t":1780932152},{"alt_m":10696.3,"lat":43.388412,"lon":-79.547427,"t":1780932153},{"alt_m":10706.3,"lat":43.389109,"lon":-79.544642,"t":1780932154},{"alt_m":10709.7,"lat":43.389806,"lon":-79.541857,"t":1780932155},{"alt_m":10705.9,"lat":43.390504,"lon":-79.539071,"t":1780932156},{"alt_m":10693.3,"lat":43.391201,"lon":-79.536286,"t":1780932157},{"alt_m":10691.0,"lat":43.391898,"lon":-79.533501,"t":1780932158},{"alt_m":10706.2,"lat":43.392595,"lon":-79.530715,"t":1780932159},{"alt_m":10691.3,"lat":43.393293,"lon":-79.52793,"t":1780932160},{"alt_m":10700.3,"lat":43.39399,"lon":-79.525145,"t":1780932161},{"alt_m":10705.5,"lat":43.394687,"lon":-79.522359,"t":1780932162},{"alt_m":10694.9,"lat":43.395384,"lon":-79.519574,"t":1780932163},{"alt_m":10696.8,"lat":43.396082,"lon":-79.516789,"t":1780932164},{"alt_m":10702.2,"lat":43.396779,"lon":-79.514003,"t":1780932165},{"alt_m":10700.8,"lat":43.397476,"lon":-79.511218,"t":1780932166},{"alt_m":10702.2,"lat":43.398173,"lon":-79.508433,"t":1780932167},{"alt_m":10705.9,"lat":43.398871,"lon":-79.505647,"t":1780932168},{"alt_m":10695.1,"lat":43.399568,"lon":-79.502862,"t":1780932169},{"alt_m":10707.3,"lat":43.400265,"lon":-79.500077,"t":1780932170},{"alt_m":10705.5,"lat":43.400962,"lon":-79.497291,"t":1780932171},{"alt_m":10701.8,"lat":43.401659,"lon":-79.494506,"t":1780932172},{"alt_m":10693.3,"lat":43.402357,"lon":-79.491721,"t":1780932173},{"alt_m":10703.0,"lat":43.403054,"lon":-79.488935,"t":1780932174},{"alt_m":10701.2,"lat":43.403751,"lon":-79.48615,"t":1780932175},{"alt_m":10702.3,"lat":43.404448,"lon":-79.483365,"t":1780932176},{"alt_m":10706.8,"lat":43.405146,"lon":-79.480579,"t":1780932177},{"alt_m":10706.2,"lat":43.405843,"lon":-79.477794,"t":1780932178},{"alt_m":10704.5,"lat":43.40654,"lon":-79.475009,"t":1780932179},{"alt_m":10694.3,"lat":43.407237,"lon":-79.472223,"t":1780932180},{"alt_m":10698.8,"lat":43.407935,"lon":-79.469438,"t":1780932181},{"alt_m":10703.8,"lat":43.408632,"lon":-79.466653,"t":1780932182},{"alt_m":10693.6,"lat":43.409329,"lon":-79.463867,"t":1780932183},{"alt_m":10699.1,"lat":43.410026,"lon":-79.461082,"t":1780932184},{"alt_m":10693.4,"lat":43.410724,"lon":-79.458297,"t":1780932185},{"alt_m":10709.1,"lat":43.411421,"lon":-79.455511,"t":1780932186},{"alt_m":10701.3,"lat":43.412118,"lon":-79.452726,"t":1780932187},{"alt_m":10695.6,"lat":43.412815,"lon":-79.449941,"t":1780932188},{"alt_m":10692.2,"lat":43.413512,"lon":-79.447155,"t":1780932189},{"alt_m":10708.3,"lat":43.41421,"lon":-79.44437,"t":1780932190},{"alt_m":10702.5,"lat":43.414907,"lon":-79.441585,"t":1780932191},{"alt_m":10707.2,"lat":43.415604,"lon":-79.438799,"t":1780932192},{"alt_m":10705.3,"lat":43.416301,"lon":-79.436014,"t":1780932193},{"alt_m":10703.3,"lat":43.416999,"lon":-79.433229,"t":1780932194},{"alt_m":10706.3,"lat":43.417696,"lon":-79.430443,"t":1780932195},{"alt_m":10696.2,"lat":43.418393,"lon":-79.427658,"t":1780932196},{"alt_m":10707.8,"lat":43.41909,"lon":-79.424873,"t":1780932197},{"alt_m":10708.6,"lat":43.419788,"lon":-79.422087,"t":1780932198},{"alt_m":10706.3,"lat":43.420485,"lon":-79.419302,"t":1780932199},{"alt_m":10696.2,"lat":43.421182,"lon":-79.416517,"t":1780932200},{"alt_m":10705.2,"lat":43.421879,"lon":-79.413731,"t":1780932201},{"alt_m":10695.5,"lat":43.422577,"lon":-79.410946,"t":1780932202},{"alt_m":10691.7,"lat":43.423274,"lon":-79.40816,"t":1780932203},{"alt_m":10699.2,"lat":43.423971,"lon":-79.405375,"t":1780932204},{"alt_m":10704.4,"lat":43.424668,"lon":-79.40259,"t":1780932205},{"alt_m":10706.3,"lat":43.425365,"lon":-79.399804,"t":1780932206},{"alt_m":10690.9,"lat":43.426063,"lon":-79.397019,"t":1780932207},{"alt_m":10704.4,"lat":43.42676,"lon":-79.394234,"t":1780932208},{"alt_m":10704.4,"lat":43.427457,"lon":-79.391448,"t":1780932209},{"alt_m":10701.4,"lat":43.428154,"lon":-79.388663,"t":1780932210},{"alt_m":10692.3,"lat":43.428852,"lon":-79.385878,"t":1780932211},{"alt_m":10707.6,"lat":43.429549,"lon":-79.383092,"t":1780932212},{"alt_m":10693.5,"lat":43.430246,"lon":-79.380307,"t":1780932213},{"alt_m":10697.2,"lat":43.430943,"lon":-79.377522,"t":1780932214},{"alt_m":10703.4,"lat":43.431641,"lon":-79.374736,"t":1780932215},{"alt_m":10691.4,"lat":43.432338,"lon":-79.371951,"t":1780932216},{"alt_m":10701.0,"lat":43.433035,"lon":-79.369166,"t":1780932217},{"alt_m":10697.2,"lat":43.433732,"lon":-79.36638,"t":1780932218},{"alt_m":10697.2,"lat":43.43443,"lon":-79.363595,"t":1780932219},{"alt_m":10696.2,"lat":43.435127,"lon":-79.36081,"t":1780932220},{"alt_m":10702.9,"lat":43.435824,"lon":-79.358024,"t":1780932221},{"alt_m":10694.2,"lat":43.436521,"lon":-79.355239,"t":1780932222},{"alt_m":10699.0,"lat":43.437218,"lon":-79.352454,"t":1780932223},{"alt_m":10696.4,"lat":43.437916,"lon":-79.349668,"t":1780932224},{"alt_m":10701.5,"lat":43.438613,"lon":-79.346883,"t":1780932225},{"alt_m":10697.6,"lat":43.43931,"lon":-79.344098,"t":1780932226},{"alt_m":10696.5,"lat":43.440007,"lon":-79.341312,"t":1780932227},{"alt_m":10700.5,"lat":43.440705,"lon":-79.338527,"t":1780932228},{"alt_m":10690.3,"lat":43.441402,"lon":-79.335742,"t":1780932229},{"alt_m":10695.6,"lat":43.442099,"lon":-79.332956,"t":1780932230},{"alt_m":10694.3,"lat":43.442796,"lon":-79.330171,"t":1780932231},{"alt_m":10701.5,"lat":43.443494,"lon":-79.327386,"t":1780932232},{"alt_m":10701.1,"lat":43.444191,"lon":-79.3246,"t":1780932233},{"alt_m":10703.9,"lat":43.444888,"lon":-79.321815,"t":1780932234},{"alt_m":10702.8,"lat":43.445585,"lon":-79.31903,"t":1780932235},{"alt_m":10707.9,"lat":43.446283,"lon":-79.316244,"t":1780932236},{"alt_m":10697.3,"lat":43.44698,"lon":-79.313459,"t":1780932237},{"alt_m":10703.1,"lat":43.447677,"lon":-79.310674,"t":1780932238},{"alt_m":10699.6,"lat":43.448374,"lon":-79.307888,"t":1780932239}]},{"anomaly":{"band":"interesting","reasons":["mean altitude 1100 m deviates 2.9σ from the local baseline (9396 m)","signal -8.0 dBFS is 3.4σ from baseline -17.6 dBFS (unusually close/strong)","vector novelty 1.00: no similar track in RuVector memory"],"score":0.57},"callsign":"CGSKY","icao24":"c0a9a9","overhead":true,"points":[{"alt_m":1093.5,"lat":43.460398,"lon":-79.825575,"t":1780936200},{"alt_m":1098.3,"lat":43.460418,"lon":-79.824808,"t":1780936201},{"alt_m":1093.3,"lat":43.460437,"lon":-79.824041,"t":1780936202},{"alt_m":1090.4,"lat":43.460457,"lon":-79.823275,"t":1780936203},{"alt_m":1102.0,"lat":43.460476,"lon":-79.822508,"t":1780936204},{"alt_m":1107.3,"lat":43.460496,"lon":-79.821741,"t":1780936205},{"alt_m":1109.7,"lat":43.460515,"lon":-79.820974,"t":1780936206},{"alt_m":1103.9,"lat":43.460535,"lon":-79.820207,"t":1780936207},{"alt_m":1103.2,"lat":43.460554,"lon":-79.81944,"t":1780936208},{"alt_m":1090.9,"lat":43.460573,"lon":-79.818673,"t":1780936209},{"alt_m":1096.6,"lat":43.460593,"lon":-79.817906,"t":1780936210},{"alt_m":1096.9,"lat":43.460612,"lon":-79.817139,"t":1780936211},{"alt_m":1103.6,"lat":43.460632,"lon":-79.816372,"t":1780936212},{"alt_m":1099.0,"lat":43.460651,"lon":-79.815605,"t":1780936213},{"alt_m":1097.7,"lat":43.460671,"lon":-79.814838,"t":1780936214},{"alt_m":1092.2,"lat":43.46069,"lon":-79.814071,"t":1780936215},{"alt_m":1106.9,"lat":43.46071,"lon":-79.813304,"t":1780936216},{"alt_m":1108.6,"lat":43.460729,"lon":-79.812537,"t":1780936217},{"alt_m":1095.2,"lat":43.460749,"lon":-79.811771,"t":1780936218},{"alt_m":1106.2,"lat":43.460768,"lon":-79.811004,"t":1780936219},{"alt_m":1094.4,"lat":43.460788,"lon":-79.810237,"t":1780936220},{"alt_m":1109.2,"lat":43.460807,"lon":-79.80947,"t":1780936221},{"alt_m":1109.5,"lat":43.460827,"lon":-79.808703,"t":1780936222},{"alt_m":1098.4,"lat":43.460846,"lon":-79.807936,"t":1780936223},{"alt_m":1107.1,"lat":43.460866,"lon":-79.807169,"t":1780936224},{"alt_m":1093.1,"lat":43.460885,"lon":-79.806402,"t":1780936225},{"alt_m":1104.3,"lat":43.460904,"lon":-79.805635,"t":1780936226},{"alt_m":1092.0,"lat":43.460924,"lon":-79.804868,"t":1780936227},{"alt_m":1092.6,"lat":43.460943,"lon":-79.804101,"t":1780936228},{"alt_m":1099.6,"lat":43.460963,"lon":-79.803334,"t":1780936229},{"alt_m":1099.7,"lat":43.460982,"lon":-79.802567,"t":1780936230},{"alt_m":1106.2,"lat":43.461002,"lon":-79.8018,"t":1780936231},{"alt_m":1108.6,"lat":43.461021,"lon":-79.801033,"t":1780936232},{"alt_m":1090.9,"lat":43.461041,"lon":-79.800267,"t":1780936233},{"alt_m":1104.3,"lat":43.46106,"lon":-79.7995,"t":1780936234},{"alt_m":1091.8,"lat":43.46108,"lon":-79.798733,"t":1780936235},{"alt_m":1094.9,"lat":43.461099,"lon":-79.797966,"t":1780936236},{"alt_m":1090.6,"lat":43.461119,"lon":-79.797199,"t":1780936237},{"alt_m":1101.6,"lat":43.461138,"lon":-79.796432,"t":1780936238},{"alt_m":1096.5,"lat":43.461158,"lon":-79.795665,"t":1780936239},{"alt_m":1096.2,"lat":43.461177,"lon":-79.794898,"t":1780936240},{"alt_m":1109.5,"lat":43.461197,"lon":-79.794131,"t":1780936241},{"alt_m":1101.8,"lat":43.461216,"lon":-79.793364,"t":1780936242},{"alt_m":1104.5,"lat":43.461235,"lon":-79.792597,"t":1780936243},{"alt_m":1101.8,"lat":43.461255,"lon":-79.79183,"t":1780936244},{"alt_m":1106.0,"lat":43.461274,"lon":-79.791063,"t":1780936245},{"alt_m":1100.5,"lat":43.461294,"lon":-79.790296,"t":1780936246},{"alt_m":1102.6,"lat":43.461313,"lon":-79.789529,"t":1780936247},{"alt_m":1107.2,"lat":43.461333,"lon":-79.788763,"t":1780936248},{"alt_m":1105.3,"lat":43.461352,"lon":-79.787996,"t":1780936249},{"alt_m":1102.4,"lat":43.461372,"lon":-79.787229,"t":1780936250},{"alt_m":1107.7,"lat":43.461391,"lon":-79.786462,"t":1780936251},{"alt_m":1092.1,"lat":43.461411,"lon":-79.785695,"t":1780936252},{"alt_m":1104.7,"lat":43.46143,"lon":-79.784928,"t":1780936253},{"alt_m":1109.2,"lat":43.46145,"lon":-79.784161,"t":1780936254},{"alt_m":1109.6,"lat":43.461469,"lon":-79.783394,"t":1780936255},{"alt_m":1109.5,"lat":43.461489,"lon":-79.782627,"t":1780936256},{"alt_m":1110.0,"lat":43.461508,"lon":-79.78186,"t":1780936257},{"alt_m":1105.1,"lat":43.461527,"lon":-79.781093,"t":1780936258},{"alt_m":1107.8,"lat":43.461547,"lon":-79.780326,"t":1780936259},{"alt_m":1102.0,"lat":43.461566,"lon":-79.779559,"t":1780936260},{"alt_m":1091.5,"lat":43.461586,"lon":-79.778792,"t":1780936261},{"alt_m":1103.1,"lat":43.461605,"lon":-79.778025,"t":1780936262},{"alt_m":1096.6,"lat":43.461625,"lon":-79.777258,"t":1780936263},{"alt_m":1102.2,"lat":43.461644,"lon":-79.776492,"t":1780936264},{"alt_m":1109.2,"lat":43.461664,"lon":-79.775725,"t":1780936265},{"alt_m":1102.8,"lat":43.461683,"lon":-79.774958,"t":1780936266},{"alt_m":1108.8,"lat":43.461703,"lon":-79.774191,"t":1780936267},{"alt_m":1107.0,"lat":43.461722,"lon":-79.773424,"t":1780936268},{"alt_m":1096.8,"lat":43.461742,"lon":-79.772657,"t":1780936269},{"alt_m":1109.4,"lat":43.461761,"lon":-79.77189,"t":1780936270},{"alt_m":1090.3,"lat":43.461781,"lon":-79.771123,"t":1780936271},{"alt_m":1100.5,"lat":43.4618,"lon":-79.770356,"t":1780936272},{"alt_m":1106.5,"lat":43.46182,"lon":-79.769589,"t":1780936273},{"alt_m":1095.7,"lat":43.461839,"lon":-79.768822,"t":1780936274},{"alt_m":1102.8,"lat":43.461858,"lon":-79.768055,"t":1780936275},{"alt_m":1105.9,"lat":43.461878,"lon":-79.767288,"t":1780936276},{"alt_m":1108.9,"lat":43.461897,"lon":-79.766521,"t":1780936277},{"alt_m":1093.8,"lat":43.461917,"lon":-79.765754,"t":1780936278},{"alt_m":1097.0,"lat":43.461936,"lon":-79.764988,"t":1780936279},{"alt_m":1107.3,"lat":43.461956,"lon":-79.764221,"t":1780936280},{"alt_m":1093.4,"lat":43.461975,"lon":-79.763454,"t":1780936281},{"alt_m":1095.6,"lat":43.461995,"lon":-79.762687,"t":1780936282},{"alt_m":1099.6,"lat":43.462014,"lon":-79.76192,"t":1780936283},{"alt_m":1090.4,"lat":43.462034,"lon":-79.761153,"t":1780936284},{"alt_m":1101.0,"lat":43.462053,"lon":-79.760386,"t":1780936285},{"alt_m":1098.3,"lat":43.462073,"lon":-79.759619,"t":1780936286},{"alt_m":1103.7,"lat":43.462092,"lon":-79.758852,"t":1780936287},{"alt_m":1109.3,"lat":43.462112,"lon":-79.758085,"t":1780936288},{"alt_m":1094.3,"lat":43.462131,"lon":-79.757318,"t":1780936289},{"alt_m":1090.7,"lat":43.462151,"lon":-79.756551,"t":1780936290},{"alt_m":1100.6,"lat":43.46217,"lon":-79.755784,"t":1780936291},{"alt_m":1109.4,"lat":43.462189,"lon":-79.755017,"t":1780936292},{"alt_m":1091.9,"lat":43.462209,"lon":-79.75425,"t":1780936293},{"alt_m":1092.7,"lat":43.462228,"lon":-79.753484,"t":1780936294},{"alt_m":1090.8,"lat":43.462248,"lon":-79.752717,"t":1780936295},{"alt_m":1107.3,"lat":43.462267,"lon":-79.75195,"t":1780936296},{"alt_m":1090.3,"lat":43.462287,"lon":-79.751183,"t":1780936297},{"alt_m":1102.1,"lat":43.462306,"lon":-79.750416,"t":1780936298},{"alt_m":1093.5,"lat":43.462326,"lon":-79.749649,"t":1780936299},{"alt_m":1109.6,"lat":43.462345,"lon":-79.748882,"t":1780936300},{"alt_m":1098.3,"lat":43.462365,"lon":-79.748115,"t":1780936301},{"alt_m":1101.8,"lat":43.462384,"lon":-79.747348,"t":1780936302},{"alt_m":1092.3,"lat":43.462404,"lon":-79.746581,"t":1780936303},{"alt_m":1107.5,"lat":43.462423,"lon":-79.745814,"t":1780936304},{"alt_m":1094.7,"lat":43.462443,"lon":-79.745047,"t":1780936305},{"alt_m":1107.9,"lat":43.462462,"lon":-79.74428,"t":1780936306},{"alt_m":1097.5,"lat":43.462482,"lon":-79.743513,"t":1780936307},{"alt_m":1101.7,"lat":43.462501,"lon":-79.742746,"t":1780936308},{"alt_m":1101.0,"lat":43.46252,"lon":-79.74198,"t":1780936309},{"alt_m":1092.5,"lat":43.46254,"lon":-79.741213,"t":1780936310},{"alt_m":1099.0,"lat":43.462559,"lon":-79.740446,"t":1780936311},{"alt_m":1102.8,"lat":43.462579,"lon":-79.739679,"t":1780936312},{"alt_m":1108.0,"lat":43.462598,"lon":-79.738912,"t":1780936313},{"alt_m":1100.3,"lat":43.462618,"lon":-79.738145,"t":1780936314},{"alt_m":1108.9,"lat":43.462637,"lon":-79.737378,"t":1780936315},{"alt_m":1097.7,"lat":43.462657,"lon":-79.736611,"t":1780936316},{"alt_m":1096.8,"lat":43.462676,"lon":-79.735844,"t":1780936317},{"alt_m":1104.7,"lat":43.462696,"lon":-79.735077,"t":1780936318},{"alt_m":1108.4,"lat":43.462715,"lon":-79.73431,"t":1780936319},{"alt_m":1100.6,"lat":43.462735,"lon":-79.733543,"t":1780936320},{"alt_m":1107.9,"lat":43.462754,"lon":-79.732776,"t":1780936321},{"alt_m":1101.6,"lat":43.462774,"lon":-79.732009,"t":1780936322},{"alt_m":1093.3,"lat":43.462793,"lon":-79.731242,"t":1780936323},{"alt_m":1091.1,"lat":43.462813,"lon":-79.730476,"t":1780936324},{"alt_m":1106.6,"lat":43.462832,"lon":-79.729709,"t":1780936325},{"alt_m":1103.1,"lat":43.462851,"lon":-79.728942,"t":1780936326},{"alt_m":1092.4,"lat":43.462871,"lon":-79.728175,"t":1780936327},{"alt_m":1097.1,"lat":43.46289,"lon":-79.727408,"t":1780936328},{"alt_m":1104.8,"lat":43.46291,"lon":-79.726641,"t":1780936329},{"alt_m":1099.0,"lat":43.462929,"lon":-79.725874,"t":1780936330},{"alt_m":1090.8,"lat":43.462949,"lon":-79.725107,"t":1780936331},{"alt_m":1101.1,"lat":43.462968,"lon":-79.72434,"t":1780936332},{"alt_m":1098.8,"lat":43.462988,"lon":-79.723573,"t":1780936333},{"alt_m":1098.0,"lat":43.463007,"lon":-79.722806,"t":1780936334},{"alt_m":1091.3,"lat":43.463027,"lon":-79.722039,"t":1780936335},{"alt_m":1105.2,"lat":43.463046,"lon":-79.721272,"t":1780936336},{"alt_m":1095.3,"lat":43.463066,"lon":-79.720505,"t":1780936337},{"alt_m":1095.6,"lat":43.463085,"lon":-79.719738,"t":1780936338},{"alt_m":1100.8,"lat":43.463105,"lon":-79.718972,"t":1780936339},{"alt_m":1094.3,"lat":43.463124,"lon":-79.718205,"t":1780936340},{"alt_m":1107.6,"lat":43.463144,"lon":-79.717438,"t":1780936341},{"alt_m":1098.6,"lat":43.463163,"lon":-79.716671,"t":1780936342},{"alt_m":1094.8,"lat":43.463182,"lon":-79.715904,"t":1780936343},{"alt_m":1093.7,"lat":43.463202,"lon":-79.715137,"t":1780936344},{"alt_m":1108.8,"lat":43.463221,"lon":-79.71437,"t":1780936345},{"alt_m":1090.8,"lat":43.463241,"lon":-79.713603,"t":1780936346},{"alt_m":1094.9,"lat":43.46326,"lon":-79.712836,"t":1780936347},{"alt_m":1099.0,"lat":43.46328,"lon":-79.712069,"t":1780936348},{"alt_m":1094.3,"lat":43.463299,"lon":-79.711302,"t":1780936349},{"alt_m":1092.8,"lat":43.463319,"lon":-79.710535,"t":1780936350},{"alt_m":1102.0,"lat":43.463338,"lon":-79.709768,"t":1780936351},{"alt_m":1101.2,"lat":43.463358,"lon":-79.709001,"t":1780936352},{"alt_m":1092.1,"lat":43.463377,"lon":-79.708234,"t":1780936353},{"alt_m":1107.8,"lat":43.463397,"lon":-79.707467,"t":1780936354},{"alt_m":1102.8,"lat":43.463416,"lon":-79.706701,"t":1780936355},{"alt_m":1104.5,"lat":43.463436,"lon":-79.705934,"t":1780936356},{"alt_m":1109.0,"lat":43.463455,"lon":-79.705167,"t":1780936357},{"alt_m":1099.1,"lat":43.463475,"lon":-79.7044,"t":1780936358},{"alt_m":1103.6,"lat":43.463494,"lon":-79.703633,"t":1780936359},{"alt_m":1096.1,"lat":43.463513,"lon":-79.702866,"t":1780936360},{"alt_m":1098.4,"lat":43.463533,"lon":-79.702099,"t":1780936361},{"alt_m":1103.0,"lat":43.463552,"lon":-79.701332,"t":1780936362},{"alt_m":1108.6,"lat":43.463572,"lon":-79.700565,"t":1780936363},{"alt_m":1104.6,"lat":43.463591,"lon":-79.699798,"t":1780936364},{"alt_m":1092.8,"lat":43.463611,"lon":-79.699031,"t":1780936365},{"alt_m":1104.6,"lat":43.46363,"lon":-79.698264,"t":1780936366},{"alt_m":1106.5,"lat":43.46365,"lon":-79.697497,"t":1780936367},{"alt_m":1098.1,"lat":43.463669,"lon":-79.69673,"t":1780936368},{"alt_m":1093.9,"lat":43.463689,"lon":-79.695963,"t":1780936369},{"alt_m":1094.6,"lat":43.463708,"lon":-79.695197,"t":1780936370},{"alt_m":1095.6,"lat":43.463728,"lon":-79.69443,"t":1780936371},{"alt_m":1109.6,"lat":43.463747,"lon":-79.693663,"t":1780936372},{"alt_m":1103.6,"lat":43.463767,"lon":-79.692896,"t":1780936373},{"alt_m":1097.7,"lat":43.463786,"lon":-79.692129,"t":1780936374},{"alt_m":1103.7,"lat":43.463806,"lon":-79.691362,"t":1780936375},{"alt_m":1106.3,"lat":43.463825,"lon":-79.690595,"t":1780936376},{"alt_m":1095.0,"lat":43.463844,"lon":-79.689828,"t":1780936377},{"alt_m":1102.9,"lat":43.463864,"lon":-79.689061,"t":1780936378},{"alt_m":1092.0,"lat":43.463883,"lon":-79.688294,"t":1780936379},{"alt_m":1107.3,"lat":43.463903,"lon":-79.687527,"t":1780936380},{"alt_m":1107.4,"lat":43.463922,"lon":-79.68676,"t":1780936381},{"alt_m":1109.1,"lat":43.463942,"lon":-79.685993,"t":1780936382},{"alt_m":1105.1,"lat":43.463961,"lon":-79.685226,"t":1780936383},{"alt_m":1104.2,"lat":43.463981,"lon":-79.684459,"t":1780936384},{"alt_m":1108.4,"lat":43.464,"lon":-79.683693,"t":1780936385},{"alt_m":1090.9,"lat":43.46402,"lon":-79.682926,"t":1780936386},{"alt_m":1095.9,"lat":43.464039,"lon":-79.682159,"t":1780936387},{"alt_m":1094.3,"lat":43.464059,"lon":-79.681392,"t":1780936388},{"alt_m":1101.6,"lat":43.464078,"lon":-79.680625,"t":1780936389},{"alt_m":1106.2,"lat":43.464098,"lon":-79.679858,"t":1780936390},{"alt_m":1092.6,"lat":43.464117,"lon":-79.679091,"t":1780936391},{"alt_m":1103.4,"lat":43.464137,"lon":-79.678324,"t":1780936392},{"alt_m":1107.8,"lat":43.464156,"lon":-79.677557,"t":1780936393},{"alt_m":1103.0,"lat":43.464175,"lon":-79.67679,"t":1780936394},{"alt_m":1090.5,"lat":43.464195,"lon":-79.676023,"t":1780936395},{"alt_m":1095.8,"lat":43.464214,"lon":-79.675256,"t":1780936396},{"alt_m":1091.9,"lat":43.464234,"lon":-79.674489,"t":1780936397},{"alt_m":1100.4,"lat":43.464253,"lon":-79.673722,"t":1780936398},{"alt_m":1106.2,"lat":43.464273,"lon":-79.672955,"t":1780936399},{"alt_m":1108.0,"lat":43.464292,"lon":-79.672189,"t":1780936400},{"alt_m":1109.9,"lat":43.464312,"lon":-79.671422,"t":1780936401},{"alt_m":1101.8,"lat":43.464331,"lon":-79.670655,"t":1780936402},{"alt_m":1101.8,"lat":43.464351,"lon":-79.669888,"t":1780936403},{"alt_m":1108.4,"lat":43.46437,"lon":-79.669121,"t":1780936404},{"alt_m":1099.7,"lat":43.46439,"lon":-79.668354,"t":1780936405},{"alt_m":1104.1,"lat":43.464409,"lon":-79.667587,"t":1780936406},{"alt_m":1095.4,"lat":43.464429,"lon":-79.66682,"t":1780936407},{"alt_m":1095.2,"lat":43.464448,"lon":-79.666053,"t":1780936408},{"alt_m":1104.3,"lat":43.464468,"lon":-79.665286,"t":1780936409},{"alt_m":1099.5,"lat":43.464487,"lon":-79.664519,"t":1780936410},{"alt_m":1096.9,"lat":43.464506,"lon":-79.663752,"t":1780936411},{"alt_m":1101.1,"lat":43.464526,"lon":-79.662985,"t":1780936412},{"alt_m":1099.9,"lat":43.464545,"lon":-79.662218,"t":1780936413},{"alt_m":1094.3,"lat":43.464565,"lon":-79.661451,"t":1780936414},{"alt_m":1097.0,"lat":43.464584,"lon":-79.660685,"t":1780936415},{"alt_m":1095.8,"lat":43.464604,"lon":-79.659918,"t":1780936416},{"alt_m":1103.6,"lat":43.464623,"lon":-79.659151,"t":1780936417},{"alt_m":1095.4,"lat":43.464643,"lon":-79.658384,"t":1780936418},{"alt_m":1103.4,"lat":43.464662,"lon":-79.657617,"t":1780936419},{"alt_m":1095.7,"lat":43.464682,"lon":-79.65685,"t":1780936420},{"alt_m":1107.7,"lat":43.464701,"lon":-79.656083,"t":1780936421},{"alt_m":1105.9,"lat":43.464721,"lon":-79.655316,"t":1780936422},{"alt_m":1104.5,"lat":43.46474,"lon":-79.654549,"t":1780936423},{"alt_m":1095.7,"lat":43.46476,"lon":-79.653782,"t":1780936424},{"alt_m":1101.4,"lat":43.464779,"lon":-79.653015,"t":1780936425},{"alt_m":1091.5,"lat":43.464799,"lon":-79.652248,"t":1780936426},{"alt_m":1096.9,"lat":43.464818,"lon":-79.651481,"t":1780936427},{"alt_m":1101.3,"lat":43.464837,"lon":-79.650714,"t":1780936428},{"alt_m":1103.3,"lat":43.464857,"lon":-79.649947,"t":1780936429},{"alt_m":1090.3,"lat":43.464876,"lon":-79.649181,"t":1780936430},{"alt_m":1108.9,"lat":43.464896,"lon":-79.648414,"t":1780936431},{"alt_m":1107.4,"lat":43.464915,"lon":-79.647647,"t":1780936432},{"alt_m":1102.9,"lat":43.464935,"lon":-79.64688,"t":1780936433},{"alt_m":1104.2,"lat":43.464954,"lon":-79.646113,"t":1780936434},{"alt_m":1104.4,"lat":43.464974,"lon":-79.645346,"t":1780936435},{"alt_m":1095.2,"lat":43.464993,"lon":-79.644579,"t":1780936436},{"alt_m":1094.1,"lat":43.465013,"lon":-79.643812,"t":1780936437},{"alt_m":1094.1,"lat":43.465032,"lon":-79.643045,"t":1780936438},{"alt_m":1098.6,"lat":43.465052,"lon":-79.642278,"t":1780936439},{"alt_m":1101.1,"lat":43.465071,"lon":-79.641511,"t":1780936440},{"alt_m":1091.0,"lat":43.465091,"lon":-79.640744,"t":1780936441},{"alt_m":1096.9,"lat":43.46511,"lon":-79.639977,"t":1780936442},{"alt_m":1106.5,"lat":43.465129,"lon":-79.63921,"t":1780936443},{"alt_m":1093.4,"lat":43.465149,"lon":-79.638443,"t":1780936444},{"alt_m":1090.8,"lat":43.465168,"lon":-79.637677,"t":1780936445},{"alt_m":1100.0,"lat":43.465188,"lon":-79.63691,"t":1780936446},{"alt_m":1099.2,"lat":43.465207,"lon":-79.636143,"t":1780936447},{"alt_m":1108.1,"lat":43.465227,"lon":-79.635376,"t":1780936448},{"alt_m":1105.1,"lat":43.465246,"lon":-79.634609,"t":1780936449},{"alt_m":1101.6,"lat":43.465266,"lon":-79.633842,"t":1780936450},{"alt_m":1093.2,"lat":43.465285,"lon":-79.633075,"t":1780936451},{"alt_m":1104.3,"lat":43.465305,"lon":-79.632308,"t":1780936452},{"alt_m":1097.4,"lat":43.465324,"lon":-79.631541,"t":1780936453},{"alt_m":1091.5,"lat":43.465344,"lon":-79.630774,"t":1780936454},{"alt_m":1091.4,"lat":43.465363,"lon":-79.630007,"t":1780936455},{"alt_m":1104.3,"lat":43.465383,"lon":-79.62924,"t":1780936456},{"alt_m":1098.0,"lat":43.465402,"lon":-79.628473,"t":1780936457},{"alt_m":1100.1,"lat":43.465422,"lon":-79.627706,"t":1780936458},{"alt_m":1108.9,"lat":43.465441,"lon":-79.626939,"t":1780936459},{"alt_m":1100.1,"lat":43.46546,"lon":-79.626172,"t":1780936460},{"alt_m":1093.9,"lat":43.46548,"lon":-79.625406,"t":1780936461},{"alt_m":1105.6,"lat":43.465499,"lon":-79.624639,"t":1780936462},{"alt_m":1101.0,"lat":43.465519,"lon":-79.623872,"t":1780936463},{"alt_m":1104.8,"lat":43.465538,"lon":-79.623105,"t":1780936464},{"alt_m":1093.5,"lat":43.465558,"lon":-79.622338,"t":1780936465},{"alt_m":1104.1,"lat":43.465577,"lon":-79.621571,"t":1780936466},{"alt_m":1096.5,"lat":43.465597,"lon":-79.620804,"t":1780936467},{"alt_m":1097.4,"lat":43.465616,"lon":-79.620037,"t":1780936468},{"alt_m":1100.6,"lat":43.465636,"lon":-79.61927,"t":1780936469},{"alt_m":1102.3,"lat":43.465655,"lon":-79.618503,"t":1780936470},{"alt_m":1090.8,"lat":43.465675,"lon":-79.617736,"t":1780936471},{"alt_m":1091.1,"lat":43.465694,"lon":-79.616969,"t":1780936472},{"alt_m":1109.0,"lat":43.465714,"lon":-79.616202,"t":1780936473},{"alt_m":1106.5,"lat":43.465733,"lon":-79.615435,"t":1780936474},{"alt_m":1101.4,"lat":43.465753,"lon":-79.614668,"t":1780936475},{"alt_m":1099.3,"lat":43.465772,"lon":-79.613902,"t":1780936476},{"alt_m":1092.5,"lat":43.465791,"lon":-79.613135,"t":1780936477},{"alt_m":1095.7,"lat":43.465811,"lon":-79.612368,"t":1780936478},{"alt_m":1109.8,"lat":43.46583,"lon":-79.611601,"t":1780936479},{"alt_m":1090.5,"lat":43.46585,"lon":-79.610834,"t":1780936480},{"alt_m":1095.7,"lat":43.465869,"lon":-79.610067,"t":1780936481},{"alt_m":1104.2,"lat":43.465889,"lon":-79.6093,"t":1780936482},{"alt_m":1100.5,"lat":43.465908,"lon":-79.608533,"t":1780936483},{"alt_m":1091.6,"lat":43.465928,"lon":-79.607766,"t":1780936484},{"alt_m":1103.1,"lat":43.465947,"lon":-79.606999,"t":1780936485},{"alt_m":1104.0,"lat":43.465967,"lon":-79.606232,"t":1780936486},{"alt_m":1094.0,"lat":43.465986,"lon":-79.605465,"t":1780936487},{"alt_m":1097.4,"lat":43.466006,"lon":-79.604698,"t":1780936488},{"alt_m":1104.4,"lat":43.466025,"lon":-79.603931,"t":1780936489},{"alt_m":1092.3,"lat":43.466045,"lon":-79.603164,"t":1780936490},{"alt_m":1090.3,"lat":43.466064,"lon":-79.602398,"t":1780936491},{"alt_m":1108.5,"lat":43.466084,"lon":-79.601631,"t":1780936492},{"alt_m":1091.7,"lat":43.466103,"lon":-79.600864,"t":1780936493},{"alt_m":1093.6,"lat":43.466122,"lon":-79.600097,"t":1780936494},{"alt_m":1109.0,"lat":43.466142,"lon":-79.59933,"t":1780936495},{"alt_m":1097.7,"lat":43.466161,"lon":-79.598563,"t":1780936496},{"alt_m":1108.8,"lat":43.466181,"lon":-79.597796,"t":1780936497},{"alt_m":1090.1,"lat":43.4662,"lon":-79.597029,"t":1780936498},{"alt_m":1092.2,"lat":43.46622,"lon":-79.596262,"t":1780936499},{"alt_m":1098.5,"lat":43.466239,"lon":-79.595495,"t":1780936500},{"alt_m":1090.0,"lat":43.466259,"lon":-79.594728,"t":1780936501},{"alt_m":1094.5,"lat":43.466278,"lon":-79.593961,"t":1780936502},{"alt_m":1105.5,"lat":43.466298,"lon":-79.593194,"t":1780936503},{"alt_m":1104.0,"lat":43.466317,"lon":-79.592427,"t":1780936504},{"alt_m":1100.2,"lat":43.466337,"lon":-79.59166,"t":1780936505},{"alt_m":1105.4,"lat":43.466356,"lon":-79.590894,"t":1780936506},{"alt_m":1101.7,"lat":43.466376,"lon":-79.590127,"t":1780936507},{"alt_m":1108.4,"lat":43.466395,"lon":-79.58936,"t":1780936508},{"alt_m":1098.4,"lat":43.466415,"lon":-79.588593,"t":1780936509},{"alt_m":1090.7,"lat":43.466434,"lon":-79.587826,"t":1780936510},{"alt_m":1108.3,"lat":43.466453,"lon":-79.587059,"t":1780936511},{"alt_m":1095.1,"lat":43.466473,"lon":-79.586292,"t":1780936512},{"alt_m":1095.2,"lat":43.466492,"lon":-79.585525,"t":1780936513},{"alt_m":1107.8,"lat":43.466512,"lon":-79.584758,"t":1780936514},{"alt_m":1098.3,"lat":43.466531,"lon":-79.583991,"t":1780936515},{"alt_m":1096.3,"lat":43.466551,"lon":-79.583224,"t":1780936516},{"alt_m":1102.6,"lat":43.46657,"lon":-79.582457,"t":1780936517},{"alt_m":1103.5,"lat":43.46659,"lon":-79.58169,"t":1780936518},{"alt_m":1094.2,"lat":43.466609,"lon":-79.580923,"t":1780936519},{"alt_m":1090.5,"lat":43.466629,"lon":-79.580156,"t":1780936520},{"alt_m":1103.8,"lat":43.466648,"lon":-79.57939,"t":1780936521},{"alt_m":1096.6,"lat":43.466668,"lon":-79.578623,"t":1780936522},{"alt_m":1094.0,"lat":43.466687,"lon":-79.577856,"t":1780936523},{"alt_m":1104.7,"lat":43.466707,"lon":-79.577089,"t":1780936524},{"alt_m":1106.7,"lat":43.466726,"lon":-79.576322,"t":1780936525},{"alt_m":1106.1,"lat":43.466746,"lon":-79.575555,"t":1780936526},{"alt_m":1109.1,"lat":43.466765,"lon":-79.574788,"t":1780936527},{"alt_m":1100.5,"lat":43.466784,"lon":-79.574021,"t":1780936528},{"alt_m":1103.4,"lat":43.466804,"lon":-79.573254,"t":1780936529},{"alt_m":1105.0,"lat":43.466823,"lon":-79.572487,"t":1780936530},{"alt_m":1099.3,"lat":43.466843,"lon":-79.57172,"t":1780936531},{"alt_m":1102.6,"lat":43.466862,"lon":-79.570953,"t":1780936532},{"alt_m":1102.2,"lat":43.466882,"lon":-79.570186,"t":1780936533},{"alt_m":1096.3,"lat":43.466901,"lon":-79.569419,"t":1780936534},{"alt_m":1105.9,"lat":43.466921,"lon":-79.568652,"t":1780936535},{"alt_m":1096.8,"lat":43.46694,"lon":-79.567886,"t":1780936536},{"alt_m":1101.9,"lat":43.46696,"lon":-79.567119,"t":1780936537},{"alt_m":1100.9,"lat":43.466979,"lon":-79.566352,"t":1780936538},{"alt_m":1096.2,"lat":43.466999,"lon":-79.565585,"t":1780936539},{"alt_m":1096.3,"lat":43.467018,"lon":-79.564818,"t":1780936540},{"alt_m":1106.1,"lat":43.467038,"lon":-79.564051,"t":1780936541},{"alt_m":1090.3,"lat":43.467057,"lon":-79.563284,"t":1780936542},{"alt_m":1097.2,"lat":43.467077,"lon":-79.562517,"t":1780936543},{"alt_m":1109.5,"lat":43.467096,"lon":-79.56175,"t":1780936544},{"alt_m":1104.5,"lat":43.467115,"lon":-79.560983,"t":1780936545},{"alt_m":1105.1,"lat":43.467135,"lon":-79.560216,"t":1780936546},{"alt_m":1100.2,"lat":43.467154,"lon":-79.559449,"t":1780936547},{"alt_m":1097.3,"lat":43.467174,"lon":-79.558682,"t":1780936548},{"alt_m":1103.6,"lat":43.467193,"lon":-79.557915,"t":1780936549},{"alt_m":1099.3,"lat":43.467213,"lon":-79.557148,"t":1780936550},{"alt_m":1100.4,"lat":43.467232,"lon":-79.556381,"t":1780936551},{"alt_m":1107.5,"lat":43.467252,"lon":-79.555615,"t":1780936552},{"alt_m":1096.7,"lat":43.467271,"lon":-79.554848,"t":1780936553},{"alt_m":1099.5,"lat":43.467291,"lon":-79.554081,"t":1780936554},{"alt_m":1104.4,"lat":43.46731,"lon":-79.553314,"t":1780936555},{"alt_m":1099.3,"lat":43.46733,"lon":-79.552547,"t":1780936556},{"alt_m":1097.2,"lat":43.467349,"lon":-79.55178,"t":1780936557},{"alt_m":1103.6,"lat":43.467369,"lon":-79.551013,"t":1780936558},{"alt_m":1109.7,"lat":43.467388,"lon":-79.550246,"t":1780936559}]},{"anomaly":{"band":"normal","reasons":["vector novelty 0.84: no similar track in RuVector memory"],"score":0.226},"callsign":"AFR606","icao24":"39a006","overhead":false,"points":[{"alt_m":11000.1,"lat":43.607912,"lon":-79.392902,"t":1780938300},{"alt_m":11004.7,"lat":43.607239,"lon":-79.395594,"t":1780938301},{"alt_m":11004.8,"lat":43.606565,"lon":-79.398286,"t":1780938302},{"alt_m":11008.4,"lat":43.605891,"lon":-79.400977,"t":1780938303},{"alt_m":11004.2,"lat":43.605217,"lon":-79.403669,"t":1780938304},{"alt_m":11007.5,"lat":43.604543,"lon":-79.406361,"t":1780938305},{"alt_m":11000.3,"lat":43.60387,"lon":-79.409053,"t":1780938306},{"alt_m":10997.2,"lat":43.603196,"lon":-79.411744,"t":1780938307},{"alt_m":11009.1,"lat":43.602522,"lon":-79.414436,"t":1780938308},{"alt_m":10993.6,"lat":43.601848,"lon":-79.417128,"t":1780938309},{"alt_m":10998.9,"lat":43.601174,"lon":-79.419819,"t":1780938310},{"alt_m":11006.3,"lat":43.600501,"lon":-79.422511,"t":1780938311},{"alt_m":10996.5,"lat":43.599827,"lon":-79.425203,"t":1780938312},{"alt_m":10990.7,"lat":43.599153,"lon":-79.427895,"t":1780938313},{"alt_m":11005.7,"lat":43.598479,"lon":-79.430586,"t":1780938314},{"alt_m":10991.4,"lat":43.597805,"lon":-79.433278,"t":1780938315},{"alt_m":10993.2,"lat":43.597132,"lon":-79.43597,"t":1780938316},{"alt_m":10997.7,"lat":43.596458,"lon":-79.438661,"t":1780938317},{"alt_m":10990.3,"lat":43.595784,"lon":-79.441353,"t":1780938318},{"alt_m":11000.2,"lat":43.59511,"lon":-79.444045,"t":1780938319},{"alt_m":10993.2,"lat":43.594436,"lon":-79.446737,"t":1780938320},{"alt_m":10994.8,"lat":43.593763,"lon":-79.449428,"t":1780938321},{"alt_m":11001.1,"lat":43.593089,"lon":-79.45212,"t":1780938322},{"alt_m":10996.2,"lat":43.592415,"lon":-79.454812,"t":1780938323},{"alt_m":11004.4,"lat":43.591741,"lon":-79.457503,"t":1780938324},{"alt_m":10996.3,"lat":43.591067,"lon":-79.460195,"t":1780938325},{"alt_m":10992.1,"lat":43.590394,"lon":-79.462887,"t":1780938326},{"alt_m":10996.4,"lat":43.58972,"lon":-79.465579,"t":1780938327},{"alt_m":11006.2,"lat":43.589046,"lon":-79.46827,"t":1780938328},{"alt_m":11005.7,"lat":43.588372,"lon":-79.470962,"t":1780938329},{"alt_m":11009.8,"lat":43.587698,"lon":-79.473654,"t":1780938330},{"alt_m":10990.3,"lat":43.587025,"lon":-79.476345,"t":1780938331},{"alt_m":10991.8,"lat":43.586351,"lon":-79.479037,"t":1780938332},{"alt_m":10999.6,"lat":43.585677,"lon":-79.481729,"t":1780938333},{"alt_m":10995.0,"lat":43.585003,"lon":-79.484421,"t":1780938334},{"alt_m":11003.0,"lat":43.584329,"lon":-79.487112,"t":1780938335},{"alt_m":11007.5,"lat":43.583656,"lon":-79.489804,"t":1780938336},{"alt_m":10994.1,"lat":43.582982,"lon":-79.492496,"t":1780938337},{"alt_m":11005.7,"lat":43.582308,"lon":-79.495187,"t":1780938338},{"alt_m":11006.6,"lat":43.581634,"lon":-79.497879,"t":1780938339},{"alt_m":11006.5,"lat":43.58096,"lon":-79.500571,"t":1780938340},{"alt_m":10992.6,"lat":43.580287,"lon":-79.503263,"t":1780938341},{"alt_m":10990.9,"lat":43.579613,"lon":-79.505954,"t":1780938342},{"alt_m":10990.2,"lat":43.578939,"lon":-79.508646,"t":1780938343},{"alt_m":11005.9,"lat":43.578265,"lon":-79.511338,"t":1780938344},{"alt_m":11003.9,"lat":43.577591,"lon":-79.514029,"t":1780938345},{"alt_m":10995.6,"lat":43.576918,"lon":-79.516721,"t":1780938346},{"alt_m":11005.7,"lat":43.576244,"lon":-79.519413,"t":1780938347},{"alt_m":11008.5,"lat":43.57557,"lon":-79.522105,"t":1780938348},{"alt_m":10991.3,"lat":43.574896,"lon":-79.524796,"t":1780938349},{"alt_m":11009.8,"lat":43.574222,"lon":-79.527488,"t":1780938350},{"alt_m":10994.2,"lat":43.573549,"lon":-79.53018,"t":1780938351},{"alt_m":11003.2,"lat":43.572875,"lon":-79.532871,"t":1780938352},{"alt_m":11003.2,"lat":43.572201,"lon":-79.535563,"t":1780938353},{"alt_m":10993.4,"lat":43.571527,"lon":-79.538255,"t":1780938354},{"alt_m":10993.2,"lat":43.570853,"lon":-79.540947,"t":1780938355},{"alt_m":11009.9,"lat":43.57018,"lon":-79.543638,"t":1780938356},{"alt_m":11001.2,"lat":43.569506,"lon":-79.54633,"t":1780938357},{"alt_m":10996.2,"lat":43.568832,"lon":-79.549022,"t":1780938358},{"alt_m":10994.5,"lat":43.568158,"lon":-79.551713,"t":1780938359},{"alt_m":10990.9,"lat":43.567484,"lon":-79.554405,"t":1780938360},{"alt_m":10993.2,"lat":43.566811,"lon":-79.557097,"t":1780938361},{"alt_m":11006.2,"lat":43.566137,"lon":-79.559789,"t":1780938362},{"alt_m":10992.5,"lat":43.565463,"lon":-79.56248,"t":1780938363},{"alt_m":11003.4,"lat":43.564789,"lon":-79.565172,"t":1780938364},{"alt_m":11008.2,"lat":43.564115,"lon":-79.567864,"t":1780938365},{"alt_m":11000.7,"lat":43.563442,"lon":-79.570555,"t":1780938366},{"alt_m":10996.4,"lat":43.562768,"lon":-79.573247,"t":1780938367},{"alt_m":11003.1,"lat":43.562094,"lon":-79.575939,"t":1780938368},{"alt_m":11008.5,"lat":43.56142,"lon":-79.578631,"t":1780938369},{"alt_m":11005.8,"lat":43.560746,"lon":-79.581322,"t":1780938370},{"alt_m":10999.2,"lat":43.560073,"lon":-79.584014,"t":1780938371},{"alt_m":11009.0,"lat":43.559399,"lon":-79.586706,"t":1780938372},{"alt_m":10998.7,"lat":43.558725,"lon":-79.589397,"t":1780938373},{"alt_m":11005.0,"lat":43.558051,"lon":-79.592089,"t":1780938374},{"alt_m":10991.9,"lat":43.557377,"lon":-79.594781,"t":1780938375},{"alt_m":10996.7,"lat":43.556704,"lon":-79.597473,"t":1780938376},{"alt_m":11007.3,"lat":43.55603,"lon":-79.600164,"t":1780938377},{"alt_m":10997.5,"lat":43.555356,"lon":-79.602856,"t":1780938378},{"alt_m":10998.6,"lat":43.554682,"lon":-79.605548,"t":1780938379},{"alt_m":11001.7,"lat":43.554008,"lon":-79.608239,"t":1780938380},{"alt_m":11004.8,"lat":43.553335,"lon":-79.610931,"t":1780938381},{"alt_m":11001.5,"lat":43.552661,"lon":-79.613623,"t":1780938382},{"alt_m":10991.1,"lat":43.551987,"lon":-79.616315,"t":1780938383},{"alt_m":10999.1,"lat":43.551313,"lon":-79.619006,"t":1780938384},{"alt_m":11006.6,"lat":43.550639,"lon":-79.621698,"t":1780938385},{"alt_m":10993.2,"lat":43.549966,"lon":-79.62439,"t":1780938386},{"alt_m":11008.1,"lat":43.549292,"lon":-79.627081,"t":1780938387},{"alt_m":10996.2,"lat":43.548618,"lon":-79.629773,"t":1780938388},{"alt_m":11003.9,"lat":43.547944,"lon":-79.632465,"t":1780938389},{"alt_m":11005.5,"lat":43.54727,"lon":-79.635157,"t":1780938390},{"alt_m":11007.5,"lat":43.546597,"lon":-79.637848,"t":1780938391},{"alt_m":10997.6,"lat":43.545923,"lon":-79.64054,"t":1780938392},{"alt_m":11002.1,"lat":43.545249,"lon":-79.643232,"t":1780938393},{"alt_m":11002.6,"lat":43.544575,"lon":-79.645923,"t":1780938394},{"alt_m":11009.9,"lat":43.543901,"lon":-79.648615,"t":1780938395},{"alt_m":11002.5,"lat":43.543228,"lon":-79.651307,"t":1780938396},{"alt_m":10999.0,"lat":43.542554,"lon":-79.653998,"t":1780938397},{"alt_m":11004.5,"lat":43.54188,"lon":-79.65669,"t":1780938398},{"alt_m":11004.1,"lat":43.541206,"lon":-79.659382,"t":1780938399},{"alt_m":11007.4,"lat":43.540532,"lon":-79.662074,"t":1780938400},{"alt_m":11005.6,"lat":43.539859,"lon":-79.664765,"t":1780938401},{"alt_m":10990.1,"lat":43.539185,"lon":-79.667457,"t":1780938402},{"alt_m":11003.0,"lat":43.538511,"lon":-79.670149,"t":1780938403},{"alt_m":11006.2,"lat":43.537837,"lon":-79.67284,"t":1780938404},{"alt_m":11007.2,"lat":43.537163,"lon":-79.675532,"t":1780938405},{"alt_m":10996.2,"lat":43.53649,"lon":-79.678224,"t":1780938406},{"alt_m":10991.2,"lat":43.535816,"lon":-79.680916,"t":1780938407},{"alt_m":10992.3,"lat":43.535142,"lon":-79.683607,"t":1780938408},{"alt_m":11005.1,"lat":43.534468,"lon":-79.686299,"t":1780938409},{"alt_m":11007.6,"lat":43.533794,"lon":-79.688991,"t":1780938410},{"alt_m":10994.2,"lat":43.533121,"lon":-79.691682,"t":1780938411},{"alt_m":11006.0,"lat":43.532447,"lon":-79.694374,"t":1780938412},{"alt_m":10996.5,"lat":43.531773,"lon":-79.697066,"t":1780938413},{"alt_m":10997.5,"lat":43.531099,"lon":-79.699758,"t":1780938414},{"alt_m":10993.0,"lat":43.530425,"lon":-79.702449,"t":1780938415},{"alt_m":11004.9,"lat":43.529752,"lon":-79.705141,"t":1780938416},{"alt_m":10999.7,"lat":43.529078,"lon":-79.707833,"t":1780938417},{"alt_m":10998.0,"lat":43.528404,"lon":-79.710524,"t":1780938418},{"alt_m":11005.8,"lat":43.52773,"lon":-79.713216,"t":1780938419},{"alt_m":11001.3,"lat":43.527056,"lon":-79.715908,"t":1780938420},{"alt_m":11006.1,"lat":43.526383,"lon":-79.7186,"t":1780938421},{"alt_m":11003.8,"lat":43.525709,"lon":-79.721291,"t":1780938422},{"alt_m":11000.5,"lat":43.525035,"lon":-79.723983,"t":1780938423},{"alt_m":11009.3,"lat":43.524361,"lon":-79.726675,"t":1780938424},{"alt_m":10990.9,"lat":43.523687,"lon":-79.729366,"t":1780938425},{"alt_m":11004.2,"lat":43.523014,"lon":-79.732058,"t":1780938426},{"alt_m":10990.1,"lat":43.52234,"lon":-79.73475,"t":1780938427},{"alt_m":10996.8,"lat":43.521666,"lon":-79.737442,"t":1780938428},{"alt_m":10999.3,"lat":43.520992,"lon":-79.740133,"t":1780938429},{"alt_m":11002.5,"lat":43.520318,"lon":-79.742825,"t":1780938430},{"alt_m":10998.6,"lat":43.519645,"lon":-79.745517,"t":1780938431},{"alt_m":11002.7,"lat":43.518971,"lon":-79.748208,"t":1780938432},{"alt_m":10991.0,"lat":43.518297,"lon":-79.7509,"t":1780938433},{"alt_m":10994.2,"lat":43.517623,"lon":-79.753592,"t":1780938434},{"alt_m":10991.4,"lat":43.516949,"lon":-79.756284,"t":1780938435},{"alt_m":11001.4,"lat":43.516276,"lon":-79.758975,"t":1780938436},{"alt_m":10996.0,"lat":43.515602,"lon":-79.761667,"t":1780938437},{"alt_m":10997.3,"lat":43.514928,"lon":-79.764359,"t":1780938438},{"alt_m":10993.7,"lat":43.514254,"lon":-79.76705,"t":1780938439},{"alt_m":11001.3,"lat":43.51358,"lon":-79.769742,"t":1780938440},{"alt_m":10998.6,"lat":43.512907,"lon":-79.772434,"t":1780938441},{"alt_m":10991.5,"lat":43.512233,"lon":-79.775126,"t":1780938442},{"alt_m":11007.8,"lat":43.511559,"lon":-79.777817,"t":1780938443},{"alt_m":10993.1,"lat":43.510885,"lon":-79.780509,"t":1780938444},{"alt_m":10993.0,"lat":43.510211,"lon":-79.783201,"t":1780938445},{"alt_m":10992.6,"lat":43.509538,"lon":-79.785892,"t":1780938446},{"alt_m":11006.0,"lat":43.508864,"lon":-79.788584,"t":1780938447},{"alt_m":11006.3,"lat":43.50819,"lon":-79.791276,"t":1780938448},{"alt_m":10990.4,"lat":43.507516,"lon":-79.793968,"t":1780938449},{"alt_m":11000.8,"lat":43.506842,"lon":-79.796659,"t":1780938450},{"alt_m":11004.0,"lat":43.506169,"lon":-79.799351,"t":1780938451},{"alt_m":10993.7,"lat":43.505495,"lon":-79.802043,"t":1780938452},{"alt_m":11001.8,"lat":43.504821,"lon":-79.804734,"t":1780938453},{"alt_m":11003.7,"lat":43.504147,"lon":-79.807426,"t":1780938454},{"alt_m":11002.4,"lat":43.503473,"lon":-79.810118,"t":1780938455},{"alt_m":11000.1,"lat":43.5028,"lon":-79.81281,"t":1780938456},{"alt_m":10991.9,"lat":43.502126,"lon":-79.815501,"t":1780938457},{"alt_m":10992.1,"lat":43.501452,"lon":-79.818193,"t":1780938458},{"alt_m":10993.3,"lat":43.500778,"lon":-79.820885,"t":1780938459},{"alt_m":10992.9,"lat":43.500104,"lon":-79.823576,"t":1780938460},{"alt_m":10996.5,"lat":43.499431,"lon":-79.826268,"t":1780938461},{"alt_m":10999.9,"lat":43.498757,"lon":-79.82896,"t":1780938462},{"alt_m":11003.1,"lat":43.498083,"lon":-79.831652,"t":1780938463},{"alt_m":10992.3,"lat":43.497409,"lon":-79.834343,"t":1780938464},{"alt_m":11003.2,"lat":43.496736,"lon":-79.837035,"t":1780938465},{"alt_m":11008.0,"lat":43.496062,"lon":-79.839727,"t":1780938466},{"alt_m":10999.7,"lat":43.495388,"lon":-79.842418,"t":1780938467},{"alt_m":10990.2,"lat":43.494714,"lon":-79.84511,"t":1780938468},{"alt_m":11002.1,"lat":43.49404,"lon":-79.847802,"t":1780938469},{"alt_m":11000.9,"lat":43.493367,"lon":-79.850494,"t":1780938470},{"alt_m":11005.2,"lat":43.492693,"lon":-79.853185,"t":1780938471},{"alt_m":10992.6,"lat":43.492019,"lon":-79.855877,"t":1780938472},{"alt_m":10995.1,"lat":43.491345,"lon":-79.858569,"t":1780938473},{"alt_m":10999.3,"lat":43.490671,"lon":-79.86126,"t":1780938474},{"alt_m":10993.6,"lat":43.489998,"lon":-79.863952,"t":1780938475},{"alt_m":11002.8,"lat":43.489324,"lon":-79.866644,"t":1780938476},{"alt_m":11006.2,"lat":43.48865,"lon":-79.869336,"t":1780938477},{"alt_m":11009.2,"lat":43.487976,"lon":-79.872027,"t":1780938478},{"alt_m":11007.6,"lat":43.487302,"lon":-79.874719,"t":1780938479},{"alt_m":11001.4,"lat":43.486629,"lon":-79.877411,"t":1780938480},{"alt_m":11000.1,"lat":43.485955,"lon":-79.880102,"t":1780938481},{"alt_m":11000.8,"lat":43.485281,"lon":-79.882794,"t":1780938482},{"alt_m":10993.5,"lat":43.484607,"lon":-79.885486,"t":1780938483},{"alt_m":11000.9,"lat":43.483933,"lon":-79.888178,"t":1780938484},{"alt_m":11006.2,"lat":43.48326,"lon":-79.890869,"t":1780938485},{"alt_m":10994.4,"lat":43.482586,"lon":-79.893561,"t":1780938486},{"alt_m":10997.4,"lat":43.481912,"lon":-79.896253,"t":1780938487},{"alt_m":11006.3,"lat":43.481238,"lon":-79.898944,"t":1780938488},{"alt_m":11001.6,"lat":43.480564,"lon":-79.901636,"t":1780938489},{"alt_m":10994.7,"lat":43.479891,"lon":-79.904328,"t":1780938490},{"alt_m":11000.8,"lat":43.479217,"lon":-79.90702,"t":1780938491},{"alt_m":11002.3,"lat":43.478543,"lon":-79.909711,"t":1780938492},{"alt_m":10992.1,"lat":43.477869,"lon":-79.912403,"t":1780938493},{"alt_m":11003.3,"lat":43.477195,"lon":-79.915095,"t":1780938494},{"alt_m":10990.3,"lat":43.476522,"lon":-79.917786,"t":1780938495},{"alt_m":10997.3,"lat":43.475848,"lon":-79.920478,"t":1780938496},{"alt_m":10997.7,"lat":43.475174,"lon":-79.92317,"t":1780938497},{"alt_m":11002.4,"lat":43.4745,"lon":-79.925862,"t":1780938498},{"alt_m":11007.7,"lat":43.473826,"lon":-79.928553,"t":1780938499},{"alt_m":11000.0,"lat":43.473153,"lon":-79.931245,"t":1780938500},{"alt_m":10995.4,"lat":43.472479,"lon":-79.933937,"t":1780938501},{"alt_m":10991.9,"lat":43.471805,"lon":-79.936628,"t":1780938502},{"alt_m":10995.7,"lat":43.471131,"lon":-79.93932,"t":1780938503},{"alt_m":11006.2,"lat":43.470457,"lon":-79.942012,"t":1780938504},{"alt_m":11005.6,"lat":43.469784,"lon":-79.944704,"t":1780938505},{"alt_m":10993.8,"lat":43.46911,"lon":-79.947395,"t":1780938506},{"alt_m":11009.4,"lat":43.468436,"lon":-79.950087,"t":1780938507},{"alt_m":11009.7,"lat":43.467762,"lon":-79.952779,"t":1780938508},{"alt_m":11002.8,"lat":43.467088,"lon":-79.95547,"t":1780938509},{"alt_m":10995.6,"lat":43.466415,"lon":-79.958162,"t":1780938510},{"alt_m":11003.6,"lat":43.465741,"lon":-79.960854,"t":1780938511},{"alt_m":11000.3,"lat":43.465067,"lon":-79.963546,"t":1780938512},{"alt_m":10995.0,"lat":43.464393,"lon":-79.966237,"t":1780938513},{"alt_m":10993.6,"lat":43.463719,"lon":-79.968929,"t":1780938514},{"alt_m":10993.2,"lat":43.463046,"lon":-79.971621,"t":1780938515},{"alt_m":11009.8,"lat":43.462372,"lon":-79.974312,"t":1780938516},{"alt_m":10995.1,"lat":43.461698,"lon":-79.977004,"t":1780938517},{"alt_m":11008.3,"lat":43.461024,"lon":-79.979696,"t":1780938518},{"alt_m":10997.3,"lat":43.46035,"lon":-79.982388,"t":1780938519},{"alt_m":11001.1,"lat":43.459677,"lon":-79.985079,"t":1780938520},{"alt_m":11001.3,"lat":43.459003,"lon":-79.987771,"t":1780938521},{"alt_m":10997.2,"lat":43.458329,"lon":-79.990463,"t":1780938522},{"alt_m":10997.7,"lat":43.457655,"lon":-79.993154,"t":1780938523},{"alt_m":11005.3,"lat":43.456981,"lon":-79.995846,"t":1780938524},{"alt_m":11006.7,"lat":43.456308,"lon":-79.998538,"t":1780938525},{"alt_m":10998.3,"lat":43.455634,"lon":-80.00123,"t":1780938526},{"alt_m":10996.0,"lat":43.45496,"lon":-80.003921,"t":1780938527},{"alt_m":11005.3,"lat":43.454286,"lon":-80.006613,"t":1780938528},{"alt_m":10993.9,"lat":43.453612,"lon":-80.009305,"t":1780938529},{"alt_m":10994.4,"lat":43.452939,"lon":-80.011996,"t":1780938530},{"alt_m":11005.0,"lat":43.452265,"lon":-80.014688,"t":1780938531},{"alt_m":11009.0,"lat":43.451591,"lon":-80.01738,"t":1780938532},{"alt_m":10995.0,"lat":43.450917,"lon":-80.020072,"t":1780938533},{"alt_m":11007.2,"lat":43.450243,"lon":-80.022763,"t":1780938534},{"alt_m":10997.0,"lat":43.44957,"lon":-80.025455,"t":1780938535},{"alt_m":11007.8,"lat":43.448896,"lon":-80.028147,"t":1780938536},{"alt_m":11002.7,"lat":43.448222,"lon":-80.030838,"t":1780938537},{"alt_m":10998.1,"lat":43.447548,"lon":-80.03353,"t":1780938538},{"alt_m":10992.3,"lat":43.446874,"lon":-80.036222,"t":1780938539}]},{"anomaly":{"band":"normal","reasons":["within normal envelope: heading 73°, altitude 10500 m, score 0.17"],"score":0.165},"callsign":"WJA404","icao24":"c04d04","overhead":false,"points":[{"alt_m":10495.5,"lat":43.341995,"lon":-79.998359,"t":1780943400},{"alt_m":10499.9,"lat":43.342611,"lon":-79.995589,"t":1780943401},{"alt_m":10505.0,"lat":43.343226,"lon":-79.99282,"t":1780943402},{"alt_m":10492.3,"lat":43.343842,"lon":-79.99005,"t":1780943403},{"alt_m":10508.6,"lat":43.344457,"lon":-79.98728,"t":1780943404},{"alt_m":10509.9,"lat":43.345073,"lon":-79.98451,"t":1780943405},{"alt_m":10491.6,"lat":43.345689,"lon":-79.98174,"t":1780943406},{"alt_m":10508.3,"lat":43.346304,"lon":-79.978971,"t":1780943407},{"alt_m":10493.6,"lat":43.34692,"lon":-79.976201,"t":1780943408},{"alt_m":10505.3,"lat":43.347536,"lon":-79.973431,"t":1780943409},{"alt_m":10507.5,"lat":43.348151,"lon":-79.970661,"t":1780943410},{"alt_m":10499.4,"lat":43.348767,"lon":-79.967892,"t":1780943411},{"alt_m":10497.1,"lat":43.349382,"lon":-79.965122,"t":1780943412},{"alt_m":10496.2,"lat":43.349998,"lon":-79.962352,"t":1780943413},{"alt_m":10495.9,"lat":43.350614,"lon":-79.959582,"t":1780943414},{"alt_m":10501.3,"lat":43.351229,"lon":-79.956813,"t":1780943415},{"alt_m":10494.8,"lat":43.351845,"lon":-79.954043,"t":1780943416},{"alt_m":10490.9,"lat":43.35246,"lon":-79.951273,"t":1780943417},{"alt_m":10493.6,"lat":43.353076,"lon":-79.948503,"t":1780943418},{"alt_m":10493.0,"lat":43.353692,"lon":-79.945734,"t":1780943419},{"alt_m":10503.5,"lat":43.354307,"lon":-79.942964,"t":1780943420},{"alt_m":10493.6,"lat":43.354923,"lon":-79.940194,"t":1780943421},{"alt_m":10502.9,"lat":43.355539,"lon":-79.937424,"t":1780943422},{"alt_m":10504.7,"lat":43.356154,"lon":-79.934654,"t":1780943423},{"alt_m":10500.7,"lat":43.35677,"lon":-79.931885,"t":1780943424},{"alt_m":10503.6,"lat":43.357385,"lon":-79.929115,"t":1780943425},{"alt_m":10503.5,"lat":43.358001,"lon":-79.926345,"t":1780943426},{"alt_m":10492.2,"lat":43.358617,"lon":-79.923575,"t":1780943427},{"alt_m":10496.1,"lat":43.359232,"lon":-79.920806,"t":1780943428},{"alt_m":10501.3,"lat":43.359848,"lon":-79.918036,"t":1780943429},{"alt_m":10499.7,"lat":43.360464,"lon":-79.915266,"t":1780943430},{"alt_m":10495.6,"lat":43.361079,"lon":-79.912496,"t":1780943431},{"alt_m":10495.8,"lat":43.361695,"lon":-79.909727,"t":1780943432},{"alt_m":10500.1,"lat":43.36231,"lon":-79.906957,"t":1780943433},{"alt_m":10501.4,"lat":43.362926,"lon":-79.904187,"t":1780943434},{"alt_m":10506.0,"lat":43.363542,"lon":-79.901417,"t":1780943435},{"alt_m":10504.1,"lat":43.364157,"lon":-79.898647,"t":1780943436},{"alt_m":10496.5,"lat":43.364773,"lon":-79.895878,"t":1780943437},{"alt_m":10499.4,"lat":43.365388,"lon":-79.893108,"t":1780943438},{"alt_m":10491.6,"lat":43.366004,"lon":-79.890338,"t":1780943439},{"alt_m":10503.2,"lat":43.36662,"lon":-79.887568,"t":1780943440},{"alt_m":10500.5,"lat":43.367235,"lon":-79.884799,"t":1780943441},{"alt_m":10494.8,"lat":43.367851,"lon":-79.882029,"t":1780943442},{"alt_m":10502.5,"lat":43.368467,"lon":-79.879259,"t":1780943443},{"alt_m":10506.3,"lat":43.369082,"lon":-79.876489,"t":1780943444},{"alt_m":10493.6,"lat":43.369698,"lon":-79.87372,"t":1780943445},{"alt_m":10496.7,"lat":43.370313,"lon":-79.87095,"t":1780943446},{"alt_m":10499.7,"lat":43.370929,"lon":-79.86818,"t":1780943447},{"alt_m":10495.8,"lat":43.371545,"lon":-79.86541,"t":1780943448},{"alt_m":10496.7,"lat":43.37216,"lon":-79.862641,"t":1780943449},{"alt_m":10494.9,"lat":43.372776,"lon":-79.859871,"t":1780943450},{"alt_m":10491.4,"lat":43.373392,"lon":-79.857101,"t":1780943451},{"alt_m":10493.3,"lat":43.374007,"lon":-79.854331,"t":1780943452},{"alt_m":10494.9,"lat":43.374623,"lon":-79.851561,"t":1780943453},{"alt_m":10503.6,"lat":43.375238,"lon":-79.848792,"t":1780943454},{"alt_m":10493.1,"lat":43.375854,"lon":-79.846022,"t":1780943455},{"alt_m":10493.7,"lat":43.37647,"lon":-79.843252,"t":1780943456},{"alt_m":10506.6,"lat":43.377085,"lon":-79.840482,"t":1780943457},{"alt_m":10498.8,"lat":43.377701,"lon":-79.837713,"t":1780943458},{"alt_m":10502.5,"lat":43.378316,"lon":-79.834943,"t":1780943459},{"alt_m":10492.6,"lat":43.378932,"lon":-79.832173,"t":1780943460},{"alt_m":10507.3,"lat":43.379548,"lon":-79.829403,"t":1780943461},{"alt_m":10507.8,"lat":43.380163,"lon":-79.826634,"t":1780943462},{"alt_m":10500.0,"lat":43.380779,"lon":-79.823864,"t":1780943463},{"alt_m":10506.9,"lat":43.381395,"lon":-79.821094,"t":1780943464},{"alt_m":10508.8,"lat":43.38201,"lon":-79.818324,"t":1780943465},{"alt_m":10494.3,"lat":43.382626,"lon":-79.815554,"t":1780943466},{"alt_m":10500.5,"lat":43.383241,"lon":-79.812785,"t":1780943467},{"alt_m":10497.6,"lat":43.383857,"lon":-79.810015,"t":1780943468},{"alt_m":10495.5,"lat":43.384473,"lon":-79.807245,"t":1780943469},{"alt_m":10491.2,"lat":43.385088,"lon":-79.804475,"t":1780943470},{"alt_m":10495.4,"lat":43.385704,"lon":-79.801706,"t":1780943471},{"alt_m":10507.2,"lat":43.38632,"lon":-79.798936,"t":1780943472},{"alt_m":10495.2,"lat":43.386935,"lon":-79.796166,"t":1780943473},{"alt_m":10509.5,"lat":43.387551,"lon":-79.793396,"t":1780943474},{"alt_m":10507.3,"lat":43.388166,"lon":-79.790627,"t":1780943475},{"alt_m":10509.0,"lat":43.388782,"lon":-79.787857,"t":1780943476},{"alt_m":10505.6,"lat":43.389398,"lon":-79.785087,"t":1780943477},{"alt_m":10505.1,"lat":43.390013,"lon":-79.782317,"t":1780943478},{"alt_m":10506.4,"lat":43.390629,"lon":-79.779548,"t":1780943479},{"alt_m":10495.1,"lat":43.391244,"lon":-79.776778,"t":1780943480},{"alt_m":10502.6,"lat":43.39186,"lon":-79.774008,"t":1780943481},{"alt_m":10497.8,"lat":43.392476,"lon":-79.771238,"t":1780943482},{"alt_m":10505.5,"lat":43.393091,"lon":-79.768468,"t":1780943483},{"alt_m":10508.9,"lat":43.393707,"lon":-79.765699,"t":1780943484},{"alt_m":10505.6,"lat":43.394323,"lon":-79.762929,"t":1780943485},{"alt_m":10502.7,"lat":43.394938,"lon":-79.760159,"t":1780943486},{"alt_m":10502.2,"lat":43.395554,"lon":-79.757389,"t":1780943487},{"alt_m":10491.5,"lat":43.396169,"lon":-79.75462,"t":1780943488},{"alt_m":10492.5,"lat":43.396785,"lon":-79.75185,"t":1780943489},{"alt_m":10497.0,"lat":43.397401,"lon":-79.74908,"t":1780943490},{"alt_m":10492.7,"lat":43.398016,"lon":-79.74631,"t":1780943491},{"alt_m":10503.4,"lat":43.398632,"lon":-79.743541,"t":1780943492},{"alt_m":10504.7,"lat":43.399248,"lon":-79.740771,"t":1780943493},{"alt_m":10495.6,"lat":43.399863,"lon":-79.738001,"t":1780943494},{"alt_m":10504.1,"lat":43.400479,"lon":-79.735231,"t":1780943495},{"alt_m":10496.4,"lat":43.401094,"lon":-79.732462,"t":1780943496},{"alt_m":10501.1,"lat":43.40171,"lon":-79.729692,"t":1780943497},{"alt_m":10494.9,"lat":43.402326,"lon":-79.726922,"t":1780943498},{"alt_m":10507.6,"lat":43.402941,"lon":-79.724152,"t":1780943499},{"alt_m":10502.2,"lat":43.403557,"lon":-79.721382,"t":1780943500},{"alt_m":10509.2,"lat":43.404172,"lon":-79.718613,"t":1780943501},{"alt_m":10508.3,"lat":43.404788,"lon":-79.715843,"t":1780943502},{"alt_m":10509.2,"lat":43.405404,"lon":-79.713073,"t":1780943503},{"alt_m":10505.9,"lat":43.406019,"lon":-79.710303,"t":1780943504},{"alt_m":10498.5,"lat":43.406635,"lon":-79.707534,"t":1780943505},{"alt_m":10500.9,"lat":43.407251,"lon":-79.704764,"t":1780943506},{"alt_m":10501.1,"lat":43.407866,"lon":-79.701994,"t":1780943507},{"alt_m":10499.6,"lat":43.408482,"lon":-79.699224,"t":1780943508},{"alt_m":10490.1,"lat":43.409097,"lon":-79.696455,"t":1780943509},{"alt_m":10498.4,"lat":43.409713,"lon":-79.693685,"t":1780943510},{"alt_m":10498.4,"lat":43.410329,"lon":-79.690915,"t":1780943511},{"alt_m":10505.4,"lat":43.410944,"lon":-79.688145,"t":1780943512},{"alt_m":10501.7,"lat":43.41156,"lon":-79.685375,"t":1780943513},{"alt_m":10493.5,"lat":43.412176,"lon":-79.682606,"t":1780943514},{"alt_m":10502.0,"lat":43.412791,"lon":-79.679836,"t":1780943515},{"alt_m":10492.7,"lat":43.413407,"lon":-79.677066,"t":1780943516},{"alt_m":10509.5,"lat":43.414022,"lon":-79.674296,"t":1780943517},{"alt_m":10509.6,"lat":43.414638,"lon":-79.671527,"t":1780943518},{"alt_m":10499.4,"lat":43.415254,"lon":-79.668757,"t":1780943519},{"alt_m":10498.8,"lat":43.415869,"lon":-79.665987,"t":1780943520},{"alt_m":10507.5,"lat":43.416485,"lon":-79.663217,"t":1780943521},{"alt_m":10499.6,"lat":43.4171,"lon":-79.660448,"t":1780943522},{"alt_m":10509.6,"lat":43.417716,"lon":-79.657678,"t":1780943523},{"alt_m":10492.0,"lat":43.418332,"lon":-79.654908,"t":1780943524},{"alt_m":10509.5,"lat":43.418947,"lon":-79.652138,"t":1780943525},{"alt_m":10505.6,"lat":43.419563,"lon":-79.649369,"t":1780943526},{"alt_m":10502.4,"lat":43.420179,"lon":-79.646599,"t":1780943527},{"alt_m":10490.7,"lat":43.420794,"lon":-79.643829,"t":1780943528},{"alt_m":10492.4,"lat":43.42141,"lon":-79.641059,"t":1780943529},{"alt_m":10491.9,"lat":43.422025,"lon":-79.638289,"t":1780943530},{"alt_m":10490.1,"lat":43.422641,"lon":-79.63552,"t":1780943531},{"alt_m":10498.6,"lat":43.423257,"lon":-79.63275,"t":1780943532},{"alt_m":10494.0,"lat":43.423872,"lon":-79.62998,"t":1780943533},{"alt_m":10493.8,"lat":43.424488,"lon":-79.62721,"t":1780943534},{"alt_m":10508.3,"lat":43.425104,"lon":-79.624441,"t":1780943535},{"alt_m":10490.8,"lat":43.425719,"lon":-79.621671,"t":1780943536},{"alt_m":10503.5,"lat":43.426335,"lon":-79.618901,"t":1780943537},{"alt_m":10503.2,"lat":43.42695,"lon":-79.616131,"t":1780943538},{"alt_m":10501.4,"lat":43.427566,"lon":-79.613362,"t":1780943539},{"alt_m":10491.4,"lat":43.428182,"lon":-79.610592,"t":1780943540},{"alt_m":10495.3,"lat":43.428797,"lon":-79.607822,"t":1780943541},{"alt_m":10500.7,"lat":43.429413,"lon":-79.605052,"t":1780943542},{"alt_m":10491.2,"lat":43.430028,"lon":-79.602282,"t":1780943543},{"alt_m":10494.2,"lat":43.430644,"lon":-79.599513,"t":1780943544},{"alt_m":10490.2,"lat":43.43126,"lon":-79.596743,"t":1780943545},{"alt_m":10499.1,"lat":43.431875,"lon":-79.593973,"t":1780943546},{"alt_m":10493.7,"lat":43.432491,"lon":-79.591203,"t":1780943547},{"alt_m":10501.4,"lat":43.433107,"lon":-79.588434,"t":1780943548},{"alt_m":10493.8,"lat":43.433722,"lon":-79.585664,"t":1780943549},{"alt_m":10495.3,"lat":43.434338,"lon":-79.582894,"t":1780943550},{"alt_m":10498.7,"lat":43.434953,"lon":-79.580124,"t":1780943551},{"alt_m":10501.9,"lat":43.435569,"lon":-79.577355,"t":1780943552},{"alt_m":10509.2,"lat":43.436185,"lon":-79.574585,"t":1780943553},{"alt_m":10507.2,"lat":43.4368,"lon":-79.571815,"t":1780943554},{"alt_m":10501.5,"lat":43.437416,"lon":-79.569045,"t":1780943555},{"alt_m":10504.4,"lat":43.438032,"lon":-79.566276,"t":1780943556},{"alt_m":10505.8,"lat":43.438647,"lon":-79.563506,"t":1780943557},{"alt_m":10503.6,"lat":43.439263,"lon":-79.560736,"t":1780943558},{"alt_m":10499.2,"lat":43.439878,"lon":-79.557966,"t":1780943559},{"alt_m":10494.9,"lat":43.440494,"lon":-79.555196,"t":1780943560},{"alt_m":10502.2,"lat":43.44111,"lon":-79.552427,"t":1780943561},{"alt_m":10491.0,"lat":43.441725,"lon":-79.549657,"t":1780943562},{"alt_m":10495.0,"lat":43.442341,"lon":-79.546887,"t":1780943563},{"alt_m":10491.7,"lat":43.442956,"lon":-79.544117,"t":1780943564},{"alt_m":10491.5,"lat":43.443572,"lon":-79.541348,"t":1780943565},{"alt_m":10497.9,"lat":43.444188,"lon":-79.538578,"t":1780943566},{"alt_m":10496.1,"lat":43.444803,"lon":-79.535808,"t":1780943567},{"alt_m":10493.4,"lat":43.445419,"lon":-79.533038,"t":1780943568},{"alt_m":10501.0,"lat":43.446035,"lon":-79.530269,"t":1780943569},{"alt_m":10502.8,"lat":43.44665,"lon":-79.527499,"t":1780943570},{"alt_m":10498.1,"lat":43.447266,"lon":-79.524729,"t":1780943571},{"alt_m":10497.1,"lat":43.447881,"lon":-79.521959,"t":1780943572},{"alt_m":10509.6,"lat":43.448497,"lon":-79.51919,"t":1780943573},{"alt_m":10508.2,"lat":43.449113,"lon":-79.51642,"t":1780943574},{"alt_m":10497.7,"lat":43.449728,"lon":-79.51365,"t":1780943575},{"alt_m":10497.8,"lat":43.450344,"lon":-79.51088,"t":1780943576},{"alt_m":10500.4,"lat":43.45096,"lon":-79.50811,"t":1780943577},{"alt_m":10508.8,"lat":43.451575,"lon":-79.505341,"t":1780943578},{"alt_m":10508.8,"lat":43.452191,"lon":-79.502571,"t":1780943579},{"alt_m":10500.0,"lat":43.452806,"lon":-79.499801,"t":1780943580},{"alt_m":10496.9,"lat":43.453422,"lon":-79.497031,"t":1780943581},{"alt_m":10496.9,"lat":43.454038,"lon":-79.494262,"t":1780943582},{"alt_m":10505.8,"lat":43.454653,"lon":-79.491492,"t":1780943583},{"alt_m":10494.3,"lat":43.455269,"lon":-79.488722,"t":1780943584},{"alt_m":10491.2,"lat":43.455884,"lon":-79.485952,"t":1780943585},{"alt_m":10501.5,"lat":43.4565,"lon":-79.483183,"t":1780943586},{"alt_m":10500.5,"lat":43.457116,"lon":-79.480413,"t":1780943587},{"alt_m":10503.1,"lat":43.457731,"lon":-79.477643,"t":1780943588},{"alt_m":10499.0,"lat":43.458347,"lon":-79.474873,"t":1780943589},{"alt_m":10503.2,"lat":43.458963,"lon":-79.472103,"t":1780943590},{"alt_m":10501.5,"lat":43.459578,"lon":-79.469334,"t":1780943591},{"alt_m":10499.4,"lat":43.460194,"lon":-79.466564,"t":1780943592},{"alt_m":10509.8,"lat":43.460809,"lon":-79.463794,"t":1780943593},{"alt_m":10490.7,"lat":43.461425,"lon":-79.461024,"t":1780943594},{"alt_m":10497.7,"lat":43.462041,"lon":-79.458255,"t":1780943595},{"alt_m":10499.0,"lat":43.462656,"lon":-79.455485,"t":1780943596},{"alt_m":10509.1,"lat":43.463272,"lon":-79.452715,"t":1780943597},{"alt_m":10509.1,"lat":43.463888,"lon":-79.449945,"t":1780943598},{"alt_m":10495.4,"lat":43.464503,"lon":-79.447176,"t":1780943599},{"alt_m":10505.3,"lat":43.465119,"lon":-79.444406,"t":1780943600},{"alt_m":10509.3,"lat":43.465734,"lon":-79.441636,"t":1780943601},{"alt_m":10491.4,"lat":43.46635,"lon":-79.438866,"t":1780943602},{"alt_m":10501.5,"lat":43.466966,"lon":-79.436097,"t":1780943603},{"alt_m":10502.9,"lat":43.467581,"lon":-79.433327,"t":1780943604},{"alt_m":10509.2,"lat":43.468197,"lon":-79.430557,"t":1780943605},{"alt_m":10505.4,"lat":43.468812,"lon":-79.427787,"t":1780943606},{"alt_m":10504.6,"lat":43.469428,"lon":-79.425017,"t":1780943607},{"alt_m":10508.7,"lat":43.470044,"lon":-79.422248,"t":1780943608},{"alt_m":10498.0,"lat":43.470659,"lon":-79.419478,"t":1780943609},{"alt_m":10502.2,"lat":43.471275,"lon":-79.416708,"t":1780943610},{"alt_m":10502.7,"lat":43.471891,"lon":-79.413938,"t":1780943611},{"alt_m":10501.3,"lat":43.472506,"lon":-79.411169,"t":1780943612},{"alt_m":10491.8,"lat":43.473122,"lon":-79.408399,"t":1780943613},{"alt_m":10497.1,"lat":43.473737,"lon":-79.405629,"t":1780943614},{"alt_m":10493.1,"lat":43.474353,"lon":-79.402859,"t":1780943615},{"alt_m":10497.9,"lat":43.474969,"lon":-79.40009,"t":1780943616},{"alt_m":10499.8,"lat":43.475584,"lon":-79.39732,"t":1780943617},{"alt_m":10500.8,"lat":43.4762,"lon":-79.39455,"t":1780943618},{"alt_m":10491.3,"lat":43.476816,"lon":-79.39178,"t":1780943619},{"alt_m":10493.8,"lat":43.477431,"lon":-79.38901,"t":1780943620},{"alt_m":10502.9,"lat":43.478047,"lon":-79.386241,"t":1780943621},{"alt_m":10496.3,"lat":43.478662,"lon":-79.383471,"t":1780943622},{"alt_m":10493.7,"lat":43.479278,"lon":-79.380701,"t":1780943623},{"alt_m":10498.9,"lat":43.479894,"lon":-79.377931,"t":1780943624},{"alt_m":10502.0,"lat":43.480509,"lon":-79.375162,"t":1780943625},{"alt_m":10499.9,"lat":43.481125,"lon":-79.372392,"t":1780943626},{"alt_m":10503.8,"lat":43.48174,"lon":-79.369622,"t":1780943627},{"alt_m":10505.0,"lat":43.482356,"lon":-79.366852,"t":1780943628},{"alt_m":10494.4,"lat":43.482972,"lon":-79.364083,"t":1780943629},{"alt_m":10503.2,"lat":43.483587,"lon":-79.361313,"t":1780943630},{"alt_m":10504.4,"lat":43.484203,"lon":-79.358543,"t":1780943631},{"alt_m":10494.5,"lat":43.484819,"lon":-79.355773,"t":1780943632},{"alt_m":10493.0,"lat":43.485434,"lon":-79.353004,"t":1780943633},{"alt_m":10491.6,"lat":43.48605,"lon":-79.350234,"t":1780943634},{"alt_m":10501.8,"lat":43.486665,"lon":-79.347464,"t":1780943635},{"alt_m":10494.6,"lat":43.487281,"lon":-79.344694,"t":1780943636},{"alt_m":10494.3,"lat":43.487897,"lon":-79.341924,"t":1780943637},{"alt_m":10494.8,"lat":43.488512,"lon":-79.339155,"t":1780943638},{"alt_m":10491.1,"lat":43.489128,"lon":-79.336385,"t":1780943639}]},{"anomaly":{"band":"mildly unusual","reasons":["mean altitude 3553 m deviates 1.4σ from the local baseline (8697 m)","vector novelty 1.00: no similar track in RuVector memory"],"score":0.339},"callsign":"SKV808","icao24":"c08f08","overhead":true,"points":[{"alt_m":4594.4,"lat":43.319779,"lon":-79.884476,"t":1780947900},{"alt_m":4587.2,"lat":43.320898,"lon":-79.883438,"t":1780947901},{"alt_m":4581.5,"lat":43.322017,"lon":-79.8824,"t":1780947902},{"alt_m":4572.6,"lat":43.323136,"lon":-79.881362,"t":1780947903},{"alt_m":4577.9,"lat":43.324255,"lon":-79.880324,"t":1780947904},{"alt_m":4556.4,"lat":43.325374,"lon":-79.879285,"t":1780947905},{"alt_m":4551.6,"lat":43.326493,"lon":-79.878247,"t":1780947906},{"alt_m":4553.3,"lat":43.327612,"lon":-79.877209,"t":1780947907},{"alt_m":4547.1,"lat":43.328731,"lon":-79.876171,"t":1780947908},{"alt_m":4545.8,"lat":43.32985,"lon":-79.875133,"t":1780947909},{"alt_m":4528.6,"lat":43.330969,"lon":-79.874094,"t":1780947910},{"alt_m":4514.4,"lat":43.332088,"lon":-79.873056,"t":1780947911},{"alt_m":4510.2,"lat":43.333206,"lon":-79.872018,"t":1780947912},{"alt_m":4518.9,"lat":43.334325,"lon":-79.87098,"t":1780947913},{"alt_m":4495.0,"lat":43.335444,"lon":-79.869942,"t":1780947914},{"alt_m":4489.6,"lat":43.336563,"lon":-79.868903,"t":1780947915},{"alt_m":4480.6,"lat":43.337682,"lon":-79.867865,"t":1780947916},{"alt_m":4472.3,"lat":43.338801,"lon":-79.866827,"t":1780947917},{"alt_m":4477.1,"lat":43.33992,"lon":-79.865789,"t":1780947918},{"alt_m":4468.3,"lat":43.341039,"lon":-79.864751,"t":1780947919},{"alt_m":4464.7,"lat":43.342158,"lon":-79.863712,"t":1780947920},{"alt_m":4460.5,"lat":43.343277,"lon":-79.862674,"t":1780947921},{"alt_m":4440.0,"lat":43.344396,"lon":-79.861636,"t":1780947922},{"alt_m":4445.5,"lat":43.345515,"lon":-79.860598,"t":1780947923},{"alt_m":4440.9,"lat":43.346634,"lon":-79.85956,"t":1780947924},{"alt_m":4416.2,"lat":43.347753,"lon":-79.858521,"t":1780947925},{"alt_m":4425.4,"lat":43.348872,"lon":-79.857483,"t":1780947926},{"alt_m":4409.5,"lat":43.349991,"lon":-79.856445,"t":1780947927},{"alt_m":4400.7,"lat":43.35111,"lon":-79.855407,"t":1780947928},{"alt_m":4392.6,"lat":43.352229,"lon":-79.854368,"t":1780947929},{"alt_m":4384.0,"lat":43.353348,"lon":-79.85333,"t":1780947930},{"alt_m":4380.3,"lat":43.354467,"lon":-79.852292,"t":1780947931},{"alt_m":4383.4,"lat":43.355586,"lon":-79.851254,"t":1780947932},{"alt_m":4362.1,"lat":43.356705,"lon":-79.850216,"t":1780947933},{"alt_m":4354.2,"lat":43.357824,"lon":-79.849177,"t":1780947934},{"alt_m":4349.9,"lat":43.358943,"lon":-79.848139,"t":1780947935},{"alt_m":4349.0,"lat":43.360062,"lon":-79.847101,"t":1780947936},{"alt_m":4336.8,"lat":43.361181,"lon":-79.846063,"t":1780947937},{"alt_m":4343.6,"lat":43.3623,"lon":-79.845025,"t":1780947938},{"alt_m":4317.3,"lat":43.363419,"lon":-79.843986,"t":1780947939},{"alt_m":4315.9,"lat":43.364538,"lon":-79.842948,"t":1780947940},{"alt_m":4304.0,"lat":43.365657,"lon":-79.84191,"t":1780947941},{"alt_m":4302.5,"lat":43.366776,"lon":-79.840872,"t":1780947942},{"alt_m":4294.8,"lat":43.367895,"lon":-79.839834,"t":1780947943},{"alt_m":4289.3,"lat":43.369014,"lon":-79.838795,"t":1780947944},{"alt_m":4293.5,"lat":43.370133,"lon":-79.837757,"t":1780947945},{"alt_m":4272.3,"lat":43.371252,"lon":-79.836719,"t":1780947946},{"alt_m":4264.1,"lat":43.372371,"lon":-79.835681,"t":1780947947},{"alt_m":4270.9,"lat":43.37349,"lon":-79.834643,"t":1780947948},{"alt_m":4262.7,"lat":43.374609,"lon":-79.833604,"t":1780947949},{"alt_m":4259.3,"lat":43.375728,"lon":-79.832566,"t":1780947950},{"alt_m":4235.4,"lat":43.376847,"lon":-79.831528,"t":1780947951},{"alt_m":4234.0,"lat":43.377966,"lon":-79.83049,"t":1780947952},{"alt_m":4227.3,"lat":43.379085,"lon":-79.829452,"t":1780947953},{"alt_m":4229.1,"lat":43.380204,"lon":-79.828413,"t":1780947954},{"alt_m":4221.1,"lat":43.381323,"lon":-79.827375,"t":1780947955},{"alt_m":4210.7,"lat":43.382442,"lon":-79.826337,"t":1780947956},{"alt_m":4192.5,"lat":43.383561,"lon":-79.825299,"t":1780947957},{"alt_m":4190.4,"lat":43.38468,"lon":-79.82426,"t":1780947958},{"alt_m":4194.5,"lat":43.385799,"lon":-79.823222,"t":1780947959},{"alt_m":4176.4,"lat":43.386918,"lon":-79.822184,"t":1780947960},{"alt_m":4181.1,"lat":43.388037,"lon":-79.821146,"t":1780947961},{"alt_m":4156.7,"lat":43.389156,"lon":-79.820108,"t":1780947962},{"alt_m":4167.3,"lat":43.390275,"lon":-79.819069,"t":1780947963},{"alt_m":4158.0,"lat":43.391394,"lon":-79.818031,"t":1780947964},{"alt_m":4144.6,"lat":43.392513,"lon":-79.816993,"t":1780947965},{"alt_m":4136.4,"lat":43.393632,"lon":-79.815955,"t":1780947966},{"alt_m":4132.4,"lat":43.394751,"lon":-79.814917,"t":1780947967},{"alt_m":4124.5,"lat":43.39587,"lon":-79.813878,"t":1780947968},{"alt_m":4120.8,"lat":43.396989,"lon":-79.81284,"t":1780947969},{"alt_m":4118.7,"lat":43.398108,"lon":-79.811802,"t":1780947970},{"alt_m":4106.5,"lat":43.399227,"lon":-79.810764,"t":1780947971},{"alt_m":4089.7,"lat":43.400346,"lon":-79.809726,"t":1780947972},{"alt_m":4087.5,"lat":43.401465,"lon":-79.808687,"t":1780947973},{"alt_m":4088.4,"lat":43.402584,"lon":-79.807649,"t":1780947974},{"alt_m":4065.1,"lat":43.403703,"lon":-79.806611,"t":1780947975},{"alt_m":4076.4,"lat":43.404822,"lon":-79.805573,"t":1780947976},{"alt_m":4051.2,"lat":43.405941,"lon":-79.804535,"t":1780947977},{"alt_m":4046.5,"lat":43.40706,"lon":-79.803496,"t":1780947978},{"alt_m":4050.4,"lat":43.408179,"lon":-79.802458,"t":1780947979},{"alt_m":4039.8,"lat":43.409298,"lon":-79.80142,"t":1780947980},{"alt_m":4023.8,"lat":43.410417,"lon":-79.800382,"t":1780947981},{"alt_m":4023.6,"lat":43.411536,"lon":-79.799344,"t":1780947982},{"alt_m":4025.7,"lat":43.412655,"lon":-79.798305,"t":1780947983},{"alt_m":4021.4,"lat":43.413774,"lon":-79.797267,"t":1780947984},{"alt_m":4011.2,"lat":43.414893,"lon":-79.796229,"t":1780947985},{"alt_m":3988.6,"lat":43.416012,"lon":-79.795191,"t":1780947986},{"alt_m":3992.1,"lat":43.417131,"lon":-79.794152,"t":1780947987},{"alt_m":3977.9,"lat":43.41825,"lon":-79.793114,"t":1780947988},{"alt_m":3971.3,"lat":43.419369,"lon":-79.792076,"t":1780947989},{"alt_m":3976.7,"lat":43.420488,"lon":-79.791038,"t":1780947990},{"alt_m":3961.8,"lat":43.421607,"lon":-79.79,"t":1780947991},{"alt_m":3965.9,"lat":43.422726,"lon":-79.788961,"t":1780947992},{"alt_m":3954.0,"lat":43.423845,"lon":-79.787923,"t":1780947993},{"alt_m":3944.5,"lat":43.424964,"lon":-79.786885,"t":1780947994},{"alt_m":3927.0,"lat":43.426083,"lon":-79.785847,"t":1780947995},{"alt_m":3919.3,"lat":43.427202,"lon":-79.784809,"t":1780947996},{"alt_m":3914.4,"lat":43.428321,"lon":-79.78377,"t":1780947997},{"alt_m":3918.3,"lat":43.42944,"lon":-79.782732,"t":1780947998},{"alt_m":3900.1,"lat":43.430559,"lon":-79.781694,"t":1780947999},{"alt_m":3894.5,"lat":43.431678,"lon":-79.780656,"t":1780948000},{"alt_m":3897.8,"lat":43.432797,"lon":-79.779618,"t":1780948001},{"alt_m":3885.8,"lat":43.433916,"lon":-79.778579,"t":1780948002},{"alt_m":3885.1,"lat":43.435035,"lon":-79.777541,"t":1780948003},{"alt_m":3869.1,"lat":43.436154,"lon":-79.776503,"t":1780948004},{"alt_m":3861.3,"lat":43.437273,"lon":-79.775465,"t":1780948005},{"alt_m":3848.4,"lat":43.438392,"lon":-79.774427,"t":1780948006},{"alt_m":3852.1,"lat":43.439511,"lon":-79.773388,"t":1780948007},{"alt_m":3842.6,"lat":43.44063,"lon":-79.77235,"t":1780948008},{"alt_m":3835.8,"lat":43.441749,"lon":-79.771312,"t":1780948009},{"alt_m":3836.7,"lat":43.442868,"lon":-79.770274,"t":1780948010},{"alt_m":3832.3,"lat":43.443987,"lon":-79.769236,"t":1780948011},{"alt_m":3811.1,"lat":43.445106,"lon":-79.768197,"t":1780948012},{"alt_m":3801.5,"lat":43.446225,"lon":-79.767159,"t":1780948013},{"alt_m":3795.1,"lat":43.447344,"lon":-79.766121,"t":1780948014},{"alt_m":3787.6,"lat":43.448462,"lon":-79.765083,"t":1780948015},{"alt_m":3782.3,"lat":43.449581,"lon":-79.764044,"t":1780948016},{"alt_m":3779.4,"lat":43.4507,"lon":-79.763006,"t":1780948017},{"alt_m":3780.5,"lat":43.451819,"lon":-79.761968,"t":1780948018},{"alt_m":3771.9,"lat":43.452938,"lon":-79.76093,"t":1780948019},{"alt_m":3766.1,"lat":43.454057,"lon":-79.759892,"t":1780948020},{"alt_m":3744.6,"lat":43.455176,"lon":-79.758853,"t":1780948021},{"alt_m":3743.0,"lat":43.456295,"lon":-79.757815,"t":1780948022},{"alt_m":3737.2,"lat":43.457414,"lon":-79.756777,"t":1780948023},{"alt_m":3730.7,"lat":43.458533,"lon":-79.755739,"t":1780948024},{"alt_m":3732.6,"lat":43.459652,"lon":-79.754701,"t":1780948025},{"alt_m":3720.3,"lat":43.460771,"lon":-79.753662,"t":1780948026},{"alt_m":3704.1,"lat":43.46189,"lon":-79.752624,"t":1780948027},{"alt_m":3696.9,"lat":43.463009,"lon":-79.751586,"t":1780948028},{"alt_m":3699.1,"lat":43.464128,"lon":-79.750548,"t":1780948029},{"alt_m":3686.1,"lat":43.465247,"lon":-79.74951,"t":1780948030},{"alt_m":3692.7,"lat":43.466366,"lon":-79.748471,"t":1780948031},{"alt_m":3671.8,"lat":43.467485,"lon":-79.747433,"t":1780948032},{"alt_m":3676.7,"lat":43.468604,"lon":-79.746395,"t":1780948033},{"alt_m":3669.9,"lat":43.469723,"lon":-79.745357,"t":1780948034},{"alt_m":3648.5,"lat":43.470842,"lon":-79.744319,"t":1780948035},{"alt_m":3654.5,"lat":43.471961,"lon":-79.74328,"t":1780948036},{"alt_m":3644.4,"lat":43.47308,"lon":-79.742242,"t":1780948037},{"alt_m":3628.0,"lat":43.474199,"lon":-79.741204,"t":1780948038},{"alt_m":3624.9,"lat":43.475318,"lon":-79.740166,"t":1780948039},{"alt_m":3615.7,"lat":43.476437,"lon":-79.739128,"t":1780948040},{"alt_m":3606.7,"lat":43.477556,"lon":-79.738089,"t":1780948041},{"alt_m":3602.9,"lat":43.478675,"lon":-79.737051,"t":1780948042},{"alt_m":3591.5,"lat":43.479794,"lon":-79.736013,"t":1780948043},{"alt_m":3595.6,"lat":43.480913,"lon":-79.734975,"t":1780948044},{"alt_m":3589.6,"lat":43.482032,"lon":-79.733936,"t":1780948045},{"alt_m":3583.5,"lat":43.483151,"lon":-79.732898,"t":1780948046},{"alt_m":3573.7,"lat":43.48427,"lon":-79.73186,"t":1780948047},{"alt_m":3557.1,"lat":43.485389,"lon":-79.730822,"t":1780948048},{"alt_m":3566.7,"lat":43.486508,"lon":-79.729784,"t":1780948049},{"alt_m":3547.2,"lat":43.487627,"lon":-79.728745,"t":1780948050},{"alt_m":3541.9,"lat":43.488746,"lon":-79.727707,"t":1780948051},{"alt_m":3538.2,"lat":43.489865,"lon":-79.726669,"t":1780948052},{"alt_m":3519.2,"lat":43.490984,"lon":-79.725631,"t":1780948053},{"alt_m":3527.2,"lat":43.492103,"lon":-79.724593,"t":1780948054},{"alt_m":3512.7,"lat":43.493222,"lon":-79.723554,"t":1780948055},{"alt_m":3503.5,"lat":43.494341,"lon":-79.722516,"t":1780948056},{"alt_m":3494.3,"lat":43.49546,"lon":-79.721478,"t":1780948057},{"alt_m":3491.3,"lat":43.496579,"lon":-79.72044,"t":1780948058},{"alt_m":3477.1,"lat":43.497698,"lon":-79.719402,"t":1780948059},{"alt_m":3473.9,"lat":43.498817,"lon":-79.718363,"t":1780948060},{"alt_m":3473.8,"lat":43.499936,"lon":-79.717325,"t":1780948061},{"alt_m":3461.5,"lat":43.501055,"lon":-79.716287,"t":1780948062},{"alt_m":3465.4,"lat":43.502174,"lon":-79.715249,"t":1780948063},{"alt_m":3449.0,"lat":43.503293,"lon":-79.714211,"t":1780948064},{"alt_m":3452.3,"lat":43.504412,"lon":-79.713172,"t":1780948065},{"alt_m":3444.0,"lat":43.505531,"lon":-79.712134,"t":1780948066},{"alt_m":3422.0,"lat":43.50665,"lon":-79.711096,"t":1780948067},{"alt_m":3417.2,"lat":43.507769,"lon":-79.710058,"t":1780948068},{"alt_m":3425.7,"lat":43.508888,"lon":-79.70902,"t":1780948069},{"alt_m":3405.2,"lat":43.510007,"lon":-79.707981,"t":1780948070},{"alt_m":3394.4,"lat":43.511126,"lon":-79.706943,"t":1780948071},{"alt_m":3399.9,"lat":43.512245,"lon":-79.705905,"t":1780948072},{"alt_m":3379.4,"lat":43.513364,"lon":-79.704867,"t":1780948073},{"alt_m":3387.4,"lat":43.514483,"lon":-79.703829,"t":1780948074},{"alt_m":3380.2,"lat":43.515602,"lon":-79.70279,"t":1780948075},{"alt_m":3370.0,"lat":43.516721,"lon":-79.701752,"t":1780948076},{"alt_m":3356.1,"lat":43.51784,"lon":-79.700714,"t":1780948077},{"alt_m":3352.9,"lat":43.518959,"lon":-79.699676,"t":1780948078},{"alt_m":3337.7,"lat":43.520078,"lon":-79.698637,"t":1780948079},{"alt_m":3347.6,"lat":43.521197,"lon":-79.697599,"t":1780948080},{"alt_m":3328.3,"lat":43.522316,"lon":-79.696561,"t":1780948081},{"alt_m":3316.1,"lat":43.523435,"lon":-79.695523,"t":1780948082},{"alt_m":3316.0,"lat":43.524554,"lon":-79.694485,"t":1780948083},{"alt_m":3308.5,"lat":43.525673,"lon":-79.693446,"t":1780948084},{"alt_m":3313.1,"lat":43.526792,"lon":-79.692408,"t":1780948085},{"alt_m":3297.7,"lat":43.527911,"lon":-79.69137,"t":1780948086},{"alt_m":3291.0,"lat":43.52903,"lon":-79.690332,"t":1780948087},{"alt_m":3287.9,"lat":43.530149,"lon":-79.689294,"t":1780948088},{"alt_m":3272.5,"lat":43.531268,"lon":-79.688255,"t":1780948089},{"alt_m":3266.2,"lat":43.532387,"lon":-79.687217,"t":1780948090},{"alt_m":3270.6,"lat":43.533506,"lon":-79.686179,"t":1780948091},{"alt_m":3254.0,"lat":43.534625,"lon":-79.685141,"t":1780948092},{"alt_m":3249.9,"lat":43.535744,"lon":-79.684103,"t":1780948093},{"alt_m":3245.8,"lat":43.536863,"lon":-79.683064,"t":1780948094},{"alt_m":3230.3,"lat":43.537982,"lon":-79.682026,"t":1780948095},{"alt_m":3229.9,"lat":43.539101,"lon":-79.680988,"t":1780948096},{"alt_m":3211.1,"lat":43.54022,"lon":-79.67995,"t":1780948097},{"alt_m":3222.6,"lat":43.541339,"lon":-79.678912,"t":1780948098},{"alt_m":3206.0,"lat":43.542458,"lon":-79.677873,"t":1780948099},{"alt_m":3207.1,"lat":43.543577,"lon":-79.676835,"t":1780948100},{"alt_m":3202.1,"lat":43.544696,"lon":-79.675797,"t":1780948101},{"alt_m":3176.2,"lat":43.545815,"lon":-79.674759,"t":1780948102},{"alt_m":3174.5,"lat":43.546934,"lon":-79.673721,"t":1780948103},{"alt_m":3172.8,"lat":43.548053,"lon":-79.672682,"t":1780948104},{"alt_m":3157.2,"lat":43.549172,"lon":-79.671644,"t":1780948105},{"alt_m":3150.4,"lat":43.550291,"lon":-79.670606,"t":1780948106},{"alt_m":3159.1,"lat":43.55141,"lon":-79.669568,"t":1780948107},{"alt_m":3138.5,"lat":43.552529,"lon":-79.668529,"t":1780948108},{"alt_m":3137.4,"lat":43.553648,"lon":-79.667491,"t":1780948109},{"alt_m":3123.1,"lat":43.554767,"lon":-79.666453,"t":1780948110},{"alt_m":3123.2,"lat":43.555886,"lon":-79.665415,"t":1780948111},{"alt_m":3116.7,"lat":43.557005,"lon":-79.664377,"t":1780948112},{"alt_m":3101.0,"lat":43.558124,"lon":-79.663338,"t":1780948113},{"alt_m":3101.2,"lat":43.559243,"lon":-79.6623,"t":1780948114},{"alt_m":3101.9,"lat":43.560362,"lon":-79.661262,"t":1780948115},{"alt_m":3092.6,"lat":43.561481,"lon":-79.660224,"t":1780948116},{"alt_m":3080.6,"lat":43.5626,"lon":-79.659186,"t":1780948117},{"alt_m":3070.8,"lat":43.563719,"lon":-79.658147,"t":1780948118},{"alt_m":3059.3,"lat":43.564837,"lon":-79.657109,"t":1780948119},{"alt_m":3069.5,"lat":43.565956,"lon":-79.656071,"t":1780948120},{"alt_m":3047.9,"lat":43.567075,"lon":-79.655033,"t":1780948121},{"alt_m":3049.2,"lat":43.568194,"lon":-79.653995,"t":1780948122},{"alt_m":3044.2,"lat":43.569313,"lon":-79.652956,"t":1780948123},{"alt_m":3040.3,"lat":43.570432,"lon":-79.651918,"t":1780948124},{"alt_m":3020.6,"lat":43.571551,"lon":-79.65088,"t":1780948125},{"alt_m":3018.6,"lat":43.57267,"lon":-79.649842,"t":1780948126},{"alt_m":3007.6,"lat":43.573789,"lon":-79.648804,"t":1780948127},{"alt_m":3002.7,"lat":43.574908,"lon":-79.647765,"t":1780948128},{"alt_m":2988.4,"lat":43.576027,"lon":-79.646727,"t":1780948129},{"alt_m":2989.9,"lat":43.577146,"lon":-79.645689,"t":1780948130},{"alt_m":2987.3,"lat":43.578265,"lon":-79.644651,"t":1780948131},{"alt_m":2966.6,"lat":43.579384,"lon":-79.643613,"t":1780948132},{"alt_m":2963.8,"lat":43.580503,"lon":-79.642574,"t":1780948133},{"alt_m":2970.4,"lat":43.581622,"lon":-79.641536,"t":1780948134},{"alt_m":2950.7,"lat":43.582741,"lon":-79.640498,"t":1780948135},{"alt_m":2946.7,"lat":43.58386,"lon":-79.63946,"t":1780948136},{"alt_m":2934.6,"lat":43.584979,"lon":-79.638421,"t":1780948137},{"alt_m":2937.4,"lat":43.586098,"lon":-79.637383,"t":1780948138},{"alt_m":2925.3,"lat":43.587217,"lon":-79.636345,"t":1780948139},{"alt_m":2923.1,"lat":43.588336,"lon":-79.635307,"t":1780948140},{"alt_m":2913.4,"lat":43.589455,"lon":-79.634269,"t":1780948141},{"alt_m":2899.7,"lat":43.590574,"lon":-79.63323,"t":1780948142},{"alt_m":2892.5,"lat":43.591693,"lon":-79.632192,"t":1780948143},{"alt_m":2896.1,"lat":43.592812,"lon":-79.631154,"t":1780948144},{"alt_m":2889.5,"lat":43.593931,"lon":-79.630116,"t":1780948145},{"alt_m":2879.5,"lat":43.59505,"lon":-79.629078,"t":1780948146},{"alt_m":2867.2,"lat":43.596169,"lon":-79.628039,"t":1780948147},{"alt_m":2856.9,"lat":43.597288,"lon":-79.627001,"t":1780948148},{"alt_m":2854.6,"lat":43.598407,"lon":-79.625963,"t":1780948149},{"alt_m":2854.4,"lat":43.599526,"lon":-79.624925,"t":1780948150},{"alt_m":2851.6,"lat":43.600645,"lon":-79.623887,"t":1780948151},{"alt_m":2827.0,"lat":43.601764,"lon":-79.622848,"t":1780948152},{"alt_m":2837.7,"lat":43.602883,"lon":-79.62181,"t":1780948153},{"alt_m":2826.3,"lat":43.604002,"lon":-79.620772,"t":1780948154},{"alt_m":2822.3,"lat":43.605121,"lon":-79.619734,"t":1780948155},{"alt_m":2816.8,"lat":43.60624,"lon":-79.618696,"t":1780948156},{"alt_m":2799.3,"lat":43.607359,"lon":-79.617657,"t":1780948157},{"alt_m":2787.8,"lat":43.608478,"lon":-79.616619,"t":1780948158},{"alt_m":2785.2,"lat":43.609597,"lon":-79.615581,"t":1780948159},{"alt_m":2770.2,"lat":43.610716,"lon":-79.614543,"t":1780948160},{"alt_m":2769.9,"lat":43.611835,"lon":-79.613505,"t":1780948161},{"alt_m":2760.7,"lat":43.612954,"lon":-79.612466,"t":1780948162},{"alt_m":2751.1,"lat":43.614073,"lon":-79.611428,"t":1780948163},{"alt_m":2743.9,"lat":43.615192,"lon":-79.61039,"t":1780948164},{"alt_m":2738.3,"lat":43.616311,"lon":-79.609352,"t":1780948165},{"alt_m":2747.7,"lat":43.61743,"lon":-79.608313,"t":1780948166},{"alt_m":2731.5,"lat":43.618549,"lon":-79.607275,"t":1780948167},{"alt_m":2729.7,"lat":43.619668,"lon":-79.606237,"t":1780948168},{"alt_m":2712.2,"lat":43.620787,"lon":-79.605199,"t":1780948169},{"alt_m":2701.6,"lat":43.621906,"lon":-79.604161,"t":1780948170},{"alt_m":2706.4,"lat":43.623025,"lon":-79.603122,"t":1780948171},{"alt_m":2690.1,"lat":43.624144,"lon":-79.602084,"t":1780948172},{"alt_m":2688.4,"lat":43.625263,"lon":-79.601046,"t":1780948173},{"alt_m":2673.6,"lat":43.626382,"lon":-79.600008,"t":1780948174},{"alt_m":2670.9,"lat":43.627501,"lon":-79.59897,"t":1780948175},{"alt_m":2676.9,"lat":43.62862,"lon":-79.597931,"t":1780948176},{"alt_m":2652.4,"lat":43.629739,"lon":-79.596893,"t":1780948177},{"alt_m":2652.9,"lat":43.630858,"lon":-79.595855,"t":1780948178},{"alt_m":2639.9,"lat":43.631977,"lon":-79.594817,"t":1780948179},{"alt_m":2634.3,"lat":43.633096,"lon":-79.593779,"t":1780948180},{"alt_m":2637.7,"lat":43.634215,"lon":-79.59274,"t":1780948181},{"alt_m":2632.4,"lat":43.635334,"lon":-79.591702,"t":1780948182},{"alt_m":2614.9,"lat":43.636453,"lon":-79.590664,"t":1780948183},{"alt_m":2616.1,"lat":43.637572,"lon":-79.589626,"t":1780948184},{"alt_m":2601.2,"lat":43.638691,"lon":-79.588588,"t":1780948185},{"alt_m":2604.3,"lat":43.63981,"lon":-79.587549,"t":1780948186},{"alt_m":2589.9,"lat":43.640929,"lon":-79.586511,"t":1780948187},{"alt_m":2592.7,"lat":43.642048,"lon":-79.585473,"t":1780948188},{"alt_m":2584.6,"lat":43.643167,"lon":-79.584435,"t":1780948189},{"alt_m":2574.7,"lat":43.644286,"lon":-79.583397,"t":1780948190},{"alt_m":2557.8,"lat":43.645405,"lon":-79.582358,"t":1780948191},{"alt_m":2561.3,"lat":43.646524,"lon":-79.58132,"t":1780948192},{"alt_m":2558.8,"lat":43.647643,"lon":-79.580282,"t":1780948193},{"alt_m":2535.8,"lat":43.648762,"lon":-79.579244,"t":1780948194},{"alt_m":2526.7,"lat":43.649881,"lon":-79.578205,"t":1780948195},{"alt_m":2524.9,"lat":43.651,"lon":-79.577167,"t":1780948196},{"alt_m":2521.3,"lat":43.652119,"lon":-79.576129,"t":1780948197},{"alt_m":2517.8,"lat":43.653238,"lon":-79.575091,"t":1780948198},{"alt_m":2504.3,"lat":43.654357,"lon":-79.574053,"t":1780948199}]},{"anomaly":{"band":"strong anomaly","reasons":["heading 165° is 77° off the nearest known corridor","mean altitude 450 m deviates 2.0σ from the local baseline (8126 m)","start time 03:xx UTC has 0 prior tracks within ±2 h","signal -3.0 dBFS is 3.3σ from baseline -16.0 dBFS (unusually close/strong)","vector novelty 1.00: no similar track in RuVector memory","no callsign broadcast"],"score":0.86},"callsign":"","icao24":"deadbf","overhead":true,"points":[{"alt_m":447.7,"lat":43.550454,"lon":-79.743903,"t":1780974600},{"alt_m":455.1,"lat":43.550037,"lon":-79.743749,"t":1780974601},{"alt_m":455.6,"lat":43.54962,"lon":-79.743595,"t":1780974602},{"alt_m":441.5,"lat":43.549203,"lon":-79.743442,"t":1780974603},{"alt_m":455.8,"lat":43.548786,"lon":-79.743288,"t":1780974604},{"alt_m":447.2,"lat":43.548368,"lon":-79.743134,"t":1780974605},{"alt_m":445.3,"lat":43.547951,"lon":-79.74298,"t":1780974606},{"alt_m":449.9,"lat":43.547534,"lon":-79.742826,"t":1780974607},{"alt_m":441.5,"lat":43.547117,"lon":-79.742673,"t":1780974608},{"alt_m":458.8,"lat":43.5467,"lon":-79.742519,"t":1780974609},{"alt_m":453.3,"lat":43.546282,"lon":-79.742365,"t":1780974610},{"alt_m":449.3,"lat":43.545865,"lon":-79.742211,"t":1780974611},{"alt_m":440.4,"lat":43.545448,"lon":-79.742058,"t":1780974612},{"alt_m":451.4,"lat":43.545031,"lon":-79.741904,"t":1780974613},{"alt_m":457.4,"lat":43.544614,"lon":-79.74175,"t":1780974614},{"alt_m":459.1,"lat":43.544196,"lon":-79.741596,"t":1780974615},{"alt_m":440.7,"lat":43.543779,"lon":-79.741443,"t":1780974616},{"alt_m":458.7,"lat":43.543362,"lon":-79.741289,"t":1780974617},{"alt_m":447.2,"lat":43.542945,"lon":-79.741135,"t":1780974618},{"alt_m":444.5,"lat":43.542528,"lon":-79.740981,"t":1780974619},{"alt_m":442.4,"lat":43.54211,"lon":-79.740827,"t":1780974620},{"alt_m":441.2,"lat":43.541693,"lon":-79.740674,"t":1780974621},{"alt_m":453.4,"lat":43.541276,"lon":-79.74052,"t":1780974622},{"alt_m":448.6,"lat":43.540859,"lon":-79.740366,"t":1780974623},{"alt_m":443.7,"lat":43.540442,"lon":-79.740212,"t":1780974624},{"alt_m":443.5,"lat":43.540024,"lon":-79.740059,"t":1780974625},{"alt_m":450.2,"lat":43.539607,"lon":-79.739905,"t":1780974626},{"alt_m":454.6,"lat":43.53919,"lon":-79.739751,"t":1780974627},{"alt_m":455.0,"lat":43.538773,"lon":-79.739597,"t":1780974628},{"alt_m":456.7,"lat":43.538356,"lon":-79.739444,"t":1780974629},{"alt_m":454.6,"lat":43.537938,"lon":-79.73929,"t":1780974630},{"alt_m":458.4,"lat":43.537521,"lon":-79.739136,"t":1780974631},{"alt_m":458.6,"lat":43.537104,"lon":-79.738982,"t":1780974632},{"alt_m":448.2,"lat":43.536687,"lon":-79.738828,"t":1780974633},{"alt_m":445.2,"lat":43.53627,"lon":-79.738675,"t":1780974634},{"alt_m":447.5,"lat":43.535852,"lon":-79.738521,"t":1780974635},{"alt_m":455.5,"lat":43.535435,"lon":-79.738367,"t":1780974636},{"alt_m":448.8,"lat":43.535018,"lon":-79.738213,"t":1780974637},{"alt_m":456.6,"lat":43.534601,"lon":-79.73806,"t":1780974638},{"alt_m":443.0,"lat":43.534184,"lon":-79.737906,"t":1780974639},{"alt_m":446.3,"lat":43.533766,"lon":-79.737752,"t":1780974640},{"alt_m":452.6,"lat":43.533349,"lon":-79.737598,"t":1780974641},{"alt_m":459.5,"lat":43.532932,"lon":-79.737445,"t":1780974642},{"alt_m":442.4,"lat":43.532515,"lon":-79.737291,"t":1780974643},{"alt_m":450.3,"lat":43.532098,"lon":-79.737137,"t":1780974644},{"alt_m":452.0,"lat":43.53168,"lon":-79.736983,"t":1780974645},{"alt_m":442.2,"lat":43.531263,"lon":-79.736829,"t":1780974646},{"alt_m":448.1,"lat":43.530846,"lon":-79.736676,"t":1780974647},{"alt_m":443.8,"lat":43.530429,"lon":-79.736522,"t":1780974648},{"alt_m":441.6,"lat":43.530012,"lon":-79.736368,"t":1780974649},{"alt_m":445.7,"lat":43.529594,"lon":-79.736214,"t":1780974650},{"alt_m":450.1,"lat":43.529177,"lon":-79.736061,"t":1780974651},{"alt_m":453.2,"lat":43.52876,"lon":-79.735907,"t":1780974652},{"alt_m":451.8,"lat":43.528343,"lon":-79.735753,"t":1780974653},{"alt_m":452.7,"lat":43.527926,"lon":-79.735599,"t":1780974654},{"alt_m":441.4,"lat":43.527508,"lon":-79.735446,"t":1780974655},{"alt_m":456.0,"lat":43.527091,"lon":-79.735292,"t":1780974656},{"alt_m":441.2,"lat":43.526674,"lon":-79.735138,"t":1780974657},{"alt_m":442.3,"lat":43.526257,"lon":-79.734984,"t":1780974658},{"alt_m":457.6,"lat":43.52584,"lon":-79.73483,"t":1780974659},{"alt_m":456.9,"lat":43.525422,"lon":-79.734677,"t":1780974660},{"alt_m":450.1,"lat":43.525005,"lon":-79.734523,"t":1780974661},{"alt_m":451.8,"lat":43.524588,"lon":-79.734369,"t":1780974662},{"alt_m":455.0,"lat":43.524171,"lon":-79.734215,"t":1780974663},{"alt_m":446.9,"lat":43.523754,"lon":-79.734062,"t":1780974664},{"alt_m":452.9,"lat":43.523336,"lon":-79.733908,"t":1780974665},{"alt_m":450.7,"lat":43.522919,"lon":-79.733754,"t":1780974666},{"alt_m":451.3,"lat":43.522502,"lon":-79.7336,"t":1780974667},{"alt_m":452.6,"lat":43.522085,"lon":-79.733447,"t":1780974668},{"alt_m":451.2,"lat":43.521668,"lon":-79.733293,"t":1780974669},{"alt_m":447.7,"lat":43.52125,"lon":-79.733139,"t":1780974670},{"alt_m":456.3,"lat":43.520833,"lon":-79.732985,"t":1780974671},{"alt_m":457.1,"lat":43.520416,"lon":-79.732831,"t":1780974672},{"alt_m":459.2,"lat":43.519999,"lon":-79.732678,"t":1780974673},{"alt_m":450.3,"lat":43.519582,"lon":-79.732524,"t":1780974674},{"alt_m":454.9,"lat":43.519164,"lon":-79.73237,"t":1780974675},{"alt_m":440.4,"lat":43.518747,"lon":-79.732216,"t":1780974676},{"alt_m":452.9,"lat":43.51833,"lon":-79.732063,"t":1780974677},{"alt_m":457.6,"lat":43.517913,"lon":-79.731909,"t":1780974678},{"alt_m":450.4,"lat":43.517496,"lon":-79.731755,"t":1780974679},{"alt_m":445.9,"lat":43.517078,"lon":-79.731601,"t":1780974680},{"alt_m":448.8,"lat":43.516661,"lon":-79.731448,"t":1780974681},{"alt_m":453.4,"lat":43.516244,"lon":-79.731294,"t":1780974682},{"alt_m":441.9,"lat":43.515827,"lon":-79.73114,"t":1780974683},{"alt_m":444.4,"lat":43.51541,"lon":-79.730986,"t":1780974684},{"alt_m":447.6,"lat":43.514992,"lon":-79.730832,"t":1780974685},{"alt_m":443.9,"lat":43.514575,"lon":-79.730679,"t":1780974686},{"alt_m":450.9,"lat":43.514158,"lon":-79.730525,"t":1780974687},{"alt_m":458.9,"lat":43.513741,"lon":-79.730371,"t":1780974688},{"alt_m":443.4,"lat":43.513324,"lon":-79.730217,"t":1780974689},{"alt_m":453.0,"lat":43.512906,"lon":-79.730064,"t":1780974690},{"alt_m":443.0,"lat":43.512489,"lon":-79.72991,"t":1780974691},{"alt_m":441.1,"lat":43.512072,"lon":-79.729756,"t":1780974692},{"alt_m":443.6,"lat":43.511655,"lon":-79.729602,"t":1780974693},{"alt_m":455.1,"lat":43.511238,"lon":-79.729449,"t":1780974694},{"alt_m":452.4,"lat":43.51082,"lon":-79.729295,"t":1780974695},{"alt_m":452.3,"lat":43.510403,"lon":-79.729141,"t":1780974696},{"alt_m":445.7,"lat":43.509986,"lon":-79.728987,"t":1780974697},{"alt_m":444.6,"lat":43.509569,"lon":-79.728833,"t":1780974698},{"alt_m":458.7,"lat":43.509152,"lon":-79.72868,"t":1780974699},{"alt_m":442.6,"lat":43.508734,"lon":-79.728526,"t":1780974700},{"alt_m":446.7,"lat":43.508317,"lon":-79.728372,"t":1780974701},{"alt_m":451.0,"lat":43.5079,"lon":-79.728218,"t":1780974702},{"alt_m":442.3,"lat":43.507483,"lon":-79.728065,"t":1780974703},{"alt_m":444.2,"lat":43.507065,"lon":-79.727911,"t":1780974704},{"alt_m":456.6,"lat":43.506648,"lon":-79.727757,"t":1780974705},{"alt_m":456.2,"lat":43.506231,"lon":-79.727603,"t":1780974706},{"alt_m":444.0,"lat":43.505814,"lon":-79.72745,"t":1780974707},{"alt_m":459.4,"lat":43.505397,"lon":-79.727296,"t":1780974708},{"alt_m":455.2,"lat":43.504979,"lon":-79.727142,"t":1780974709},{"alt_m":455.2,"lat":43.504562,"lon":-79.726988,"t":1780974710},{"alt_m":458.6,"lat":43.504145,"lon":-79.726835,"t":1780974711},{"alt_m":451.1,"lat":43.503728,"lon":-79.726681,"t":1780974712},{"alt_m":456.6,"lat":43.503311,"lon":-79.726527,"t":1780974713},{"alt_m":446.1,"lat":43.502893,"lon":-79.726373,"t":1780974714},{"alt_m":446.3,"lat":43.502476,"lon":-79.726219,"t":1780974715},{"alt_m":444.5,"lat":43.502059,"lon":-79.726066,"t":1780974716},{"alt_m":455.7,"lat":43.501642,"lon":-79.725912,"t":1780974717},{"alt_m":451.4,"lat":43.501225,"lon":-79.725758,"t":1780974718},{"alt_m":456.3,"lat":43.500807,"lon":-79.725604,"t":1780974719},{"alt_m":444.9,"lat":43.50039,"lon":-79.725451,"t":1780974720},{"alt_m":454.1,"lat":43.499973,"lon":-79.725297,"t":1780974721},{"alt_m":458.2,"lat":43.499556,"lon":-79.725143,"t":1780974722},{"alt_m":454.1,"lat":43.499139,"lon":-79.724989,"t":1780974723},{"alt_m":451.9,"lat":43.498721,"lon":-79.724836,"t":1780974724},{"alt_m":454.8,"lat":43.498304,"lon":-79.724682,"t":1780974725},{"alt_m":457.2,"lat":43.497887,"lon":-79.724528,"t":1780974726},{"alt_m":442.3,"lat":43.49747,"lon":-79.724374,"t":1780974727},{"alt_m":449.4,"lat":43.497053,"lon":-79.72422,"t":1780974728},{"alt_m":458.5,"lat":43.496635,"lon":-79.724067,"t":1780974729},{"alt_m":449.0,"lat":43.496218,"lon":-79.723913,"t":1780974730},{"alt_m":447.1,"lat":43.495801,"lon":-79.723759,"t":1780974731},{"alt_m":459.4,"lat":43.495384,"lon":-79.723605,"t":1780974732},{"alt_m":457.9,"lat":43.494967,"lon":-79.723452,"t":1780974733},{"alt_m":448.3,"lat":43.494549,"lon":-79.723298,"t":1780974734},{"alt_m":447.1,"lat":43.494132,"lon":-79.723144,"t":1780974735},{"alt_m":442.0,"lat":43.493715,"lon":-79.72299,"t":1780974736},{"alt_m":446.1,"lat":43.493298,"lon":-79.722837,"t":1780974737},{"alt_m":450.4,"lat":43.492881,"lon":-79.722683,"t":1780974738},{"alt_m":459.8,"lat":43.492463,"lon":-79.722529,"t":1780974739},{"alt_m":455.7,"lat":43.492046,"lon":-79.722375,"t":1780974740},{"alt_m":447.9,"lat":43.491629,"lon":-79.722221,"t":1780974741},{"alt_m":457.7,"lat":43.491212,"lon":-79.722068,"t":1780974742},{"alt_m":440.2,"lat":43.490795,"lon":-79.721914,"t":1780974743},{"alt_m":451.9,"lat":43.490377,"lon":-79.72176,"t":1780974744},{"alt_m":454.9,"lat":43.48996,"lon":-79.721606,"t":1780974745},{"alt_m":441.1,"lat":43.489543,"lon":-79.721453,"t":1780974746},{"alt_m":449.9,"lat":43.489126,"lon":-79.721299,"t":1780974747},{"alt_m":446.5,"lat":43.488709,"lon":-79.721145,"t":1780974748},{"alt_m":447.7,"lat":43.488291,"lon":-79.720991,"t":1780974749},{"alt_m":458.9,"lat":43.487874,"lon":-79.720838,"t":1780974750},{"alt_m":457.8,"lat":43.487457,"lon":-79.720684,"t":1780974751},{"alt_m":444.9,"lat":43.48704,"lon":-79.72053,"t":1780974752},{"alt_m":453.6,"lat":43.486623,"lon":-79.720376,"t":1780974753},{"alt_m":445.1,"lat":43.486205,"lon":-79.720222,"t":1780974754},{"alt_m":440.4,"lat":43.485788,"lon":-79.720069,"t":1780974755},{"alt_m":448.0,"lat":43.485371,"lon":-79.719915,"t":1780974756},{"alt_m":445.7,"lat":43.484954,"lon":-79.719761,"t":1780974757},{"alt_m":442.0,"lat":43.484537,"lon":-79.719607,"t":1780974758},{"alt_m":447.8,"lat":43.484119,"lon":-79.719454,"t":1780974759},{"alt_m":451.1,"lat":43.483702,"lon":-79.7193,"t":1780974760},{"alt_m":443.6,"lat":43.483285,"lon":-79.719146,"t":1780974761},{"alt_m":453.8,"lat":43.482868,"lon":-79.718992,"t":1780974762},{"alt_m":444.7,"lat":43.482451,"lon":-79.718839,"t":1780974763},{"alt_m":443.4,"lat":43.482033,"lon":-79.718685,"t":1780974764},{"alt_m":448.3,"lat":43.481616,"lon":-79.718531,"t":1780974765},{"alt_m":446.2,"lat":43.481199,"lon":-79.718377,"t":1780974766},{"alt_m":458.2,"lat":43.480782,"lon":-79.718223,"t":1780974767},{"alt_m":451.9,"lat":43.480365,"lon":-79.71807,"t":1780974768},{"alt_m":450.9,"lat":43.479947,"lon":-79.717916,"t":1780974769},{"alt_m":446.4,"lat":43.47953,"lon":-79.717762,"t":1780974770},{"alt_m":449.9,"lat":43.479113,"lon":-79.717608,"t":1780974771},{"alt_m":448.2,"lat":43.478696,"lon":-79.717455,"t":1780974772},{"alt_m":457.8,"lat":43.478279,"lon":-79.717301,"t":1780974773},{"alt_m":445.4,"lat":43.477861,"lon":-79.717147,"t":1780974774},{"alt_m":458.4,"lat":43.477444,"lon":-79.716993,"t":1780974775},{"alt_m":455.7,"lat":43.477027,"lon":-79.71684,"t":1780974776},{"alt_m":458.0,"lat":43.47661,"lon":-79.716686,"t":1780974777},{"alt_m":446.9,"lat":43.476193,"lon":-79.716532,"t":1780974778},{"alt_m":443.5,"lat":43.475775,"lon":-79.716378,"t":1780974779},{"alt_m":455.8,"lat":43.475358,"lon":-79.716224,"t":1780974780},{"alt_m":453.4,"lat":43.474941,"lon":-79.716071,"t":1780974781},{"alt_m":448.9,"lat":43.474524,"lon":-79.715917,"t":1780974782},{"alt_m":456.0,"lat":43.474107,"lon":-79.715763,"t":1780974783},{"alt_m":449.5,"lat":43.473689,"lon":-79.715609,"t":1780974784},{"alt_m":440.0,"lat":43.473272,"lon":-79.715456,"t":1780974785},{"alt_m":443.8,"lat":43.472855,"lon":-79.715302,"t":1780974786},{"alt_m":447.7,"lat":43.472438,"lon":-79.715148,"t":1780974787},{"alt_m":446.0,"lat":43.472021,"lon":-79.714994,"t":1780974788},{"alt_m":450.2,"lat":43.471603,"lon":-79.714841,"t":1780974789},{"alt_m":456.0,"lat":43.471186,"lon":-79.714687,"t":1780974790},{"alt_m":444.8,"lat":43.470769,"lon":-79.714533,"t":1780974791},{"alt_m":444.8,"lat":43.470352,"lon":-79.714379,"t":1780974792},{"alt_m":451.2,"lat":43.469935,"lon":-79.714225,"t":1780974793},{"alt_m":440.4,"lat":43.469517,"lon":-79.714072,"t":1780974794},{"alt_m":446.6,"lat":43.4691,"lon":-79.713918,"t":1780974795},{"alt_m":457.3,"lat":43.468683,"lon":-79.713764,"t":1780974796},{"alt_m":455.2,"lat":43.468266,"lon":-79.71361,"t":1780974797},{"alt_m":441.4,"lat":43.467849,"lon":-79.713457,"t":1780974798},{"alt_m":458.7,"lat":43.467431,"lon":-79.713303,"t":1780974799},{"alt_m":456.8,"lat":43.467014,"lon":-79.713149,"t":1780974800},{"alt_m":459.6,"lat":43.466597,"lon":-79.712995,"t":1780974801},{"alt_m":444.0,"lat":43.46618,"lon":-79.712842,"t":1780974802},{"alt_m":447.8,"lat":43.465763,"lon":-79.712688,"t":1780974803},{"alt_m":444.2,"lat":43.465345,"lon":-79.712534,"t":1780974804},{"alt_m":452.9,"lat":43.464928,"lon":-79.71238,"t":1780974805},{"alt_m":458.5,"lat":43.464511,"lon":-79.712226,"t":1780974806},{"alt_m":443.7,"lat":43.464094,"lon":-79.712073,"t":1780974807},{"alt_m":457.9,"lat":43.463677,"lon":-79.711919,"t":1780974808},{"alt_m":451.2,"lat":43.463259,"lon":-79.711765,"t":1780974809},{"alt_m":458.0,"lat":43.462842,"lon":-79.711611,"t":1780974810},{"alt_m":450.1,"lat":43.462425,"lon":-79.711458,"t":1780974811},{"alt_m":442.9,"lat":43.462008,"lon":-79.711304,"t":1780974812},{"alt_m":459.9,"lat":43.461591,"lon":-79.71115,"t":1780974813},{"alt_m":440.8,"lat":43.461173,"lon":-79.710996,"t":1780974814},{"alt_m":442.0,"lat":43.460756,"lon":-79.710843,"t":1780974815},{"alt_m":457.1,"lat":43.460339,"lon":-79.710689,"t":1780974816},{"alt_m":457.8,"lat":43.459922,"lon":-79.710535,"t":1780974817},{"alt_m":448.7,"lat":43.459505,"lon":-79.710381,"t":1780974818},{"alt_m":451.4,"lat":43.459087,"lon":-79.710227,"t":1780974819},{"alt_m":445.8,"lat":43.45867,"lon":-79.710074,"t":1780974820},{"alt_m":448.8,"lat":43.458253,"lon":-79.70992,"t":1780974821},{"alt_m":453.9,"lat":43.457836,"lon":-79.709766,"t":1780974822},{"alt_m":445.7,"lat":43.457419,"lon":-79.709612,"t":1780974823},{"alt_m":449.4,"lat":43.457001,"lon":-79.709459,"t":1780974824},{"alt_m":459.4,"lat":43.456584,"lon":-79.709305,"t":1780974825},{"alt_m":444.2,"lat":43.456167,"lon":-79.709151,"t":1780974826},{"alt_m":453.9,"lat":43.45575,"lon":-79.708997,"t":1780974827},{"alt_m":444.1,"lat":43.455333,"lon":-79.708844,"t":1780974828},{"alt_m":446.0,"lat":43.454915,"lon":-79.70869,"t":1780974829},{"alt_m":458.5,"lat":43.454498,"lon":-79.708536,"t":1780974830},{"alt_m":443.2,"lat":43.454081,"lon":-79.708382,"t":1780974831},{"alt_m":449.4,"lat":43.453664,"lon":-79.708228,"t":1780974832},{"alt_m":445.4,"lat":43.453246,"lon":-79.708075,"t":1780974833},{"alt_m":455.4,"lat":43.452829,"lon":-79.707921,"t":1780974834},{"alt_m":446.4,"lat":43.452412,"lon":-79.707767,"t":1780974835},{"alt_m":458.5,"lat":43.451995,"lon":-79.707613,"t":1780974836},{"alt_m":457.7,"lat":43.451578,"lon":-79.70746,"t":1780974837},{"alt_m":445.5,"lat":43.45116,"lon":-79.707306,"t":1780974838},{"alt_m":457.3,"lat":43.450743,"lon":-79.707152,"t":1780974839},{"alt_m":441.1,"lat":43.450326,"lon":-79.706998,"t":1780974840},{"alt_m":452.0,"lat":43.449909,"lon":-79.706845,"t":1780974841},{"alt_m":453.1,"lat":43.449492,"lon":-79.706691,"t":1780974842},{"alt_m":451.0,"lat":43.449074,"lon":-79.706537,"t":1780974843},{"alt_m":457.1,"lat":43.448657,"lon":-79.706383,"t":1780974844},{"alt_m":445.3,"lat":43.44824,"lon":-79.706229,"t":1780974845},{"alt_m":444.4,"lat":43.447823,"lon":-79.706076,"t":1780974846},{"alt_m":457.1,"lat":43.447406,"lon":-79.705922,"t":1780974847},{"alt_m":458.3,"lat":43.446988,"lon":-79.705768,"t":1780974848},{"alt_m":442.4,"lat":43.446571,"lon":-79.705614,"t":1780974849},{"alt_m":446.2,"lat":43.446154,"lon":-79.705461,"t":1780974850},{"alt_m":454.9,"lat":43.445737,"lon":-79.705307,"t":1780974851},{"alt_m":453.9,"lat":43.44532,"lon":-79.705153,"t":1780974852},{"alt_m":457.6,"lat":43.444902,"lon":-79.704999,"t":1780974853},{"alt_m":452.7,"lat":43.444485,"lon":-79.704846,"t":1780974854},{"alt_m":442.3,"lat":43.444068,"lon":-79.704692,"t":1780974855},{"alt_m":453.3,"lat":43.443651,"lon":-79.704538,"t":1780974856},{"alt_m":441.3,"lat":43.443234,"lon":-79.704384,"t":1780974857},{"alt_m":452.5,"lat":43.442816,"lon":-79.70423,"t":1780974858},{"alt_m":447.7,"lat":43.442399,"lon":-79.704077,"t":1780974859},{"alt_m":441.4,"lat":43.441982,"lon":-79.703923,"t":1780974860},{"alt_m":452.7,"lat":43.441565,"lon":-79.703769,"t":1780974861},{"alt_m":442.9,"lat":43.441148,"lon":-79.703615,"t":1780974862},{"alt_m":458.2,"lat":43.44073,"lon":-79.703462,"t":1780974863},{"alt_m":449.2,"lat":43.440313,"lon":-79.703308,"t":1780974864},{"alt_m":456.1,"lat":43.439896,"lon":-79.703154,"t":1780974865},{"alt_m":450.5,"lat":43.439479,"lon":-79.703,"t":1780974866},{"alt_m":448.7,"lat":43.439062,"lon":-79.702847,"t":1780974867},{"alt_m":455.5,"lat":43.438644,"lon":-79.702693,"t":1780974868},{"alt_m":454.5,"lat":43.438227,"lon":-79.702539,"t":1780974869},{"alt_m":448.2,"lat":43.43781,"lon":-79.702385,"t":1780974870},{"alt_m":442.1,"lat":43.437393,"lon":-79.702231,"t":1780974871},{"alt_m":459.1,"lat":43.436976,"lon":-79.702078,"t":1780974872},{"alt_m":445.7,"lat":43.436558,"lon":-79.701924,"t":1780974873},{"alt_m":448.4,"lat":43.436141,"lon":-79.70177,"t":1780974874},{"alt_m":453.4,"lat":43.435724,"lon":-79.701616,"t":1780974875},{"alt_m":450.5,"lat":43.435307,"lon":-79.701463,"t":1780974876},{"alt_m":445.0,"lat":43.43489,"lon":-79.701309,"t":1780974877},{"alt_m":447.3,"lat":43.434472,"lon":-79.701155,"t":1780974878},{"alt_m":451.3,"lat":43.434055,"lon":-79.701001,"t":1780974879},{"alt_m":453.4,"lat":43.433638,"lon":-79.700848,"t":1780974880},{"alt_m":448.2,"lat":43.433221,"lon":-79.700694,"t":1780974881},{"alt_m":448.2,"lat":43.432804,"lon":-79.70054,"t":1780974882},{"alt_m":449.1,"lat":43.432386,"lon":-79.700386,"t":1780974883},{"alt_m":452.4,"lat":43.431969,"lon":-79.700232,"t":1780974884},{"alt_m":454.7,"lat":43.431552,"lon":-79.700079,"t":1780974885},{"alt_m":444.6,"lat":43.431135,"lon":-79.699925,"t":1780974886},{"alt_m":449.9,"lat":43.430718,"lon":-79.699771,"t":1780974887},{"alt_m":440.9,"lat":43.4303,"lon":-79.699617,"t":1780974888},{"alt_m":452.6,"lat":43.429883,"lon":-79.699464,"t":1780974889},{"alt_m":441.7,"lat":43.429466,"lon":-79.69931,"t":1780974890},{"alt_m":450.2,"lat":43.429049,"lon":-79.699156,"t":1780974891},{"alt_m":442.8,"lat":43.428632,"lon":-79.699002,"t":1780974892},{"alt_m":442.9,"lat":43.428214,"lon":-79.698849,"t":1780974893},{"alt_m":444.1,"lat":43.427797,"lon":-79.698695,"t":1780974894},{"alt_m":447.9,"lat":43.42738,"lon":-79.698541,"t":1780974895},{"alt_m":447.7,"lat":43.426963,"lon":-79.698387,"t":1780974896},{"alt_m":443.2,"lat":43.426546,"lon":-79.698233,"t":1780974897},{"alt_m":453.3,"lat":43.426128,"lon":-79.69808,"t":1780974898},{"alt_m":453.0,"lat":43.425711,"lon":-79.697926,"t":1780974899},{"alt_m":446.1,"lat":43.425294,"lon":-79.697772,"t":1780974900},{"alt_m":459.9,"lat":43.424877,"lon":-79.697618,"t":1780974901},{"alt_m":455.2,"lat":43.42446,"lon":-79.697465,"t":1780974902},{"alt_m":455.8,"lat":43.424042,"lon":-79.697311,"t":1780974903},{"alt_m":445.8,"lat":43.423625,"lon":-79.697157,"t":1780974904},{"alt_m":450.6,"lat":43.423208,"lon":-79.697003,"t":1780974905},{"alt_m":455.4,"lat":43.422791,"lon":-79.69685,"t":1780974906},{"alt_m":454.7,"lat":43.422374,"lon":-79.696696,"t":1780974907},{"alt_m":444.7,"lat":43.421956,"lon":-79.696542,"t":1780974908},{"alt_m":446.7,"lat":43.421539,"lon":-79.696388,"t":1780974909},{"alt_m":449.5,"lat":43.421122,"lon":-79.696234,"t":1780974910},{"alt_m":458.5,"lat":43.420705,"lon":-79.696081,"t":1780974911},{"alt_m":454.7,"lat":43.420288,"lon":-79.695927,"t":1780974912},{"alt_m":440.0,"lat":43.41987,"lon":-79.695773,"t":1780974913},{"alt_m":452.7,"lat":43.419453,"lon":-79.695619,"t":1780974914},{"alt_m":442.2,"lat":43.419036,"lon":-79.695466,"t":1780974915},{"alt_m":449.2,"lat":43.418619,"lon":-79.695312,"t":1780974916},{"alt_m":454.6,"lat":43.418202,"lon":-79.695158,"t":1780974917},{"alt_m":450.2,"lat":43.417784,"lon":-79.695004,"t":1780974918},{"alt_m":457.8,"lat":43.417367,"lon":-79.694851,"t":1780974919},{"alt_m":452.9,"lat":43.41695,"lon":-79.694697,"t":1780974920},{"alt_m":452.6,"lat":43.416533,"lon":-79.694543,"t":1780974921},{"alt_m":446.1,"lat":43.416116,"lon":-79.694389,"t":1780974922},{"alt_m":459.8,"lat":43.415698,"lon":-79.694236,"t":1780974923},{"alt_m":456.7,"lat":43.415281,"lon":-79.694082,"t":1780974924},{"alt_m":441.7,"lat":43.414864,"lon":-79.693928,"t":1780974925},{"alt_m":447.9,"lat":43.414447,"lon":-79.693774,"t":1780974926},{"alt_m":442.0,"lat":43.41403,"lon":-79.69362,"t":1780974927},{"alt_m":444.6,"lat":43.413612,"lon":-79.693467,"t":1780974928},{"alt_m":450.2,"lat":43.413195,"lon":-79.693313,"t":1780974929},{"alt_m":458.6,"lat":43.412778,"lon":-79.693159,"t":1780974930},{"alt_m":444.0,"lat":43.412361,"lon":-79.693005,"t":1780974931},{"alt_m":440.9,"lat":43.411944,"lon":-79.692852,"t":1780974932},{"alt_m":448.9,"lat":43.411526,"lon":-79.692698,"t":1780974933},{"alt_m":440.8,"lat":43.411109,"lon":-79.692544,"t":1780974934},{"alt_m":459.6,"lat":43.410692,"lon":-79.69239,"t":1780974935},{"alt_m":455.4,"lat":43.410275,"lon":-79.692237,"t":1780974936},{"alt_m":450.9,"lat":43.409858,"lon":-79.692083,"t":1780974937},{"alt_m":455.5,"lat":43.40944,"lon":-79.691929,"t":1780974938},{"alt_m":454.7,"lat":43.409023,"lon":-79.691775,"t":1780974939},{"alt_m":453.1,"lat":43.408606,"lon":-79.691621,"t":1780974940},{"alt_m":447.2,"lat":43.408189,"lon":-79.691468,"t":1780974941},{"alt_m":458.4,"lat":43.407772,"lon":-79.691314,"t":1780974942},{"alt_m":441.6,"lat":43.407354,"lon":-79.69116,"t":1780974943},{"alt_m":456.3,"lat":43.406937,"lon":-79.691006,"t":1780974944},{"alt_m":452.9,"lat":43.40652,"lon":-79.690853,"t":1780974945},{"alt_m":458.9,"lat":43.406103,"lon":-79.690699,"t":1780974946},{"alt_m":449.4,"lat":43.405686,"lon":-79.690545,"t":1780974947},{"alt_m":458.2,"lat":43.405268,"lon":-79.690391,"t":1780974948},{"alt_m":445.6,"lat":43.404851,"lon":-79.690238,"t":1780974949},{"alt_m":449.4,"lat":43.404434,"lon":-79.690084,"t":1780974950},{"alt_m":457.6,"lat":43.404017,"lon":-79.68993,"t":1780974951},{"alt_m":455.2,"lat":43.4036,"lon":-79.689776,"t":1780974952},{"alt_m":454.8,"lat":43.403182,"lon":-79.689622,"t":1780974953},{"alt_m":442.3,"lat":43.402765,"lon":-79.689469,"t":1780974954},{"alt_m":451.3,"lat":43.402348,"lon":-79.689315,"t":1780974955},{"alt_m":459.5,"lat":43.401931,"lon":-79.689161,"t":1780974956},{"alt_m":449.5,"lat":43.401514,"lon":-79.689007,"t":1780974957},{"alt_m":453.4,"lat":43.401096,"lon":-79.688854,"t":1780974958},{"alt_m":440.0,"lat":43.400679,"lon":-79.6887,"t":1780974959},{"alt_m":443.2,"lat":43.400262,"lon":-79.688546,"t":1780974960},{"alt_m":457.8,"lat":43.399845,"lon":-79.688392,"t":1780974961},{"alt_m":451.0,"lat":43.399428,"lon":-79.688239,"t":1780974962},{"alt_m":451.1,"lat":43.39901,"lon":-79.688085,"t":1780974963},{"alt_m":457.1,"lat":43.398593,"lon":-79.687931,"t":1780974964},{"alt_m":458.3,"lat":43.398176,"lon":-79.687777,"t":1780974965},{"alt_m":455.1,"lat":43.397759,"lon":-79.687623,"t":1780974966},{"alt_m":458.7,"lat":43.397341,"lon":-79.68747,"t":1780974967},{"alt_m":444.4,"lat":43.396924,"lon":-79.687316,"t":1780974968},{"alt_m":459.6,"lat":43.396507,"lon":-79.687162,"t":1780974969},{"alt_m":442.9,"lat":43.39609,"lon":-79.687008,"t":1780974970},{"alt_m":458.6,"lat":43.395673,"lon":-79.686855,"t":1780974971},{"alt_m":456.3,"lat":43.395255,"lon":-79.686701,"t":1780974972},{"alt_m":445.1,"lat":43.394838,"lon":-79.686547,"t":1780974973},{"alt_m":444.2,"lat":43.394421,"lon":-79.686393,"t":1780974974},{"alt_m":454.8,"lat":43.394004,"lon":-79.68624,"t":1780974975},{"alt_m":457.8,"lat":43.393587,"lon":-79.686086,"t":1780974976},{"alt_m":442.3,"lat":43.393169,"lon":-79.685932,"t":1780974977},{"alt_m":454.1,"lat":43.392752,"lon":-79.685778,"t":1780974978},{"alt_m":447.2,"lat":43.392335,"lon":-79.685624,"t":1780974979},{"alt_m":456.0,"lat":43.391918,"lon":-79.685471,"t":1780974980},{"alt_m":454.2,"lat":43.391501,"lon":-79.685317,"t":1780974981},{"alt_m":448.2,"lat":43.391083,"lon":-79.685163,"t":1780974982},{"alt_m":447.5,"lat":43.390666,"lon":-79.685009,"t":1780974983},{"alt_m":457.1,"lat":43.390249,"lon":-79.684856,"t":1780974984},{"alt_m":458.6,"lat":43.389832,"lon":-79.684702,"t":1780974985},{"alt_m":447.2,"lat":43.389415,"lon":-79.684548,"t":1780974986},{"alt_m":450.8,"lat":43.388997,"lon":-79.684394,"t":1780974987},{"alt_m":444.4,"lat":43.38858,"lon":-79.684241,"t":1780974988},{"alt_m":443.6,"lat":43.388163,"lon":-79.684087,"t":1780974989},{"alt_m":440.1,"lat":43.387746,"lon":-79.683933,"t":1780974990},{"alt_m":452.5,"lat":43.387329,"lon":-79.683779,"t":1780974991},{"alt_m":443.3,"lat":43.386911,"lon":-79.683625,"t":1780974992},{"alt_m":440.2,"lat":43.386494,"lon":-79.683472,"t":1780974993},{"alt_m":451.1,"lat":43.386077,"lon":-79.683318,"t":1780974994},{"alt_m":456.5,"lat":43.38566,"lon":-79.683164,"t":1780974995},{"alt_m":451.7,"lat":43.385243,"lon":-79.68301,"t":1780974996},{"alt_m":449.9,"lat":43.384825,"lon":-79.682857,"t":1780974997},{"alt_m":458.9,"lat":43.384408,"lon":-79.682703,"t":1780974998},{"alt_m":458.8,"lat":43.383991,"lon":-79.682549,"t":1780974999},{"alt_m":458.1,"lat":43.383574,"lon":-79.682395,"t":1780975000},{"alt_m":440.5,"lat":43.383157,"lon":-79.682242,"t":1780975001},{"alt_m":455.7,"lat":43.382739,"lon":-79.682088,"t":1780975002},{"alt_m":441.0,"lat":43.382322,"lon":-79.681934,"t":1780975003},{"alt_m":457.3,"lat":43.381905,"lon":-79.68178,"t":1780975004},{"alt_m":446.0,"lat":43.381488,"lon":-79.681626,"t":1780975005},{"alt_m":457.2,"lat":43.381071,"lon":-79.681473,"t":1780975006},{"alt_m":450.2,"lat":43.380653,"lon":-79.681319,"t":1780975007},{"alt_m":441.6,"lat":43.380236,"lon":-79.681165,"t":1780975008},{"alt_m":456.3,"lat":43.379819,"lon":-79.681011,"t":1780975009},{"alt_m":458.1,"lat":43.379402,"lon":-79.680858,"t":1780975010},{"alt_m":445.3,"lat":43.378985,"lon":-79.680704,"t":1780975011},{"alt_m":455.4,"lat":43.378567,"lon":-79.68055,"t":1780975012},{"alt_m":452.7,"lat":43.37815,"lon":-79.680396,"t":1780975013},{"alt_m":458.6,"lat":43.377733,"lon":-79.680243,"t":1780975014},{"alt_m":448.0,"lat":43.377316,"lon":-79.680089,"t":1780975015},{"alt_m":451.0,"lat":43.376899,"lon":-79.679935,"t":1780975016},{"alt_m":440.7,"lat":43.376481,"lon":-79.679781,"t":1780975017},{"alt_m":444.5,"lat":43.376064,"lon":-79.679627,"t":1780975018},{"alt_m":444.2,"lat":43.375647,"lon":-79.679474,"t":1780975019}]}]}; diff --git a/examples/sky-monitor/ui/dashboard/sky.js b/examples/sky-monitor/ui/dashboard/sky.js new file mode 100644 index 0000000000..3a7b7126ec --- /dev/null +++ b/examples/sky-monitor/ui/dashboard/sky.js @@ -0,0 +1,347 @@ +// RuView SkyGraph dashboard (ADR-199 presentation plane, "dashboard first"). +// +// Renders the embedded deterministic scenario (sky-demo-data.js, generated by +// `cargo run -p sky-monitor --release -- --emit-json ui/dashboard/sky-demo-data.js`) +// on an all-sky polar plot with replay, trails, and anomaly badges. +// +// Projection math: the JS functions below mirror coords.rs (WGS-84 geodetic -> +// ECEF -> ENU -> az/el/range) so the page works standalone. When the wasm-pack +// output is present at ./pkg/sky_monitor_wasm.js (see README.md) it is loaded +// automatically and its SkyProjector.project_batch replaces the JS math. + +/* global SKY_DATA */ + +// --------------------------------------------------------------------------- +// Projection (JS mirror of examples/sky-monitor/src/coords.rs — replaced by +// the wasm module when built). +// --------------------------------------------------------------------------- + +const WGS84_A = 6378137.0; +const WGS84_F = 1.0 / 298.257223563; +const WGS84_E2 = WGS84_F * (2.0 - WGS84_F); +const DEG = Math.PI / 180.0; + +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, + ]; +} + +function normalizeDeg(d) { + const r = d % 360.0; + return r < 0.0 ? r + 360.0 : r; +} + +// Full WGS-84 -> observer az/el/range/bearing projection (coords.rs +// observer_frame). Returns [azDeg, elDeg, rangeM, bearingDeg]. +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; + // Great-circle initial bearing observer -> target. + const phi2 = lat * DEG, dl = (lon - obs.lon) * DEG; + const by = Math.sin(dl) * Math.cos(phi2); + const bx = Math.cos(la) * Math.sin(phi2) - Math.sin(la) * Math.cos(phi2) * Math.cos(dl); + const bearing = normalizeDeg(Math.atan2(by, bx) / DEG); + return [az, el, range, bearing]; +} + +// Polar "fisheye" all-sky mapping (mirror of wasm/src/screen.rs): zenith at +// the centre, horizon on the inscribed circle, azimuth 0 = North = up. +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). +// --------------------------------------------------------------------------- + +async function loadWasmProjector(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), version: mod.version() }; + } catch (_e) { + return null; // pkg not built — JS fallback stays active + } +} + +// --------------------------------------------------------------------------- +// Scene preparation: project every track point once, cache az/el/range. +// --------------------------------------------------------------------------- + +const BAND_COLORS = { + "normal": "#3ddc84", + "mildly unusual": "#e8d44d", + "interesting": "#ff9f43", + "strong anomaly": "#ff5252", + "rare": "#d05aff", + "baseline": "#5a6378", +}; +const TRAIL_SECS = 150; // trail length behind the dot +const LINGER_SECS = 20; // dot stays this long after the last sample +const PLAY_SPEED = 60; // replay seconds per wall-clock second + +function projectAll(tracks, projectBatch, obs, obsEcef) { + for (const tr of tracks) { + if (projectBatch) { + const flat = new Float64Array(tr.points.length * 3); + tr.points.forEach((p, i) => { flat[i * 3] = p.lat; flat[i * 3 + 1] = p.lon; flat[i * 3 + 2] = p.alt_m; }); + const out = projectBatch(flat); // [az, el, range, bearing] * N + tr.points.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 tr.points) { + const [az, el, range] = observerFrameJs(obs, 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.band = tr.anomaly ? tr.anomaly.band : "baseline"; + tr.color = BAND_COLORS[tr.band] || BAND_COLORS.baseline; + tr.label = tr.callsign || tr.icao24; + } +} + +// Last point index with p.t <= t (binary search; points are 1 Hz ordered). +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; +} + +// --------------------------------------------------------------------------- +// Rendering. +// --------------------------------------------------------------------------- + +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"; +} + +function drawTrack(ctx, tr, t, w, h, selected) { + const i = indexAt(tr, t); + if (i < 0 || t > tr.t1 + LINGER_SECS) return false; + // Fading trail. + ctx.lineWidth = 1.5; + for (let j = Math.max(1, i - TRAIL_SECS); 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) / TRAIL_SECS; + 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. + const p = tr.points[i]; + const [x, y, visible] = polarScreenXY(p.az, p.el, w, h); + if (!visible) return false; + const gone = t > tr.t1; // lingering after last sample + ctx.globalAlpha = gone ? Math.max(0, 1 - (t - tr.t1) / LINGER_SECS) : 1; + ctx.fillStyle = tr.color; + ctx.beginPath(); ctx.arc(x, y, selected ? 5 : 3.5, 0, Math.PI * 2); ctx.fill(); + // Overhead-candidate highlight ring. + if (tr.overhead) { + ctx.strokeStyle = "#5aa9ff"; + ctx.lineWidth = 1.2; + ctx.beginPath(); ctx.arc(x, y, 9, 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(); + } + // Callsign + altitude label. + ctx.fillStyle = "#c7d2e8"; + ctx.font = "11px monospace"; + ctx.fillText(`${tr.label} ${Math.round(p.alt_m)}m`, x + 12, y - 6); + ctx.globalAlpha = 1; + return true; +} + +// --------------------------------------------------------------------------- +// App. +// --------------------------------------------------------------------------- + +async function main() { + const obs = SKY_DATA.observer; + const tracks = SKY_DATA.tracks; + const canvas = document.getElementById("sky"); + const ctx = canvas.getContext("2d"); + const scrubber = document.getElementById("scrubber"); + const clock = document.getElementById("clock"); + const playBtn = document.getElementById("play"); + const engineLabel = document.getElementById("engine"); + document.getElementById("observer-label").textContent = + `observer: ${obs.name} (${obs.lat.toFixed(4)}, ${obs.lon.toFixed(4)}, ${obs.alt_m} m)`; + + // Prefer wasm projection when ./pkg is present; otherwise mirror in JS. + const wasm = await loadWasmProjector(obs); + engineLabel.textContent = wasm + ? `projection: wasm (sky-monitor-wasm ${wasm.version})` + : "projection: JS fallback (build ./pkg for wasm)"; + projectAll(tracks, wasm && wasm.projectBatch, obs, geodeticToEcef(obs.lat, obs.lon, obs.alt_m)); + + const tMin = Math.min(...tracks.map((tr) => tr.t0)); + const tMax = Math.max(...tracks.map((tr) => tr.t1)) + LINGER_SECS; + let t = tMin; + let playing = false; + let selected = null; + let lastFrame = performance.now(); + + // --- Side panel ----------------------------------------------------------- + const tbody = document.querySelector("#track-table tbody"); + const reasons = document.getElementById("reasons"); + const rows = new Map(); + for (const tr of tracks) { + const row = document.createElement("tr"); + row.className = "track-row"; + const score = tr.anomaly ? tr.anomaly.score.toFixed(3) : "—"; + const last = tr.points[tr.points.length - 1]; + row.innerHTML = + `${tr.label}${tr.overhead ? " ◎" : ""}` + + `${Math.round(last.alt_m)}` + + `${headingOf(tr)}°` + + `${tr.band}` + + `${score}`; + row.addEventListener("click", () => { + selected = selected === tr ? null : tr; + if (selected) t = Math.max(tMin, tr.t0); // jump replay to the track + showReasons(); + syncScrubber(); + render(); + }); + tbody.appendChild(row); + rows.set(tr, row); + } + + function headingOf(tr) { + // Circular mean over the projected ground path (display only). + let s = 0, c = 0; + for (let i = 1; i < tr.points.length; i += 10) { + const a = tr.points[i - 1], b = tr.points[i]; + const brg = Math.atan2(b.lon - a.lon, b.lat - a.lat); + s += Math.sin(brg); c += Math.cos(brg); + } + return Math.round(normalizeDeg(Math.atan2(s, c) / DEG)); + } + + function showReasons() { + if (!selected) { + reasons.innerHTML = '
Select a track below.
'; + return; + } + const who = `
${selected.label} (icao24 ${selected.icao24})` + + `${selected.overhead ? " — overhead candidate" : ""}
`; + if (!selected.anomaly) { + reasons.innerHTML = `${who}
baseline track — unscored (ADR §26: baseline before alerting)
`; + return; + } + reasons.innerHTML = who + selected.anomaly.reasons + .map((r) => `
${r}
`) + .join(""); + } + + // --- Replay / scrubber ---------------------------------------------------- + function syncScrubber() { + scrubber.value = String(Math.round(((t - tMin) / (tMax - tMin)) * 1000)); + } + scrubber.addEventListener("input", () => { + t = tMin + (Number(scrubber.value) / 1000) * (tMax - tMin); + render(); + }); + playBtn.addEventListener("click", () => { + playing = !playing; + playBtn.textContent = playing ? "Pause" : "Play"; + lastFrame = performance.now(); + if (playing) requestAnimationFrame(tick); + }); + + function tick(now) { + if (!playing) return; + t += ((now - lastFrame) / 1000) * PLAY_SPEED; + lastFrame = now; + if (t >= tMax) { t = tMin; } // loop the day + syncScrubber(); + render(); + requestAnimationFrame(tick); + } + + // --- Render ---------------------------------------------------------------- + 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); + for (const tr of tracks) { + const live = drawTrack(ctx, tr, t, w, h, tr === selected); + rows.get(tr).classList.toggle("live", live); + rows.get(tr).classList.toggle("active", tr === selected); + } + clock.textContent = new Date(t * 1000).toISOString().replace("T", " ").slice(0, 19) + " UTC"; + } + + window.addEventListener("resize", render); + syncScrubber(); + render(); +} + +main(); diff --git a/examples/sky-monitor/wasm/Cargo.toml b/examples/sky-monitor/wasm/Cargo.toml new file mode 100644 index 0000000000..7f05e1edc6 --- /dev/null +++ b/examples/sky-monitor/wasm/Cargo.toml @@ -0,0 +1,21 @@ +[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" diff --git a/examples/sky-monitor/wasm/src/lib.rs b/examples/sky-monitor/wasm/src/lib.rs new file mode 100644 index 0000000000..9383ce80c4 --- /dev/null +++ b/examples/sky-monitor/wasm/src/lib.rs @@ -0,0 +1,174 @@ +//! # 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. +//! * [`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 screen; + +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/screen.rs b/examples/sky-monitor/wasm/src/screen.rs new file mode 100644 index 0000000000..f413199004 --- /dev/null +++ b/examples/sky-monitor/wasm/src/screen.rs @@ -0,0 +1,92 @@ +//! 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()); + } +} From e114502d75f5e40428d5bc503af605be53dcf2b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 21:50:07 +0000 Subject: [PATCH 4/8] docs(examples): sky-monitor benchmark report and ADR-199 acceptance mapping Criterion results (baseline vs tuned): observer-frame projection -12% single / -10% batch (p<0.05), single-pass embedding -4%; anomaly/pipeline deltas attributed to the TrackSummary adapter that gives native/WASM scorer parity. Includes 1 Hz real-time headroom analysis (~129 ns/projection, ~6k tracks/s anomaly scoring, full synthetic day in ~7 ms) and the mapping of all 8 acceptance tests to ADR-199 s31/s22 criteria. 32/32 tests green across both crates. https://claude.ai/code/session_013Nh9Naw8gim75DGY9LBvK7 --- examples/sky-monitor/BENCHMARKS.md | 102 +++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 examples/sky-monitor/BENCHMARKS.md diff --git a/examples/sky-monitor/BENCHMARKS.md b/examples/sky-monitor/BENCHMARKS.md new file mode 100644 index 0000000000..d16b431ecf --- /dev/null +++ b/examples/sky-monitor/BENCHMARKS.md @@ -0,0 +1,102 @@ +# 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). + +## 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 +``` From 2877d837f86049348778a333576e51e7ba20df66 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 22:05:36 +0000 Subject: [PATCH 5/8] fix(examples): make sky-monitor-wasm buildable offline; record WASM functional verification Disable wasm-opt in wasm-pack metadata so the dashboard pkg builds in air-gapped/appliance environments where the binaryen download is unavailable (size optimization only; documented in Cargo.toml). Verified the built module end-to-end in Node: projection geometry matches native coords (10 km north -> az 0.00, el 5.10, range 10029 m), zenith->center screen mapping, Float64Array batch projection, anomaly scorer parity through the shared TrackSummary path (night track 0.900 strong anomaly vs corridor 0.055 normal), and dump1090 JSON parsing. Recorded in BENCHMARKS.md. https://claude.ai/code/session_013Nh9Naw8gim75DGY9LBvK7 --- examples/sky-monitor/BENCHMARKS.md | 16 ++++++++++++++++ examples/sky-monitor/wasm/Cargo.toml | 7 +++++++ 2 files changed, 23 insertions(+) diff --git a/examples/sky-monitor/BENCHMARKS.md b/examples/sky-monitor/BENCHMARKS.md index d16b431ecf..3b170a9732 100644 --- a/examples/sky-monitor/BENCHMARKS.md +++ b/examples/sky-monitor/BENCHMARKS.md @@ -84,6 +84,22 @@ 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 diff --git a/examples/sky-monitor/wasm/Cargo.toml b/examples/sky-monitor/wasm/Cargo.toml index 7f05e1edc6..1e6ee70c19 100644 --- a/examples/sky-monitor/wasm/Cargo.toml +++ b/examples/sky-monitor/wasm/Cargo.toml @@ -19,3 +19,10 @@ js-sys = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } serde-wasm-bindgen = "0.6" + +# 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 + From 3da082d22cbeeed039ab519d3eb04b0ee07abdb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 22:24:45 +0000 Subject: [PATCH 6/8] style(examples): rustfmt sky-monitor and sky-monitor-wasm Fixes the Rustfmt CI failure on PR #549; no functional changes (32/32 tests still pass, wasm32 release build clean). https://claude.ai/code/session_013Nh9Naw8gim75DGY9LBvK7 --- examples/sky-monitor/benches/sky_bench.rs | 13 +- examples/sky-monitor/src/adsb.rs | 165 +++++++++++++++++++--- examples/sky-monitor/src/anomaly.rs | 15 +- examples/sky-monitor/src/brief.rs | 5 +- examples/sky-monitor/src/coords.rs | 24 +++- examples/sky-monitor/src/embedding.rs | 30 +++- examples/sky-monitor/src/indexer.rs | 26 +++- examples/sky-monitor/src/main.rs | 32 ++++- examples/sky-monitor/src/observation.rs | 12 +- examples/sky-monitor/src/pipeline.rs | 67 ++++++--- examples/sky-monitor/src/skygraph.rs | 107 ++++++++++---- examples/sky-monitor/src/track.rs | 42 +++++- examples/sky-monitor/src/weather.rs | 6 +- examples/sky-monitor/tests/acceptance.rs | 94 +++++++++--- examples/sky-monitor/wasm/src/lib.rs | 3 +- examples/sky-monitor/wasm/src/screen.rs | 20 ++- 16 files changed, 534 insertions(+), 127 deletions(-) diff --git a/examples/sky-monitor/benches/sky_bench.rs b/examples/sky-monitor/benches/sky_bench.rs index 39051d5b4e..66f571b078 100644 --- a/examples/sky-monitor/benches/sky_bench.rs +++ b/examples/sky-monitor/benches/sky_bench.rs @@ -29,7 +29,11 @@ fn bench_projection(c: &mut Criterion) { 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) + ( + 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"); @@ -38,7 +42,9 @@ fn bench_projection(c: &mut Criterion) { b.iter(|| { targets .iter() - .map(|(la, lo, al)| observer_frame(cfg.lat, cfg.lon, cfg.alt_m, *la, *lo, *al).range_m) + .map(|(la, lo, al)| { + observer_frame(cfg.lat, cfg.lon, cfg.alt_m, *la, *lo, *al).range_m + }) .sum::() }) }); @@ -73,7 +79,8 @@ fn bench_vector_db(c: &mut Criterion) { t.track_id = format!("bench-{i}"); idx.insert_track(&t, e.clone()).unwrap(); } - idx.similar_tracks(black_box(&embeddings[0]), None, 10).unwrap() + idx.similar_tracks(black_box(&embeddings[0]), None, 10) + .unwrap() }) }); } diff --git a/examples/sky-monitor/src/adsb.rs b/examples/sky-monitor/src/adsb.rs index 7126b517a8..987e128520 100644 --- a/examples/sky-monitor/src/adsb.rs +++ b/examples/sky-monitor/src/adsb.rs @@ -49,10 +49,16 @@ pub struct Lcg(u64); impl Lcg { pub fn new(seed: u64) -> Self { - Self(seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407)) + 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 + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); self.0 ^ (self.0 >> 33) } /// Uniform in `[0, 1)`. @@ -91,21 +97,131 @@ 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 }, + 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 }, + 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 }, + 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 }, + 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 }, + 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, + }, ] } @@ -136,8 +252,11 @@ pub fn generate_scenario( // 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); + // 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; @@ -193,7 +312,11 @@ pub fn parse_dump1090(json: &str) -> Result, serde_json::Erro }; 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(), + icao24: ac + .get("hex") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_lowercase(), callsign: ac .get("flight") .and_then(|x| x.as_str()) @@ -223,8 +346,16 @@ mod tests { 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.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)); diff --git a/examples/sky-monitor/src/anomaly.rs b/examples/sky-monitor/src/anomaly.rs index 2bc6b0fced..920740ee4e 100644 --- a/examples/sky-monitor/src/anomaly.rs +++ b/examples/sky-monitor/src/anomaly.rs @@ -232,7 +232,14 @@ pub fn score_track( novelty: f64, cross_sensor: f64, ) -> AnomalyReport { - score_summary_as(cfg, &track.track_id, &TrackSummary::from(track), baseline, novelty, cross_sensor) + score_summary_as( + cfg, + &track.track_id, + &TrackSummary::from(track), + baseline, + novelty, + cross_sensor, + ) } /// Score a [`TrackSummary`] against the baseline — same formula, reasons, and @@ -270,7 +277,8 @@ fn score_summary_as( 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 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 @@ -282,7 +290,8 @@ fn score_summary_as( } 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 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 { diff --git a/examples/sky-monitor/src/brief.rs b/examples/sky-monitor/src/brief.rs index 67366a6f87..c6e17c9f31 100644 --- a/examples/sky-monitor/src/brief.rs +++ b/examples/sky-monitor/src/brief.rs @@ -52,7 +52,10 @@ impl DailySkyBrief { 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 unusual = reports + .iter() + .filter(|r| r.band > Interpretation::MildlyUnusual) + .count(); let mut weather_events = Vec::new(); let mut rain_run: Option<(usize, usize)> = None; diff --git a/examples/sky-monitor/src/coords.rs b/examples/sky-monitor/src/coords.rs index 628349531d..9617c04f1e 100644 --- a/examples/sky-monitor/src/coords.rs +++ b/examples/sky-monitor/src/coords.rs @@ -186,8 +186,16 @@ mod tests { // ~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); + 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, @@ -211,8 +219,16 @@ mod tests { 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); + 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] diff --git a/examples/sky-monitor/src/embedding.rs b/examples/sky-monitor/src/embedding.rs index c2641df4fb..86f477935c 100644 --- a/examples/sky-monitor/src/embedding.rs +++ b/examples/sky-monitor/src/embedding.rs @@ -162,7 +162,11 @@ pub fn track_embedding(track: &Track) -> Vec { e[27] = clamp01(pts.first().map(|p| p.frame.range_m).unwrap_or(0.0) / 50_000.0); e[28] = clamp01(pts.last().map(|p| p.frame.range_m).unwrap_or(0.0) / 50_000.0); e[29] = clamp01(count as f64 / 600.0); - e[30] = if track.is_overhead_candidate { 1.0 } else { 0.0 }; + e[30] = if track.is_overhead_candidate { + 1.0 + } else { + 0.0 + }; e[31] = 0.0; // reserved e } @@ -212,8 +216,16 @@ mod tests { 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 }, + 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, ) @@ -223,14 +235,20 @@ mod tests { } fn dist(a: &[f32], b: &[f32]) -> f32 { - a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum::().sqrt() + 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(); + 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))); diff --git a/examples/sky-monitor/src/indexer.rs b/examples/sky-monitor/src/indexer.rs index 63293f2072..aaf5914ded 100644 --- a/examples/sky-monitor/src/indexer.rs +++ b/examples/sky-monitor/src/indexer.rs @@ -56,7 +56,10 @@ impl TrackIndexer { hnsw_config: None, quantization: None, }; - Ok(Self { db: VectorDB::new(options)?, len: 0 }) + Ok(Self { + db: VectorDB::new(options)?, + len: 0, + }) } /// Number of indexed tracks. @@ -71,12 +74,19 @@ impl TrackIndexer { /// 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 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)); + metadata.insert( + "overhead".to_string(), + serde_json::json!(track.is_overhead_candidate), + ); self.db.insert(VectorEntry { id: Some(track.track_id.clone()), vector: embedding, @@ -141,9 +151,15 @@ mod tests { // 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(); + 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; + 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}" diff --git a/examples/sky-monitor/src/main.rs b/examples/sky-monitor/src/main.rs index 37c5b35db3..0ca2685ac8 100644 --- a/examples/sky-monitor/src/main.rs +++ b/examples/sky-monitor/src/main.rs @@ -32,7 +32,11 @@ fn parse_args() -> sky_monitor::Result> { /// 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<()> { +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!( @@ -75,7 +79,11 @@ fn main() -> sky_monitor::Result<()> { 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 }, + if t.callsign.is_empty() { + "-" + } else { + &t.callsign + }, t.min_range_m / 1000.0, f.azimuth_deg, f.elevation_deg, @@ -90,7 +98,10 @@ fn main() -> sky_monitor::Result<()> { let (nodes, edges) = report.skygraph.stats(); println!("\n== SkyGraph =="); println!("nodes: {nodes} edges: {edges}"); - println!("overhead candidates: {:?}", report.skygraph.overhead_candidates()); + println!( + "overhead candidates: {:?}", + report.skygraph.overhead_candidates() + ); // ---- Similarity -------------------------------------------------------- println!("\n== Top similar-track pairs (RuVector, euclidean) =="); @@ -100,12 +111,19 @@ fn main() -> sky_monitor::Result<()> { // ---- Anomalies --------------------------------------------------------- println!("\n== Anomaly scores (ADR-199 §15) =="); - println!("{:<16} {:<7} {:>6} {:<16} reasons", "track", "call", "score", "band"); + 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 }, + if r.callsign.is_empty() { + "-" + } else { + &r.callsign + }, r.score, r.band.to_string(), r.reasons.join(" | ") @@ -121,7 +139,9 @@ fn main() -> sky_monitor::Result<()> { { println!( "\n== Explain {} ({}, action: {}) ==", - worst.track_id, worst.band, worst.band.action() + worst.track_id, + worst.band, + worst.band.action() ); if let Some(explanation) = report.skygraph.explain(&worst.track_id) { for line in &explanation.evidence { diff --git a/examples/sky-monitor/src/observation.rs b/examples/sky-monitor/src/observation.rs index 30ab46da86..e501032165 100644 --- a/examples/sky-monitor/src/observation.rs +++ b/examples/sky-monitor/src/observation.rs @@ -153,8 +153,16 @@ mod tests { 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 }, + 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, ); diff --git a/examples/sky-monitor/src/pipeline.rs b/examples/sky-monitor/src/pipeline.rs index 92887be4fa..2b8ae63c47 100644 --- a/examples/sky-monitor/src/pipeline.rs +++ b/examples/sky-monitor/src/pipeline.rs @@ -52,26 +52,35 @@ impl Default for Pipeline { 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() + 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 @@ -106,7 +115,13 @@ impl Pipeline { // 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)); + reports.push(score_track( + &self.anomaly, + track, + &baseline, + novelty, + cross_sensor, + )); nearest_baseline[i] = indexer .similar_tracks(embedding, Some(&track.track_id), 1)? .first() @@ -118,7 +133,10 @@ impl Pipeline { // 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() { + 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 { @@ -231,7 +249,10 @@ mod tests { 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_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"); diff --git a/examples/sky-monitor/src/skygraph.rs b/examples/sky-monitor/src/skygraph.rs index 2685fca5b8..1eeb01c2ac 100644 --- a/examples/sky-monitor/src/skygraph.rs +++ b/examples/sky-monitor/src/skygraph.rs @@ -75,7 +75,10 @@ impl SkyGraph { .property("alt_m", observer.alt_m) .build(), )?; - Ok(Self { graph, observer_node_id }) + Ok(Self { + graph, + observer_node_id, + }) } fn time_window_id(ts: DateTime) -> String { @@ -106,7 +109,11 @@ impl SkyGraph { /// 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 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); @@ -131,7 +138,8 @@ impl SkyGraph { )?; 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())?; + self.graph + .create_edge(EdgeBuilder::new(w.window_id.clone(), win, "during").build())?; } Ok(()) } @@ -175,7 +183,11 @@ impl SkyGraph { .build(), )?; // Evidence observation nodes (first / closest / last samples). - for (role, oid) in [("first", first_obs), ("closest_approach", closest_obs), ("last", last_obs)] { + 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() @@ -192,36 +204,59 @@ impl SkyGraph { )?; } // 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(), + 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(), + 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())?; + 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)) { + 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(), + 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<()> { + 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) @@ -232,7 +267,11 @@ impl SkyGraph { /// 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 { + 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() @@ -245,14 +284,23 @@ impl SkyGraph { .build(), )?; self.graph.create_edge( - EdgeBuilder::new(report.track_id.clone(), anomaly_id.clone(), "correlated_with") - .property("kind", "anomaly_score") - .build(), + 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(), + EdgeBuilder::new( + anomaly_id.clone(), + baseline.to_string(), + "anomalous_relative_to", + ) + .build(), )?; } } @@ -260,7 +308,11 @@ impl SkyGraph { } /// Aircraft active in `[start, end]`: `(icao24, track_id)` pairs. - pub fn aircraft_in_window(&self, start: DateTime, end: DateTime) -> Vec<(String, String)> { + 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 @@ -312,7 +364,9 @@ impl SkyGraph { 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)), + "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)), @@ -334,7 +388,8 @@ impl SkyGraph { )); 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)); + evidence + .push(format!("anomalous_relative_to baseline {}", be.to)); } } } diff --git a/examples/sky-monitor/src/track.rs b/examples/sky-monitor/src/track.rs index a88bcb80d1..21747486bb 100644 --- a/examples/sky-monitor/src/track.rs +++ b/examples/sky-monitor/src/track.rs @@ -53,7 +53,12 @@ pub struct Track { } impl Track { - fn from_points(icao24: String, callsign: String, segment: usize, points: Vec) -> Self { + 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 @@ -229,7 +234,11 @@ fn flat_distance_m(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 { /// 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 { +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 { @@ -246,7 +255,12 @@ pub fn stitch_tracks(_observer: &ObserverConfig, observations: &[Observation], g 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))); + tracks.push(Track::from_points( + icao24.clone(), + callsign.clone(), + seg_no, + std::mem::take(&mut segment), + )); seg_no += 1; } } @@ -292,8 +306,16 @@ mod tests { 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 }, + 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, ) @@ -307,12 +329,18 @@ mod tests { 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(); + 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.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 index d0c8138287..e7a915c134 100644 --- a/examples/sky-monitor/src/weather.rs +++ b/examples/sky-monitor/src/weather.rs @@ -101,7 +101,11 @@ mod tests { 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"); + 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 index e0880f48c4..2eeab93ddf 100644 --- a/examples/sky-monitor/tests/acceptance.rs +++ b/examples/sky-monitor/tests/acceptance.rs @@ -19,10 +19,29 @@ fn run() -> sky_monitor::PipelineReport { 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); + 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(); @@ -59,17 +78,24 @@ fn acceptance_3_aircraft_by_time_window() { 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)); + 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)); + 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()); + 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 @@ -91,7 +117,10 @@ fn acceptance_4_anomaly_scoring_separates_corridor_traffic() { anomaly.components ); assert!(anomaly.band >= Interpretation::StrongAnomaly); - assert!(!anomaly.reasons.is_empty(), "governance rule 2: reasons required"); + assert!( + !anomaly.reasons.is_empty(), + "governance rule 2: reasons required" + ); let corridor: Vec<_> = report .reports @@ -101,7 +130,10 @@ fn acceptance_4_anomaly_scoring_separates_corridor_traffic() { || WESTBOUND_CORRIDOR.contains(&r.icao24.as_str()) }) .collect(); - assert!(!corridor.is_empty(), "some corridor flights must be scored post-baseline"); + assert!( + !corridor.is_empty(), + "some corridor flights must be scored post-baseline" + ); for r in corridor { assert!( r.score <= 0.55, @@ -124,16 +156,31 @@ fn acceptance_5_explain_cites_observation_ids() { .iter() .find(|t| t.icao24 == ANOMALOUS_ICAO24) .unwrap(); - let explanation = report.skygraph.explain(&anomalous.track_id).expect("track explainable"); + 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"); + 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(&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"); + 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 @@ -166,7 +213,10 @@ fn acceptance_6_similarity_prefers_same_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:?}"); + 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. @@ -177,7 +227,10 @@ fn acceptance_7_brief_renders_with_counts() { 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"); + 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")); @@ -203,6 +256,13 @@ fn acceptance_8_dump1090_parser() { 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); + 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/wasm/src/lib.rs b/examples/sky-monitor/wasm/src/lib.rs index 9383ce80c4..3af2ddb8d6 100644 --- a/examples/sky-monitor/wasm/src/lib.rs +++ b/examples/sky-monitor/wasm/src/lib.rs @@ -133,8 +133,7 @@ impl AnomalyScorer { /// (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)?; + 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 { diff --git a/examples/sky-monitor/wasm/src/screen.rs b/examples/sky-monitor/wasm/src/screen.rs index f413199004..a15dcb2f65 100644 --- a/examples/sky-monitor/wasm/src/screen.rs +++ b/examples/sky-monitor/wasm/src/screen.rs @@ -63,11 +63,20 @@ mod tests { #[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})"); + 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})"); + 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})"); + assert!( + (x - (400.0 - R)).abs() < 1e-6 && (y - 300.0).abs() < 1e-6, + "W ({x},{y})" + ); } #[test] @@ -84,7 +93,10 @@ mod tests { 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}"); + 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()); From 4895551ab9d9f38fcce8762eda597e4297ccef34 Mon Sep 17 00:00:00 2001 From: ruv Date: Wed, 10 Jun 2026 11:53:16 -0400 Subject: [PATCH 7/8] =?UTF-8?q?feat(sky-monitor):=20realtime-only=20dashbo?= =?UTF-8?q?ard=20with=20satellites,=20live=20=C2=A715=20scoring,=20and=20S?= =?UTF-8?q?OTA=20pack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dashboard rewritten realtime-only (synthetic-day replay removed): live ADS-B (airplanes.live/adsb.lol) + Open-Meteo, smoothed dead reckoning, ⚙ drawer - wasm: SatPropagator (SGP4 + pass prediction), embed_track/novelty (§13/§15), AnomalyScorer wired to live tracks with IndexedDB vector-novelty store - Sun/moon + naked-eye satellite visibility, behavior badges, CPA conflict alerts, adsbdb routes, NOAA SWPC Kp, WebGPU sat layer (fallback-safe), recorded-replay ring buffer - 13 wasm-crate tests, 10 node detector tests, Playwright-verified incl. offline Co-Authored-By: claude-flow --- Cargo.lock | 17 +- examples/sky-monitor/README.md | 46 +- examples/sky-monitor/src/embedding.rs | 142 +++- examples/sky-monitor/src/lib.rs | 5 +- examples/sky-monitor/ui/dashboard/README.md | 103 ++- examples/sky-monitor/ui/dashboard/astro.js | 78 +++ examples/sky-monitor/ui/dashboard/behavior.js | 164 +++++ examples/sky-monitor/ui/dashboard/conflict.js | 123 ++++ examples/sky-monitor/ui/dashboard/draw.js | 164 +++++ examples/sky-monitor/ui/dashboard/gpu-sats.js | 163 +++++ examples/sky-monitor/ui/dashboard/index.html | 130 +++- .../sky-monitor/ui/dashboard/live-feed.js | 278 ++++++++ examples/sky-monitor/ui/dashboard/novelty.js | 112 +++ examples/sky-monitor/ui/dashboard/panels.js | 114 +++ examples/sky-monitor/ui/dashboard/passes.js | 104 +++ examples/sky-monitor/ui/dashboard/project.js | 77 +++ examples/sky-monitor/ui/dashboard/record.js | 115 +++ .../sky-monitor/ui/dashboard/route-info.js | 87 +++ examples/sky-monitor/ui/dashboard/sat-feed.js | 68 ++ .../sky-monitor/ui/dashboard/score-live.js | 47 ++ examples/sky-monitor/ui/dashboard/settings.js | 88 +++ .../sky-monitor/ui/dashboard/sky-demo-data.js | 3 - examples/sky-monitor/ui/dashboard/sky.js | 653 ++++++++++-------- examples/sky-monitor/ui/dashboard/space-wx.js | 75 ++ .../ui/dashboard/test/behavior.test.mjs | 100 +++ .../ui/dashboard/test/conflict.test.mjs | 84 +++ examples/sky-monitor/wasm/Cargo.toml | 5 + examples/sky-monitor/wasm/src/embed.rs | 174 +++++ examples/sky-monitor/wasm/src/lib.rs | 9 + examples/sky-monitor/wasm/src/sat.rs | 368 ++++++++++ 30 files changed, 3318 insertions(+), 378 deletions(-) create mode 100644 examples/sky-monitor/ui/dashboard/astro.js create mode 100644 examples/sky-monitor/ui/dashboard/behavior.js create mode 100644 examples/sky-monitor/ui/dashboard/conflict.js create mode 100644 examples/sky-monitor/ui/dashboard/draw.js create mode 100644 examples/sky-monitor/ui/dashboard/gpu-sats.js create mode 100644 examples/sky-monitor/ui/dashboard/live-feed.js create mode 100644 examples/sky-monitor/ui/dashboard/novelty.js create mode 100644 examples/sky-monitor/ui/dashboard/panels.js create mode 100644 examples/sky-monitor/ui/dashboard/passes.js create mode 100644 examples/sky-monitor/ui/dashboard/project.js create mode 100644 examples/sky-monitor/ui/dashboard/record.js create mode 100644 examples/sky-monitor/ui/dashboard/route-info.js create mode 100644 examples/sky-monitor/ui/dashboard/sat-feed.js create mode 100644 examples/sky-monitor/ui/dashboard/score-live.js create mode 100644 examples/sky-monitor/ui/dashboard/settings.js delete mode 100644 examples/sky-monitor/ui/dashboard/sky-demo-data.js create mode 100644 examples/sky-monitor/ui/dashboard/space-wx.js create mode 100644 examples/sky-monitor/ui/dashboard/test/behavior.test.mjs create mode 100644 examples/sky-monitor/ui/dashboard/test/conflict.test.mjs create mode 100644 examples/sky-monitor/wasm/src/embed.rs create mode 100644 examples/sky-monitor/wasm/src/sat.rs diff --git a/Cargo.lock b/Cargo.lock index d8cd1d38b8..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" @@ -11654,10 +11665,12 @@ dependencies = [ name = "sky-monitor-wasm" version = "0.1.0" dependencies = [ + "chrono", "js-sys", "serde", "serde-wasm-bindgen", "serde_json", + "sgp4", "sky-monitor", "wasm-bindgen", ] @@ -12306,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/examples/sky-monitor/README.md b/examples/sky-monitor/README.md index cf56e4713c..d9d889ce33 100644 --- a/examples/sky-monitor/README.md +++ b/examples/sky-monitor/README.md @@ -36,8 +36,8 @@ the door open for real RTL-SDR data. Vectors live in # demo (from the repository root) cargo run -p sky-monitor --release -# demo + dashboard data export (writes `const SKY_DATA = {...};` for .js paths) -cargo run -p sky-monitor --release -- --emit-json examples/sky-monitor/ui/dashboard/sky-demo-data.js +# 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 @@ -74,6 +74,17 @@ first"), wrapping the pure subset with `wasm-bindgen`: 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. @@ -85,14 +96,29 @@ wasm-pack build examples/sky-monitor/wasm --target web --out-dir ../ui/dashboard ## Canvas dashboard (`ui/dashboard/`) -Vanilla JS + Canvas, no build tooling (see `ui/dashboard/README.md`): an -all-sky polar plot (elevation rings at 0/30/60°, compass labels) showing -aircraft as labelled dots with fading trails, overhead-candidate highlight -rings, anomaly badges colored by band, a side panel with the live track table -and §15 anomaly reasons, and a replay scrubber over the embedded deterministic -scenario (`sky-demo-data.js`, regenerated via `--emit-json` above). -Projection runs in JS by default and switches to `sky-monitor-wasm` -automatically when the wasm-pack output is present at `ui/dashboard/pkg/`. +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 diff --git a/examples/sky-monitor/src/embedding.rs b/examples/sky-monitor/src/embedding.rs index 86f477935c..85c96320cc 100644 --- a/examples/sky-monitor/src/embedding.rs +++ b/examples/sky-monitor/src/embedding.rs @@ -47,14 +47,62 @@ 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. +/// 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 (the Track stat methods - // would walk the point list ~14 times and allocate a temp Vec). - let pts = &track.points; + // once instead of one full iteration per feature. let count = pts.len(); let nf = count as f64; let mut alt_sum = 0.0f64; @@ -74,6 +122,8 @@ pub fn track_embedding(track: &Track) -> Vec { 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; @@ -82,7 +132,7 @@ pub fn track_embedding(track: &Track) -> Vec { speed_sum += p.speed_mps; speed_sq += p.speed_mps * p.speed_mps; sig_sum += p.signal_dbfs; - elev_sum += p.frame.elevation_deg; + elev_sum += p.elevation_deg; vr_abs_sum += p.vertical_rate_mps.abs(); if p.vertical_rate_mps > 2.0 { climb += 1; @@ -93,8 +143,10 @@ pub fn track_embedding(track: &Track) -> Vec { let r = p.track_deg.to_radians(); head_s += r.sin(); head_c += r.cos(); - let b = ((p.frame.azimuth_deg.rem_euclid(360.0)) / 45.0) as usize % 8; + 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; @@ -104,14 +156,10 @@ pub fn track_embedding(track: &Track) -> Vec { prev_lat = p.lat; prev_lon = p.lon; } - let mean = |sum: f64| if count == 0 { 0.0 } else { sum / nf }; + let mean = |sum: f64| sum / nf; let std = |sq: f64, sum: f64| { - if count == 0 { - 0.0 - } else { - let m = sum / nf; - (sq / nf - m * m).max(0.0).sqrt() - } + let m = sum / nf; + (sq / nf - m * m).max(0.0).sqrt() }; e[0] = clamp01(mean(alt_sum) / 12_000.0); @@ -125,23 +173,25 @@ pub fn track_embedding(track: &Track) -> Vec { 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. - let frac = (track.started.hour() as f64 + track.started.minute() as f64 / 60.0) / 24.0; + // 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(track.min_range_m / 50_000.0); - e[9] = clamp01(track.max_elevation_deg / 90.0); + 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(track.duration_secs() / 1_800.0); - e[12] = clamp01(if count == 0 { 0.0 } else { climb as f64 / nf }); - e[13] = clamp01(if count == 0 { 0.0 } else { descent as f64 / nf }); + 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.first().unwrap(), pts.last().unwrap()); + 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) @@ -151,18 +201,20 @@ pub fn track_embedding(track: &Track) -> Vec { // Coarse azimuth occupancy: fraction of samples seen in each 45° sky // sector around the observer (route-class signature). - let n = count.max(1) as f64; for (b, &hits) in buckets.iter().enumerate() { - e[16 + b] = (f64::from(hits) / n) as f32; + 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.first().map(|p| p.frame.range_m).unwrap_or(0.0) / 50_000.0); - e[28] = clamp01(pts.last().map(|p| p.frame.range_m).unwrap_or(0.0) / 50_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); - e[30] = if track.is_overhead_candidate { + // 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 @@ -271,4 +323,42 @@ mod tests { 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/lib.rs b/examples/sky-monitor/src/lib.rs index 908796e05b..9c89f027bf 100644 --- a/examples/sky-monitor/src/lib.rs +++ b/examples/sky-monitor/src/lib.rs @@ -47,7 +47,10 @@ pub use anomaly::{ 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, weather_window_embedding, TRACK_EMBEDDING_DIM}; +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}; diff --git a/examples/sky-monitor/ui/dashboard/README.md b/examples/sky-monitor/ui/dashboard/README.md index eb33b2ec91..06d50a840e 100644 --- a/examples/sky-monitor/ui/dashboard/README.md +++ b/examples/sky-monitor/ui/dashboard/README.md @@ -1,13 +1,68 @@ # SkyGraph all-sky dashboard (ADR-199 presentation plane) -Vanilla JS + Canvas, no build tooling. Renders the embedded deterministic -scenario (`sky-demo-data.js`) 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; overhead candidates get a blue highlight -ring; anomaly badges are colored by band (normal / mildly unusual / -interesting / strong anomaly / rare, gray = unscored baseline). The side panel -lists tracks (click to select + jump the replay) and shows the §15 anomaly -reasons; the footer scrubber replays the synthetic day at 60×. +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 @@ -17,24 +72,34 @@ python3 -m http.server 8000 # open http://localhost:8000/ ``` -## Regenerate the demo data - -```bash -# from the repository root -cargo run -p sky-monitor --release -- --emit-json examples/sky-monitor/ui/dashboard/sky-demo-data.js -``` - ## 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 (`SkyProjector.project_batch`): +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 (`projection: wasm …` vs -`projection: JS fallback`). Without `./pkg` the dashboard is fully functional -on the JS fallback. +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 index 91103b44b9..2c01ffcc0c 100644 --- a/examples/sky-monitor/ui/dashboard/index.html +++ b/examples/sky-monitor/ui/dashboard/index.html @@ -3,7 +3,7 @@ -RuView SkyGraph — all-sky dashboard (ADR-199) +RuView SkyGraph — realtime all-sky dashboard (ADR-199)
-

RuView SkyGraph — all-sky view

+

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-demo-data.js b/examples/sky-monitor/ui/dashboard/sky-demo-data.js deleted file mode 100644 index b0c2319b69..0000000000 --- a/examples/sky-monitor/ui/dashboard/sky-demo-data.js +++ /dev/null @@ -1,3 +0,0 @@ -// Generated by `cargo run -p sky-monitor --release -- --emit-json /home/user/RuVector/examples/sky-monitor/ui/dashboard/sky-demo-data.js`. -// Deterministic synthetic scenario (seed 42) — do not edit by hand. -const SKY_DATA = {"day_start":"2026-06-08T00:00:00+00:00","observer":{"alt_m":100.0,"lat":43.4675,"lon":-79.6877,"name":"oakville_node"},"tracks":[{"anomaly":null,"callsign":"ACA101","icao24":"c01a01","overhead":false,"points":[{"alt_m":10602.6,"lat":43.320289,"lon":-79.990474,"t":1780916700},{"alt_m":10599.5,"lat":43.320946,"lon":-79.987696,"t":1780916701},{"alt_m":10596.5,"lat":43.321602,"lon":-79.984918,"t":1780916702},{"alt_m":10598.4,"lat":43.322258,"lon":-79.98214,"t":1780916703},{"alt_m":10596.9,"lat":43.322914,"lon":-79.979362,"t":1780916704},{"alt_m":10598.3,"lat":43.32357,"lon":-79.976584,"t":1780916705},{"alt_m":10602.9,"lat":43.324227,"lon":-79.973806,"t":1780916706},{"alt_m":10608.7,"lat":43.324883,"lon":-79.971028,"t":1780916707},{"alt_m":10608.7,"lat":43.325539,"lon":-79.968249,"t":1780916708},{"alt_m":10594.4,"lat":43.326195,"lon":-79.965471,"t":1780916709},{"alt_m":10600.2,"lat":43.326852,"lon":-79.962693,"t":1780916710},{"alt_m":10590.9,"lat":43.327508,"lon":-79.959915,"t":1780916711},{"alt_m":10605.1,"lat":43.328164,"lon":-79.957137,"t":1780916712},{"alt_m":10595.2,"lat":43.32882,"lon":-79.954359,"t":1780916713},{"alt_m":10607.6,"lat":43.329477,"lon":-79.951581,"t":1780916714},{"alt_m":10597.3,"lat":43.330133,"lon":-79.948803,"t":1780916715},{"alt_m":10597.1,"lat":43.330789,"lon":-79.946025,"t":1780916716},{"alt_m":10604.1,"lat":43.331445,"lon":-79.943247,"t":1780916717},{"alt_m":10608.1,"lat":43.332101,"lon":-79.940468,"t":1780916718},{"alt_m":10604.6,"lat":43.332758,"lon":-79.93769,"t":1780916719},{"alt_m":10597.3,"lat":43.333414,"lon":-79.934912,"t":1780916720},{"alt_m":10603.0,"lat":43.33407,"lon":-79.932134,"t":1780916721},{"alt_m":10603.4,"lat":43.334726,"lon":-79.929356,"t":1780916722},{"alt_m":10608.6,"lat":43.335383,"lon":-79.926578,"t":1780916723},{"alt_m":10592.3,"lat":43.336039,"lon":-79.9238,"t":1780916724},{"alt_m":10596.8,"lat":43.336695,"lon":-79.921022,"t":1780916725},{"alt_m":10602.4,"lat":43.337351,"lon":-79.918244,"t":1780916726},{"alt_m":10590.7,"lat":43.338008,"lon":-79.915465,"t":1780916727},{"alt_m":10609.7,"lat":43.338664,"lon":-79.912687,"t":1780916728},{"alt_m":10594.3,"lat":43.33932,"lon":-79.909909,"t":1780916729},{"alt_m":10590.4,"lat":43.339976,"lon":-79.907131,"t":1780916730},{"alt_m":10606.0,"lat":43.340632,"lon":-79.904353,"t":1780916731},{"alt_m":10592.6,"lat":43.341289,"lon":-79.901575,"t":1780916732},{"alt_m":10602.6,"lat":43.341945,"lon":-79.898797,"t":1780916733},{"alt_m":10607.9,"lat":43.342601,"lon":-79.896019,"t":1780916734},{"alt_m":10600.3,"lat":43.343257,"lon":-79.893241,"t":1780916735},{"alt_m":10594.7,"lat":43.343914,"lon":-79.890462,"t":1780916736},{"alt_m":10607.8,"lat":43.34457,"lon":-79.887684,"t":1780916737},{"alt_m":10599.2,"lat":43.345226,"lon":-79.884906,"t":1780916738},{"alt_m":10590.2,"lat":43.345882,"lon":-79.882128,"t":1780916739},{"alt_m":10607.4,"lat":43.346539,"lon":-79.87935,"t":1780916740},{"alt_m":10601.7,"lat":43.347195,"lon":-79.876572,"t":1780916741},{"alt_m":10604.7,"lat":43.347851,"lon":-79.873794,"t":1780916742},{"alt_m":10592.0,"lat":43.348507,"lon":-79.871016,"t":1780916743},{"alt_m":10591.4,"lat":43.349163,"lon":-79.868238,"t":1780916744},{"alt_m":10595.1,"lat":43.34982,"lon":-79.865459,"t":1780916745},{"alt_m":10608.7,"lat":43.350476,"lon":-79.862681,"t":1780916746},{"alt_m":10605.1,"lat":43.351132,"lon":-79.859903,"t":1780916747},{"alt_m":10609.3,"lat":43.351788,"lon":-79.857125,"t":1780916748},{"alt_m":10595.3,"lat":43.352445,"lon":-79.854347,"t":1780916749},{"alt_m":10603.4,"lat":43.353101,"lon":-79.851569,"t":1780916750},{"alt_m":10595.7,"lat":43.353757,"lon":-79.848791,"t":1780916751},{"alt_m":10593.8,"lat":43.354413,"lon":-79.846013,"t":1780916752},{"alt_m":10602.7,"lat":43.355069,"lon":-79.843235,"t":1780916753},{"alt_m":10591.7,"lat":43.355726,"lon":-79.840456,"t":1780916754},{"alt_m":10604.1,"lat":43.356382,"lon":-79.837678,"t":1780916755},{"alt_m":10607.4,"lat":43.357038,"lon":-79.8349,"t":1780916756},{"alt_m":10605.2,"lat":43.357694,"lon":-79.832122,"t":1780916757},{"alt_m":10602.8,"lat":43.358351,"lon":-79.829344,"t":1780916758},{"alt_m":10600.5,"lat":43.359007,"lon":-79.826566,"t":1780916759},{"alt_m":10594.3,"lat":43.359663,"lon":-79.823788,"t":1780916760},{"alt_m":10599.2,"lat":43.360319,"lon":-79.82101,"t":1780916761},{"alt_m":10601.9,"lat":43.360976,"lon":-79.818232,"t":1780916762},{"alt_m":10591.5,"lat":43.361632,"lon":-79.815453,"t":1780916763},{"alt_m":10608.0,"lat":43.362288,"lon":-79.812675,"t":1780916764},{"alt_m":10607.9,"lat":43.362944,"lon":-79.809897,"t":1780916765},{"alt_m":10595.1,"lat":43.3636,"lon":-79.807119,"t":1780916766},{"alt_m":10609.7,"lat":43.364257,"lon":-79.804341,"t":1780916767},{"alt_m":10600.9,"lat":43.364913,"lon":-79.801563,"t":1780916768},{"alt_m":10608.5,"lat":43.365569,"lon":-79.798785,"t":1780916769},{"alt_m":10595.5,"lat":43.366225,"lon":-79.796007,"t":1780916770},{"alt_m":10607.0,"lat":43.366882,"lon":-79.793229,"t":1780916771},{"alt_m":10590.6,"lat":43.367538,"lon":-79.790451,"t":1780916772},{"alt_m":10592.9,"lat":43.368194,"lon":-79.787672,"t":1780916773},{"alt_m":10602.2,"lat":43.36885,"lon":-79.784894,"t":1780916774},{"alt_m":10593.2,"lat":43.369507,"lon":-79.782116,"t":1780916775},{"alt_m":10597.0,"lat":43.370163,"lon":-79.779338,"t":1780916776},{"alt_m":10604.2,"lat":43.370819,"lon":-79.77656,"t":1780916777},{"alt_m":10591.2,"lat":43.371475,"lon":-79.773782,"t":1780916778},{"alt_m":10605.0,"lat":43.372131,"lon":-79.771004,"t":1780916779},{"alt_m":10608.7,"lat":43.372788,"lon":-79.768226,"t":1780916780},{"alt_m":10592.4,"lat":43.373444,"lon":-79.765448,"t":1780916781},{"alt_m":10603.9,"lat":43.3741,"lon":-79.762669,"t":1780916782},{"alt_m":10609.5,"lat":43.374756,"lon":-79.759891,"t":1780916783},{"alt_m":10597.6,"lat":43.375413,"lon":-79.757113,"t":1780916784},{"alt_m":10592.9,"lat":43.376069,"lon":-79.754335,"t":1780916785},{"alt_m":10597.7,"lat":43.376725,"lon":-79.751557,"t":1780916786},{"alt_m":10599.1,"lat":43.377381,"lon":-79.748779,"t":1780916787},{"alt_m":10603.6,"lat":43.378037,"lon":-79.746001,"t":1780916788},{"alt_m":10605.9,"lat":43.378694,"lon":-79.743223,"t":1780916789},{"alt_m":10590.6,"lat":43.37935,"lon":-79.740445,"t":1780916790},{"alt_m":10607.1,"lat":43.380006,"lon":-79.737666,"t":1780916791},{"alt_m":10590.5,"lat":43.380662,"lon":-79.734888,"t":1780916792},{"alt_m":10602.4,"lat":43.381319,"lon":-79.73211,"t":1780916793},{"alt_m":10595.2,"lat":43.381975,"lon":-79.729332,"t":1780916794},{"alt_m":10591.9,"lat":43.382631,"lon":-79.726554,"t":1780916795},{"alt_m":10606.2,"lat":43.383287,"lon":-79.723776,"t":1780916796},{"alt_m":10602.5,"lat":43.383944,"lon":-79.720998,"t":1780916797},{"alt_m":10600.0,"lat":43.3846,"lon":-79.71822,"t":1780916798},{"alt_m":10598.7,"lat":43.385256,"lon":-79.715442,"t":1780916799},{"alt_m":10590.1,"lat":43.385912,"lon":-79.712663,"t":1780916800},{"alt_m":10600.0,"lat":43.386568,"lon":-79.709885,"t":1780916801},{"alt_m":10609.9,"lat":43.387225,"lon":-79.707107,"t":1780916802},{"alt_m":10596.2,"lat":43.387881,"lon":-79.704329,"t":1780916803},{"alt_m":10595.5,"lat":43.388537,"lon":-79.701551,"t":1780916804},{"alt_m":10598.2,"lat":43.389193,"lon":-79.698773,"t":1780916805},{"alt_m":10609.4,"lat":43.38985,"lon":-79.695995,"t":1780916806},{"alt_m":10606.3,"lat":43.390506,"lon":-79.693217,"t":1780916807},{"alt_m":10590.5,"lat":43.391162,"lon":-79.690439,"t":1780916808},{"alt_m":10607.2,"lat":43.391818,"lon":-79.68766,"t":1780916809},{"alt_m":10608.6,"lat":43.392475,"lon":-79.684882,"t":1780916810},{"alt_m":10593.8,"lat":43.393131,"lon":-79.682104,"t":1780916811},{"alt_m":10597.6,"lat":43.393787,"lon":-79.679326,"t":1780916812},{"alt_m":10594.3,"lat":43.394443,"lon":-79.676548,"t":1780916813},{"alt_m":10602.5,"lat":43.395099,"lon":-79.67377,"t":1780916814},{"alt_m":10605.2,"lat":43.395756,"lon":-79.670992,"t":1780916815},{"alt_m":10609.9,"lat":43.396412,"lon":-79.668214,"t":1780916816},{"alt_m":10600.6,"lat":43.397068,"lon":-79.665436,"t":1780916817},{"alt_m":10608.5,"lat":43.397724,"lon":-79.662657,"t":1780916818},{"alt_m":10594.6,"lat":43.398381,"lon":-79.659879,"t":1780916819},{"alt_m":10591.0,"lat":43.399037,"lon":-79.657101,"t":1780916820},{"alt_m":10599.6,"lat":43.399693,"lon":-79.654323,"t":1780916821},{"alt_m":10591.2,"lat":43.400349,"lon":-79.651545,"t":1780916822},{"alt_m":10592.2,"lat":43.401005,"lon":-79.648767,"t":1780916823},{"alt_m":10601.0,"lat":43.401662,"lon":-79.645989,"t":1780916824},{"alt_m":10602.5,"lat":43.402318,"lon":-79.643211,"t":1780916825},{"alt_m":10593.2,"lat":43.402974,"lon":-79.640433,"t":1780916826},{"alt_m":10590.8,"lat":43.40363,"lon":-79.637655,"t":1780916827},{"alt_m":10603.9,"lat":43.404287,"lon":-79.634876,"t":1780916828},{"alt_m":10608.9,"lat":43.404943,"lon":-79.632098,"t":1780916829},{"alt_m":10591.7,"lat":43.405599,"lon":-79.62932,"t":1780916830},{"alt_m":10591.2,"lat":43.406255,"lon":-79.626542,"t":1780916831},{"alt_m":10601.4,"lat":43.406912,"lon":-79.623764,"t":1780916832},{"alt_m":10597.0,"lat":43.407568,"lon":-79.620986,"t":1780916833},{"alt_m":10593.0,"lat":43.408224,"lon":-79.618208,"t":1780916834},{"alt_m":10604.5,"lat":43.40888,"lon":-79.61543,"t":1780916835},{"alt_m":10590.7,"lat":43.409536,"lon":-79.612652,"t":1780916836},{"alt_m":10607.6,"lat":43.410193,"lon":-79.609873,"t":1780916837},{"alt_m":10607.2,"lat":43.410849,"lon":-79.607095,"t":1780916838},{"alt_m":10608.6,"lat":43.411505,"lon":-79.604317,"t":1780916839},{"alt_m":10608.4,"lat":43.412161,"lon":-79.601539,"t":1780916840},{"alt_m":10607.0,"lat":43.412818,"lon":-79.598761,"t":1780916841},{"alt_m":10593.2,"lat":43.413474,"lon":-79.595983,"t":1780916842},{"alt_m":10599.5,"lat":43.41413,"lon":-79.593205,"t":1780916843},{"alt_m":10594.9,"lat":43.414786,"lon":-79.590427,"t":1780916844},{"alt_m":10600.9,"lat":43.415443,"lon":-79.587649,"t":1780916845},{"alt_m":10609.6,"lat":43.416099,"lon":-79.58487,"t":1780916846},{"alt_m":10596.0,"lat":43.416755,"lon":-79.582092,"t":1780916847},{"alt_m":10597.2,"lat":43.417411,"lon":-79.579314,"t":1780916848},{"alt_m":10605.2,"lat":43.418067,"lon":-79.576536,"t":1780916849},{"alt_m":10597.0,"lat":43.418724,"lon":-79.573758,"t":1780916850},{"alt_m":10600.6,"lat":43.41938,"lon":-79.57098,"t":1780916851},{"alt_m":10601.8,"lat":43.420036,"lon":-79.568202,"t":1780916852},{"alt_m":10609.0,"lat":43.420692,"lon":-79.565424,"t":1780916853},{"alt_m":10599.5,"lat":43.421349,"lon":-79.562646,"t":1780916854},{"alt_m":10605.3,"lat":43.422005,"lon":-79.559867,"t":1780916855},{"alt_m":10595.0,"lat":43.422661,"lon":-79.557089,"t":1780916856},{"alt_m":10599.8,"lat":43.423317,"lon":-79.554311,"t":1780916857},{"alt_m":10596.7,"lat":43.423973,"lon":-79.551533,"t":1780916858},{"alt_m":10605.4,"lat":43.42463,"lon":-79.548755,"t":1780916859},{"alt_m":10597.9,"lat":43.425286,"lon":-79.545977,"t":1780916860},{"alt_m":10596.9,"lat":43.425942,"lon":-79.543199,"t":1780916861},{"alt_m":10602.7,"lat":43.426598,"lon":-79.540421,"t":1780916862},{"alt_m":10596.5,"lat":43.427255,"lon":-79.537643,"t":1780916863},{"alt_m":10602.6,"lat":43.427911,"lon":-79.534864,"t":1780916864},{"alt_m":10598.1,"lat":43.428567,"lon":-79.532086,"t":1780916865},{"alt_m":10605.7,"lat":43.429223,"lon":-79.529308,"t":1780916866},{"alt_m":10590.6,"lat":43.42988,"lon":-79.52653,"t":1780916867},{"alt_m":10596.2,"lat":43.430536,"lon":-79.523752,"t":1780916868},{"alt_m":10602.9,"lat":43.431192,"lon":-79.520974,"t":1780916869},{"alt_m":10601.2,"lat":43.431848,"lon":-79.518196,"t":1780916870},{"alt_m":10594.9,"lat":43.432504,"lon":-79.515418,"t":1780916871},{"alt_m":10593.8,"lat":43.433161,"lon":-79.51264,"t":1780916872},{"alt_m":10595.0,"lat":43.433817,"lon":-79.509861,"t":1780916873},{"alt_m":10595.7,"lat":43.434473,"lon":-79.507083,"t":1780916874},{"alt_m":10591.1,"lat":43.435129,"lon":-79.504305,"t":1780916875},{"alt_m":10609.0,"lat":43.435786,"lon":-79.501527,"t":1780916876},{"alt_m":10603.9,"lat":43.436442,"lon":-79.498749,"t":1780916877},{"alt_m":10590.4,"lat":43.437098,"lon":-79.495971,"t":1780916878},{"alt_m":10594.6,"lat":43.437754,"lon":-79.493193,"t":1780916879},{"alt_m":10605.9,"lat":43.438411,"lon":-79.490415,"t":1780916880},{"alt_m":10597.1,"lat":43.439067,"lon":-79.487637,"t":1780916881},{"alt_m":10605.1,"lat":43.439723,"lon":-79.484859,"t":1780916882},{"alt_m":10593.0,"lat":43.440379,"lon":-79.48208,"t":1780916883},{"alt_m":10606.4,"lat":43.441035,"lon":-79.479302,"t":1780916884},{"alt_m":10595.5,"lat":43.441692,"lon":-79.476524,"t":1780916885},{"alt_m":10592.4,"lat":43.442348,"lon":-79.473746,"t":1780916886},{"alt_m":10594.4,"lat":43.443004,"lon":-79.470968,"t":1780916887},{"alt_m":10605.5,"lat":43.44366,"lon":-79.46819,"t":1780916888},{"alt_m":10608.2,"lat":43.444317,"lon":-79.465412,"t":1780916889},{"alt_m":10603.2,"lat":43.444973,"lon":-79.462634,"t":1780916890},{"alt_m":10595.8,"lat":43.445629,"lon":-79.459856,"t":1780916891},{"alt_m":10594.9,"lat":43.446285,"lon":-79.457077,"t":1780916892},{"alt_m":10609.5,"lat":43.446941,"lon":-79.454299,"t":1780916893},{"alt_m":10592.1,"lat":43.447598,"lon":-79.451521,"t":1780916894},{"alt_m":10597.6,"lat":43.448254,"lon":-79.448743,"t":1780916895},{"alt_m":10603.8,"lat":43.44891,"lon":-79.445965,"t":1780916896},{"alt_m":10605.3,"lat":43.449566,"lon":-79.443187,"t":1780916897},{"alt_m":10598.3,"lat":43.450223,"lon":-79.440409,"t":1780916898},{"alt_m":10596.9,"lat":43.450879,"lon":-79.437631,"t":1780916899},{"alt_m":10591.5,"lat":43.451535,"lon":-79.434853,"t":1780916900},{"alt_m":10604.2,"lat":43.452191,"lon":-79.432074,"t":1780916901},{"alt_m":10599.4,"lat":43.452848,"lon":-79.429296,"t":1780916902},{"alt_m":10595.2,"lat":43.453504,"lon":-79.426518,"t":1780916903},{"alt_m":10596.4,"lat":43.45416,"lon":-79.42374,"t":1780916904},{"alt_m":10597.8,"lat":43.454816,"lon":-79.420962,"t":1780916905},{"alt_m":10590.5,"lat":43.455472,"lon":-79.418184,"t":1780916906},{"alt_m":10599.5,"lat":43.456129,"lon":-79.415406,"t":1780916907},{"alt_m":10599.5,"lat":43.456785,"lon":-79.412628,"t":1780916908},{"alt_m":10591.3,"lat":43.457441,"lon":-79.40985,"t":1780916909},{"alt_m":10597.7,"lat":43.458097,"lon":-79.407071,"t":1780916910},{"alt_m":10595.2,"lat":43.458754,"lon":-79.404293,"t":1780916911},{"alt_m":10601.2,"lat":43.45941,"lon":-79.401515,"t":1780916912},{"alt_m":10608.8,"lat":43.460066,"lon":-79.398737,"t":1780916913},{"alt_m":10605.6,"lat":43.460722,"lon":-79.395959,"t":1780916914},{"alt_m":10591.8,"lat":43.461379,"lon":-79.393181,"t":1780916915},{"alt_m":10595.0,"lat":43.462035,"lon":-79.390403,"t":1780916916},{"alt_m":10599.6,"lat":43.462691,"lon":-79.387625,"t":1780916917},{"alt_m":10598.9,"lat":43.463347,"lon":-79.384847,"t":1780916918},{"alt_m":10591.1,"lat":43.464003,"lon":-79.382068,"t":1780916919},{"alt_m":10598.8,"lat":43.46466,"lon":-79.37929,"t":1780916920},{"alt_m":10605.8,"lat":43.465316,"lon":-79.376512,"t":1780916921},{"alt_m":10593.4,"lat":43.465972,"lon":-79.373734,"t":1780916922},{"alt_m":10600.1,"lat":43.466628,"lon":-79.370956,"t":1780916923},{"alt_m":10593.2,"lat":43.467285,"lon":-79.368178,"t":1780916924},{"alt_m":10607.9,"lat":43.467941,"lon":-79.3654,"t":1780916925},{"alt_m":10594.0,"lat":43.468597,"lon":-79.362622,"t":1780916926},{"alt_m":10604.7,"lat":43.469253,"lon":-79.359844,"t":1780916927},{"alt_m":10600.1,"lat":43.46991,"lon":-79.357066,"t":1780916928},{"alt_m":10591.0,"lat":43.470566,"lon":-79.354287,"t":1780916929},{"alt_m":10593.8,"lat":43.471222,"lon":-79.351509,"t":1780916930},{"alt_m":10605.1,"lat":43.471878,"lon":-79.348731,"t":1780916931},{"alt_m":10603.2,"lat":43.472534,"lon":-79.345953,"t":1780916932},{"alt_m":10606.5,"lat":43.473191,"lon":-79.343175,"t":1780916933},{"alt_m":10602.5,"lat":43.473847,"lon":-79.340397,"t":1780916934},{"alt_m":10590.9,"lat":43.474503,"lon":-79.337619,"t":1780916935},{"alt_m":10597.5,"lat":43.475159,"lon":-79.334841,"t":1780916936},{"alt_m":10597.9,"lat":43.475816,"lon":-79.332063,"t":1780916937},{"alt_m":10605.9,"lat":43.476472,"lon":-79.329284,"t":1780916938},{"alt_m":10599.0,"lat":43.477128,"lon":-79.326506,"t":1780916939}]},{"anomaly":null,"callsign":"BAW505","icao24":"400a05","overhead":false,"points":[{"alt_m":11200.0,"lat":43.457999,"lon":-79.327379,"t":1780920600},{"alt_m":11190.7,"lat":43.457365,"lon":-79.330063,"t":1780920601},{"alt_m":11200.2,"lat":43.456731,"lon":-79.332747,"t":1780920602},{"alt_m":11208.2,"lat":43.456097,"lon":-79.335431,"t":1780920603},{"alt_m":11200.5,"lat":43.455463,"lon":-79.338115,"t":1780920604},{"alt_m":11193.3,"lat":43.454829,"lon":-79.340799,"t":1780920605},{"alt_m":11200.8,"lat":43.454195,"lon":-79.343483,"t":1780920606},{"alt_m":11200.5,"lat":43.453561,"lon":-79.346167,"t":1780920607},{"alt_m":11202.7,"lat":43.452927,"lon":-79.348851,"t":1780920608},{"alt_m":11199.5,"lat":43.452293,"lon":-79.351535,"t":1780920609},{"alt_m":11205.9,"lat":43.451659,"lon":-79.354219,"t":1780920610},{"alt_m":11196.7,"lat":43.451025,"lon":-79.356903,"t":1780920611},{"alt_m":11195.3,"lat":43.450391,"lon":-79.359587,"t":1780920612},{"alt_m":11200.3,"lat":43.449757,"lon":-79.36227,"t":1780920613},{"alt_m":11209.2,"lat":43.449123,"lon":-79.364954,"t":1780920614},{"alt_m":11190.0,"lat":43.448489,"lon":-79.367638,"t":1780920615},{"alt_m":11194.0,"lat":43.447855,"lon":-79.370322,"t":1780920616},{"alt_m":11195.3,"lat":43.447221,"lon":-79.373006,"t":1780920617},{"alt_m":11206.5,"lat":43.446587,"lon":-79.37569,"t":1780920618},{"alt_m":11203.2,"lat":43.445953,"lon":-79.378374,"t":1780920619},{"alt_m":11203.8,"lat":43.445319,"lon":-79.381058,"t":1780920620},{"alt_m":11194.2,"lat":43.444685,"lon":-79.383742,"t":1780920621},{"alt_m":11208.1,"lat":43.444051,"lon":-79.386426,"t":1780920622},{"alt_m":11191.7,"lat":43.443417,"lon":-79.38911,"t":1780920623},{"alt_m":11209.0,"lat":43.442783,"lon":-79.391794,"t":1780920624},{"alt_m":11197.7,"lat":43.442149,"lon":-79.394478,"t":1780920625},{"alt_m":11193.1,"lat":43.441515,"lon":-79.397162,"t":1780920626},{"alt_m":11204.3,"lat":43.440881,"lon":-79.399846,"t":1780920627},{"alt_m":11204.8,"lat":43.440248,"lon":-79.40253,"t":1780920628},{"alt_m":11201.2,"lat":43.439614,"lon":-79.405213,"t":1780920629},{"alt_m":11200.0,"lat":43.43898,"lon":-79.407897,"t":1780920630},{"alt_m":11196.3,"lat":43.438346,"lon":-79.410581,"t":1780920631},{"alt_m":11197.3,"lat":43.437712,"lon":-79.413265,"t":1780920632},{"alt_m":11191.3,"lat":43.437078,"lon":-79.415949,"t":1780920633},{"alt_m":11191.1,"lat":43.436444,"lon":-79.418633,"t":1780920634},{"alt_m":11195.0,"lat":43.43581,"lon":-79.421317,"t":1780920635},{"alt_m":11195.4,"lat":43.435176,"lon":-79.424001,"t":1780920636},{"alt_m":11199.6,"lat":43.434542,"lon":-79.426685,"t":1780920637},{"alt_m":11205.8,"lat":43.433908,"lon":-79.429369,"t":1780920638},{"alt_m":11192.7,"lat":43.433274,"lon":-79.432053,"t":1780920639},{"alt_m":11196.9,"lat":43.43264,"lon":-79.434737,"t":1780920640},{"alt_m":11195.3,"lat":43.432006,"lon":-79.437421,"t":1780920641},{"alt_m":11208.7,"lat":43.431372,"lon":-79.440105,"t":1780920642},{"alt_m":11208.8,"lat":43.430738,"lon":-79.442789,"t":1780920643},{"alt_m":11204.7,"lat":43.430104,"lon":-79.445472,"t":1780920644},{"alt_m":11205.9,"lat":43.42947,"lon":-79.448156,"t":1780920645},{"alt_m":11190.3,"lat":43.428836,"lon":-79.45084,"t":1780920646},{"alt_m":11207.3,"lat":43.428202,"lon":-79.453524,"t":1780920647},{"alt_m":11197.9,"lat":43.427568,"lon":-79.456208,"t":1780920648},{"alt_m":11204.5,"lat":43.426934,"lon":-79.458892,"t":1780920649},{"alt_m":11191.0,"lat":43.4263,"lon":-79.461576,"t":1780920650},{"alt_m":11198.3,"lat":43.425666,"lon":-79.46426,"t":1780920651},{"alt_m":11195.6,"lat":43.425032,"lon":-79.466944,"t":1780920652},{"alt_m":11206.3,"lat":43.424398,"lon":-79.469628,"t":1780920653},{"alt_m":11204.1,"lat":43.423764,"lon":-79.472312,"t":1780920654},{"alt_m":11202.3,"lat":43.42313,"lon":-79.474996,"t":1780920655},{"alt_m":11193.0,"lat":43.422496,"lon":-79.47768,"t":1780920656},{"alt_m":11199.5,"lat":43.421862,"lon":-79.480364,"t":1780920657},{"alt_m":11206.8,"lat":43.421228,"lon":-79.483048,"t":1780920658},{"alt_m":11209.0,"lat":43.420594,"lon":-79.485732,"t":1780920659},{"alt_m":11193.1,"lat":43.41996,"lon":-79.488415,"t":1780920660},{"alt_m":11200.7,"lat":43.419326,"lon":-79.491099,"t":1780920661},{"alt_m":11203.3,"lat":43.418692,"lon":-79.493783,"t":1780920662},{"alt_m":11200.7,"lat":43.418058,"lon":-79.496467,"t":1780920663},{"alt_m":11199.8,"lat":43.417424,"lon":-79.499151,"t":1780920664},{"alt_m":11205.7,"lat":43.41679,"lon":-79.501835,"t":1780920665},{"alt_m":11209.4,"lat":43.416156,"lon":-79.504519,"t":1780920666},{"alt_m":11201.4,"lat":43.415522,"lon":-79.507203,"t":1780920667},{"alt_m":11197.0,"lat":43.414888,"lon":-79.509887,"t":1780920668},{"alt_m":11193.1,"lat":43.414254,"lon":-79.512571,"t":1780920669},{"alt_m":11198.6,"lat":43.41362,"lon":-79.515255,"t":1780920670},{"alt_m":11191.1,"lat":43.412986,"lon":-79.517939,"t":1780920671},{"alt_m":11191.8,"lat":43.412352,"lon":-79.520623,"t":1780920672},{"alt_m":11207.5,"lat":43.411718,"lon":-79.523307,"t":1780920673},{"alt_m":11202.3,"lat":43.411084,"lon":-79.525991,"t":1780920674},{"alt_m":11209.7,"lat":43.41045,"lon":-79.528674,"t":1780920675},{"alt_m":11206.6,"lat":43.409816,"lon":-79.531358,"t":1780920676},{"alt_m":11206.4,"lat":43.409182,"lon":-79.534042,"t":1780920677},{"alt_m":11191.3,"lat":43.408548,"lon":-79.536726,"t":1780920678},{"alt_m":11206.7,"lat":43.407914,"lon":-79.53941,"t":1780920679},{"alt_m":11200.0,"lat":43.40728,"lon":-79.542094,"t":1780920680},{"alt_m":11194.8,"lat":43.406646,"lon":-79.544778,"t":1780920681},{"alt_m":11208.3,"lat":43.406012,"lon":-79.547462,"t":1780920682},{"alt_m":11203.9,"lat":43.405378,"lon":-79.550146,"t":1780920683},{"alt_m":11195.0,"lat":43.404744,"lon":-79.55283,"t":1780920684},{"alt_m":11203.9,"lat":43.40411,"lon":-79.555514,"t":1780920685},{"alt_m":11208.9,"lat":43.403476,"lon":-79.558198,"t":1780920686},{"alt_m":11205.1,"lat":43.402842,"lon":-79.560882,"t":1780920687},{"alt_m":11207.9,"lat":43.402208,"lon":-79.563566,"t":1780920688},{"alt_m":11209.6,"lat":43.401574,"lon":-79.56625,"t":1780920689},{"alt_m":11198.6,"lat":43.400941,"lon":-79.568934,"t":1780920690},{"alt_m":11205.9,"lat":43.400307,"lon":-79.571617,"t":1780920691},{"alt_m":11207.7,"lat":43.399673,"lon":-79.574301,"t":1780920692},{"alt_m":11194.5,"lat":43.399039,"lon":-79.576985,"t":1780920693},{"alt_m":11204.1,"lat":43.398405,"lon":-79.579669,"t":1780920694},{"alt_m":11194.5,"lat":43.397771,"lon":-79.582353,"t":1780920695},{"alt_m":11205.4,"lat":43.397137,"lon":-79.585037,"t":1780920696},{"alt_m":11196.8,"lat":43.396503,"lon":-79.587721,"t":1780920697},{"alt_m":11204.1,"lat":43.395869,"lon":-79.590405,"t":1780920698},{"alt_m":11203.7,"lat":43.395235,"lon":-79.593089,"t":1780920699},{"alt_m":11202.3,"lat":43.394601,"lon":-79.595773,"t":1780920700},{"alt_m":11192.6,"lat":43.393967,"lon":-79.598457,"t":1780920701},{"alt_m":11208.5,"lat":43.393333,"lon":-79.601141,"t":1780920702},{"alt_m":11206.2,"lat":43.392699,"lon":-79.603825,"t":1780920703},{"alt_m":11198.5,"lat":43.392065,"lon":-79.606509,"t":1780920704},{"alt_m":11192.8,"lat":43.391431,"lon":-79.609193,"t":1780920705},{"alt_m":11192.6,"lat":43.390797,"lon":-79.611876,"t":1780920706},{"alt_m":11190.1,"lat":43.390163,"lon":-79.61456,"t":1780920707},{"alt_m":11198.6,"lat":43.389529,"lon":-79.617244,"t":1780920708},{"alt_m":11201.0,"lat":43.388895,"lon":-79.619928,"t":1780920709},{"alt_m":11196.8,"lat":43.388261,"lon":-79.622612,"t":1780920710},{"alt_m":11206.4,"lat":43.387627,"lon":-79.625296,"t":1780920711},{"alt_m":11195.2,"lat":43.386993,"lon":-79.62798,"t":1780920712},{"alt_m":11190.3,"lat":43.386359,"lon":-79.630664,"t":1780920713},{"alt_m":11207.6,"lat":43.385725,"lon":-79.633348,"t":1780920714},{"alt_m":11204.7,"lat":43.385091,"lon":-79.636032,"t":1780920715},{"alt_m":11200.7,"lat":43.384457,"lon":-79.638716,"t":1780920716},{"alt_m":11194.2,"lat":43.383823,"lon":-79.6414,"t":1780920717},{"alt_m":11190.4,"lat":43.383189,"lon":-79.644084,"t":1780920718},{"alt_m":11200.2,"lat":43.382555,"lon":-79.646768,"t":1780920719},{"alt_m":11190.9,"lat":43.381921,"lon":-79.649452,"t":1780920720},{"alt_m":11208.7,"lat":43.381287,"lon":-79.652136,"t":1780920721},{"alt_m":11198.7,"lat":43.380653,"lon":-79.654819,"t":1780920722},{"alt_m":11207.2,"lat":43.380019,"lon":-79.657503,"t":1780920723},{"alt_m":11202.4,"lat":43.379385,"lon":-79.660187,"t":1780920724},{"alt_m":11191.4,"lat":43.378751,"lon":-79.662871,"t":1780920725},{"alt_m":11206.8,"lat":43.378117,"lon":-79.665555,"t":1780920726},{"alt_m":11208.4,"lat":43.377483,"lon":-79.668239,"t":1780920727},{"alt_m":11191.8,"lat":43.376849,"lon":-79.670923,"t":1780920728},{"alt_m":11207.9,"lat":43.376215,"lon":-79.673607,"t":1780920729},{"alt_m":11199.0,"lat":43.375581,"lon":-79.676291,"t":1780920730},{"alt_m":11204.9,"lat":43.374947,"lon":-79.678975,"t":1780920731},{"alt_m":11195.6,"lat":43.374313,"lon":-79.681659,"t":1780920732},{"alt_m":11207.0,"lat":43.373679,"lon":-79.684343,"t":1780920733},{"alt_m":11193.5,"lat":43.373045,"lon":-79.687027,"t":1780920734},{"alt_m":11203.9,"lat":43.372411,"lon":-79.689711,"t":1780920735},{"alt_m":11209.7,"lat":43.371777,"lon":-79.692395,"t":1780920736},{"alt_m":11196.3,"lat":43.371143,"lon":-79.695079,"t":1780920737},{"alt_m":11200.3,"lat":43.370509,"lon":-79.697762,"t":1780920738},{"alt_m":11195.7,"lat":43.369875,"lon":-79.700446,"t":1780920739},{"alt_m":11207.0,"lat":43.369241,"lon":-79.70313,"t":1780920740},{"alt_m":11201.6,"lat":43.368607,"lon":-79.705814,"t":1780920741},{"alt_m":11194.0,"lat":43.367973,"lon":-79.708498,"t":1780920742},{"alt_m":11194.8,"lat":43.367339,"lon":-79.711182,"t":1780920743},{"alt_m":11202.2,"lat":43.366705,"lon":-79.713866,"t":1780920744},{"alt_m":11202.0,"lat":43.366071,"lon":-79.71655,"t":1780920745},{"alt_m":11197.9,"lat":43.365437,"lon":-79.719234,"t":1780920746},{"alt_m":11199.5,"lat":43.364803,"lon":-79.721918,"t":1780920747},{"alt_m":11191.2,"lat":43.364169,"lon":-79.724602,"t":1780920748},{"alt_m":11210.0,"lat":43.363535,"lon":-79.727286,"t":1780920749},{"alt_m":11196.1,"lat":43.362901,"lon":-79.72997,"t":1780920750},{"alt_m":11203.9,"lat":43.362268,"lon":-79.732654,"t":1780920751},{"alt_m":11197.0,"lat":43.361634,"lon":-79.735338,"t":1780920752},{"alt_m":11193.8,"lat":43.361,"lon":-79.738021,"t":1780920753},{"alt_m":11203.5,"lat":43.360366,"lon":-79.740705,"t":1780920754},{"alt_m":11203.1,"lat":43.359732,"lon":-79.743389,"t":1780920755},{"alt_m":11207.6,"lat":43.359098,"lon":-79.746073,"t":1780920756},{"alt_m":11208.2,"lat":43.358464,"lon":-79.748757,"t":1780920757},{"alt_m":11201.3,"lat":43.35783,"lon":-79.751441,"t":1780920758},{"alt_m":11193.2,"lat":43.357196,"lon":-79.754125,"t":1780920759},{"alt_m":11200.2,"lat":43.356562,"lon":-79.756809,"t":1780920760},{"alt_m":11195.4,"lat":43.355928,"lon":-79.759493,"t":1780920761},{"alt_m":11209.4,"lat":43.355294,"lon":-79.762177,"t":1780920762},{"alt_m":11190.6,"lat":43.35466,"lon":-79.764861,"t":1780920763},{"alt_m":11201.8,"lat":43.354026,"lon":-79.767545,"t":1780920764},{"alt_m":11194.5,"lat":43.353392,"lon":-79.770229,"t":1780920765},{"alt_m":11195.8,"lat":43.352758,"lon":-79.772913,"t":1780920766},{"alt_m":11192.9,"lat":43.352124,"lon":-79.775597,"t":1780920767},{"alt_m":11200.9,"lat":43.35149,"lon":-79.778281,"t":1780920768},{"alt_m":11201.4,"lat":43.350856,"lon":-79.780964,"t":1780920769},{"alt_m":11191.1,"lat":43.350222,"lon":-79.783648,"t":1780920770},{"alt_m":11199.5,"lat":43.349588,"lon":-79.786332,"t":1780920771},{"alt_m":11196.1,"lat":43.348954,"lon":-79.789016,"t":1780920772},{"alt_m":11204.0,"lat":43.34832,"lon":-79.7917,"t":1780920773},{"alt_m":11197.5,"lat":43.347686,"lon":-79.794384,"t":1780920774},{"alt_m":11195.5,"lat":43.347052,"lon":-79.797068,"t":1780920775},{"alt_m":11195.7,"lat":43.346418,"lon":-79.799752,"t":1780920776},{"alt_m":11198.4,"lat":43.345784,"lon":-79.802436,"t":1780920777},{"alt_m":11197.9,"lat":43.34515,"lon":-79.80512,"t":1780920778},{"alt_m":11194.6,"lat":43.344516,"lon":-79.807804,"t":1780920779},{"alt_m":11206.7,"lat":43.343882,"lon":-79.810488,"t":1780920780},{"alt_m":11199.2,"lat":43.343248,"lon":-79.813172,"t":1780920781},{"alt_m":11191.6,"lat":43.342614,"lon":-79.815856,"t":1780920782},{"alt_m":11205.4,"lat":43.34198,"lon":-79.81854,"t":1780920783},{"alt_m":11194.6,"lat":43.341346,"lon":-79.821223,"t":1780920784},{"alt_m":11198.9,"lat":43.340712,"lon":-79.823907,"t":1780920785},{"alt_m":11204.6,"lat":43.340078,"lon":-79.826591,"t":1780920786},{"alt_m":11207.8,"lat":43.339444,"lon":-79.829275,"t":1780920787},{"alt_m":11200.2,"lat":43.33881,"lon":-79.831959,"t":1780920788},{"alt_m":11198.5,"lat":43.338176,"lon":-79.834643,"t":1780920789},{"alt_m":11204.7,"lat":43.337542,"lon":-79.837327,"t":1780920790},{"alt_m":11206.3,"lat":43.336908,"lon":-79.840011,"t":1780920791},{"alt_m":11194.0,"lat":43.336274,"lon":-79.842695,"t":1780920792},{"alt_m":11196.8,"lat":43.33564,"lon":-79.845379,"t":1780920793},{"alt_m":11205.9,"lat":43.335006,"lon":-79.848063,"t":1780920794},{"alt_m":11202.6,"lat":43.334372,"lon":-79.850747,"t":1780920795},{"alt_m":11192.6,"lat":43.333738,"lon":-79.853431,"t":1780920796},{"alt_m":11194.4,"lat":43.333104,"lon":-79.856115,"t":1780920797},{"alt_m":11192.2,"lat":43.33247,"lon":-79.858799,"t":1780920798},{"alt_m":11194.7,"lat":43.331836,"lon":-79.861483,"t":1780920799},{"alt_m":11202.5,"lat":43.331202,"lon":-79.864166,"t":1780920800},{"alt_m":11203.4,"lat":43.330568,"lon":-79.86685,"t":1780920801},{"alt_m":11204.9,"lat":43.329934,"lon":-79.869534,"t":1780920802},{"alt_m":11202.3,"lat":43.3293,"lon":-79.872218,"t":1780920803},{"alt_m":11201.5,"lat":43.328666,"lon":-79.874902,"t":1780920804},{"alt_m":11198.8,"lat":43.328032,"lon":-79.877586,"t":1780920805},{"alt_m":11202.5,"lat":43.327398,"lon":-79.88027,"t":1780920806},{"alt_m":11206.2,"lat":43.326764,"lon":-79.882954,"t":1780920807},{"alt_m":11197.9,"lat":43.32613,"lon":-79.885638,"t":1780920808},{"alt_m":11197.5,"lat":43.325496,"lon":-79.888322,"t":1780920809},{"alt_m":11208.2,"lat":43.324862,"lon":-79.891006,"t":1780920810},{"alt_m":11196.0,"lat":43.324228,"lon":-79.89369,"t":1780920811},{"alt_m":11201.0,"lat":43.323595,"lon":-79.896374,"t":1780920812},{"alt_m":11201.2,"lat":43.322961,"lon":-79.899058,"t":1780920813},{"alt_m":11192.8,"lat":43.322327,"lon":-79.901742,"t":1780920814},{"alt_m":11191.6,"lat":43.321693,"lon":-79.904425,"t":1780920815},{"alt_m":11203.0,"lat":43.321059,"lon":-79.907109,"t":1780920816},{"alt_m":11204.4,"lat":43.320425,"lon":-79.909793,"t":1780920817},{"alt_m":11206.9,"lat":43.319791,"lon":-79.912477,"t":1780920818},{"alt_m":11207.0,"lat":43.319157,"lon":-79.915161,"t":1780920819},{"alt_m":11190.7,"lat":43.318523,"lon":-79.917845,"t":1780920820},{"alt_m":11199.8,"lat":43.317889,"lon":-79.920529,"t":1780920821},{"alt_m":11190.5,"lat":43.317255,"lon":-79.923213,"t":1780920822},{"alt_m":11196.0,"lat":43.316621,"lon":-79.925897,"t":1780920823},{"alt_m":11203.0,"lat":43.315987,"lon":-79.928581,"t":1780920824},{"alt_m":11191.9,"lat":43.315353,"lon":-79.931265,"t":1780920825},{"alt_m":11207.1,"lat":43.314719,"lon":-79.933949,"t":1780920826},{"alt_m":11191.9,"lat":43.314085,"lon":-79.936633,"t":1780920827},{"alt_m":11206.9,"lat":43.313451,"lon":-79.939317,"t":1780920828},{"alt_m":11195.5,"lat":43.312817,"lon":-79.942001,"t":1780920829},{"alt_m":11193.9,"lat":43.312183,"lon":-79.944685,"t":1780920830},{"alt_m":11191.8,"lat":43.311549,"lon":-79.947368,"t":1780920831},{"alt_m":11202.6,"lat":43.310915,"lon":-79.950052,"t":1780920832},{"alt_m":11197.6,"lat":43.310281,"lon":-79.952736,"t":1780920833},{"alt_m":11208.3,"lat":43.309647,"lon":-79.95542,"t":1780920834},{"alt_m":11201.7,"lat":43.309013,"lon":-79.958104,"t":1780920835},{"alt_m":11190.4,"lat":43.308379,"lon":-79.960788,"t":1780920836},{"alt_m":11195.2,"lat":43.307745,"lon":-79.963472,"t":1780920837},{"alt_m":11206.8,"lat":43.307111,"lon":-79.966156,"t":1780920838},{"alt_m":11199.1,"lat":43.306477,"lon":-79.96884,"t":1780920839}]},{"anomaly":null,"callsign":"DAL202","icao24":"a02b02","overhead":false,"points":[{"alt_m":10791.9,"lat":43.450348,"lon":-80.039409,"t":1780926000},{"alt_m":10792.4,"lat":43.450923,"lon":-80.036649,"t":1780926001},{"alt_m":10798.4,"lat":43.451499,"lon":-80.033889,"t":1780926002},{"alt_m":10801.2,"lat":43.452074,"lon":-80.031128,"t":1780926003},{"alt_m":10792.3,"lat":43.452649,"lon":-80.028368,"t":1780926004},{"alt_m":10801.2,"lat":43.453225,"lon":-80.025608,"t":1780926005},{"alt_m":10801.5,"lat":43.4538,"lon":-80.022847,"t":1780926006},{"alt_m":10805.5,"lat":43.454376,"lon":-80.020087,"t":1780926007},{"alt_m":10794.0,"lat":43.454951,"lon":-80.017327,"t":1780926008},{"alt_m":10800.5,"lat":43.455526,"lon":-80.014566,"t":1780926009},{"alt_m":10802.6,"lat":43.456102,"lon":-80.011806,"t":1780926010},{"alt_m":10791.8,"lat":43.456677,"lon":-80.009046,"t":1780926011},{"alt_m":10808.4,"lat":43.457253,"lon":-80.006285,"t":1780926012},{"alt_m":10797.1,"lat":43.457828,"lon":-80.003525,"t":1780926013},{"alt_m":10807.8,"lat":43.458404,"lon":-80.000765,"t":1780926014},{"alt_m":10792.4,"lat":43.458979,"lon":-79.998004,"t":1780926015},{"alt_m":10790.3,"lat":43.459554,"lon":-79.995244,"t":1780926016},{"alt_m":10807.2,"lat":43.46013,"lon":-79.992484,"t":1780926017},{"alt_m":10805.2,"lat":43.460705,"lon":-79.989723,"t":1780926018},{"alt_m":10794.0,"lat":43.461281,"lon":-79.986963,"t":1780926019},{"alt_m":10790.9,"lat":43.461856,"lon":-79.984203,"t":1780926020},{"alt_m":10800.8,"lat":43.462432,"lon":-79.981443,"t":1780926021},{"alt_m":10793.2,"lat":43.463007,"lon":-79.978682,"t":1780926022},{"alt_m":10793.5,"lat":43.463582,"lon":-79.975922,"t":1780926023},{"alt_m":10792.6,"lat":43.464158,"lon":-79.973162,"t":1780926024},{"alt_m":10808.9,"lat":43.464733,"lon":-79.970401,"t":1780926025},{"alt_m":10801.7,"lat":43.465309,"lon":-79.967641,"t":1780926026},{"alt_m":10797.4,"lat":43.465884,"lon":-79.964881,"t":1780926027},{"alt_m":10796.9,"lat":43.466459,"lon":-79.96212,"t":1780926028},{"alt_m":10791.6,"lat":43.467035,"lon":-79.95936,"t":1780926029},{"alt_m":10792.1,"lat":43.46761,"lon":-79.9566,"t":1780926030},{"alt_m":10803.0,"lat":43.468186,"lon":-79.953839,"t":1780926031},{"alt_m":10799.5,"lat":43.468761,"lon":-79.951079,"t":1780926032},{"alt_m":10790.5,"lat":43.469337,"lon":-79.948319,"t":1780926033},{"alt_m":10800.1,"lat":43.469912,"lon":-79.945558,"t":1780926034},{"alt_m":10804.3,"lat":43.470487,"lon":-79.942798,"t":1780926035},{"alt_m":10795.9,"lat":43.471063,"lon":-79.940038,"t":1780926036},{"alt_m":10798.8,"lat":43.471638,"lon":-79.937277,"t":1780926037},{"alt_m":10808.4,"lat":43.472214,"lon":-79.934517,"t":1780926038},{"alt_m":10801.0,"lat":43.472789,"lon":-79.931757,"t":1780926039},{"alt_m":10801.9,"lat":43.473365,"lon":-79.928996,"t":1780926040},{"alt_m":10791.0,"lat":43.47394,"lon":-79.926236,"t":1780926041},{"alt_m":10807.1,"lat":43.474515,"lon":-79.923476,"t":1780926042},{"alt_m":10800.1,"lat":43.475091,"lon":-79.920715,"t":1780926043},{"alt_m":10801.4,"lat":43.475666,"lon":-79.917955,"t":1780926044},{"alt_m":10795.2,"lat":43.476242,"lon":-79.915195,"t":1780926045},{"alt_m":10801.3,"lat":43.476817,"lon":-79.912434,"t":1780926046},{"alt_m":10794.2,"lat":43.477393,"lon":-79.909674,"t":1780926047},{"alt_m":10805.1,"lat":43.477968,"lon":-79.906914,"t":1780926048},{"alt_m":10797.9,"lat":43.478543,"lon":-79.904153,"t":1780926049},{"alt_m":10805.9,"lat":43.479119,"lon":-79.901393,"t":1780926050},{"alt_m":10796.9,"lat":43.479694,"lon":-79.898633,"t":1780926051},{"alt_m":10793.2,"lat":43.48027,"lon":-79.895872,"t":1780926052},{"alt_m":10809.0,"lat":43.480845,"lon":-79.893112,"t":1780926053},{"alt_m":10801.8,"lat":43.48142,"lon":-79.890352,"t":1780926054},{"alt_m":10802.9,"lat":43.481996,"lon":-79.887591,"t":1780926055},{"alt_m":10798.4,"lat":43.482571,"lon":-79.884831,"t":1780926056},{"alt_m":10797.6,"lat":43.483147,"lon":-79.882071,"t":1780926057},{"alt_m":10804.8,"lat":43.483722,"lon":-79.87931,"t":1780926058},{"alt_m":10803.4,"lat":43.484298,"lon":-79.87655,"t":1780926059},{"alt_m":10805.2,"lat":43.484873,"lon":-79.87379,"t":1780926060},{"alt_m":10800.4,"lat":43.485448,"lon":-79.871029,"t":1780926061},{"alt_m":10791.5,"lat":43.486024,"lon":-79.868269,"t":1780926062},{"alt_m":10801.8,"lat":43.486599,"lon":-79.865509,"t":1780926063},{"alt_m":10805.4,"lat":43.487175,"lon":-79.862748,"t":1780926064},{"alt_m":10790.2,"lat":43.48775,"lon":-79.859988,"t":1780926065},{"alt_m":10792.8,"lat":43.488326,"lon":-79.857228,"t":1780926066},{"alt_m":10790.5,"lat":43.488901,"lon":-79.854467,"t":1780926067},{"alt_m":10803.6,"lat":43.489476,"lon":-79.851707,"t":1780926068},{"alt_m":10790.2,"lat":43.490052,"lon":-79.848947,"t":1780926069},{"alt_m":10796.3,"lat":43.490627,"lon":-79.846186,"t":1780926070},{"alt_m":10790.2,"lat":43.491203,"lon":-79.843426,"t":1780926071},{"alt_m":10799.9,"lat":43.491778,"lon":-79.840666,"t":1780926072},{"alt_m":10801.3,"lat":43.492354,"lon":-79.837905,"t":1780926073},{"alt_m":10792.2,"lat":43.492929,"lon":-79.835145,"t":1780926074},{"alt_m":10804.3,"lat":43.493504,"lon":-79.832385,"t":1780926075},{"alt_m":10792.0,"lat":43.49408,"lon":-79.829625,"t":1780926076},{"alt_m":10808.0,"lat":43.494655,"lon":-79.826864,"t":1780926077},{"alt_m":10806.6,"lat":43.495231,"lon":-79.824104,"t":1780926078},{"alt_m":10808.8,"lat":43.495806,"lon":-79.821344,"t":1780926079},{"alt_m":10796.6,"lat":43.496381,"lon":-79.818583,"t":1780926080},{"alt_m":10796.2,"lat":43.496957,"lon":-79.815823,"t":1780926081},{"alt_m":10807.1,"lat":43.497532,"lon":-79.813063,"t":1780926082},{"alt_m":10796.8,"lat":43.498108,"lon":-79.810302,"t":1780926083},{"alt_m":10801.0,"lat":43.498683,"lon":-79.807542,"t":1780926084},{"alt_m":10801.6,"lat":43.499259,"lon":-79.804782,"t":1780926085},{"alt_m":10797.7,"lat":43.499834,"lon":-79.802021,"t":1780926086},{"alt_m":10805.5,"lat":43.500409,"lon":-79.799261,"t":1780926087},{"alt_m":10806.5,"lat":43.500985,"lon":-79.796501,"t":1780926088},{"alt_m":10793.0,"lat":43.50156,"lon":-79.79374,"t":1780926089},{"alt_m":10796.7,"lat":43.502136,"lon":-79.79098,"t":1780926090},{"alt_m":10801.6,"lat":43.502711,"lon":-79.78822,"t":1780926091},{"alt_m":10805.7,"lat":43.503287,"lon":-79.785459,"t":1780926092},{"alt_m":10798.9,"lat":43.503862,"lon":-79.782699,"t":1780926093},{"alt_m":10797.9,"lat":43.504437,"lon":-79.779939,"t":1780926094},{"alt_m":10799.1,"lat":43.505013,"lon":-79.777178,"t":1780926095},{"alt_m":10808.7,"lat":43.505588,"lon":-79.774418,"t":1780926096},{"alt_m":10791.8,"lat":43.506164,"lon":-79.771658,"t":1780926097},{"alt_m":10796.3,"lat":43.506739,"lon":-79.768897,"t":1780926098},{"alt_m":10809.3,"lat":43.507314,"lon":-79.766137,"t":1780926099},{"alt_m":10791.5,"lat":43.50789,"lon":-79.763377,"t":1780926100},{"alt_m":10807.6,"lat":43.508465,"lon":-79.760616,"t":1780926101},{"alt_m":10800.6,"lat":43.509041,"lon":-79.757856,"t":1780926102},{"alt_m":10800.9,"lat":43.509616,"lon":-79.755096,"t":1780926103},{"alt_m":10796.0,"lat":43.510192,"lon":-79.752335,"t":1780926104},{"alt_m":10790.1,"lat":43.510767,"lon":-79.749575,"t":1780926105},{"alt_m":10801.5,"lat":43.511342,"lon":-79.746815,"t":1780926106},{"alt_m":10791.9,"lat":43.511918,"lon":-79.744054,"t":1780926107},{"alt_m":10800.5,"lat":43.512493,"lon":-79.741294,"t":1780926108},{"alt_m":10791.1,"lat":43.513069,"lon":-79.738534,"t":1780926109},{"alt_m":10808.9,"lat":43.513644,"lon":-79.735773,"t":1780926110},{"alt_m":10804.0,"lat":43.51422,"lon":-79.733013,"t":1780926111},{"alt_m":10802.4,"lat":43.514795,"lon":-79.730253,"t":1780926112},{"alt_m":10796.4,"lat":43.51537,"lon":-79.727492,"t":1780926113},{"alt_m":10804.4,"lat":43.515946,"lon":-79.724732,"t":1780926114},{"alt_m":10803.6,"lat":43.516521,"lon":-79.721972,"t":1780926115},{"alt_m":10798.9,"lat":43.517097,"lon":-79.719211,"t":1780926116},{"alt_m":10796.1,"lat":43.517672,"lon":-79.716451,"t":1780926117},{"alt_m":10805.2,"lat":43.518248,"lon":-79.713691,"t":1780926118},{"alt_m":10803.2,"lat":43.518823,"lon":-79.71093,"t":1780926119},{"alt_m":10796.4,"lat":43.519398,"lon":-79.70817,"t":1780926120},{"alt_m":10797.6,"lat":43.519974,"lon":-79.70541,"t":1780926121},{"alt_m":10792.8,"lat":43.520549,"lon":-79.702649,"t":1780926122},{"alt_m":10790.1,"lat":43.521125,"lon":-79.699889,"t":1780926123},{"alt_m":10798.8,"lat":43.5217,"lon":-79.697129,"t":1780926124},{"alt_m":10806.8,"lat":43.522275,"lon":-79.694368,"t":1780926125},{"alt_m":10807.6,"lat":43.522851,"lon":-79.691608,"t":1780926126},{"alt_m":10801.5,"lat":43.523426,"lon":-79.688848,"t":1780926127},{"alt_m":10804.3,"lat":43.524002,"lon":-79.686087,"t":1780926128},{"alt_m":10790.7,"lat":43.524577,"lon":-79.683327,"t":1780926129},{"alt_m":10795.4,"lat":43.525153,"lon":-79.680567,"t":1780926130},{"alt_m":10796.7,"lat":43.525728,"lon":-79.677807,"t":1780926131},{"alt_m":10790.3,"lat":43.526303,"lon":-79.675046,"t":1780926132},{"alt_m":10805.0,"lat":43.526879,"lon":-79.672286,"t":1780926133},{"alt_m":10807.4,"lat":43.527454,"lon":-79.669526,"t":1780926134},{"alt_m":10796.5,"lat":43.52803,"lon":-79.666765,"t":1780926135},{"alt_m":10795.5,"lat":43.528605,"lon":-79.664005,"t":1780926136},{"alt_m":10801.8,"lat":43.529181,"lon":-79.661245,"t":1780926137},{"alt_m":10791.1,"lat":43.529756,"lon":-79.658484,"t":1780926138},{"alt_m":10797.7,"lat":43.530331,"lon":-79.655724,"t":1780926139},{"alt_m":10803.4,"lat":43.530907,"lon":-79.652964,"t":1780926140},{"alt_m":10791.1,"lat":43.531482,"lon":-79.650203,"t":1780926141},{"alt_m":10804.5,"lat":43.532058,"lon":-79.647443,"t":1780926142},{"alt_m":10793.1,"lat":43.532633,"lon":-79.644683,"t":1780926143},{"alt_m":10798.0,"lat":43.533209,"lon":-79.641922,"t":1780926144},{"alt_m":10794.3,"lat":43.533784,"lon":-79.639162,"t":1780926145},{"alt_m":10802.6,"lat":43.534359,"lon":-79.636402,"t":1780926146},{"alt_m":10791.0,"lat":43.534935,"lon":-79.633641,"t":1780926147},{"alt_m":10795.6,"lat":43.53551,"lon":-79.630881,"t":1780926148},{"alt_m":10807.5,"lat":43.536086,"lon":-79.628121,"t":1780926149},{"alt_m":10805.6,"lat":43.536661,"lon":-79.62536,"t":1780926150},{"alt_m":10800.4,"lat":43.537236,"lon":-79.6226,"t":1780926151},{"alt_m":10796.4,"lat":43.537812,"lon":-79.61984,"t":1780926152},{"alt_m":10794.0,"lat":43.538387,"lon":-79.617079,"t":1780926153},{"alt_m":10793.7,"lat":43.538963,"lon":-79.614319,"t":1780926154},{"alt_m":10793.1,"lat":43.539538,"lon":-79.611559,"t":1780926155},{"alt_m":10809.8,"lat":43.540114,"lon":-79.608798,"t":1780926156},{"alt_m":10798.1,"lat":43.540689,"lon":-79.606038,"t":1780926157},{"alt_m":10808.7,"lat":43.541264,"lon":-79.603278,"t":1780926158},{"alt_m":10791.8,"lat":43.54184,"lon":-79.600517,"t":1780926159},{"alt_m":10802.7,"lat":43.542415,"lon":-79.597757,"t":1780926160},{"alt_m":10790.5,"lat":43.542991,"lon":-79.594997,"t":1780926161},{"alt_m":10807.0,"lat":43.543566,"lon":-79.592236,"t":1780926162},{"alt_m":10790.8,"lat":43.544142,"lon":-79.589476,"t":1780926163},{"alt_m":10791.0,"lat":43.544717,"lon":-79.586716,"t":1780926164},{"alt_m":10796.9,"lat":43.545292,"lon":-79.583955,"t":1780926165},{"alt_m":10796.1,"lat":43.545868,"lon":-79.581195,"t":1780926166},{"alt_m":10791.8,"lat":43.546443,"lon":-79.578435,"t":1780926167},{"alt_m":10797.4,"lat":43.547019,"lon":-79.575674,"t":1780926168},{"alt_m":10801.6,"lat":43.547594,"lon":-79.572914,"t":1780926169},{"alt_m":10804.0,"lat":43.54817,"lon":-79.570154,"t":1780926170},{"alt_m":10793.2,"lat":43.548745,"lon":-79.567393,"t":1780926171},{"alt_m":10791.8,"lat":43.54932,"lon":-79.564633,"t":1780926172},{"alt_m":10807.5,"lat":43.549896,"lon":-79.561873,"t":1780926173},{"alt_m":10809.0,"lat":43.550471,"lon":-79.559112,"t":1780926174},{"alt_m":10804.0,"lat":43.551047,"lon":-79.556352,"t":1780926175},{"alt_m":10802.7,"lat":43.551622,"lon":-79.553592,"t":1780926176},{"alt_m":10799.1,"lat":43.552197,"lon":-79.550831,"t":1780926177},{"alt_m":10806.1,"lat":43.552773,"lon":-79.548071,"t":1780926178},{"alt_m":10799.0,"lat":43.553348,"lon":-79.545311,"t":1780926179},{"alt_m":10805.5,"lat":43.553924,"lon":-79.54255,"t":1780926180},{"alt_m":10791.4,"lat":43.554499,"lon":-79.53979,"t":1780926181},{"alt_m":10806.6,"lat":43.555075,"lon":-79.53703,"t":1780926182},{"alt_m":10805.8,"lat":43.55565,"lon":-79.534269,"t":1780926183},{"alt_m":10803.3,"lat":43.556225,"lon":-79.531509,"t":1780926184},{"alt_m":10794.2,"lat":43.556801,"lon":-79.528749,"t":1780926185},{"alt_m":10804.4,"lat":43.557376,"lon":-79.525989,"t":1780926186},{"alt_m":10801.3,"lat":43.557952,"lon":-79.523228,"t":1780926187},{"alt_m":10793.7,"lat":43.558527,"lon":-79.520468,"t":1780926188},{"alt_m":10792.0,"lat":43.559103,"lon":-79.517708,"t":1780926189},{"alt_m":10800.8,"lat":43.559678,"lon":-79.514947,"t":1780926190},{"alt_m":10798.5,"lat":43.560253,"lon":-79.512187,"t":1780926191},{"alt_m":10804.7,"lat":43.560829,"lon":-79.509427,"t":1780926192},{"alt_m":10807.7,"lat":43.561404,"lon":-79.506666,"t":1780926193},{"alt_m":10799.1,"lat":43.56198,"lon":-79.503906,"t":1780926194},{"alt_m":10796.0,"lat":43.562555,"lon":-79.501146,"t":1780926195},{"alt_m":10805.6,"lat":43.56313,"lon":-79.498385,"t":1780926196},{"alt_m":10791.5,"lat":43.563706,"lon":-79.495625,"t":1780926197},{"alt_m":10803.4,"lat":43.564281,"lon":-79.492865,"t":1780926198},{"alt_m":10794.1,"lat":43.564857,"lon":-79.490104,"t":1780926199},{"alt_m":10800.4,"lat":43.565432,"lon":-79.487344,"t":1780926200},{"alt_m":10804.3,"lat":43.566008,"lon":-79.484584,"t":1780926201},{"alt_m":10807.3,"lat":43.566583,"lon":-79.481823,"t":1780926202},{"alt_m":10807.7,"lat":43.567158,"lon":-79.479063,"t":1780926203},{"alt_m":10809.4,"lat":43.567734,"lon":-79.476303,"t":1780926204},{"alt_m":10807.1,"lat":43.568309,"lon":-79.473542,"t":1780926205},{"alt_m":10797.3,"lat":43.568885,"lon":-79.470782,"t":1780926206},{"alt_m":10797.9,"lat":43.56946,"lon":-79.468022,"t":1780926207},{"alt_m":10804.2,"lat":43.570036,"lon":-79.465261,"t":1780926208},{"alt_m":10798.1,"lat":43.570611,"lon":-79.462501,"t":1780926209},{"alt_m":10800.5,"lat":43.571186,"lon":-79.459741,"t":1780926210},{"alt_m":10808.0,"lat":43.571762,"lon":-79.45698,"t":1780926211},{"alt_m":10808.2,"lat":43.572337,"lon":-79.45422,"t":1780926212},{"alt_m":10802.1,"lat":43.572913,"lon":-79.45146,"t":1780926213},{"alt_m":10801.0,"lat":43.573488,"lon":-79.448699,"t":1780926214},{"alt_m":10805.4,"lat":43.574064,"lon":-79.445939,"t":1780926215},{"alt_m":10807.2,"lat":43.574639,"lon":-79.443179,"t":1780926216},{"alt_m":10799.0,"lat":43.575214,"lon":-79.440418,"t":1780926217},{"alt_m":10800.0,"lat":43.57579,"lon":-79.437658,"t":1780926218},{"alt_m":10804.2,"lat":43.576365,"lon":-79.434898,"t":1780926219},{"alt_m":10803.6,"lat":43.576941,"lon":-79.432137,"t":1780926220},{"alt_m":10804.0,"lat":43.577516,"lon":-79.429377,"t":1780926221},{"alt_m":10803.6,"lat":43.578091,"lon":-79.426617,"t":1780926222},{"alt_m":10793.3,"lat":43.578667,"lon":-79.423856,"t":1780926223},{"alt_m":10809.1,"lat":43.579242,"lon":-79.421096,"t":1780926224},{"alt_m":10802.1,"lat":43.579818,"lon":-79.418336,"t":1780926225},{"alt_m":10808.2,"lat":43.580393,"lon":-79.415575,"t":1780926226},{"alt_m":10804.5,"lat":43.580969,"lon":-79.412815,"t":1780926227},{"alt_m":10806.2,"lat":43.581544,"lon":-79.410055,"t":1780926228},{"alt_m":10799.2,"lat":43.582119,"lon":-79.407294,"t":1780926229},{"alt_m":10795.8,"lat":43.582695,"lon":-79.404534,"t":1780926230},{"alt_m":10805.2,"lat":43.58327,"lon":-79.401774,"t":1780926231},{"alt_m":10808.5,"lat":43.583846,"lon":-79.399013,"t":1780926232},{"alt_m":10809.3,"lat":43.584421,"lon":-79.396253,"t":1780926233},{"alt_m":10794.2,"lat":43.584997,"lon":-79.393493,"t":1780926234},{"alt_m":10806.3,"lat":43.585572,"lon":-79.390732,"t":1780926235},{"alt_m":10792.2,"lat":43.586147,"lon":-79.387972,"t":1780926236},{"alt_m":10794.3,"lat":43.586723,"lon":-79.385212,"t":1780926237},{"alt_m":10798.3,"lat":43.587298,"lon":-79.382451,"t":1780926238},{"alt_m":10802.8,"lat":43.587874,"lon":-79.379691,"t":1780926239}]},{"anomaly":null,"callsign":"JZA707","icao24":"c07e07","overhead":true,"points":[{"alt_m":4795.2,"lat":43.272916,"lon":-79.767379,"t":1780928100},{"alt_m":4790.5,"lat":43.274022,"lon":-79.766428,"t":1780928101},{"alt_m":4782.2,"lat":43.275129,"lon":-79.765477,"t":1780928102},{"alt_m":4771.3,"lat":43.276235,"lon":-79.764526,"t":1780928103},{"alt_m":4764.5,"lat":43.277342,"lon":-79.763575,"t":1780928104},{"alt_m":4759.2,"lat":43.278448,"lon":-79.762624,"t":1780928105},{"alt_m":4756.1,"lat":43.279554,"lon":-79.761673,"t":1780928106},{"alt_m":4750.4,"lat":43.280661,"lon":-79.760722,"t":1780928107},{"alt_m":4738.6,"lat":43.281767,"lon":-79.759771,"t":1780928108},{"alt_m":4734.4,"lat":43.282874,"lon":-79.75882,"t":1780928109},{"alt_m":4719.6,"lat":43.28398,"lon":-79.757869,"t":1780928110},{"alt_m":4710.9,"lat":43.285087,"lon":-79.756918,"t":1780928111},{"alt_m":4709.1,"lat":43.286193,"lon":-79.755967,"t":1780928112},{"alt_m":4710.6,"lat":43.2873,"lon":-79.755015,"t":1780928113},{"alt_m":4693.6,"lat":43.288406,"lon":-79.754064,"t":1780928114},{"alt_m":4695.3,"lat":43.289513,"lon":-79.753113,"t":1780928115},{"alt_m":4689.0,"lat":43.290619,"lon":-79.752162,"t":1780928116},{"alt_m":4679.2,"lat":43.291726,"lon":-79.751211,"t":1780928117},{"alt_m":4668.5,"lat":43.292832,"lon":-79.75026,"t":1780928118},{"alt_m":4661.2,"lat":43.293939,"lon":-79.749309,"t":1780928119},{"alt_m":4653.4,"lat":43.295045,"lon":-79.748358,"t":1780928120},{"alt_m":4642.8,"lat":43.296152,"lon":-79.747407,"t":1780928121},{"alt_m":4628.5,"lat":43.297258,"lon":-79.746456,"t":1780928122},{"alt_m":4618.5,"lat":43.298365,"lon":-79.745505,"t":1780928123},{"alt_m":4615.2,"lat":43.299471,"lon":-79.744554,"t":1780928124},{"alt_m":4604.7,"lat":43.300578,"lon":-79.743603,"t":1780928125},{"alt_m":4609.8,"lat":43.301684,"lon":-79.742652,"t":1780928126},{"alt_m":4594.7,"lat":43.302791,"lon":-79.741701,"t":1780928127},{"alt_m":4582.8,"lat":43.303897,"lon":-79.74075,"t":1780928128},{"alt_m":4573.3,"lat":43.305004,"lon":-79.739798,"t":1780928129},{"alt_m":4576.6,"lat":43.30611,"lon":-79.738847,"t":1780928130},{"alt_m":4561.9,"lat":43.307217,"lon":-79.737896,"t":1780928131},{"alt_m":4559.7,"lat":43.308323,"lon":-79.736945,"t":1780928132},{"alt_m":4556.8,"lat":43.30943,"lon":-79.735994,"t":1780928133},{"alt_m":4542.9,"lat":43.310536,"lon":-79.735043,"t":1780928134},{"alt_m":4543.4,"lat":43.311643,"lon":-79.734092,"t":1780928135},{"alt_m":4530.9,"lat":43.312749,"lon":-79.733141,"t":1780928136},{"alt_m":4513.1,"lat":43.313856,"lon":-79.73219,"t":1780928137},{"alt_m":4524.5,"lat":43.314962,"lon":-79.731239,"t":1780928138},{"alt_m":4508.0,"lat":43.316069,"lon":-79.730288,"t":1780928139},{"alt_m":4504.3,"lat":43.317175,"lon":-79.729337,"t":1780928140},{"alt_m":4498.6,"lat":43.318282,"lon":-79.728386,"t":1780928141},{"alt_m":4476.6,"lat":43.319388,"lon":-79.727435,"t":1780928142},{"alt_m":4471.9,"lat":43.320495,"lon":-79.726484,"t":1780928143},{"alt_m":4480.0,"lat":43.321601,"lon":-79.725533,"t":1780928144},{"alt_m":4460.8,"lat":43.322708,"lon":-79.724581,"t":1780928145},{"alt_m":4449.6,"lat":43.323814,"lon":-79.72363,"t":1780928146},{"alt_m":4447.8,"lat":43.324921,"lon":-79.722679,"t":1780928147},{"alt_m":4443.7,"lat":43.326027,"lon":-79.721728,"t":1780928148},{"alt_m":4428.1,"lat":43.327134,"lon":-79.720777,"t":1780928149},{"alt_m":4418.5,"lat":43.32824,"lon":-79.719826,"t":1780928150},{"alt_m":4419.4,"lat":43.329347,"lon":-79.718875,"t":1780928151},{"alt_m":4401.2,"lat":43.330453,"lon":-79.717924,"t":1780928152},{"alt_m":4401.0,"lat":43.33156,"lon":-79.716973,"t":1780928153},{"alt_m":4389.9,"lat":43.332666,"lon":-79.716022,"t":1780928154},{"alt_m":4379.4,"lat":43.333773,"lon":-79.715071,"t":1780928155},{"alt_m":4385.2,"lat":43.334879,"lon":-79.71412,"t":1780928156},{"alt_m":4378.1,"lat":43.335986,"lon":-79.713169,"t":1780928157},{"alt_m":4358.4,"lat":43.337092,"lon":-79.712218,"t":1780928158},{"alt_m":4352.9,"lat":43.338199,"lon":-79.711267,"t":1780928159},{"alt_m":4345.4,"lat":43.339305,"lon":-79.710316,"t":1780928160},{"alt_m":4337.8,"lat":43.340412,"lon":-79.709364,"t":1780928161},{"alt_m":4340.6,"lat":43.341518,"lon":-79.708413,"t":1780928162},{"alt_m":4334.5,"lat":43.342625,"lon":-79.707462,"t":1780928163},{"alt_m":4317.2,"lat":43.343731,"lon":-79.706511,"t":1780928164},{"alt_m":4318.3,"lat":43.344838,"lon":-79.70556,"t":1780928165},{"alt_m":4301.2,"lat":43.345944,"lon":-79.704609,"t":1780928166},{"alt_m":4296.3,"lat":43.347051,"lon":-79.703658,"t":1780928167},{"alt_m":4299.0,"lat":43.348157,"lon":-79.702707,"t":1780928168},{"alt_m":4277.0,"lat":43.349264,"lon":-79.701756,"t":1780928169},{"alt_m":4277.0,"lat":43.35037,"lon":-79.700805,"t":1780928170},{"alt_m":4274.3,"lat":43.351477,"lon":-79.699854,"t":1780928171},{"alt_m":4261.3,"lat":43.352583,"lon":-79.698903,"t":1780928172},{"alt_m":4248.8,"lat":43.35369,"lon":-79.697952,"t":1780928173},{"alt_m":4250.1,"lat":43.354796,"lon":-79.697001,"t":1780928174},{"alt_m":4235.2,"lat":43.355903,"lon":-79.69605,"t":1780928175},{"alt_m":4230.4,"lat":43.357009,"lon":-79.695099,"t":1780928176},{"alt_m":4214.9,"lat":43.358116,"lon":-79.694147,"t":1780928177},{"alt_m":4207.0,"lat":43.359222,"lon":-79.693196,"t":1780928178},{"alt_m":4200.2,"lat":43.360329,"lon":-79.692245,"t":1780928179},{"alt_m":4208.6,"lat":43.361435,"lon":-79.691294,"t":1780928180},{"alt_m":4183.3,"lat":43.362542,"lon":-79.690343,"t":1780928181},{"alt_m":4194.8,"lat":43.363648,"lon":-79.689392,"t":1780928182},{"alt_m":4181.2,"lat":43.364755,"lon":-79.688441,"t":1780928183},{"alt_m":4168.8,"lat":43.365861,"lon":-79.68749,"t":1780928184},{"alt_m":4167.4,"lat":43.366968,"lon":-79.686539,"t":1780928185},{"alt_m":4163.1,"lat":43.368074,"lon":-79.685588,"t":1780928186},{"alt_m":4151.5,"lat":43.369181,"lon":-79.684637,"t":1780928187},{"alt_m":4149.8,"lat":43.370287,"lon":-79.683686,"t":1780928188},{"alt_m":4132.9,"lat":43.371394,"lon":-79.682735,"t":1780928189},{"alt_m":4130.1,"lat":43.3725,"lon":-79.681784,"t":1780928190},{"alt_m":4119.2,"lat":43.373607,"lon":-79.680833,"t":1780928191},{"alt_m":4110.8,"lat":43.374713,"lon":-79.679882,"t":1780928192},{"alt_m":4103.4,"lat":43.37582,"lon":-79.678931,"t":1780928193},{"alt_m":4101.0,"lat":43.376926,"lon":-79.677979,"t":1780928194},{"alt_m":4089.4,"lat":43.378033,"lon":-79.677028,"t":1780928195},{"alt_m":4086.0,"lat":43.379139,"lon":-79.676077,"t":1780928196},{"alt_m":4064.2,"lat":43.380246,"lon":-79.675126,"t":1780928197},{"alt_m":4068.0,"lat":43.381352,"lon":-79.674175,"t":1780928198},{"alt_m":4063.1,"lat":43.382459,"lon":-79.673224,"t":1780928199},{"alt_m":4041.5,"lat":43.383565,"lon":-79.672273,"t":1780928200},{"alt_m":4042.8,"lat":43.384671,"lon":-79.671322,"t":1780928201},{"alt_m":4042.8,"lat":43.385778,"lon":-79.670371,"t":1780928202},{"alt_m":4018.9,"lat":43.386884,"lon":-79.66942,"t":1780928203},{"alt_m":4013.7,"lat":43.387991,"lon":-79.668469,"t":1780928204},{"alt_m":4010.8,"lat":43.389097,"lon":-79.667518,"t":1780928205},{"alt_m":4009.1,"lat":43.390204,"lon":-79.666567,"t":1780928206},{"alt_m":3993.0,"lat":43.39131,"lon":-79.665616,"t":1780928207},{"alt_m":3988.3,"lat":43.392417,"lon":-79.664665,"t":1780928208},{"alt_m":3976.2,"lat":43.393523,"lon":-79.663714,"t":1780928209},{"alt_m":3969.4,"lat":43.39463,"lon":-79.662762,"t":1780928210},{"alt_m":3969.9,"lat":43.395736,"lon":-79.661811,"t":1780928211},{"alt_m":3953.1,"lat":43.396843,"lon":-79.66086,"t":1780928212},{"alt_m":3958.3,"lat":43.397949,"lon":-79.659909,"t":1780928213},{"alt_m":3949.4,"lat":43.399056,"lon":-79.658958,"t":1780928214},{"alt_m":3931.3,"lat":43.400162,"lon":-79.658007,"t":1780928215},{"alt_m":3932.8,"lat":43.401269,"lon":-79.657056,"t":1780928216},{"alt_m":3923.1,"lat":43.402375,"lon":-79.656105,"t":1780928217},{"alt_m":3916.7,"lat":43.403482,"lon":-79.655154,"t":1780928218},{"alt_m":3910.9,"lat":43.404588,"lon":-79.654203,"t":1780928219},{"alt_m":3902.7,"lat":43.405695,"lon":-79.653252,"t":1780928220},{"alt_m":3900.7,"lat":43.406801,"lon":-79.652301,"t":1780928221},{"alt_m":3890.7,"lat":43.407908,"lon":-79.65135,"t":1780928222},{"alt_m":3870.3,"lat":43.409014,"lon":-79.650399,"t":1780928223},{"alt_m":3866.4,"lat":43.410121,"lon":-79.649448,"t":1780928224},{"alt_m":3855.9,"lat":43.411227,"lon":-79.648497,"t":1780928225},{"alt_m":3855.8,"lat":43.412334,"lon":-79.647545,"t":1780928226},{"alt_m":3852.6,"lat":43.41344,"lon":-79.646594,"t":1780928227},{"alt_m":3841.8,"lat":43.414547,"lon":-79.645643,"t":1780928228},{"alt_m":3839.0,"lat":43.415653,"lon":-79.644692,"t":1780928229},{"alt_m":3828.1,"lat":43.41676,"lon":-79.643741,"t":1780928230},{"alt_m":3826.2,"lat":43.417866,"lon":-79.64279,"t":1780928231},{"alt_m":3817.0,"lat":43.418973,"lon":-79.641839,"t":1780928232},{"alt_m":3794.9,"lat":43.420079,"lon":-79.640888,"t":1780928233},{"alt_m":3802.9,"lat":43.421186,"lon":-79.639937,"t":1780928234},{"alt_m":3782.2,"lat":43.422292,"lon":-79.638986,"t":1780928235},{"alt_m":3783.0,"lat":43.423399,"lon":-79.638035,"t":1780928236},{"alt_m":3770.4,"lat":43.424505,"lon":-79.637084,"t":1780928237},{"alt_m":3773.3,"lat":43.425612,"lon":-79.636133,"t":1780928238},{"alt_m":3760.4,"lat":43.426718,"lon":-79.635182,"t":1780928239},{"alt_m":3748.2,"lat":43.427825,"lon":-79.634231,"t":1780928240},{"alt_m":3752.3,"lat":43.428931,"lon":-79.63328,"t":1780928241},{"alt_m":3729.5,"lat":43.430038,"lon":-79.632328,"t":1780928242},{"alt_m":3718.7,"lat":43.431144,"lon":-79.631377,"t":1780928243},{"alt_m":3711.7,"lat":43.432251,"lon":-79.630426,"t":1780928244},{"alt_m":3710.1,"lat":43.433357,"lon":-79.629475,"t":1780928245},{"alt_m":3699.5,"lat":43.434464,"lon":-79.628524,"t":1780928246},{"alt_m":3695.3,"lat":43.43557,"lon":-79.627573,"t":1780928247},{"alt_m":3697.7,"lat":43.436677,"lon":-79.626622,"t":1780928248},{"alt_m":3682.5,"lat":43.437783,"lon":-79.625671,"t":1780928249},{"alt_m":3667.1,"lat":43.43889,"lon":-79.62472,"t":1780928250},{"alt_m":3662.5,"lat":43.439996,"lon":-79.623769,"t":1780928251},{"alt_m":3653.4,"lat":43.441103,"lon":-79.622818,"t":1780928252},{"alt_m":3650.7,"lat":43.442209,"lon":-79.621867,"t":1780928253},{"alt_m":3638.4,"lat":43.443316,"lon":-79.620916,"t":1780928254},{"alt_m":3638.0,"lat":43.444422,"lon":-79.619965,"t":1780928255},{"alt_m":3632.0,"lat":43.445529,"lon":-79.619014,"t":1780928256},{"alt_m":3626.3,"lat":43.446635,"lon":-79.618063,"t":1780928257},{"alt_m":3624.5,"lat":43.447742,"lon":-79.617112,"t":1780928258},{"alt_m":3608.6,"lat":43.448848,"lon":-79.61616,"t":1780928259},{"alt_m":3595.2,"lat":43.449955,"lon":-79.615209,"t":1780928260},{"alt_m":3598.9,"lat":43.451061,"lon":-79.614258,"t":1780928261},{"alt_m":3590.2,"lat":43.452168,"lon":-79.613307,"t":1780928262},{"alt_m":3575.2,"lat":43.453274,"lon":-79.612356,"t":1780928263},{"alt_m":3567.4,"lat":43.454381,"lon":-79.611405,"t":1780928264},{"alt_m":3566.5,"lat":43.455487,"lon":-79.610454,"t":1780928265},{"alt_m":3555.6,"lat":43.456594,"lon":-79.609503,"t":1780928266},{"alt_m":3543.6,"lat":43.4577,"lon":-79.608552,"t":1780928267},{"alt_m":3544.5,"lat":43.458807,"lon":-79.607601,"t":1780928268},{"alt_m":3529.4,"lat":43.459913,"lon":-79.60665,"t":1780928269},{"alt_m":3516.6,"lat":43.46102,"lon":-79.605699,"t":1780928270},{"alt_m":3511.0,"lat":43.462126,"lon":-79.604748,"t":1780928271},{"alt_m":3519.2,"lat":43.463233,"lon":-79.603797,"t":1780928272},{"alt_m":3512.1,"lat":43.464339,"lon":-79.602846,"t":1780928273},{"alt_m":3498.9,"lat":43.465446,"lon":-79.601895,"t":1780928274},{"alt_m":3482.2,"lat":43.466552,"lon":-79.600943,"t":1780928275},{"alt_m":3489.1,"lat":43.467659,"lon":-79.599992,"t":1780928276},{"alt_m":3479.5,"lat":43.468765,"lon":-79.599041,"t":1780928277},{"alt_m":3459.5,"lat":43.469872,"lon":-79.59809,"t":1780928278},{"alt_m":3467.2,"lat":43.470978,"lon":-79.597139,"t":1780928279},{"alt_m":3441.5,"lat":43.472085,"lon":-79.596188,"t":1780928280},{"alt_m":3451.4,"lat":43.473191,"lon":-79.595237,"t":1780928281},{"alt_m":3433.7,"lat":43.474298,"lon":-79.594286,"t":1780928282},{"alt_m":3419.2,"lat":43.475404,"lon":-79.593335,"t":1780928283},{"alt_m":3423.6,"lat":43.476511,"lon":-79.592384,"t":1780928284},{"alt_m":3408.5,"lat":43.477617,"lon":-79.591433,"t":1780928285},{"alt_m":3401.7,"lat":43.478724,"lon":-79.590482,"t":1780928286},{"alt_m":3398.3,"lat":43.47983,"lon":-79.589531,"t":1780928287},{"alt_m":3392.8,"lat":43.480937,"lon":-79.58858,"t":1780928288},{"alt_m":3386.1,"lat":43.482043,"lon":-79.587629,"t":1780928289},{"alt_m":3380.5,"lat":43.48315,"lon":-79.586678,"t":1780928290},{"alt_m":3365.1,"lat":43.484256,"lon":-79.585726,"t":1780928291},{"alt_m":3355.3,"lat":43.485363,"lon":-79.584775,"t":1780928292},{"alt_m":3347.0,"lat":43.486469,"lon":-79.583824,"t":1780928293},{"alt_m":3335.3,"lat":43.487576,"lon":-79.582873,"t":1780928294},{"alt_m":3341.9,"lat":43.488682,"lon":-79.581922,"t":1780928295},{"alt_m":3330.8,"lat":43.489788,"lon":-79.580971,"t":1780928296},{"alt_m":3326.8,"lat":43.490895,"lon":-79.58002,"t":1780928297},{"alt_m":3309.7,"lat":43.492001,"lon":-79.579069,"t":1780928298},{"alt_m":3312.7,"lat":43.493108,"lon":-79.578118,"t":1780928299},{"alt_m":3307.4,"lat":43.494214,"lon":-79.577167,"t":1780928300},{"alt_m":3300.6,"lat":43.495321,"lon":-79.576216,"t":1780928301},{"alt_m":3276.8,"lat":43.496427,"lon":-79.575265,"t":1780928302},{"alt_m":3277.9,"lat":43.497534,"lon":-79.574314,"t":1780928303},{"alt_m":3265.0,"lat":43.49864,"lon":-79.573363,"t":1780928304},{"alt_m":3256.5,"lat":43.499747,"lon":-79.572412,"t":1780928305},{"alt_m":3247.2,"lat":43.500853,"lon":-79.571461,"t":1780928306},{"alt_m":3239.2,"lat":43.50196,"lon":-79.570509,"t":1780928307},{"alt_m":3231.0,"lat":43.503066,"lon":-79.569558,"t":1780928308},{"alt_m":3232.6,"lat":43.504173,"lon":-79.568607,"t":1780928309},{"alt_m":3217.9,"lat":43.505279,"lon":-79.567656,"t":1780928310},{"alt_m":3225.3,"lat":43.506386,"lon":-79.566705,"t":1780928311},{"alt_m":3205.6,"lat":43.507492,"lon":-79.565754,"t":1780928312},{"alt_m":3209.3,"lat":43.508599,"lon":-79.564803,"t":1780928313},{"alt_m":3199.1,"lat":43.509705,"lon":-79.563852,"t":1780928314},{"alt_m":3195.6,"lat":43.510812,"lon":-79.562901,"t":1780928315},{"alt_m":3178.3,"lat":43.511918,"lon":-79.56195,"t":1780928316},{"alt_m":3180.9,"lat":43.513025,"lon":-79.560999,"t":1780928317},{"alt_m":3166.0,"lat":43.514131,"lon":-79.560048,"t":1780928318},{"alt_m":3161.0,"lat":43.515238,"lon":-79.559097,"t":1780928319},{"alt_m":3150.1,"lat":43.516344,"lon":-79.558146,"t":1780928320},{"alt_m":3133.8,"lat":43.517451,"lon":-79.557195,"t":1780928321},{"alt_m":3126.0,"lat":43.518557,"lon":-79.556244,"t":1780928322},{"alt_m":3118.6,"lat":43.519664,"lon":-79.555292,"t":1780928323},{"alt_m":3118.6,"lat":43.52077,"lon":-79.554341,"t":1780928324},{"alt_m":3122.3,"lat":43.521877,"lon":-79.55339,"t":1780928325},{"alt_m":3097.4,"lat":43.522983,"lon":-79.552439,"t":1780928326},{"alt_m":3092.0,"lat":43.52409,"lon":-79.551488,"t":1780928327},{"alt_m":3086.6,"lat":43.525196,"lon":-79.550537,"t":1780928328},{"alt_m":3076.7,"lat":43.526303,"lon":-79.549586,"t":1780928329},{"alt_m":3078.1,"lat":43.527409,"lon":-79.548635,"t":1780928330},{"alt_m":3058.9,"lat":43.528516,"lon":-79.547684,"t":1780928331},{"alt_m":3054.5,"lat":43.529622,"lon":-79.546733,"t":1780928332},{"alt_m":3057.8,"lat":43.530729,"lon":-79.545782,"t":1780928333},{"alt_m":3037.1,"lat":43.531835,"lon":-79.544831,"t":1780928334},{"alt_m":3039.9,"lat":43.532942,"lon":-79.54388,"t":1780928335},{"alt_m":3032.7,"lat":43.534048,"lon":-79.542929,"t":1780928336},{"alt_m":3027.4,"lat":43.535155,"lon":-79.541978,"t":1780928337},{"alt_m":3005.5,"lat":43.536261,"lon":-79.541027,"t":1780928338},{"alt_m":3004.8,"lat":43.537368,"lon":-79.540076,"t":1780928339},{"alt_m":2997.4,"lat":43.538474,"lon":-79.539124,"t":1780928340},{"alt_m":2988.1,"lat":43.539581,"lon":-79.538173,"t":1780928341},{"alt_m":2978.1,"lat":43.540687,"lon":-79.537222,"t":1780928342},{"alt_m":2974.7,"lat":43.541794,"lon":-79.536271,"t":1780928343},{"alt_m":2965.6,"lat":43.5429,"lon":-79.53532,"t":1780928344},{"alt_m":2953.9,"lat":43.544007,"lon":-79.534369,"t":1780928345},{"alt_m":2948.6,"lat":43.545113,"lon":-79.533418,"t":1780928346},{"alt_m":2949.2,"lat":43.54622,"lon":-79.532467,"t":1780928347},{"alt_m":2941.8,"lat":43.547326,"lon":-79.531516,"t":1780928348},{"alt_m":2935.5,"lat":43.548433,"lon":-79.530565,"t":1780928349},{"alt_m":2921.9,"lat":43.549539,"lon":-79.529614,"t":1780928350},{"alt_m":2913.4,"lat":43.550646,"lon":-79.528663,"t":1780928351},{"alt_m":2910.9,"lat":43.551752,"lon":-79.527712,"t":1780928352},{"alt_m":2905.6,"lat":43.552859,"lon":-79.526761,"t":1780928353},{"alt_m":2892.9,"lat":43.553965,"lon":-79.52581,"t":1780928354},{"alt_m":2894.6,"lat":43.555072,"lon":-79.524859,"t":1780928355},{"alt_m":2889.9,"lat":43.556178,"lon":-79.523907,"t":1780928356},{"alt_m":2882.1,"lat":43.557285,"lon":-79.522956,"t":1780928357},{"alt_m":2862.4,"lat":43.558391,"lon":-79.522005,"t":1780928358},{"alt_m":2862.7,"lat":43.559498,"lon":-79.521054,"t":1780928359},{"alt_m":2851.3,"lat":43.560604,"lon":-79.520103,"t":1780928360},{"alt_m":2848.3,"lat":43.561711,"lon":-79.519152,"t":1780928361},{"alt_m":2836.9,"lat":43.562817,"lon":-79.518201,"t":1780928362},{"alt_m":2829.7,"lat":43.563924,"lon":-79.51725,"t":1780928363},{"alt_m":2820.2,"lat":43.56503,"lon":-79.516299,"t":1780928364},{"alt_m":2812.1,"lat":43.566137,"lon":-79.515348,"t":1780928365},{"alt_m":2798.0,"lat":43.567243,"lon":-79.514397,"t":1780928366},{"alt_m":2790.4,"lat":43.56835,"lon":-79.513446,"t":1780928367},{"alt_m":2799.7,"lat":43.569456,"lon":-79.512495,"t":1780928368},{"alt_m":2789.9,"lat":43.570563,"lon":-79.511544,"t":1780928369},{"alt_m":2772.5,"lat":43.571669,"lon":-79.510593,"t":1780928370},{"alt_m":2759.3,"lat":43.572776,"lon":-79.509642,"t":1780928371},{"alt_m":2762.2,"lat":43.573882,"lon":-79.50869,"t":1780928372},{"alt_m":2760.1,"lat":43.574989,"lon":-79.507739,"t":1780928373},{"alt_m":2747.4,"lat":43.576095,"lon":-79.506788,"t":1780928374},{"alt_m":2731.8,"lat":43.577202,"lon":-79.505837,"t":1780928375},{"alt_m":2724.8,"lat":43.578308,"lon":-79.504886,"t":1780928376},{"alt_m":2717.3,"lat":43.579415,"lon":-79.503935,"t":1780928377},{"alt_m":2707.5,"lat":43.580521,"lon":-79.502984,"t":1780928378},{"alt_m":2715.3,"lat":43.581628,"lon":-79.502033,"t":1780928379},{"alt_m":2697.4,"lat":43.582734,"lon":-79.501082,"t":1780928380},{"alt_m":2690.1,"lat":43.583841,"lon":-79.500131,"t":1780928381},{"alt_m":2677.2,"lat":43.584947,"lon":-79.49918,"t":1780928382},{"alt_m":2672.0,"lat":43.586054,"lon":-79.498229,"t":1780928383},{"alt_m":2673.8,"lat":43.58716,"lon":-79.497278,"t":1780928384},{"alt_m":2661.6,"lat":43.588267,"lon":-79.496327,"t":1780928385},{"alt_m":2650.4,"lat":43.589373,"lon":-79.495376,"t":1780928386},{"alt_m":2652.8,"lat":43.59048,"lon":-79.494425,"t":1780928387},{"alt_m":2643.6,"lat":43.591586,"lon":-79.493473,"t":1780928388},{"alt_m":2626.0,"lat":43.592693,"lon":-79.492522,"t":1780928389},{"alt_m":2620.0,"lat":43.593799,"lon":-79.491571,"t":1780928390},{"alt_m":2622.1,"lat":43.594905,"lon":-79.49062,"t":1780928391},{"alt_m":2608.3,"lat":43.596012,"lon":-79.489669,"t":1780928392},{"alt_m":2611.4,"lat":43.597118,"lon":-79.488718,"t":1780928393},{"alt_m":2603.1,"lat":43.598225,"lon":-79.487767,"t":1780928394},{"alt_m":2589.0,"lat":43.599331,"lon":-79.486816,"t":1780928395},{"alt_m":2573.0,"lat":43.600438,"lon":-79.485865,"t":1780928396},{"alt_m":2580.0,"lat":43.601544,"lon":-79.484914,"t":1780928397},{"alt_m":2575.0,"lat":43.602651,"lon":-79.483963,"t":1780928398},{"alt_m":2553.5,"lat":43.603757,"lon":-79.483012,"t":1780928399}]},{"anomaly":null,"callsign":"UAL303","icao24":"a03c03","overhead":false,"points":[{"alt_m":10699.9,"lat":43.281735,"lon":-79.973584,"t":1780932000},{"alt_m":10690.8,"lat":43.282432,"lon":-79.970799,"t":1780932001},{"alt_m":10704.5,"lat":43.283129,"lon":-79.968014,"t":1780932002},{"alt_m":10706.8,"lat":43.283827,"lon":-79.965228,"t":1780932003},{"alt_m":10704.2,"lat":43.284524,"lon":-79.962443,"t":1780932004},{"alt_m":10704.7,"lat":43.285221,"lon":-79.959658,"t":1780932005},{"alt_m":10703.5,"lat":43.285918,"lon":-79.956872,"t":1780932006},{"alt_m":10704.5,"lat":43.286616,"lon":-79.954087,"t":1780932007},{"alt_m":10693.9,"lat":43.287313,"lon":-79.951301,"t":1780932008},{"alt_m":10703.0,"lat":43.28801,"lon":-79.948516,"t":1780932009},{"alt_m":10697.7,"lat":43.288707,"lon":-79.945731,"t":1780932010},{"alt_m":10705.0,"lat":43.289404,"lon":-79.942945,"t":1780932011},{"alt_m":10706.4,"lat":43.290102,"lon":-79.94016,"t":1780932012},{"alt_m":10706.4,"lat":43.290799,"lon":-79.937375,"t":1780932013},{"alt_m":10703.8,"lat":43.291496,"lon":-79.934589,"t":1780932014},{"alt_m":10698.8,"lat":43.292193,"lon":-79.931804,"t":1780932015},{"alt_m":10705.4,"lat":43.292891,"lon":-79.929019,"t":1780932016},{"alt_m":10702.4,"lat":43.293588,"lon":-79.926233,"t":1780932017},{"alt_m":10704.8,"lat":43.294285,"lon":-79.923448,"t":1780932018},{"alt_m":10709.0,"lat":43.294982,"lon":-79.920663,"t":1780932019},{"alt_m":10694.7,"lat":43.29568,"lon":-79.917877,"t":1780932020},{"alt_m":10694.0,"lat":43.296377,"lon":-79.915092,"t":1780932021},{"alt_m":10701.6,"lat":43.297074,"lon":-79.912307,"t":1780932022},{"alt_m":10707.3,"lat":43.297771,"lon":-79.909521,"t":1780932023},{"alt_m":10697.8,"lat":43.298469,"lon":-79.906736,"t":1780932024},{"alt_m":10705.6,"lat":43.299166,"lon":-79.903951,"t":1780932025},{"alt_m":10704.3,"lat":43.299863,"lon":-79.901165,"t":1780932026},{"alt_m":10695.9,"lat":43.30056,"lon":-79.89838,"t":1780932027},{"alt_m":10696.5,"lat":43.301257,"lon":-79.895595,"t":1780932028},{"alt_m":10690.2,"lat":43.301955,"lon":-79.892809,"t":1780932029},{"alt_m":10703.6,"lat":43.302652,"lon":-79.890024,"t":1780932030},{"alt_m":10702.2,"lat":43.303349,"lon":-79.887239,"t":1780932031},{"alt_m":10706.9,"lat":43.304046,"lon":-79.884453,"t":1780932032},{"alt_m":10696.8,"lat":43.304744,"lon":-79.881668,"t":1780932033},{"alt_m":10709.8,"lat":43.305441,"lon":-79.878883,"t":1780932034},{"alt_m":10691.9,"lat":43.306138,"lon":-79.876097,"t":1780932035},{"alt_m":10691.1,"lat":43.306835,"lon":-79.873312,"t":1780932036},{"alt_m":10696.9,"lat":43.307533,"lon":-79.870527,"t":1780932037},{"alt_m":10707.8,"lat":43.30823,"lon":-79.867741,"t":1780932038},{"alt_m":10706.3,"lat":43.308927,"lon":-79.864956,"t":1780932039},{"alt_m":10705.1,"lat":43.309624,"lon":-79.862171,"t":1780932040},{"alt_m":10703.8,"lat":43.310322,"lon":-79.859385,"t":1780932041},{"alt_m":10697.5,"lat":43.311019,"lon":-79.8566,"t":1780932042},{"alt_m":10705.5,"lat":43.311716,"lon":-79.853815,"t":1780932043},{"alt_m":10690.1,"lat":43.312413,"lon":-79.851029,"t":1780932044},{"alt_m":10690.8,"lat":43.31311,"lon":-79.848244,"t":1780932045},{"alt_m":10701.3,"lat":43.313808,"lon":-79.845459,"t":1780932046},{"alt_m":10690.4,"lat":43.314505,"lon":-79.842673,"t":1780932047},{"alt_m":10690.6,"lat":43.315202,"lon":-79.839888,"t":1780932048},{"alt_m":10696.2,"lat":43.315899,"lon":-79.837103,"t":1780932049},{"alt_m":10704.6,"lat":43.316597,"lon":-79.834317,"t":1780932050},{"alt_m":10695.3,"lat":43.317294,"lon":-79.831532,"t":1780932051},{"alt_m":10700.8,"lat":43.317991,"lon":-79.828747,"t":1780932052},{"alt_m":10697.5,"lat":43.318688,"lon":-79.825961,"t":1780932053},{"alt_m":10704.9,"lat":43.319386,"lon":-79.823176,"t":1780932054},{"alt_m":10708.8,"lat":43.320083,"lon":-79.820391,"t":1780932055},{"alt_m":10697.8,"lat":43.32078,"lon":-79.817605,"t":1780932056},{"alt_m":10690.4,"lat":43.321477,"lon":-79.81482,"t":1780932057},{"alt_m":10709.6,"lat":43.322175,"lon":-79.812035,"t":1780932058},{"alt_m":10691.7,"lat":43.322872,"lon":-79.809249,"t":1780932059},{"alt_m":10695.6,"lat":43.323569,"lon":-79.806464,"t":1780932060},{"alt_m":10709.8,"lat":43.324266,"lon":-79.803679,"t":1780932061},{"alt_m":10699.3,"lat":43.324964,"lon":-79.800893,"t":1780932062},{"alt_m":10691.3,"lat":43.325661,"lon":-79.798108,"t":1780932063},{"alt_m":10702.1,"lat":43.326358,"lon":-79.795323,"t":1780932064},{"alt_m":10705.3,"lat":43.327055,"lon":-79.792537,"t":1780932065},{"alt_m":10702.4,"lat":43.327752,"lon":-79.789752,"t":1780932066},{"alt_m":10694.1,"lat":43.32845,"lon":-79.786967,"t":1780932067},{"alt_m":10697.0,"lat":43.329147,"lon":-79.784181,"t":1780932068},{"alt_m":10701.5,"lat":43.329844,"lon":-79.781396,"t":1780932069},{"alt_m":10707.7,"lat":43.330541,"lon":-79.778611,"t":1780932070},{"alt_m":10696.6,"lat":43.331239,"lon":-79.775825,"t":1780932071},{"alt_m":10694.0,"lat":43.331936,"lon":-79.77304,"t":1780932072},{"alt_m":10694.2,"lat":43.332633,"lon":-79.770254,"t":1780932073},{"alt_m":10699.5,"lat":43.33333,"lon":-79.767469,"t":1780932074},{"alt_m":10709.8,"lat":43.334028,"lon":-79.764684,"t":1780932075},{"alt_m":10705.3,"lat":43.334725,"lon":-79.761898,"t":1780932076},{"alt_m":10698.6,"lat":43.335422,"lon":-79.759113,"t":1780932077},{"alt_m":10690.5,"lat":43.336119,"lon":-79.756328,"t":1780932078},{"alt_m":10692.1,"lat":43.336817,"lon":-79.753542,"t":1780932079},{"alt_m":10707.4,"lat":43.337514,"lon":-79.750757,"t":1780932080},{"alt_m":10694.3,"lat":43.338211,"lon":-79.747972,"t":1780932081},{"alt_m":10694.1,"lat":43.338908,"lon":-79.745186,"t":1780932082},{"alt_m":10693.8,"lat":43.339605,"lon":-79.742401,"t":1780932083},{"alt_m":10704.9,"lat":43.340303,"lon":-79.739616,"t":1780932084},{"alt_m":10696.2,"lat":43.341,"lon":-79.73683,"t":1780932085},{"alt_m":10691.0,"lat":43.341697,"lon":-79.734045,"t":1780932086},{"alt_m":10696.2,"lat":43.342394,"lon":-79.73126,"t":1780932087},{"alt_m":10696.7,"lat":43.343092,"lon":-79.728474,"t":1780932088},{"alt_m":10703.4,"lat":43.343789,"lon":-79.725689,"t":1780932089},{"alt_m":10709.3,"lat":43.344486,"lon":-79.722904,"t":1780932090},{"alt_m":10709.2,"lat":43.345183,"lon":-79.720118,"t":1780932091},{"alt_m":10694.2,"lat":43.345881,"lon":-79.717333,"t":1780932092},{"alt_m":10700.3,"lat":43.346578,"lon":-79.714548,"t":1780932093},{"alt_m":10691.2,"lat":43.347275,"lon":-79.711762,"t":1780932094},{"alt_m":10710.0,"lat":43.347972,"lon":-79.708977,"t":1780932095},{"alt_m":10693.6,"lat":43.34867,"lon":-79.706192,"t":1780932096},{"alt_m":10702.6,"lat":43.349367,"lon":-79.703406,"t":1780932097},{"alt_m":10703.7,"lat":43.350064,"lon":-79.700621,"t":1780932098},{"alt_m":10705.4,"lat":43.350761,"lon":-79.697836,"t":1780932099},{"alt_m":10702.0,"lat":43.351458,"lon":-79.69505,"t":1780932100},{"alt_m":10690.2,"lat":43.352156,"lon":-79.692265,"t":1780932101},{"alt_m":10706.7,"lat":43.352853,"lon":-79.68948,"t":1780932102},{"alt_m":10698.2,"lat":43.35355,"lon":-79.686694,"t":1780932103},{"alt_m":10707.1,"lat":43.354247,"lon":-79.683909,"t":1780932104},{"alt_m":10706.4,"lat":43.354945,"lon":-79.681124,"t":1780932105},{"alt_m":10691.8,"lat":43.355642,"lon":-79.678338,"t":1780932106},{"alt_m":10700.6,"lat":43.356339,"lon":-79.675553,"t":1780932107},{"alt_m":10691.9,"lat":43.357036,"lon":-79.672768,"t":1780932108},{"alt_m":10695.6,"lat":43.357734,"lon":-79.669982,"t":1780932109},{"alt_m":10691.5,"lat":43.358431,"lon":-79.667197,"t":1780932110},{"alt_m":10693.4,"lat":43.359128,"lon":-79.664412,"t":1780932111},{"alt_m":10696.8,"lat":43.359825,"lon":-79.661626,"t":1780932112},{"alt_m":10704.7,"lat":43.360523,"lon":-79.658841,"t":1780932113},{"alt_m":10690.6,"lat":43.36122,"lon":-79.656056,"t":1780932114},{"alt_m":10691.3,"lat":43.361917,"lon":-79.65327,"t":1780932115},{"alt_m":10690.8,"lat":43.362614,"lon":-79.650485,"t":1780932116},{"alt_m":10701.4,"lat":43.363311,"lon":-79.6477,"t":1780932117},{"alt_m":10708.4,"lat":43.364009,"lon":-79.644914,"t":1780932118},{"alt_m":10696.7,"lat":43.364706,"lon":-79.642129,"t":1780932119},{"alt_m":10696.0,"lat":43.365403,"lon":-79.639344,"t":1780932120},{"alt_m":10700.5,"lat":43.3661,"lon":-79.636558,"t":1780932121},{"alt_m":10694.7,"lat":43.366798,"lon":-79.633773,"t":1780932122},{"alt_m":10690.0,"lat":43.367495,"lon":-79.630988,"t":1780932123},{"alt_m":10690.3,"lat":43.368192,"lon":-79.628202,"t":1780932124},{"alt_m":10704.8,"lat":43.368889,"lon":-79.625417,"t":1780932125},{"alt_m":10704.5,"lat":43.369587,"lon":-79.622632,"t":1780932126},{"alt_m":10701.6,"lat":43.370284,"lon":-79.619846,"t":1780932127},{"alt_m":10699.4,"lat":43.370981,"lon":-79.617061,"t":1780932128},{"alt_m":10702.3,"lat":43.371678,"lon":-79.614276,"t":1780932129},{"alt_m":10691.0,"lat":43.372376,"lon":-79.61149,"t":1780932130},{"alt_m":10709.6,"lat":43.373073,"lon":-79.608705,"t":1780932131},{"alt_m":10693.1,"lat":43.37377,"lon":-79.60592,"t":1780932132},{"alt_m":10692.0,"lat":43.374467,"lon":-79.603134,"t":1780932133},{"alt_m":10705.1,"lat":43.375164,"lon":-79.600349,"t":1780932134},{"alt_m":10707.3,"lat":43.375862,"lon":-79.597564,"t":1780932135},{"alt_m":10703.6,"lat":43.376559,"lon":-79.594778,"t":1780932136},{"alt_m":10702.2,"lat":43.377256,"lon":-79.591993,"t":1780932137},{"alt_m":10693.4,"lat":43.377953,"lon":-79.589207,"t":1780932138},{"alt_m":10706.1,"lat":43.378651,"lon":-79.586422,"t":1780932139},{"alt_m":10704.3,"lat":43.379348,"lon":-79.583637,"t":1780932140},{"alt_m":10702.9,"lat":43.380045,"lon":-79.580851,"t":1780932141},{"alt_m":10704.6,"lat":43.380742,"lon":-79.578066,"t":1780932142},{"alt_m":10694.7,"lat":43.38144,"lon":-79.575281,"t":1780932143},{"alt_m":10696.8,"lat":43.382137,"lon":-79.572495,"t":1780932144},{"alt_m":10696.9,"lat":43.382834,"lon":-79.56971,"t":1780932145},{"alt_m":10694.6,"lat":43.383531,"lon":-79.566925,"t":1780932146},{"alt_m":10696.9,"lat":43.384229,"lon":-79.564139,"t":1780932147},{"alt_m":10694.6,"lat":43.384926,"lon":-79.561354,"t":1780932148},{"alt_m":10706.6,"lat":43.385623,"lon":-79.558569,"t":1780932149},{"alt_m":10705.9,"lat":43.38632,"lon":-79.555783,"t":1780932150},{"alt_m":10693.0,"lat":43.387018,"lon":-79.552998,"t":1780932151},{"alt_m":10701.4,"lat":43.387715,"lon":-79.550213,"t":1780932152},{"alt_m":10696.3,"lat":43.388412,"lon":-79.547427,"t":1780932153},{"alt_m":10706.3,"lat":43.389109,"lon":-79.544642,"t":1780932154},{"alt_m":10709.7,"lat":43.389806,"lon":-79.541857,"t":1780932155},{"alt_m":10705.9,"lat":43.390504,"lon":-79.539071,"t":1780932156},{"alt_m":10693.3,"lat":43.391201,"lon":-79.536286,"t":1780932157},{"alt_m":10691.0,"lat":43.391898,"lon":-79.533501,"t":1780932158},{"alt_m":10706.2,"lat":43.392595,"lon":-79.530715,"t":1780932159},{"alt_m":10691.3,"lat":43.393293,"lon":-79.52793,"t":1780932160},{"alt_m":10700.3,"lat":43.39399,"lon":-79.525145,"t":1780932161},{"alt_m":10705.5,"lat":43.394687,"lon":-79.522359,"t":1780932162},{"alt_m":10694.9,"lat":43.395384,"lon":-79.519574,"t":1780932163},{"alt_m":10696.8,"lat":43.396082,"lon":-79.516789,"t":1780932164},{"alt_m":10702.2,"lat":43.396779,"lon":-79.514003,"t":1780932165},{"alt_m":10700.8,"lat":43.397476,"lon":-79.511218,"t":1780932166},{"alt_m":10702.2,"lat":43.398173,"lon":-79.508433,"t":1780932167},{"alt_m":10705.9,"lat":43.398871,"lon":-79.505647,"t":1780932168},{"alt_m":10695.1,"lat":43.399568,"lon":-79.502862,"t":1780932169},{"alt_m":10707.3,"lat":43.400265,"lon":-79.500077,"t":1780932170},{"alt_m":10705.5,"lat":43.400962,"lon":-79.497291,"t":1780932171},{"alt_m":10701.8,"lat":43.401659,"lon":-79.494506,"t":1780932172},{"alt_m":10693.3,"lat":43.402357,"lon":-79.491721,"t":1780932173},{"alt_m":10703.0,"lat":43.403054,"lon":-79.488935,"t":1780932174},{"alt_m":10701.2,"lat":43.403751,"lon":-79.48615,"t":1780932175},{"alt_m":10702.3,"lat":43.404448,"lon":-79.483365,"t":1780932176},{"alt_m":10706.8,"lat":43.405146,"lon":-79.480579,"t":1780932177},{"alt_m":10706.2,"lat":43.405843,"lon":-79.477794,"t":1780932178},{"alt_m":10704.5,"lat":43.40654,"lon":-79.475009,"t":1780932179},{"alt_m":10694.3,"lat":43.407237,"lon":-79.472223,"t":1780932180},{"alt_m":10698.8,"lat":43.407935,"lon":-79.469438,"t":1780932181},{"alt_m":10703.8,"lat":43.408632,"lon":-79.466653,"t":1780932182},{"alt_m":10693.6,"lat":43.409329,"lon":-79.463867,"t":1780932183},{"alt_m":10699.1,"lat":43.410026,"lon":-79.461082,"t":1780932184},{"alt_m":10693.4,"lat":43.410724,"lon":-79.458297,"t":1780932185},{"alt_m":10709.1,"lat":43.411421,"lon":-79.455511,"t":1780932186},{"alt_m":10701.3,"lat":43.412118,"lon":-79.452726,"t":1780932187},{"alt_m":10695.6,"lat":43.412815,"lon":-79.449941,"t":1780932188},{"alt_m":10692.2,"lat":43.413512,"lon":-79.447155,"t":1780932189},{"alt_m":10708.3,"lat":43.41421,"lon":-79.44437,"t":1780932190},{"alt_m":10702.5,"lat":43.414907,"lon":-79.441585,"t":1780932191},{"alt_m":10707.2,"lat":43.415604,"lon":-79.438799,"t":1780932192},{"alt_m":10705.3,"lat":43.416301,"lon":-79.436014,"t":1780932193},{"alt_m":10703.3,"lat":43.416999,"lon":-79.433229,"t":1780932194},{"alt_m":10706.3,"lat":43.417696,"lon":-79.430443,"t":1780932195},{"alt_m":10696.2,"lat":43.418393,"lon":-79.427658,"t":1780932196},{"alt_m":10707.8,"lat":43.41909,"lon":-79.424873,"t":1780932197},{"alt_m":10708.6,"lat":43.419788,"lon":-79.422087,"t":1780932198},{"alt_m":10706.3,"lat":43.420485,"lon":-79.419302,"t":1780932199},{"alt_m":10696.2,"lat":43.421182,"lon":-79.416517,"t":1780932200},{"alt_m":10705.2,"lat":43.421879,"lon":-79.413731,"t":1780932201},{"alt_m":10695.5,"lat":43.422577,"lon":-79.410946,"t":1780932202},{"alt_m":10691.7,"lat":43.423274,"lon":-79.40816,"t":1780932203},{"alt_m":10699.2,"lat":43.423971,"lon":-79.405375,"t":1780932204},{"alt_m":10704.4,"lat":43.424668,"lon":-79.40259,"t":1780932205},{"alt_m":10706.3,"lat":43.425365,"lon":-79.399804,"t":1780932206},{"alt_m":10690.9,"lat":43.426063,"lon":-79.397019,"t":1780932207},{"alt_m":10704.4,"lat":43.42676,"lon":-79.394234,"t":1780932208},{"alt_m":10704.4,"lat":43.427457,"lon":-79.391448,"t":1780932209},{"alt_m":10701.4,"lat":43.428154,"lon":-79.388663,"t":1780932210},{"alt_m":10692.3,"lat":43.428852,"lon":-79.385878,"t":1780932211},{"alt_m":10707.6,"lat":43.429549,"lon":-79.383092,"t":1780932212},{"alt_m":10693.5,"lat":43.430246,"lon":-79.380307,"t":1780932213},{"alt_m":10697.2,"lat":43.430943,"lon":-79.377522,"t":1780932214},{"alt_m":10703.4,"lat":43.431641,"lon":-79.374736,"t":1780932215},{"alt_m":10691.4,"lat":43.432338,"lon":-79.371951,"t":1780932216},{"alt_m":10701.0,"lat":43.433035,"lon":-79.369166,"t":1780932217},{"alt_m":10697.2,"lat":43.433732,"lon":-79.36638,"t":1780932218},{"alt_m":10697.2,"lat":43.43443,"lon":-79.363595,"t":1780932219},{"alt_m":10696.2,"lat":43.435127,"lon":-79.36081,"t":1780932220},{"alt_m":10702.9,"lat":43.435824,"lon":-79.358024,"t":1780932221},{"alt_m":10694.2,"lat":43.436521,"lon":-79.355239,"t":1780932222},{"alt_m":10699.0,"lat":43.437218,"lon":-79.352454,"t":1780932223},{"alt_m":10696.4,"lat":43.437916,"lon":-79.349668,"t":1780932224},{"alt_m":10701.5,"lat":43.438613,"lon":-79.346883,"t":1780932225},{"alt_m":10697.6,"lat":43.43931,"lon":-79.344098,"t":1780932226},{"alt_m":10696.5,"lat":43.440007,"lon":-79.341312,"t":1780932227},{"alt_m":10700.5,"lat":43.440705,"lon":-79.338527,"t":1780932228},{"alt_m":10690.3,"lat":43.441402,"lon":-79.335742,"t":1780932229},{"alt_m":10695.6,"lat":43.442099,"lon":-79.332956,"t":1780932230},{"alt_m":10694.3,"lat":43.442796,"lon":-79.330171,"t":1780932231},{"alt_m":10701.5,"lat":43.443494,"lon":-79.327386,"t":1780932232},{"alt_m":10701.1,"lat":43.444191,"lon":-79.3246,"t":1780932233},{"alt_m":10703.9,"lat":43.444888,"lon":-79.321815,"t":1780932234},{"alt_m":10702.8,"lat":43.445585,"lon":-79.31903,"t":1780932235},{"alt_m":10707.9,"lat":43.446283,"lon":-79.316244,"t":1780932236},{"alt_m":10697.3,"lat":43.44698,"lon":-79.313459,"t":1780932237},{"alt_m":10703.1,"lat":43.447677,"lon":-79.310674,"t":1780932238},{"alt_m":10699.6,"lat":43.448374,"lon":-79.307888,"t":1780932239}]},{"anomaly":{"band":"interesting","reasons":["mean altitude 1100 m deviates 2.9σ from the local baseline (9396 m)","signal -8.0 dBFS is 3.4σ from baseline -17.6 dBFS (unusually close/strong)","vector novelty 1.00: no similar track in RuVector memory"],"score":0.57},"callsign":"CGSKY","icao24":"c0a9a9","overhead":true,"points":[{"alt_m":1093.5,"lat":43.460398,"lon":-79.825575,"t":1780936200},{"alt_m":1098.3,"lat":43.460418,"lon":-79.824808,"t":1780936201},{"alt_m":1093.3,"lat":43.460437,"lon":-79.824041,"t":1780936202},{"alt_m":1090.4,"lat":43.460457,"lon":-79.823275,"t":1780936203},{"alt_m":1102.0,"lat":43.460476,"lon":-79.822508,"t":1780936204},{"alt_m":1107.3,"lat":43.460496,"lon":-79.821741,"t":1780936205},{"alt_m":1109.7,"lat":43.460515,"lon":-79.820974,"t":1780936206},{"alt_m":1103.9,"lat":43.460535,"lon":-79.820207,"t":1780936207},{"alt_m":1103.2,"lat":43.460554,"lon":-79.81944,"t":1780936208},{"alt_m":1090.9,"lat":43.460573,"lon":-79.818673,"t":1780936209},{"alt_m":1096.6,"lat":43.460593,"lon":-79.817906,"t":1780936210},{"alt_m":1096.9,"lat":43.460612,"lon":-79.817139,"t":1780936211},{"alt_m":1103.6,"lat":43.460632,"lon":-79.816372,"t":1780936212},{"alt_m":1099.0,"lat":43.460651,"lon":-79.815605,"t":1780936213},{"alt_m":1097.7,"lat":43.460671,"lon":-79.814838,"t":1780936214},{"alt_m":1092.2,"lat":43.46069,"lon":-79.814071,"t":1780936215},{"alt_m":1106.9,"lat":43.46071,"lon":-79.813304,"t":1780936216},{"alt_m":1108.6,"lat":43.460729,"lon":-79.812537,"t":1780936217},{"alt_m":1095.2,"lat":43.460749,"lon":-79.811771,"t":1780936218},{"alt_m":1106.2,"lat":43.460768,"lon":-79.811004,"t":1780936219},{"alt_m":1094.4,"lat":43.460788,"lon":-79.810237,"t":1780936220},{"alt_m":1109.2,"lat":43.460807,"lon":-79.80947,"t":1780936221},{"alt_m":1109.5,"lat":43.460827,"lon":-79.808703,"t":1780936222},{"alt_m":1098.4,"lat":43.460846,"lon":-79.807936,"t":1780936223},{"alt_m":1107.1,"lat":43.460866,"lon":-79.807169,"t":1780936224},{"alt_m":1093.1,"lat":43.460885,"lon":-79.806402,"t":1780936225},{"alt_m":1104.3,"lat":43.460904,"lon":-79.805635,"t":1780936226},{"alt_m":1092.0,"lat":43.460924,"lon":-79.804868,"t":1780936227},{"alt_m":1092.6,"lat":43.460943,"lon":-79.804101,"t":1780936228},{"alt_m":1099.6,"lat":43.460963,"lon":-79.803334,"t":1780936229},{"alt_m":1099.7,"lat":43.460982,"lon":-79.802567,"t":1780936230},{"alt_m":1106.2,"lat":43.461002,"lon":-79.8018,"t":1780936231},{"alt_m":1108.6,"lat":43.461021,"lon":-79.801033,"t":1780936232},{"alt_m":1090.9,"lat":43.461041,"lon":-79.800267,"t":1780936233},{"alt_m":1104.3,"lat":43.46106,"lon":-79.7995,"t":1780936234},{"alt_m":1091.8,"lat":43.46108,"lon":-79.798733,"t":1780936235},{"alt_m":1094.9,"lat":43.461099,"lon":-79.797966,"t":1780936236},{"alt_m":1090.6,"lat":43.461119,"lon":-79.797199,"t":1780936237},{"alt_m":1101.6,"lat":43.461138,"lon":-79.796432,"t":1780936238},{"alt_m":1096.5,"lat":43.461158,"lon":-79.795665,"t":1780936239},{"alt_m":1096.2,"lat":43.461177,"lon":-79.794898,"t":1780936240},{"alt_m":1109.5,"lat":43.461197,"lon":-79.794131,"t":1780936241},{"alt_m":1101.8,"lat":43.461216,"lon":-79.793364,"t":1780936242},{"alt_m":1104.5,"lat":43.461235,"lon":-79.792597,"t":1780936243},{"alt_m":1101.8,"lat":43.461255,"lon":-79.79183,"t":1780936244},{"alt_m":1106.0,"lat":43.461274,"lon":-79.791063,"t":1780936245},{"alt_m":1100.5,"lat":43.461294,"lon":-79.790296,"t":1780936246},{"alt_m":1102.6,"lat":43.461313,"lon":-79.789529,"t":1780936247},{"alt_m":1107.2,"lat":43.461333,"lon":-79.788763,"t":1780936248},{"alt_m":1105.3,"lat":43.461352,"lon":-79.787996,"t":1780936249},{"alt_m":1102.4,"lat":43.461372,"lon":-79.787229,"t":1780936250},{"alt_m":1107.7,"lat":43.461391,"lon":-79.786462,"t":1780936251},{"alt_m":1092.1,"lat":43.461411,"lon":-79.785695,"t":1780936252},{"alt_m":1104.7,"lat":43.46143,"lon":-79.784928,"t":1780936253},{"alt_m":1109.2,"lat":43.46145,"lon":-79.784161,"t":1780936254},{"alt_m":1109.6,"lat":43.461469,"lon":-79.783394,"t":1780936255},{"alt_m":1109.5,"lat":43.461489,"lon":-79.782627,"t":1780936256},{"alt_m":1110.0,"lat":43.461508,"lon":-79.78186,"t":1780936257},{"alt_m":1105.1,"lat":43.461527,"lon":-79.781093,"t":1780936258},{"alt_m":1107.8,"lat":43.461547,"lon":-79.780326,"t":1780936259},{"alt_m":1102.0,"lat":43.461566,"lon":-79.779559,"t":1780936260},{"alt_m":1091.5,"lat":43.461586,"lon":-79.778792,"t":1780936261},{"alt_m":1103.1,"lat":43.461605,"lon":-79.778025,"t":1780936262},{"alt_m":1096.6,"lat":43.461625,"lon":-79.777258,"t":1780936263},{"alt_m":1102.2,"lat":43.461644,"lon":-79.776492,"t":1780936264},{"alt_m":1109.2,"lat":43.461664,"lon":-79.775725,"t":1780936265},{"alt_m":1102.8,"lat":43.461683,"lon":-79.774958,"t":1780936266},{"alt_m":1108.8,"lat":43.461703,"lon":-79.774191,"t":1780936267},{"alt_m":1107.0,"lat":43.461722,"lon":-79.773424,"t":1780936268},{"alt_m":1096.8,"lat":43.461742,"lon":-79.772657,"t":1780936269},{"alt_m":1109.4,"lat":43.461761,"lon":-79.77189,"t":1780936270},{"alt_m":1090.3,"lat":43.461781,"lon":-79.771123,"t":1780936271},{"alt_m":1100.5,"lat":43.4618,"lon":-79.770356,"t":1780936272},{"alt_m":1106.5,"lat":43.46182,"lon":-79.769589,"t":1780936273},{"alt_m":1095.7,"lat":43.461839,"lon":-79.768822,"t":1780936274},{"alt_m":1102.8,"lat":43.461858,"lon":-79.768055,"t":1780936275},{"alt_m":1105.9,"lat":43.461878,"lon":-79.767288,"t":1780936276},{"alt_m":1108.9,"lat":43.461897,"lon":-79.766521,"t":1780936277},{"alt_m":1093.8,"lat":43.461917,"lon":-79.765754,"t":1780936278},{"alt_m":1097.0,"lat":43.461936,"lon":-79.764988,"t":1780936279},{"alt_m":1107.3,"lat":43.461956,"lon":-79.764221,"t":1780936280},{"alt_m":1093.4,"lat":43.461975,"lon":-79.763454,"t":1780936281},{"alt_m":1095.6,"lat":43.461995,"lon":-79.762687,"t":1780936282},{"alt_m":1099.6,"lat":43.462014,"lon":-79.76192,"t":1780936283},{"alt_m":1090.4,"lat":43.462034,"lon":-79.761153,"t":1780936284},{"alt_m":1101.0,"lat":43.462053,"lon":-79.760386,"t":1780936285},{"alt_m":1098.3,"lat":43.462073,"lon":-79.759619,"t":1780936286},{"alt_m":1103.7,"lat":43.462092,"lon":-79.758852,"t":1780936287},{"alt_m":1109.3,"lat":43.462112,"lon":-79.758085,"t":1780936288},{"alt_m":1094.3,"lat":43.462131,"lon":-79.757318,"t":1780936289},{"alt_m":1090.7,"lat":43.462151,"lon":-79.756551,"t":1780936290},{"alt_m":1100.6,"lat":43.46217,"lon":-79.755784,"t":1780936291},{"alt_m":1109.4,"lat":43.462189,"lon":-79.755017,"t":1780936292},{"alt_m":1091.9,"lat":43.462209,"lon":-79.75425,"t":1780936293},{"alt_m":1092.7,"lat":43.462228,"lon":-79.753484,"t":1780936294},{"alt_m":1090.8,"lat":43.462248,"lon":-79.752717,"t":1780936295},{"alt_m":1107.3,"lat":43.462267,"lon":-79.75195,"t":1780936296},{"alt_m":1090.3,"lat":43.462287,"lon":-79.751183,"t":1780936297},{"alt_m":1102.1,"lat":43.462306,"lon":-79.750416,"t":1780936298},{"alt_m":1093.5,"lat":43.462326,"lon":-79.749649,"t":1780936299},{"alt_m":1109.6,"lat":43.462345,"lon":-79.748882,"t":1780936300},{"alt_m":1098.3,"lat":43.462365,"lon":-79.748115,"t":1780936301},{"alt_m":1101.8,"lat":43.462384,"lon":-79.747348,"t":1780936302},{"alt_m":1092.3,"lat":43.462404,"lon":-79.746581,"t":1780936303},{"alt_m":1107.5,"lat":43.462423,"lon":-79.745814,"t":1780936304},{"alt_m":1094.7,"lat":43.462443,"lon":-79.745047,"t":1780936305},{"alt_m":1107.9,"lat":43.462462,"lon":-79.74428,"t":1780936306},{"alt_m":1097.5,"lat":43.462482,"lon":-79.743513,"t":1780936307},{"alt_m":1101.7,"lat":43.462501,"lon":-79.742746,"t":1780936308},{"alt_m":1101.0,"lat":43.46252,"lon":-79.74198,"t":1780936309},{"alt_m":1092.5,"lat":43.46254,"lon":-79.741213,"t":1780936310},{"alt_m":1099.0,"lat":43.462559,"lon":-79.740446,"t":1780936311},{"alt_m":1102.8,"lat":43.462579,"lon":-79.739679,"t":1780936312},{"alt_m":1108.0,"lat":43.462598,"lon":-79.738912,"t":1780936313},{"alt_m":1100.3,"lat":43.462618,"lon":-79.738145,"t":1780936314},{"alt_m":1108.9,"lat":43.462637,"lon":-79.737378,"t":1780936315},{"alt_m":1097.7,"lat":43.462657,"lon":-79.736611,"t":1780936316},{"alt_m":1096.8,"lat":43.462676,"lon":-79.735844,"t":1780936317},{"alt_m":1104.7,"lat":43.462696,"lon":-79.735077,"t":1780936318},{"alt_m":1108.4,"lat":43.462715,"lon":-79.73431,"t":1780936319},{"alt_m":1100.6,"lat":43.462735,"lon":-79.733543,"t":1780936320},{"alt_m":1107.9,"lat":43.462754,"lon":-79.732776,"t":1780936321},{"alt_m":1101.6,"lat":43.462774,"lon":-79.732009,"t":1780936322},{"alt_m":1093.3,"lat":43.462793,"lon":-79.731242,"t":1780936323},{"alt_m":1091.1,"lat":43.462813,"lon":-79.730476,"t":1780936324},{"alt_m":1106.6,"lat":43.462832,"lon":-79.729709,"t":1780936325},{"alt_m":1103.1,"lat":43.462851,"lon":-79.728942,"t":1780936326},{"alt_m":1092.4,"lat":43.462871,"lon":-79.728175,"t":1780936327},{"alt_m":1097.1,"lat":43.46289,"lon":-79.727408,"t":1780936328},{"alt_m":1104.8,"lat":43.46291,"lon":-79.726641,"t":1780936329},{"alt_m":1099.0,"lat":43.462929,"lon":-79.725874,"t":1780936330},{"alt_m":1090.8,"lat":43.462949,"lon":-79.725107,"t":1780936331},{"alt_m":1101.1,"lat":43.462968,"lon":-79.72434,"t":1780936332},{"alt_m":1098.8,"lat":43.462988,"lon":-79.723573,"t":1780936333},{"alt_m":1098.0,"lat":43.463007,"lon":-79.722806,"t":1780936334},{"alt_m":1091.3,"lat":43.463027,"lon":-79.722039,"t":1780936335},{"alt_m":1105.2,"lat":43.463046,"lon":-79.721272,"t":1780936336},{"alt_m":1095.3,"lat":43.463066,"lon":-79.720505,"t":1780936337},{"alt_m":1095.6,"lat":43.463085,"lon":-79.719738,"t":1780936338},{"alt_m":1100.8,"lat":43.463105,"lon":-79.718972,"t":1780936339},{"alt_m":1094.3,"lat":43.463124,"lon":-79.718205,"t":1780936340},{"alt_m":1107.6,"lat":43.463144,"lon":-79.717438,"t":1780936341},{"alt_m":1098.6,"lat":43.463163,"lon":-79.716671,"t":1780936342},{"alt_m":1094.8,"lat":43.463182,"lon":-79.715904,"t":1780936343},{"alt_m":1093.7,"lat":43.463202,"lon":-79.715137,"t":1780936344},{"alt_m":1108.8,"lat":43.463221,"lon":-79.71437,"t":1780936345},{"alt_m":1090.8,"lat":43.463241,"lon":-79.713603,"t":1780936346},{"alt_m":1094.9,"lat":43.46326,"lon":-79.712836,"t":1780936347},{"alt_m":1099.0,"lat":43.46328,"lon":-79.712069,"t":1780936348},{"alt_m":1094.3,"lat":43.463299,"lon":-79.711302,"t":1780936349},{"alt_m":1092.8,"lat":43.463319,"lon":-79.710535,"t":1780936350},{"alt_m":1102.0,"lat":43.463338,"lon":-79.709768,"t":1780936351},{"alt_m":1101.2,"lat":43.463358,"lon":-79.709001,"t":1780936352},{"alt_m":1092.1,"lat":43.463377,"lon":-79.708234,"t":1780936353},{"alt_m":1107.8,"lat":43.463397,"lon":-79.707467,"t":1780936354},{"alt_m":1102.8,"lat":43.463416,"lon":-79.706701,"t":1780936355},{"alt_m":1104.5,"lat":43.463436,"lon":-79.705934,"t":1780936356},{"alt_m":1109.0,"lat":43.463455,"lon":-79.705167,"t":1780936357},{"alt_m":1099.1,"lat":43.463475,"lon":-79.7044,"t":1780936358},{"alt_m":1103.6,"lat":43.463494,"lon":-79.703633,"t":1780936359},{"alt_m":1096.1,"lat":43.463513,"lon":-79.702866,"t":1780936360},{"alt_m":1098.4,"lat":43.463533,"lon":-79.702099,"t":1780936361},{"alt_m":1103.0,"lat":43.463552,"lon":-79.701332,"t":1780936362},{"alt_m":1108.6,"lat":43.463572,"lon":-79.700565,"t":1780936363},{"alt_m":1104.6,"lat":43.463591,"lon":-79.699798,"t":1780936364},{"alt_m":1092.8,"lat":43.463611,"lon":-79.699031,"t":1780936365},{"alt_m":1104.6,"lat":43.46363,"lon":-79.698264,"t":1780936366},{"alt_m":1106.5,"lat":43.46365,"lon":-79.697497,"t":1780936367},{"alt_m":1098.1,"lat":43.463669,"lon":-79.69673,"t":1780936368},{"alt_m":1093.9,"lat":43.463689,"lon":-79.695963,"t":1780936369},{"alt_m":1094.6,"lat":43.463708,"lon":-79.695197,"t":1780936370},{"alt_m":1095.6,"lat":43.463728,"lon":-79.69443,"t":1780936371},{"alt_m":1109.6,"lat":43.463747,"lon":-79.693663,"t":1780936372},{"alt_m":1103.6,"lat":43.463767,"lon":-79.692896,"t":1780936373},{"alt_m":1097.7,"lat":43.463786,"lon":-79.692129,"t":1780936374},{"alt_m":1103.7,"lat":43.463806,"lon":-79.691362,"t":1780936375},{"alt_m":1106.3,"lat":43.463825,"lon":-79.690595,"t":1780936376},{"alt_m":1095.0,"lat":43.463844,"lon":-79.689828,"t":1780936377},{"alt_m":1102.9,"lat":43.463864,"lon":-79.689061,"t":1780936378},{"alt_m":1092.0,"lat":43.463883,"lon":-79.688294,"t":1780936379},{"alt_m":1107.3,"lat":43.463903,"lon":-79.687527,"t":1780936380},{"alt_m":1107.4,"lat":43.463922,"lon":-79.68676,"t":1780936381},{"alt_m":1109.1,"lat":43.463942,"lon":-79.685993,"t":1780936382},{"alt_m":1105.1,"lat":43.463961,"lon":-79.685226,"t":1780936383},{"alt_m":1104.2,"lat":43.463981,"lon":-79.684459,"t":1780936384},{"alt_m":1108.4,"lat":43.464,"lon":-79.683693,"t":1780936385},{"alt_m":1090.9,"lat":43.46402,"lon":-79.682926,"t":1780936386},{"alt_m":1095.9,"lat":43.464039,"lon":-79.682159,"t":1780936387},{"alt_m":1094.3,"lat":43.464059,"lon":-79.681392,"t":1780936388},{"alt_m":1101.6,"lat":43.464078,"lon":-79.680625,"t":1780936389},{"alt_m":1106.2,"lat":43.464098,"lon":-79.679858,"t":1780936390},{"alt_m":1092.6,"lat":43.464117,"lon":-79.679091,"t":1780936391},{"alt_m":1103.4,"lat":43.464137,"lon":-79.678324,"t":1780936392},{"alt_m":1107.8,"lat":43.464156,"lon":-79.677557,"t":1780936393},{"alt_m":1103.0,"lat":43.464175,"lon":-79.67679,"t":1780936394},{"alt_m":1090.5,"lat":43.464195,"lon":-79.676023,"t":1780936395},{"alt_m":1095.8,"lat":43.464214,"lon":-79.675256,"t":1780936396},{"alt_m":1091.9,"lat":43.464234,"lon":-79.674489,"t":1780936397},{"alt_m":1100.4,"lat":43.464253,"lon":-79.673722,"t":1780936398},{"alt_m":1106.2,"lat":43.464273,"lon":-79.672955,"t":1780936399},{"alt_m":1108.0,"lat":43.464292,"lon":-79.672189,"t":1780936400},{"alt_m":1109.9,"lat":43.464312,"lon":-79.671422,"t":1780936401},{"alt_m":1101.8,"lat":43.464331,"lon":-79.670655,"t":1780936402},{"alt_m":1101.8,"lat":43.464351,"lon":-79.669888,"t":1780936403},{"alt_m":1108.4,"lat":43.46437,"lon":-79.669121,"t":1780936404},{"alt_m":1099.7,"lat":43.46439,"lon":-79.668354,"t":1780936405},{"alt_m":1104.1,"lat":43.464409,"lon":-79.667587,"t":1780936406},{"alt_m":1095.4,"lat":43.464429,"lon":-79.66682,"t":1780936407},{"alt_m":1095.2,"lat":43.464448,"lon":-79.666053,"t":1780936408},{"alt_m":1104.3,"lat":43.464468,"lon":-79.665286,"t":1780936409},{"alt_m":1099.5,"lat":43.464487,"lon":-79.664519,"t":1780936410},{"alt_m":1096.9,"lat":43.464506,"lon":-79.663752,"t":1780936411},{"alt_m":1101.1,"lat":43.464526,"lon":-79.662985,"t":1780936412},{"alt_m":1099.9,"lat":43.464545,"lon":-79.662218,"t":1780936413},{"alt_m":1094.3,"lat":43.464565,"lon":-79.661451,"t":1780936414},{"alt_m":1097.0,"lat":43.464584,"lon":-79.660685,"t":1780936415},{"alt_m":1095.8,"lat":43.464604,"lon":-79.659918,"t":1780936416},{"alt_m":1103.6,"lat":43.464623,"lon":-79.659151,"t":1780936417},{"alt_m":1095.4,"lat":43.464643,"lon":-79.658384,"t":1780936418},{"alt_m":1103.4,"lat":43.464662,"lon":-79.657617,"t":1780936419},{"alt_m":1095.7,"lat":43.464682,"lon":-79.65685,"t":1780936420},{"alt_m":1107.7,"lat":43.464701,"lon":-79.656083,"t":1780936421},{"alt_m":1105.9,"lat":43.464721,"lon":-79.655316,"t":1780936422},{"alt_m":1104.5,"lat":43.46474,"lon":-79.654549,"t":1780936423},{"alt_m":1095.7,"lat":43.46476,"lon":-79.653782,"t":1780936424},{"alt_m":1101.4,"lat":43.464779,"lon":-79.653015,"t":1780936425},{"alt_m":1091.5,"lat":43.464799,"lon":-79.652248,"t":1780936426},{"alt_m":1096.9,"lat":43.464818,"lon":-79.651481,"t":1780936427},{"alt_m":1101.3,"lat":43.464837,"lon":-79.650714,"t":1780936428},{"alt_m":1103.3,"lat":43.464857,"lon":-79.649947,"t":1780936429},{"alt_m":1090.3,"lat":43.464876,"lon":-79.649181,"t":1780936430},{"alt_m":1108.9,"lat":43.464896,"lon":-79.648414,"t":1780936431},{"alt_m":1107.4,"lat":43.464915,"lon":-79.647647,"t":1780936432},{"alt_m":1102.9,"lat":43.464935,"lon":-79.64688,"t":1780936433},{"alt_m":1104.2,"lat":43.464954,"lon":-79.646113,"t":1780936434},{"alt_m":1104.4,"lat":43.464974,"lon":-79.645346,"t":1780936435},{"alt_m":1095.2,"lat":43.464993,"lon":-79.644579,"t":1780936436},{"alt_m":1094.1,"lat":43.465013,"lon":-79.643812,"t":1780936437},{"alt_m":1094.1,"lat":43.465032,"lon":-79.643045,"t":1780936438},{"alt_m":1098.6,"lat":43.465052,"lon":-79.642278,"t":1780936439},{"alt_m":1101.1,"lat":43.465071,"lon":-79.641511,"t":1780936440},{"alt_m":1091.0,"lat":43.465091,"lon":-79.640744,"t":1780936441},{"alt_m":1096.9,"lat":43.46511,"lon":-79.639977,"t":1780936442},{"alt_m":1106.5,"lat":43.465129,"lon":-79.63921,"t":1780936443},{"alt_m":1093.4,"lat":43.465149,"lon":-79.638443,"t":1780936444},{"alt_m":1090.8,"lat":43.465168,"lon":-79.637677,"t":1780936445},{"alt_m":1100.0,"lat":43.465188,"lon":-79.63691,"t":1780936446},{"alt_m":1099.2,"lat":43.465207,"lon":-79.636143,"t":1780936447},{"alt_m":1108.1,"lat":43.465227,"lon":-79.635376,"t":1780936448},{"alt_m":1105.1,"lat":43.465246,"lon":-79.634609,"t":1780936449},{"alt_m":1101.6,"lat":43.465266,"lon":-79.633842,"t":1780936450},{"alt_m":1093.2,"lat":43.465285,"lon":-79.633075,"t":1780936451},{"alt_m":1104.3,"lat":43.465305,"lon":-79.632308,"t":1780936452},{"alt_m":1097.4,"lat":43.465324,"lon":-79.631541,"t":1780936453},{"alt_m":1091.5,"lat":43.465344,"lon":-79.630774,"t":1780936454},{"alt_m":1091.4,"lat":43.465363,"lon":-79.630007,"t":1780936455},{"alt_m":1104.3,"lat":43.465383,"lon":-79.62924,"t":1780936456},{"alt_m":1098.0,"lat":43.465402,"lon":-79.628473,"t":1780936457},{"alt_m":1100.1,"lat":43.465422,"lon":-79.627706,"t":1780936458},{"alt_m":1108.9,"lat":43.465441,"lon":-79.626939,"t":1780936459},{"alt_m":1100.1,"lat":43.46546,"lon":-79.626172,"t":1780936460},{"alt_m":1093.9,"lat":43.46548,"lon":-79.625406,"t":1780936461},{"alt_m":1105.6,"lat":43.465499,"lon":-79.624639,"t":1780936462},{"alt_m":1101.0,"lat":43.465519,"lon":-79.623872,"t":1780936463},{"alt_m":1104.8,"lat":43.465538,"lon":-79.623105,"t":1780936464},{"alt_m":1093.5,"lat":43.465558,"lon":-79.622338,"t":1780936465},{"alt_m":1104.1,"lat":43.465577,"lon":-79.621571,"t":1780936466},{"alt_m":1096.5,"lat":43.465597,"lon":-79.620804,"t":1780936467},{"alt_m":1097.4,"lat":43.465616,"lon":-79.620037,"t":1780936468},{"alt_m":1100.6,"lat":43.465636,"lon":-79.61927,"t":1780936469},{"alt_m":1102.3,"lat":43.465655,"lon":-79.618503,"t":1780936470},{"alt_m":1090.8,"lat":43.465675,"lon":-79.617736,"t":1780936471},{"alt_m":1091.1,"lat":43.465694,"lon":-79.616969,"t":1780936472},{"alt_m":1109.0,"lat":43.465714,"lon":-79.616202,"t":1780936473},{"alt_m":1106.5,"lat":43.465733,"lon":-79.615435,"t":1780936474},{"alt_m":1101.4,"lat":43.465753,"lon":-79.614668,"t":1780936475},{"alt_m":1099.3,"lat":43.465772,"lon":-79.613902,"t":1780936476},{"alt_m":1092.5,"lat":43.465791,"lon":-79.613135,"t":1780936477},{"alt_m":1095.7,"lat":43.465811,"lon":-79.612368,"t":1780936478},{"alt_m":1109.8,"lat":43.46583,"lon":-79.611601,"t":1780936479},{"alt_m":1090.5,"lat":43.46585,"lon":-79.610834,"t":1780936480},{"alt_m":1095.7,"lat":43.465869,"lon":-79.610067,"t":1780936481},{"alt_m":1104.2,"lat":43.465889,"lon":-79.6093,"t":1780936482},{"alt_m":1100.5,"lat":43.465908,"lon":-79.608533,"t":1780936483},{"alt_m":1091.6,"lat":43.465928,"lon":-79.607766,"t":1780936484},{"alt_m":1103.1,"lat":43.465947,"lon":-79.606999,"t":1780936485},{"alt_m":1104.0,"lat":43.465967,"lon":-79.606232,"t":1780936486},{"alt_m":1094.0,"lat":43.465986,"lon":-79.605465,"t":1780936487},{"alt_m":1097.4,"lat":43.466006,"lon":-79.604698,"t":1780936488},{"alt_m":1104.4,"lat":43.466025,"lon":-79.603931,"t":1780936489},{"alt_m":1092.3,"lat":43.466045,"lon":-79.603164,"t":1780936490},{"alt_m":1090.3,"lat":43.466064,"lon":-79.602398,"t":1780936491},{"alt_m":1108.5,"lat":43.466084,"lon":-79.601631,"t":1780936492},{"alt_m":1091.7,"lat":43.466103,"lon":-79.600864,"t":1780936493},{"alt_m":1093.6,"lat":43.466122,"lon":-79.600097,"t":1780936494},{"alt_m":1109.0,"lat":43.466142,"lon":-79.59933,"t":1780936495},{"alt_m":1097.7,"lat":43.466161,"lon":-79.598563,"t":1780936496},{"alt_m":1108.8,"lat":43.466181,"lon":-79.597796,"t":1780936497},{"alt_m":1090.1,"lat":43.4662,"lon":-79.597029,"t":1780936498},{"alt_m":1092.2,"lat":43.46622,"lon":-79.596262,"t":1780936499},{"alt_m":1098.5,"lat":43.466239,"lon":-79.595495,"t":1780936500},{"alt_m":1090.0,"lat":43.466259,"lon":-79.594728,"t":1780936501},{"alt_m":1094.5,"lat":43.466278,"lon":-79.593961,"t":1780936502},{"alt_m":1105.5,"lat":43.466298,"lon":-79.593194,"t":1780936503},{"alt_m":1104.0,"lat":43.466317,"lon":-79.592427,"t":1780936504},{"alt_m":1100.2,"lat":43.466337,"lon":-79.59166,"t":1780936505},{"alt_m":1105.4,"lat":43.466356,"lon":-79.590894,"t":1780936506},{"alt_m":1101.7,"lat":43.466376,"lon":-79.590127,"t":1780936507},{"alt_m":1108.4,"lat":43.466395,"lon":-79.58936,"t":1780936508},{"alt_m":1098.4,"lat":43.466415,"lon":-79.588593,"t":1780936509},{"alt_m":1090.7,"lat":43.466434,"lon":-79.587826,"t":1780936510},{"alt_m":1108.3,"lat":43.466453,"lon":-79.587059,"t":1780936511},{"alt_m":1095.1,"lat":43.466473,"lon":-79.586292,"t":1780936512},{"alt_m":1095.2,"lat":43.466492,"lon":-79.585525,"t":1780936513},{"alt_m":1107.8,"lat":43.466512,"lon":-79.584758,"t":1780936514},{"alt_m":1098.3,"lat":43.466531,"lon":-79.583991,"t":1780936515},{"alt_m":1096.3,"lat":43.466551,"lon":-79.583224,"t":1780936516},{"alt_m":1102.6,"lat":43.46657,"lon":-79.582457,"t":1780936517},{"alt_m":1103.5,"lat":43.46659,"lon":-79.58169,"t":1780936518},{"alt_m":1094.2,"lat":43.466609,"lon":-79.580923,"t":1780936519},{"alt_m":1090.5,"lat":43.466629,"lon":-79.580156,"t":1780936520},{"alt_m":1103.8,"lat":43.466648,"lon":-79.57939,"t":1780936521},{"alt_m":1096.6,"lat":43.466668,"lon":-79.578623,"t":1780936522},{"alt_m":1094.0,"lat":43.466687,"lon":-79.577856,"t":1780936523},{"alt_m":1104.7,"lat":43.466707,"lon":-79.577089,"t":1780936524},{"alt_m":1106.7,"lat":43.466726,"lon":-79.576322,"t":1780936525},{"alt_m":1106.1,"lat":43.466746,"lon":-79.575555,"t":1780936526},{"alt_m":1109.1,"lat":43.466765,"lon":-79.574788,"t":1780936527},{"alt_m":1100.5,"lat":43.466784,"lon":-79.574021,"t":1780936528},{"alt_m":1103.4,"lat":43.466804,"lon":-79.573254,"t":1780936529},{"alt_m":1105.0,"lat":43.466823,"lon":-79.572487,"t":1780936530},{"alt_m":1099.3,"lat":43.466843,"lon":-79.57172,"t":1780936531},{"alt_m":1102.6,"lat":43.466862,"lon":-79.570953,"t":1780936532},{"alt_m":1102.2,"lat":43.466882,"lon":-79.570186,"t":1780936533},{"alt_m":1096.3,"lat":43.466901,"lon":-79.569419,"t":1780936534},{"alt_m":1105.9,"lat":43.466921,"lon":-79.568652,"t":1780936535},{"alt_m":1096.8,"lat":43.46694,"lon":-79.567886,"t":1780936536},{"alt_m":1101.9,"lat":43.46696,"lon":-79.567119,"t":1780936537},{"alt_m":1100.9,"lat":43.466979,"lon":-79.566352,"t":1780936538},{"alt_m":1096.2,"lat":43.466999,"lon":-79.565585,"t":1780936539},{"alt_m":1096.3,"lat":43.467018,"lon":-79.564818,"t":1780936540},{"alt_m":1106.1,"lat":43.467038,"lon":-79.564051,"t":1780936541},{"alt_m":1090.3,"lat":43.467057,"lon":-79.563284,"t":1780936542},{"alt_m":1097.2,"lat":43.467077,"lon":-79.562517,"t":1780936543},{"alt_m":1109.5,"lat":43.467096,"lon":-79.56175,"t":1780936544},{"alt_m":1104.5,"lat":43.467115,"lon":-79.560983,"t":1780936545},{"alt_m":1105.1,"lat":43.467135,"lon":-79.560216,"t":1780936546},{"alt_m":1100.2,"lat":43.467154,"lon":-79.559449,"t":1780936547},{"alt_m":1097.3,"lat":43.467174,"lon":-79.558682,"t":1780936548},{"alt_m":1103.6,"lat":43.467193,"lon":-79.557915,"t":1780936549},{"alt_m":1099.3,"lat":43.467213,"lon":-79.557148,"t":1780936550},{"alt_m":1100.4,"lat":43.467232,"lon":-79.556381,"t":1780936551},{"alt_m":1107.5,"lat":43.467252,"lon":-79.555615,"t":1780936552},{"alt_m":1096.7,"lat":43.467271,"lon":-79.554848,"t":1780936553},{"alt_m":1099.5,"lat":43.467291,"lon":-79.554081,"t":1780936554},{"alt_m":1104.4,"lat":43.46731,"lon":-79.553314,"t":1780936555},{"alt_m":1099.3,"lat":43.46733,"lon":-79.552547,"t":1780936556},{"alt_m":1097.2,"lat":43.467349,"lon":-79.55178,"t":1780936557},{"alt_m":1103.6,"lat":43.467369,"lon":-79.551013,"t":1780936558},{"alt_m":1109.7,"lat":43.467388,"lon":-79.550246,"t":1780936559}]},{"anomaly":{"band":"normal","reasons":["vector novelty 0.84: no similar track in RuVector memory"],"score":0.226},"callsign":"AFR606","icao24":"39a006","overhead":false,"points":[{"alt_m":11000.1,"lat":43.607912,"lon":-79.392902,"t":1780938300},{"alt_m":11004.7,"lat":43.607239,"lon":-79.395594,"t":1780938301},{"alt_m":11004.8,"lat":43.606565,"lon":-79.398286,"t":1780938302},{"alt_m":11008.4,"lat":43.605891,"lon":-79.400977,"t":1780938303},{"alt_m":11004.2,"lat":43.605217,"lon":-79.403669,"t":1780938304},{"alt_m":11007.5,"lat":43.604543,"lon":-79.406361,"t":1780938305},{"alt_m":11000.3,"lat":43.60387,"lon":-79.409053,"t":1780938306},{"alt_m":10997.2,"lat":43.603196,"lon":-79.411744,"t":1780938307},{"alt_m":11009.1,"lat":43.602522,"lon":-79.414436,"t":1780938308},{"alt_m":10993.6,"lat":43.601848,"lon":-79.417128,"t":1780938309},{"alt_m":10998.9,"lat":43.601174,"lon":-79.419819,"t":1780938310},{"alt_m":11006.3,"lat":43.600501,"lon":-79.422511,"t":1780938311},{"alt_m":10996.5,"lat":43.599827,"lon":-79.425203,"t":1780938312},{"alt_m":10990.7,"lat":43.599153,"lon":-79.427895,"t":1780938313},{"alt_m":11005.7,"lat":43.598479,"lon":-79.430586,"t":1780938314},{"alt_m":10991.4,"lat":43.597805,"lon":-79.433278,"t":1780938315},{"alt_m":10993.2,"lat":43.597132,"lon":-79.43597,"t":1780938316},{"alt_m":10997.7,"lat":43.596458,"lon":-79.438661,"t":1780938317},{"alt_m":10990.3,"lat":43.595784,"lon":-79.441353,"t":1780938318},{"alt_m":11000.2,"lat":43.59511,"lon":-79.444045,"t":1780938319},{"alt_m":10993.2,"lat":43.594436,"lon":-79.446737,"t":1780938320},{"alt_m":10994.8,"lat":43.593763,"lon":-79.449428,"t":1780938321},{"alt_m":11001.1,"lat":43.593089,"lon":-79.45212,"t":1780938322},{"alt_m":10996.2,"lat":43.592415,"lon":-79.454812,"t":1780938323},{"alt_m":11004.4,"lat":43.591741,"lon":-79.457503,"t":1780938324},{"alt_m":10996.3,"lat":43.591067,"lon":-79.460195,"t":1780938325},{"alt_m":10992.1,"lat":43.590394,"lon":-79.462887,"t":1780938326},{"alt_m":10996.4,"lat":43.58972,"lon":-79.465579,"t":1780938327},{"alt_m":11006.2,"lat":43.589046,"lon":-79.46827,"t":1780938328},{"alt_m":11005.7,"lat":43.588372,"lon":-79.470962,"t":1780938329},{"alt_m":11009.8,"lat":43.587698,"lon":-79.473654,"t":1780938330},{"alt_m":10990.3,"lat":43.587025,"lon":-79.476345,"t":1780938331},{"alt_m":10991.8,"lat":43.586351,"lon":-79.479037,"t":1780938332},{"alt_m":10999.6,"lat":43.585677,"lon":-79.481729,"t":1780938333},{"alt_m":10995.0,"lat":43.585003,"lon":-79.484421,"t":1780938334},{"alt_m":11003.0,"lat":43.584329,"lon":-79.487112,"t":1780938335},{"alt_m":11007.5,"lat":43.583656,"lon":-79.489804,"t":1780938336},{"alt_m":10994.1,"lat":43.582982,"lon":-79.492496,"t":1780938337},{"alt_m":11005.7,"lat":43.582308,"lon":-79.495187,"t":1780938338},{"alt_m":11006.6,"lat":43.581634,"lon":-79.497879,"t":1780938339},{"alt_m":11006.5,"lat":43.58096,"lon":-79.500571,"t":1780938340},{"alt_m":10992.6,"lat":43.580287,"lon":-79.503263,"t":1780938341},{"alt_m":10990.9,"lat":43.579613,"lon":-79.505954,"t":1780938342},{"alt_m":10990.2,"lat":43.578939,"lon":-79.508646,"t":1780938343},{"alt_m":11005.9,"lat":43.578265,"lon":-79.511338,"t":1780938344},{"alt_m":11003.9,"lat":43.577591,"lon":-79.514029,"t":1780938345},{"alt_m":10995.6,"lat":43.576918,"lon":-79.516721,"t":1780938346},{"alt_m":11005.7,"lat":43.576244,"lon":-79.519413,"t":1780938347},{"alt_m":11008.5,"lat":43.57557,"lon":-79.522105,"t":1780938348},{"alt_m":10991.3,"lat":43.574896,"lon":-79.524796,"t":1780938349},{"alt_m":11009.8,"lat":43.574222,"lon":-79.527488,"t":1780938350},{"alt_m":10994.2,"lat":43.573549,"lon":-79.53018,"t":1780938351},{"alt_m":11003.2,"lat":43.572875,"lon":-79.532871,"t":1780938352},{"alt_m":11003.2,"lat":43.572201,"lon":-79.535563,"t":1780938353},{"alt_m":10993.4,"lat":43.571527,"lon":-79.538255,"t":1780938354},{"alt_m":10993.2,"lat":43.570853,"lon":-79.540947,"t":1780938355},{"alt_m":11009.9,"lat":43.57018,"lon":-79.543638,"t":1780938356},{"alt_m":11001.2,"lat":43.569506,"lon":-79.54633,"t":1780938357},{"alt_m":10996.2,"lat":43.568832,"lon":-79.549022,"t":1780938358},{"alt_m":10994.5,"lat":43.568158,"lon":-79.551713,"t":1780938359},{"alt_m":10990.9,"lat":43.567484,"lon":-79.554405,"t":1780938360},{"alt_m":10993.2,"lat":43.566811,"lon":-79.557097,"t":1780938361},{"alt_m":11006.2,"lat":43.566137,"lon":-79.559789,"t":1780938362},{"alt_m":10992.5,"lat":43.565463,"lon":-79.56248,"t":1780938363},{"alt_m":11003.4,"lat":43.564789,"lon":-79.565172,"t":1780938364},{"alt_m":11008.2,"lat":43.564115,"lon":-79.567864,"t":1780938365},{"alt_m":11000.7,"lat":43.563442,"lon":-79.570555,"t":1780938366},{"alt_m":10996.4,"lat":43.562768,"lon":-79.573247,"t":1780938367},{"alt_m":11003.1,"lat":43.562094,"lon":-79.575939,"t":1780938368},{"alt_m":11008.5,"lat":43.56142,"lon":-79.578631,"t":1780938369},{"alt_m":11005.8,"lat":43.560746,"lon":-79.581322,"t":1780938370},{"alt_m":10999.2,"lat":43.560073,"lon":-79.584014,"t":1780938371},{"alt_m":11009.0,"lat":43.559399,"lon":-79.586706,"t":1780938372},{"alt_m":10998.7,"lat":43.558725,"lon":-79.589397,"t":1780938373},{"alt_m":11005.0,"lat":43.558051,"lon":-79.592089,"t":1780938374},{"alt_m":10991.9,"lat":43.557377,"lon":-79.594781,"t":1780938375},{"alt_m":10996.7,"lat":43.556704,"lon":-79.597473,"t":1780938376},{"alt_m":11007.3,"lat":43.55603,"lon":-79.600164,"t":1780938377},{"alt_m":10997.5,"lat":43.555356,"lon":-79.602856,"t":1780938378},{"alt_m":10998.6,"lat":43.554682,"lon":-79.605548,"t":1780938379},{"alt_m":11001.7,"lat":43.554008,"lon":-79.608239,"t":1780938380},{"alt_m":11004.8,"lat":43.553335,"lon":-79.610931,"t":1780938381},{"alt_m":11001.5,"lat":43.552661,"lon":-79.613623,"t":1780938382},{"alt_m":10991.1,"lat":43.551987,"lon":-79.616315,"t":1780938383},{"alt_m":10999.1,"lat":43.551313,"lon":-79.619006,"t":1780938384},{"alt_m":11006.6,"lat":43.550639,"lon":-79.621698,"t":1780938385},{"alt_m":10993.2,"lat":43.549966,"lon":-79.62439,"t":1780938386},{"alt_m":11008.1,"lat":43.549292,"lon":-79.627081,"t":1780938387},{"alt_m":10996.2,"lat":43.548618,"lon":-79.629773,"t":1780938388},{"alt_m":11003.9,"lat":43.547944,"lon":-79.632465,"t":1780938389},{"alt_m":11005.5,"lat":43.54727,"lon":-79.635157,"t":1780938390},{"alt_m":11007.5,"lat":43.546597,"lon":-79.637848,"t":1780938391},{"alt_m":10997.6,"lat":43.545923,"lon":-79.64054,"t":1780938392},{"alt_m":11002.1,"lat":43.545249,"lon":-79.643232,"t":1780938393},{"alt_m":11002.6,"lat":43.544575,"lon":-79.645923,"t":1780938394},{"alt_m":11009.9,"lat":43.543901,"lon":-79.648615,"t":1780938395},{"alt_m":11002.5,"lat":43.543228,"lon":-79.651307,"t":1780938396},{"alt_m":10999.0,"lat":43.542554,"lon":-79.653998,"t":1780938397},{"alt_m":11004.5,"lat":43.54188,"lon":-79.65669,"t":1780938398},{"alt_m":11004.1,"lat":43.541206,"lon":-79.659382,"t":1780938399},{"alt_m":11007.4,"lat":43.540532,"lon":-79.662074,"t":1780938400},{"alt_m":11005.6,"lat":43.539859,"lon":-79.664765,"t":1780938401},{"alt_m":10990.1,"lat":43.539185,"lon":-79.667457,"t":1780938402},{"alt_m":11003.0,"lat":43.538511,"lon":-79.670149,"t":1780938403},{"alt_m":11006.2,"lat":43.537837,"lon":-79.67284,"t":1780938404},{"alt_m":11007.2,"lat":43.537163,"lon":-79.675532,"t":1780938405},{"alt_m":10996.2,"lat":43.53649,"lon":-79.678224,"t":1780938406},{"alt_m":10991.2,"lat":43.535816,"lon":-79.680916,"t":1780938407},{"alt_m":10992.3,"lat":43.535142,"lon":-79.683607,"t":1780938408},{"alt_m":11005.1,"lat":43.534468,"lon":-79.686299,"t":1780938409},{"alt_m":11007.6,"lat":43.533794,"lon":-79.688991,"t":1780938410},{"alt_m":10994.2,"lat":43.533121,"lon":-79.691682,"t":1780938411},{"alt_m":11006.0,"lat":43.532447,"lon":-79.694374,"t":1780938412},{"alt_m":10996.5,"lat":43.531773,"lon":-79.697066,"t":1780938413},{"alt_m":10997.5,"lat":43.531099,"lon":-79.699758,"t":1780938414},{"alt_m":10993.0,"lat":43.530425,"lon":-79.702449,"t":1780938415},{"alt_m":11004.9,"lat":43.529752,"lon":-79.705141,"t":1780938416},{"alt_m":10999.7,"lat":43.529078,"lon":-79.707833,"t":1780938417},{"alt_m":10998.0,"lat":43.528404,"lon":-79.710524,"t":1780938418},{"alt_m":11005.8,"lat":43.52773,"lon":-79.713216,"t":1780938419},{"alt_m":11001.3,"lat":43.527056,"lon":-79.715908,"t":1780938420},{"alt_m":11006.1,"lat":43.526383,"lon":-79.7186,"t":1780938421},{"alt_m":11003.8,"lat":43.525709,"lon":-79.721291,"t":1780938422},{"alt_m":11000.5,"lat":43.525035,"lon":-79.723983,"t":1780938423},{"alt_m":11009.3,"lat":43.524361,"lon":-79.726675,"t":1780938424},{"alt_m":10990.9,"lat":43.523687,"lon":-79.729366,"t":1780938425},{"alt_m":11004.2,"lat":43.523014,"lon":-79.732058,"t":1780938426},{"alt_m":10990.1,"lat":43.52234,"lon":-79.73475,"t":1780938427},{"alt_m":10996.8,"lat":43.521666,"lon":-79.737442,"t":1780938428},{"alt_m":10999.3,"lat":43.520992,"lon":-79.740133,"t":1780938429},{"alt_m":11002.5,"lat":43.520318,"lon":-79.742825,"t":1780938430},{"alt_m":10998.6,"lat":43.519645,"lon":-79.745517,"t":1780938431},{"alt_m":11002.7,"lat":43.518971,"lon":-79.748208,"t":1780938432},{"alt_m":10991.0,"lat":43.518297,"lon":-79.7509,"t":1780938433},{"alt_m":10994.2,"lat":43.517623,"lon":-79.753592,"t":1780938434},{"alt_m":10991.4,"lat":43.516949,"lon":-79.756284,"t":1780938435},{"alt_m":11001.4,"lat":43.516276,"lon":-79.758975,"t":1780938436},{"alt_m":10996.0,"lat":43.515602,"lon":-79.761667,"t":1780938437},{"alt_m":10997.3,"lat":43.514928,"lon":-79.764359,"t":1780938438},{"alt_m":10993.7,"lat":43.514254,"lon":-79.76705,"t":1780938439},{"alt_m":11001.3,"lat":43.51358,"lon":-79.769742,"t":1780938440},{"alt_m":10998.6,"lat":43.512907,"lon":-79.772434,"t":1780938441},{"alt_m":10991.5,"lat":43.512233,"lon":-79.775126,"t":1780938442},{"alt_m":11007.8,"lat":43.511559,"lon":-79.777817,"t":1780938443},{"alt_m":10993.1,"lat":43.510885,"lon":-79.780509,"t":1780938444},{"alt_m":10993.0,"lat":43.510211,"lon":-79.783201,"t":1780938445},{"alt_m":10992.6,"lat":43.509538,"lon":-79.785892,"t":1780938446},{"alt_m":11006.0,"lat":43.508864,"lon":-79.788584,"t":1780938447},{"alt_m":11006.3,"lat":43.50819,"lon":-79.791276,"t":1780938448},{"alt_m":10990.4,"lat":43.507516,"lon":-79.793968,"t":1780938449},{"alt_m":11000.8,"lat":43.506842,"lon":-79.796659,"t":1780938450},{"alt_m":11004.0,"lat":43.506169,"lon":-79.799351,"t":1780938451},{"alt_m":10993.7,"lat":43.505495,"lon":-79.802043,"t":1780938452},{"alt_m":11001.8,"lat":43.504821,"lon":-79.804734,"t":1780938453},{"alt_m":11003.7,"lat":43.504147,"lon":-79.807426,"t":1780938454},{"alt_m":11002.4,"lat":43.503473,"lon":-79.810118,"t":1780938455},{"alt_m":11000.1,"lat":43.5028,"lon":-79.81281,"t":1780938456},{"alt_m":10991.9,"lat":43.502126,"lon":-79.815501,"t":1780938457},{"alt_m":10992.1,"lat":43.501452,"lon":-79.818193,"t":1780938458},{"alt_m":10993.3,"lat":43.500778,"lon":-79.820885,"t":1780938459},{"alt_m":10992.9,"lat":43.500104,"lon":-79.823576,"t":1780938460},{"alt_m":10996.5,"lat":43.499431,"lon":-79.826268,"t":1780938461},{"alt_m":10999.9,"lat":43.498757,"lon":-79.82896,"t":1780938462},{"alt_m":11003.1,"lat":43.498083,"lon":-79.831652,"t":1780938463},{"alt_m":10992.3,"lat":43.497409,"lon":-79.834343,"t":1780938464},{"alt_m":11003.2,"lat":43.496736,"lon":-79.837035,"t":1780938465},{"alt_m":11008.0,"lat":43.496062,"lon":-79.839727,"t":1780938466},{"alt_m":10999.7,"lat":43.495388,"lon":-79.842418,"t":1780938467},{"alt_m":10990.2,"lat":43.494714,"lon":-79.84511,"t":1780938468},{"alt_m":11002.1,"lat":43.49404,"lon":-79.847802,"t":1780938469},{"alt_m":11000.9,"lat":43.493367,"lon":-79.850494,"t":1780938470},{"alt_m":11005.2,"lat":43.492693,"lon":-79.853185,"t":1780938471},{"alt_m":10992.6,"lat":43.492019,"lon":-79.855877,"t":1780938472},{"alt_m":10995.1,"lat":43.491345,"lon":-79.858569,"t":1780938473},{"alt_m":10999.3,"lat":43.490671,"lon":-79.86126,"t":1780938474},{"alt_m":10993.6,"lat":43.489998,"lon":-79.863952,"t":1780938475},{"alt_m":11002.8,"lat":43.489324,"lon":-79.866644,"t":1780938476},{"alt_m":11006.2,"lat":43.48865,"lon":-79.869336,"t":1780938477},{"alt_m":11009.2,"lat":43.487976,"lon":-79.872027,"t":1780938478},{"alt_m":11007.6,"lat":43.487302,"lon":-79.874719,"t":1780938479},{"alt_m":11001.4,"lat":43.486629,"lon":-79.877411,"t":1780938480},{"alt_m":11000.1,"lat":43.485955,"lon":-79.880102,"t":1780938481},{"alt_m":11000.8,"lat":43.485281,"lon":-79.882794,"t":1780938482},{"alt_m":10993.5,"lat":43.484607,"lon":-79.885486,"t":1780938483},{"alt_m":11000.9,"lat":43.483933,"lon":-79.888178,"t":1780938484},{"alt_m":11006.2,"lat":43.48326,"lon":-79.890869,"t":1780938485},{"alt_m":10994.4,"lat":43.482586,"lon":-79.893561,"t":1780938486},{"alt_m":10997.4,"lat":43.481912,"lon":-79.896253,"t":1780938487},{"alt_m":11006.3,"lat":43.481238,"lon":-79.898944,"t":1780938488},{"alt_m":11001.6,"lat":43.480564,"lon":-79.901636,"t":1780938489},{"alt_m":10994.7,"lat":43.479891,"lon":-79.904328,"t":1780938490},{"alt_m":11000.8,"lat":43.479217,"lon":-79.90702,"t":1780938491},{"alt_m":11002.3,"lat":43.478543,"lon":-79.909711,"t":1780938492},{"alt_m":10992.1,"lat":43.477869,"lon":-79.912403,"t":1780938493},{"alt_m":11003.3,"lat":43.477195,"lon":-79.915095,"t":1780938494},{"alt_m":10990.3,"lat":43.476522,"lon":-79.917786,"t":1780938495},{"alt_m":10997.3,"lat":43.475848,"lon":-79.920478,"t":1780938496},{"alt_m":10997.7,"lat":43.475174,"lon":-79.92317,"t":1780938497},{"alt_m":11002.4,"lat":43.4745,"lon":-79.925862,"t":1780938498},{"alt_m":11007.7,"lat":43.473826,"lon":-79.928553,"t":1780938499},{"alt_m":11000.0,"lat":43.473153,"lon":-79.931245,"t":1780938500},{"alt_m":10995.4,"lat":43.472479,"lon":-79.933937,"t":1780938501},{"alt_m":10991.9,"lat":43.471805,"lon":-79.936628,"t":1780938502},{"alt_m":10995.7,"lat":43.471131,"lon":-79.93932,"t":1780938503},{"alt_m":11006.2,"lat":43.470457,"lon":-79.942012,"t":1780938504},{"alt_m":11005.6,"lat":43.469784,"lon":-79.944704,"t":1780938505},{"alt_m":10993.8,"lat":43.46911,"lon":-79.947395,"t":1780938506},{"alt_m":11009.4,"lat":43.468436,"lon":-79.950087,"t":1780938507},{"alt_m":11009.7,"lat":43.467762,"lon":-79.952779,"t":1780938508},{"alt_m":11002.8,"lat":43.467088,"lon":-79.95547,"t":1780938509},{"alt_m":10995.6,"lat":43.466415,"lon":-79.958162,"t":1780938510},{"alt_m":11003.6,"lat":43.465741,"lon":-79.960854,"t":1780938511},{"alt_m":11000.3,"lat":43.465067,"lon":-79.963546,"t":1780938512},{"alt_m":10995.0,"lat":43.464393,"lon":-79.966237,"t":1780938513},{"alt_m":10993.6,"lat":43.463719,"lon":-79.968929,"t":1780938514},{"alt_m":10993.2,"lat":43.463046,"lon":-79.971621,"t":1780938515},{"alt_m":11009.8,"lat":43.462372,"lon":-79.974312,"t":1780938516},{"alt_m":10995.1,"lat":43.461698,"lon":-79.977004,"t":1780938517},{"alt_m":11008.3,"lat":43.461024,"lon":-79.979696,"t":1780938518},{"alt_m":10997.3,"lat":43.46035,"lon":-79.982388,"t":1780938519},{"alt_m":11001.1,"lat":43.459677,"lon":-79.985079,"t":1780938520},{"alt_m":11001.3,"lat":43.459003,"lon":-79.987771,"t":1780938521},{"alt_m":10997.2,"lat":43.458329,"lon":-79.990463,"t":1780938522},{"alt_m":10997.7,"lat":43.457655,"lon":-79.993154,"t":1780938523},{"alt_m":11005.3,"lat":43.456981,"lon":-79.995846,"t":1780938524},{"alt_m":11006.7,"lat":43.456308,"lon":-79.998538,"t":1780938525},{"alt_m":10998.3,"lat":43.455634,"lon":-80.00123,"t":1780938526},{"alt_m":10996.0,"lat":43.45496,"lon":-80.003921,"t":1780938527},{"alt_m":11005.3,"lat":43.454286,"lon":-80.006613,"t":1780938528},{"alt_m":10993.9,"lat":43.453612,"lon":-80.009305,"t":1780938529},{"alt_m":10994.4,"lat":43.452939,"lon":-80.011996,"t":1780938530},{"alt_m":11005.0,"lat":43.452265,"lon":-80.014688,"t":1780938531},{"alt_m":11009.0,"lat":43.451591,"lon":-80.01738,"t":1780938532},{"alt_m":10995.0,"lat":43.450917,"lon":-80.020072,"t":1780938533},{"alt_m":11007.2,"lat":43.450243,"lon":-80.022763,"t":1780938534},{"alt_m":10997.0,"lat":43.44957,"lon":-80.025455,"t":1780938535},{"alt_m":11007.8,"lat":43.448896,"lon":-80.028147,"t":1780938536},{"alt_m":11002.7,"lat":43.448222,"lon":-80.030838,"t":1780938537},{"alt_m":10998.1,"lat":43.447548,"lon":-80.03353,"t":1780938538},{"alt_m":10992.3,"lat":43.446874,"lon":-80.036222,"t":1780938539}]},{"anomaly":{"band":"normal","reasons":["within normal envelope: heading 73°, altitude 10500 m, score 0.17"],"score":0.165},"callsign":"WJA404","icao24":"c04d04","overhead":false,"points":[{"alt_m":10495.5,"lat":43.341995,"lon":-79.998359,"t":1780943400},{"alt_m":10499.9,"lat":43.342611,"lon":-79.995589,"t":1780943401},{"alt_m":10505.0,"lat":43.343226,"lon":-79.99282,"t":1780943402},{"alt_m":10492.3,"lat":43.343842,"lon":-79.99005,"t":1780943403},{"alt_m":10508.6,"lat":43.344457,"lon":-79.98728,"t":1780943404},{"alt_m":10509.9,"lat":43.345073,"lon":-79.98451,"t":1780943405},{"alt_m":10491.6,"lat":43.345689,"lon":-79.98174,"t":1780943406},{"alt_m":10508.3,"lat":43.346304,"lon":-79.978971,"t":1780943407},{"alt_m":10493.6,"lat":43.34692,"lon":-79.976201,"t":1780943408},{"alt_m":10505.3,"lat":43.347536,"lon":-79.973431,"t":1780943409},{"alt_m":10507.5,"lat":43.348151,"lon":-79.970661,"t":1780943410},{"alt_m":10499.4,"lat":43.348767,"lon":-79.967892,"t":1780943411},{"alt_m":10497.1,"lat":43.349382,"lon":-79.965122,"t":1780943412},{"alt_m":10496.2,"lat":43.349998,"lon":-79.962352,"t":1780943413},{"alt_m":10495.9,"lat":43.350614,"lon":-79.959582,"t":1780943414},{"alt_m":10501.3,"lat":43.351229,"lon":-79.956813,"t":1780943415},{"alt_m":10494.8,"lat":43.351845,"lon":-79.954043,"t":1780943416},{"alt_m":10490.9,"lat":43.35246,"lon":-79.951273,"t":1780943417},{"alt_m":10493.6,"lat":43.353076,"lon":-79.948503,"t":1780943418},{"alt_m":10493.0,"lat":43.353692,"lon":-79.945734,"t":1780943419},{"alt_m":10503.5,"lat":43.354307,"lon":-79.942964,"t":1780943420},{"alt_m":10493.6,"lat":43.354923,"lon":-79.940194,"t":1780943421},{"alt_m":10502.9,"lat":43.355539,"lon":-79.937424,"t":1780943422},{"alt_m":10504.7,"lat":43.356154,"lon":-79.934654,"t":1780943423},{"alt_m":10500.7,"lat":43.35677,"lon":-79.931885,"t":1780943424},{"alt_m":10503.6,"lat":43.357385,"lon":-79.929115,"t":1780943425},{"alt_m":10503.5,"lat":43.358001,"lon":-79.926345,"t":1780943426},{"alt_m":10492.2,"lat":43.358617,"lon":-79.923575,"t":1780943427},{"alt_m":10496.1,"lat":43.359232,"lon":-79.920806,"t":1780943428},{"alt_m":10501.3,"lat":43.359848,"lon":-79.918036,"t":1780943429},{"alt_m":10499.7,"lat":43.360464,"lon":-79.915266,"t":1780943430},{"alt_m":10495.6,"lat":43.361079,"lon":-79.912496,"t":1780943431},{"alt_m":10495.8,"lat":43.361695,"lon":-79.909727,"t":1780943432},{"alt_m":10500.1,"lat":43.36231,"lon":-79.906957,"t":1780943433},{"alt_m":10501.4,"lat":43.362926,"lon":-79.904187,"t":1780943434},{"alt_m":10506.0,"lat":43.363542,"lon":-79.901417,"t":1780943435},{"alt_m":10504.1,"lat":43.364157,"lon":-79.898647,"t":1780943436},{"alt_m":10496.5,"lat":43.364773,"lon":-79.895878,"t":1780943437},{"alt_m":10499.4,"lat":43.365388,"lon":-79.893108,"t":1780943438},{"alt_m":10491.6,"lat":43.366004,"lon":-79.890338,"t":1780943439},{"alt_m":10503.2,"lat":43.36662,"lon":-79.887568,"t":1780943440},{"alt_m":10500.5,"lat":43.367235,"lon":-79.884799,"t":1780943441},{"alt_m":10494.8,"lat":43.367851,"lon":-79.882029,"t":1780943442},{"alt_m":10502.5,"lat":43.368467,"lon":-79.879259,"t":1780943443},{"alt_m":10506.3,"lat":43.369082,"lon":-79.876489,"t":1780943444},{"alt_m":10493.6,"lat":43.369698,"lon":-79.87372,"t":1780943445},{"alt_m":10496.7,"lat":43.370313,"lon":-79.87095,"t":1780943446},{"alt_m":10499.7,"lat":43.370929,"lon":-79.86818,"t":1780943447},{"alt_m":10495.8,"lat":43.371545,"lon":-79.86541,"t":1780943448},{"alt_m":10496.7,"lat":43.37216,"lon":-79.862641,"t":1780943449},{"alt_m":10494.9,"lat":43.372776,"lon":-79.859871,"t":1780943450},{"alt_m":10491.4,"lat":43.373392,"lon":-79.857101,"t":1780943451},{"alt_m":10493.3,"lat":43.374007,"lon":-79.854331,"t":1780943452},{"alt_m":10494.9,"lat":43.374623,"lon":-79.851561,"t":1780943453},{"alt_m":10503.6,"lat":43.375238,"lon":-79.848792,"t":1780943454},{"alt_m":10493.1,"lat":43.375854,"lon":-79.846022,"t":1780943455},{"alt_m":10493.7,"lat":43.37647,"lon":-79.843252,"t":1780943456},{"alt_m":10506.6,"lat":43.377085,"lon":-79.840482,"t":1780943457},{"alt_m":10498.8,"lat":43.377701,"lon":-79.837713,"t":1780943458},{"alt_m":10502.5,"lat":43.378316,"lon":-79.834943,"t":1780943459},{"alt_m":10492.6,"lat":43.378932,"lon":-79.832173,"t":1780943460},{"alt_m":10507.3,"lat":43.379548,"lon":-79.829403,"t":1780943461},{"alt_m":10507.8,"lat":43.380163,"lon":-79.826634,"t":1780943462},{"alt_m":10500.0,"lat":43.380779,"lon":-79.823864,"t":1780943463},{"alt_m":10506.9,"lat":43.381395,"lon":-79.821094,"t":1780943464},{"alt_m":10508.8,"lat":43.38201,"lon":-79.818324,"t":1780943465},{"alt_m":10494.3,"lat":43.382626,"lon":-79.815554,"t":1780943466},{"alt_m":10500.5,"lat":43.383241,"lon":-79.812785,"t":1780943467},{"alt_m":10497.6,"lat":43.383857,"lon":-79.810015,"t":1780943468},{"alt_m":10495.5,"lat":43.384473,"lon":-79.807245,"t":1780943469},{"alt_m":10491.2,"lat":43.385088,"lon":-79.804475,"t":1780943470},{"alt_m":10495.4,"lat":43.385704,"lon":-79.801706,"t":1780943471},{"alt_m":10507.2,"lat":43.38632,"lon":-79.798936,"t":1780943472},{"alt_m":10495.2,"lat":43.386935,"lon":-79.796166,"t":1780943473},{"alt_m":10509.5,"lat":43.387551,"lon":-79.793396,"t":1780943474},{"alt_m":10507.3,"lat":43.388166,"lon":-79.790627,"t":1780943475},{"alt_m":10509.0,"lat":43.388782,"lon":-79.787857,"t":1780943476},{"alt_m":10505.6,"lat":43.389398,"lon":-79.785087,"t":1780943477},{"alt_m":10505.1,"lat":43.390013,"lon":-79.782317,"t":1780943478},{"alt_m":10506.4,"lat":43.390629,"lon":-79.779548,"t":1780943479},{"alt_m":10495.1,"lat":43.391244,"lon":-79.776778,"t":1780943480},{"alt_m":10502.6,"lat":43.39186,"lon":-79.774008,"t":1780943481},{"alt_m":10497.8,"lat":43.392476,"lon":-79.771238,"t":1780943482},{"alt_m":10505.5,"lat":43.393091,"lon":-79.768468,"t":1780943483},{"alt_m":10508.9,"lat":43.393707,"lon":-79.765699,"t":1780943484},{"alt_m":10505.6,"lat":43.394323,"lon":-79.762929,"t":1780943485},{"alt_m":10502.7,"lat":43.394938,"lon":-79.760159,"t":1780943486},{"alt_m":10502.2,"lat":43.395554,"lon":-79.757389,"t":1780943487},{"alt_m":10491.5,"lat":43.396169,"lon":-79.75462,"t":1780943488},{"alt_m":10492.5,"lat":43.396785,"lon":-79.75185,"t":1780943489},{"alt_m":10497.0,"lat":43.397401,"lon":-79.74908,"t":1780943490},{"alt_m":10492.7,"lat":43.398016,"lon":-79.74631,"t":1780943491},{"alt_m":10503.4,"lat":43.398632,"lon":-79.743541,"t":1780943492},{"alt_m":10504.7,"lat":43.399248,"lon":-79.740771,"t":1780943493},{"alt_m":10495.6,"lat":43.399863,"lon":-79.738001,"t":1780943494},{"alt_m":10504.1,"lat":43.400479,"lon":-79.735231,"t":1780943495},{"alt_m":10496.4,"lat":43.401094,"lon":-79.732462,"t":1780943496},{"alt_m":10501.1,"lat":43.40171,"lon":-79.729692,"t":1780943497},{"alt_m":10494.9,"lat":43.402326,"lon":-79.726922,"t":1780943498},{"alt_m":10507.6,"lat":43.402941,"lon":-79.724152,"t":1780943499},{"alt_m":10502.2,"lat":43.403557,"lon":-79.721382,"t":1780943500},{"alt_m":10509.2,"lat":43.404172,"lon":-79.718613,"t":1780943501},{"alt_m":10508.3,"lat":43.404788,"lon":-79.715843,"t":1780943502},{"alt_m":10509.2,"lat":43.405404,"lon":-79.713073,"t":1780943503},{"alt_m":10505.9,"lat":43.406019,"lon":-79.710303,"t":1780943504},{"alt_m":10498.5,"lat":43.406635,"lon":-79.707534,"t":1780943505},{"alt_m":10500.9,"lat":43.407251,"lon":-79.704764,"t":1780943506},{"alt_m":10501.1,"lat":43.407866,"lon":-79.701994,"t":1780943507},{"alt_m":10499.6,"lat":43.408482,"lon":-79.699224,"t":1780943508},{"alt_m":10490.1,"lat":43.409097,"lon":-79.696455,"t":1780943509},{"alt_m":10498.4,"lat":43.409713,"lon":-79.693685,"t":1780943510},{"alt_m":10498.4,"lat":43.410329,"lon":-79.690915,"t":1780943511},{"alt_m":10505.4,"lat":43.410944,"lon":-79.688145,"t":1780943512},{"alt_m":10501.7,"lat":43.41156,"lon":-79.685375,"t":1780943513},{"alt_m":10493.5,"lat":43.412176,"lon":-79.682606,"t":1780943514},{"alt_m":10502.0,"lat":43.412791,"lon":-79.679836,"t":1780943515},{"alt_m":10492.7,"lat":43.413407,"lon":-79.677066,"t":1780943516},{"alt_m":10509.5,"lat":43.414022,"lon":-79.674296,"t":1780943517},{"alt_m":10509.6,"lat":43.414638,"lon":-79.671527,"t":1780943518},{"alt_m":10499.4,"lat":43.415254,"lon":-79.668757,"t":1780943519},{"alt_m":10498.8,"lat":43.415869,"lon":-79.665987,"t":1780943520},{"alt_m":10507.5,"lat":43.416485,"lon":-79.663217,"t":1780943521},{"alt_m":10499.6,"lat":43.4171,"lon":-79.660448,"t":1780943522},{"alt_m":10509.6,"lat":43.417716,"lon":-79.657678,"t":1780943523},{"alt_m":10492.0,"lat":43.418332,"lon":-79.654908,"t":1780943524},{"alt_m":10509.5,"lat":43.418947,"lon":-79.652138,"t":1780943525},{"alt_m":10505.6,"lat":43.419563,"lon":-79.649369,"t":1780943526},{"alt_m":10502.4,"lat":43.420179,"lon":-79.646599,"t":1780943527},{"alt_m":10490.7,"lat":43.420794,"lon":-79.643829,"t":1780943528},{"alt_m":10492.4,"lat":43.42141,"lon":-79.641059,"t":1780943529},{"alt_m":10491.9,"lat":43.422025,"lon":-79.638289,"t":1780943530},{"alt_m":10490.1,"lat":43.422641,"lon":-79.63552,"t":1780943531},{"alt_m":10498.6,"lat":43.423257,"lon":-79.63275,"t":1780943532},{"alt_m":10494.0,"lat":43.423872,"lon":-79.62998,"t":1780943533},{"alt_m":10493.8,"lat":43.424488,"lon":-79.62721,"t":1780943534},{"alt_m":10508.3,"lat":43.425104,"lon":-79.624441,"t":1780943535},{"alt_m":10490.8,"lat":43.425719,"lon":-79.621671,"t":1780943536},{"alt_m":10503.5,"lat":43.426335,"lon":-79.618901,"t":1780943537},{"alt_m":10503.2,"lat":43.42695,"lon":-79.616131,"t":1780943538},{"alt_m":10501.4,"lat":43.427566,"lon":-79.613362,"t":1780943539},{"alt_m":10491.4,"lat":43.428182,"lon":-79.610592,"t":1780943540},{"alt_m":10495.3,"lat":43.428797,"lon":-79.607822,"t":1780943541},{"alt_m":10500.7,"lat":43.429413,"lon":-79.605052,"t":1780943542},{"alt_m":10491.2,"lat":43.430028,"lon":-79.602282,"t":1780943543},{"alt_m":10494.2,"lat":43.430644,"lon":-79.599513,"t":1780943544},{"alt_m":10490.2,"lat":43.43126,"lon":-79.596743,"t":1780943545},{"alt_m":10499.1,"lat":43.431875,"lon":-79.593973,"t":1780943546},{"alt_m":10493.7,"lat":43.432491,"lon":-79.591203,"t":1780943547},{"alt_m":10501.4,"lat":43.433107,"lon":-79.588434,"t":1780943548},{"alt_m":10493.8,"lat":43.433722,"lon":-79.585664,"t":1780943549},{"alt_m":10495.3,"lat":43.434338,"lon":-79.582894,"t":1780943550},{"alt_m":10498.7,"lat":43.434953,"lon":-79.580124,"t":1780943551},{"alt_m":10501.9,"lat":43.435569,"lon":-79.577355,"t":1780943552},{"alt_m":10509.2,"lat":43.436185,"lon":-79.574585,"t":1780943553},{"alt_m":10507.2,"lat":43.4368,"lon":-79.571815,"t":1780943554},{"alt_m":10501.5,"lat":43.437416,"lon":-79.569045,"t":1780943555},{"alt_m":10504.4,"lat":43.438032,"lon":-79.566276,"t":1780943556},{"alt_m":10505.8,"lat":43.438647,"lon":-79.563506,"t":1780943557},{"alt_m":10503.6,"lat":43.439263,"lon":-79.560736,"t":1780943558},{"alt_m":10499.2,"lat":43.439878,"lon":-79.557966,"t":1780943559},{"alt_m":10494.9,"lat":43.440494,"lon":-79.555196,"t":1780943560},{"alt_m":10502.2,"lat":43.44111,"lon":-79.552427,"t":1780943561},{"alt_m":10491.0,"lat":43.441725,"lon":-79.549657,"t":1780943562},{"alt_m":10495.0,"lat":43.442341,"lon":-79.546887,"t":1780943563},{"alt_m":10491.7,"lat":43.442956,"lon":-79.544117,"t":1780943564},{"alt_m":10491.5,"lat":43.443572,"lon":-79.541348,"t":1780943565},{"alt_m":10497.9,"lat":43.444188,"lon":-79.538578,"t":1780943566},{"alt_m":10496.1,"lat":43.444803,"lon":-79.535808,"t":1780943567},{"alt_m":10493.4,"lat":43.445419,"lon":-79.533038,"t":1780943568},{"alt_m":10501.0,"lat":43.446035,"lon":-79.530269,"t":1780943569},{"alt_m":10502.8,"lat":43.44665,"lon":-79.527499,"t":1780943570},{"alt_m":10498.1,"lat":43.447266,"lon":-79.524729,"t":1780943571},{"alt_m":10497.1,"lat":43.447881,"lon":-79.521959,"t":1780943572},{"alt_m":10509.6,"lat":43.448497,"lon":-79.51919,"t":1780943573},{"alt_m":10508.2,"lat":43.449113,"lon":-79.51642,"t":1780943574},{"alt_m":10497.7,"lat":43.449728,"lon":-79.51365,"t":1780943575},{"alt_m":10497.8,"lat":43.450344,"lon":-79.51088,"t":1780943576},{"alt_m":10500.4,"lat":43.45096,"lon":-79.50811,"t":1780943577},{"alt_m":10508.8,"lat":43.451575,"lon":-79.505341,"t":1780943578},{"alt_m":10508.8,"lat":43.452191,"lon":-79.502571,"t":1780943579},{"alt_m":10500.0,"lat":43.452806,"lon":-79.499801,"t":1780943580},{"alt_m":10496.9,"lat":43.453422,"lon":-79.497031,"t":1780943581},{"alt_m":10496.9,"lat":43.454038,"lon":-79.494262,"t":1780943582},{"alt_m":10505.8,"lat":43.454653,"lon":-79.491492,"t":1780943583},{"alt_m":10494.3,"lat":43.455269,"lon":-79.488722,"t":1780943584},{"alt_m":10491.2,"lat":43.455884,"lon":-79.485952,"t":1780943585},{"alt_m":10501.5,"lat":43.4565,"lon":-79.483183,"t":1780943586},{"alt_m":10500.5,"lat":43.457116,"lon":-79.480413,"t":1780943587},{"alt_m":10503.1,"lat":43.457731,"lon":-79.477643,"t":1780943588},{"alt_m":10499.0,"lat":43.458347,"lon":-79.474873,"t":1780943589},{"alt_m":10503.2,"lat":43.458963,"lon":-79.472103,"t":1780943590},{"alt_m":10501.5,"lat":43.459578,"lon":-79.469334,"t":1780943591},{"alt_m":10499.4,"lat":43.460194,"lon":-79.466564,"t":1780943592},{"alt_m":10509.8,"lat":43.460809,"lon":-79.463794,"t":1780943593},{"alt_m":10490.7,"lat":43.461425,"lon":-79.461024,"t":1780943594},{"alt_m":10497.7,"lat":43.462041,"lon":-79.458255,"t":1780943595},{"alt_m":10499.0,"lat":43.462656,"lon":-79.455485,"t":1780943596},{"alt_m":10509.1,"lat":43.463272,"lon":-79.452715,"t":1780943597},{"alt_m":10509.1,"lat":43.463888,"lon":-79.449945,"t":1780943598},{"alt_m":10495.4,"lat":43.464503,"lon":-79.447176,"t":1780943599},{"alt_m":10505.3,"lat":43.465119,"lon":-79.444406,"t":1780943600},{"alt_m":10509.3,"lat":43.465734,"lon":-79.441636,"t":1780943601},{"alt_m":10491.4,"lat":43.46635,"lon":-79.438866,"t":1780943602},{"alt_m":10501.5,"lat":43.466966,"lon":-79.436097,"t":1780943603},{"alt_m":10502.9,"lat":43.467581,"lon":-79.433327,"t":1780943604},{"alt_m":10509.2,"lat":43.468197,"lon":-79.430557,"t":1780943605},{"alt_m":10505.4,"lat":43.468812,"lon":-79.427787,"t":1780943606},{"alt_m":10504.6,"lat":43.469428,"lon":-79.425017,"t":1780943607},{"alt_m":10508.7,"lat":43.470044,"lon":-79.422248,"t":1780943608},{"alt_m":10498.0,"lat":43.470659,"lon":-79.419478,"t":1780943609},{"alt_m":10502.2,"lat":43.471275,"lon":-79.416708,"t":1780943610},{"alt_m":10502.7,"lat":43.471891,"lon":-79.413938,"t":1780943611},{"alt_m":10501.3,"lat":43.472506,"lon":-79.411169,"t":1780943612},{"alt_m":10491.8,"lat":43.473122,"lon":-79.408399,"t":1780943613},{"alt_m":10497.1,"lat":43.473737,"lon":-79.405629,"t":1780943614},{"alt_m":10493.1,"lat":43.474353,"lon":-79.402859,"t":1780943615},{"alt_m":10497.9,"lat":43.474969,"lon":-79.40009,"t":1780943616},{"alt_m":10499.8,"lat":43.475584,"lon":-79.39732,"t":1780943617},{"alt_m":10500.8,"lat":43.4762,"lon":-79.39455,"t":1780943618},{"alt_m":10491.3,"lat":43.476816,"lon":-79.39178,"t":1780943619},{"alt_m":10493.8,"lat":43.477431,"lon":-79.38901,"t":1780943620},{"alt_m":10502.9,"lat":43.478047,"lon":-79.386241,"t":1780943621},{"alt_m":10496.3,"lat":43.478662,"lon":-79.383471,"t":1780943622},{"alt_m":10493.7,"lat":43.479278,"lon":-79.380701,"t":1780943623},{"alt_m":10498.9,"lat":43.479894,"lon":-79.377931,"t":1780943624},{"alt_m":10502.0,"lat":43.480509,"lon":-79.375162,"t":1780943625},{"alt_m":10499.9,"lat":43.481125,"lon":-79.372392,"t":1780943626},{"alt_m":10503.8,"lat":43.48174,"lon":-79.369622,"t":1780943627},{"alt_m":10505.0,"lat":43.482356,"lon":-79.366852,"t":1780943628},{"alt_m":10494.4,"lat":43.482972,"lon":-79.364083,"t":1780943629},{"alt_m":10503.2,"lat":43.483587,"lon":-79.361313,"t":1780943630},{"alt_m":10504.4,"lat":43.484203,"lon":-79.358543,"t":1780943631},{"alt_m":10494.5,"lat":43.484819,"lon":-79.355773,"t":1780943632},{"alt_m":10493.0,"lat":43.485434,"lon":-79.353004,"t":1780943633},{"alt_m":10491.6,"lat":43.48605,"lon":-79.350234,"t":1780943634},{"alt_m":10501.8,"lat":43.486665,"lon":-79.347464,"t":1780943635},{"alt_m":10494.6,"lat":43.487281,"lon":-79.344694,"t":1780943636},{"alt_m":10494.3,"lat":43.487897,"lon":-79.341924,"t":1780943637},{"alt_m":10494.8,"lat":43.488512,"lon":-79.339155,"t":1780943638},{"alt_m":10491.1,"lat":43.489128,"lon":-79.336385,"t":1780943639}]},{"anomaly":{"band":"mildly unusual","reasons":["mean altitude 3553 m deviates 1.4σ from the local baseline (8697 m)","vector novelty 1.00: no similar track in RuVector memory"],"score":0.339},"callsign":"SKV808","icao24":"c08f08","overhead":true,"points":[{"alt_m":4594.4,"lat":43.319779,"lon":-79.884476,"t":1780947900},{"alt_m":4587.2,"lat":43.320898,"lon":-79.883438,"t":1780947901},{"alt_m":4581.5,"lat":43.322017,"lon":-79.8824,"t":1780947902},{"alt_m":4572.6,"lat":43.323136,"lon":-79.881362,"t":1780947903},{"alt_m":4577.9,"lat":43.324255,"lon":-79.880324,"t":1780947904},{"alt_m":4556.4,"lat":43.325374,"lon":-79.879285,"t":1780947905},{"alt_m":4551.6,"lat":43.326493,"lon":-79.878247,"t":1780947906},{"alt_m":4553.3,"lat":43.327612,"lon":-79.877209,"t":1780947907},{"alt_m":4547.1,"lat":43.328731,"lon":-79.876171,"t":1780947908},{"alt_m":4545.8,"lat":43.32985,"lon":-79.875133,"t":1780947909},{"alt_m":4528.6,"lat":43.330969,"lon":-79.874094,"t":1780947910},{"alt_m":4514.4,"lat":43.332088,"lon":-79.873056,"t":1780947911},{"alt_m":4510.2,"lat":43.333206,"lon":-79.872018,"t":1780947912},{"alt_m":4518.9,"lat":43.334325,"lon":-79.87098,"t":1780947913},{"alt_m":4495.0,"lat":43.335444,"lon":-79.869942,"t":1780947914},{"alt_m":4489.6,"lat":43.336563,"lon":-79.868903,"t":1780947915},{"alt_m":4480.6,"lat":43.337682,"lon":-79.867865,"t":1780947916},{"alt_m":4472.3,"lat":43.338801,"lon":-79.866827,"t":1780947917},{"alt_m":4477.1,"lat":43.33992,"lon":-79.865789,"t":1780947918},{"alt_m":4468.3,"lat":43.341039,"lon":-79.864751,"t":1780947919},{"alt_m":4464.7,"lat":43.342158,"lon":-79.863712,"t":1780947920},{"alt_m":4460.5,"lat":43.343277,"lon":-79.862674,"t":1780947921},{"alt_m":4440.0,"lat":43.344396,"lon":-79.861636,"t":1780947922},{"alt_m":4445.5,"lat":43.345515,"lon":-79.860598,"t":1780947923},{"alt_m":4440.9,"lat":43.346634,"lon":-79.85956,"t":1780947924},{"alt_m":4416.2,"lat":43.347753,"lon":-79.858521,"t":1780947925},{"alt_m":4425.4,"lat":43.348872,"lon":-79.857483,"t":1780947926},{"alt_m":4409.5,"lat":43.349991,"lon":-79.856445,"t":1780947927},{"alt_m":4400.7,"lat":43.35111,"lon":-79.855407,"t":1780947928},{"alt_m":4392.6,"lat":43.352229,"lon":-79.854368,"t":1780947929},{"alt_m":4384.0,"lat":43.353348,"lon":-79.85333,"t":1780947930},{"alt_m":4380.3,"lat":43.354467,"lon":-79.852292,"t":1780947931},{"alt_m":4383.4,"lat":43.355586,"lon":-79.851254,"t":1780947932},{"alt_m":4362.1,"lat":43.356705,"lon":-79.850216,"t":1780947933},{"alt_m":4354.2,"lat":43.357824,"lon":-79.849177,"t":1780947934},{"alt_m":4349.9,"lat":43.358943,"lon":-79.848139,"t":1780947935},{"alt_m":4349.0,"lat":43.360062,"lon":-79.847101,"t":1780947936},{"alt_m":4336.8,"lat":43.361181,"lon":-79.846063,"t":1780947937},{"alt_m":4343.6,"lat":43.3623,"lon":-79.845025,"t":1780947938},{"alt_m":4317.3,"lat":43.363419,"lon":-79.843986,"t":1780947939},{"alt_m":4315.9,"lat":43.364538,"lon":-79.842948,"t":1780947940},{"alt_m":4304.0,"lat":43.365657,"lon":-79.84191,"t":1780947941},{"alt_m":4302.5,"lat":43.366776,"lon":-79.840872,"t":1780947942},{"alt_m":4294.8,"lat":43.367895,"lon":-79.839834,"t":1780947943},{"alt_m":4289.3,"lat":43.369014,"lon":-79.838795,"t":1780947944},{"alt_m":4293.5,"lat":43.370133,"lon":-79.837757,"t":1780947945},{"alt_m":4272.3,"lat":43.371252,"lon":-79.836719,"t":1780947946},{"alt_m":4264.1,"lat":43.372371,"lon":-79.835681,"t":1780947947},{"alt_m":4270.9,"lat":43.37349,"lon":-79.834643,"t":1780947948},{"alt_m":4262.7,"lat":43.374609,"lon":-79.833604,"t":1780947949},{"alt_m":4259.3,"lat":43.375728,"lon":-79.832566,"t":1780947950},{"alt_m":4235.4,"lat":43.376847,"lon":-79.831528,"t":1780947951},{"alt_m":4234.0,"lat":43.377966,"lon":-79.83049,"t":1780947952},{"alt_m":4227.3,"lat":43.379085,"lon":-79.829452,"t":1780947953},{"alt_m":4229.1,"lat":43.380204,"lon":-79.828413,"t":1780947954},{"alt_m":4221.1,"lat":43.381323,"lon":-79.827375,"t":1780947955},{"alt_m":4210.7,"lat":43.382442,"lon":-79.826337,"t":1780947956},{"alt_m":4192.5,"lat":43.383561,"lon":-79.825299,"t":1780947957},{"alt_m":4190.4,"lat":43.38468,"lon":-79.82426,"t":1780947958},{"alt_m":4194.5,"lat":43.385799,"lon":-79.823222,"t":1780947959},{"alt_m":4176.4,"lat":43.386918,"lon":-79.822184,"t":1780947960},{"alt_m":4181.1,"lat":43.388037,"lon":-79.821146,"t":1780947961},{"alt_m":4156.7,"lat":43.389156,"lon":-79.820108,"t":1780947962},{"alt_m":4167.3,"lat":43.390275,"lon":-79.819069,"t":1780947963},{"alt_m":4158.0,"lat":43.391394,"lon":-79.818031,"t":1780947964},{"alt_m":4144.6,"lat":43.392513,"lon":-79.816993,"t":1780947965},{"alt_m":4136.4,"lat":43.393632,"lon":-79.815955,"t":1780947966},{"alt_m":4132.4,"lat":43.394751,"lon":-79.814917,"t":1780947967},{"alt_m":4124.5,"lat":43.39587,"lon":-79.813878,"t":1780947968},{"alt_m":4120.8,"lat":43.396989,"lon":-79.81284,"t":1780947969},{"alt_m":4118.7,"lat":43.398108,"lon":-79.811802,"t":1780947970},{"alt_m":4106.5,"lat":43.399227,"lon":-79.810764,"t":1780947971},{"alt_m":4089.7,"lat":43.400346,"lon":-79.809726,"t":1780947972},{"alt_m":4087.5,"lat":43.401465,"lon":-79.808687,"t":1780947973},{"alt_m":4088.4,"lat":43.402584,"lon":-79.807649,"t":1780947974},{"alt_m":4065.1,"lat":43.403703,"lon":-79.806611,"t":1780947975},{"alt_m":4076.4,"lat":43.404822,"lon":-79.805573,"t":1780947976},{"alt_m":4051.2,"lat":43.405941,"lon":-79.804535,"t":1780947977},{"alt_m":4046.5,"lat":43.40706,"lon":-79.803496,"t":1780947978},{"alt_m":4050.4,"lat":43.408179,"lon":-79.802458,"t":1780947979},{"alt_m":4039.8,"lat":43.409298,"lon":-79.80142,"t":1780947980},{"alt_m":4023.8,"lat":43.410417,"lon":-79.800382,"t":1780947981},{"alt_m":4023.6,"lat":43.411536,"lon":-79.799344,"t":1780947982},{"alt_m":4025.7,"lat":43.412655,"lon":-79.798305,"t":1780947983},{"alt_m":4021.4,"lat":43.413774,"lon":-79.797267,"t":1780947984},{"alt_m":4011.2,"lat":43.414893,"lon":-79.796229,"t":1780947985},{"alt_m":3988.6,"lat":43.416012,"lon":-79.795191,"t":1780947986},{"alt_m":3992.1,"lat":43.417131,"lon":-79.794152,"t":1780947987},{"alt_m":3977.9,"lat":43.41825,"lon":-79.793114,"t":1780947988},{"alt_m":3971.3,"lat":43.419369,"lon":-79.792076,"t":1780947989},{"alt_m":3976.7,"lat":43.420488,"lon":-79.791038,"t":1780947990},{"alt_m":3961.8,"lat":43.421607,"lon":-79.79,"t":1780947991},{"alt_m":3965.9,"lat":43.422726,"lon":-79.788961,"t":1780947992},{"alt_m":3954.0,"lat":43.423845,"lon":-79.787923,"t":1780947993},{"alt_m":3944.5,"lat":43.424964,"lon":-79.786885,"t":1780947994},{"alt_m":3927.0,"lat":43.426083,"lon":-79.785847,"t":1780947995},{"alt_m":3919.3,"lat":43.427202,"lon":-79.784809,"t":1780947996},{"alt_m":3914.4,"lat":43.428321,"lon":-79.78377,"t":1780947997},{"alt_m":3918.3,"lat":43.42944,"lon":-79.782732,"t":1780947998},{"alt_m":3900.1,"lat":43.430559,"lon":-79.781694,"t":1780947999},{"alt_m":3894.5,"lat":43.431678,"lon":-79.780656,"t":1780948000},{"alt_m":3897.8,"lat":43.432797,"lon":-79.779618,"t":1780948001},{"alt_m":3885.8,"lat":43.433916,"lon":-79.778579,"t":1780948002},{"alt_m":3885.1,"lat":43.435035,"lon":-79.777541,"t":1780948003},{"alt_m":3869.1,"lat":43.436154,"lon":-79.776503,"t":1780948004},{"alt_m":3861.3,"lat":43.437273,"lon":-79.775465,"t":1780948005},{"alt_m":3848.4,"lat":43.438392,"lon":-79.774427,"t":1780948006},{"alt_m":3852.1,"lat":43.439511,"lon":-79.773388,"t":1780948007},{"alt_m":3842.6,"lat":43.44063,"lon":-79.77235,"t":1780948008},{"alt_m":3835.8,"lat":43.441749,"lon":-79.771312,"t":1780948009},{"alt_m":3836.7,"lat":43.442868,"lon":-79.770274,"t":1780948010},{"alt_m":3832.3,"lat":43.443987,"lon":-79.769236,"t":1780948011},{"alt_m":3811.1,"lat":43.445106,"lon":-79.768197,"t":1780948012},{"alt_m":3801.5,"lat":43.446225,"lon":-79.767159,"t":1780948013},{"alt_m":3795.1,"lat":43.447344,"lon":-79.766121,"t":1780948014},{"alt_m":3787.6,"lat":43.448462,"lon":-79.765083,"t":1780948015},{"alt_m":3782.3,"lat":43.449581,"lon":-79.764044,"t":1780948016},{"alt_m":3779.4,"lat":43.4507,"lon":-79.763006,"t":1780948017},{"alt_m":3780.5,"lat":43.451819,"lon":-79.761968,"t":1780948018},{"alt_m":3771.9,"lat":43.452938,"lon":-79.76093,"t":1780948019},{"alt_m":3766.1,"lat":43.454057,"lon":-79.759892,"t":1780948020},{"alt_m":3744.6,"lat":43.455176,"lon":-79.758853,"t":1780948021},{"alt_m":3743.0,"lat":43.456295,"lon":-79.757815,"t":1780948022},{"alt_m":3737.2,"lat":43.457414,"lon":-79.756777,"t":1780948023},{"alt_m":3730.7,"lat":43.458533,"lon":-79.755739,"t":1780948024},{"alt_m":3732.6,"lat":43.459652,"lon":-79.754701,"t":1780948025},{"alt_m":3720.3,"lat":43.460771,"lon":-79.753662,"t":1780948026},{"alt_m":3704.1,"lat":43.46189,"lon":-79.752624,"t":1780948027},{"alt_m":3696.9,"lat":43.463009,"lon":-79.751586,"t":1780948028},{"alt_m":3699.1,"lat":43.464128,"lon":-79.750548,"t":1780948029},{"alt_m":3686.1,"lat":43.465247,"lon":-79.74951,"t":1780948030},{"alt_m":3692.7,"lat":43.466366,"lon":-79.748471,"t":1780948031},{"alt_m":3671.8,"lat":43.467485,"lon":-79.747433,"t":1780948032},{"alt_m":3676.7,"lat":43.468604,"lon":-79.746395,"t":1780948033},{"alt_m":3669.9,"lat":43.469723,"lon":-79.745357,"t":1780948034},{"alt_m":3648.5,"lat":43.470842,"lon":-79.744319,"t":1780948035},{"alt_m":3654.5,"lat":43.471961,"lon":-79.74328,"t":1780948036},{"alt_m":3644.4,"lat":43.47308,"lon":-79.742242,"t":1780948037},{"alt_m":3628.0,"lat":43.474199,"lon":-79.741204,"t":1780948038},{"alt_m":3624.9,"lat":43.475318,"lon":-79.740166,"t":1780948039},{"alt_m":3615.7,"lat":43.476437,"lon":-79.739128,"t":1780948040},{"alt_m":3606.7,"lat":43.477556,"lon":-79.738089,"t":1780948041},{"alt_m":3602.9,"lat":43.478675,"lon":-79.737051,"t":1780948042},{"alt_m":3591.5,"lat":43.479794,"lon":-79.736013,"t":1780948043},{"alt_m":3595.6,"lat":43.480913,"lon":-79.734975,"t":1780948044},{"alt_m":3589.6,"lat":43.482032,"lon":-79.733936,"t":1780948045},{"alt_m":3583.5,"lat":43.483151,"lon":-79.732898,"t":1780948046},{"alt_m":3573.7,"lat":43.48427,"lon":-79.73186,"t":1780948047},{"alt_m":3557.1,"lat":43.485389,"lon":-79.730822,"t":1780948048},{"alt_m":3566.7,"lat":43.486508,"lon":-79.729784,"t":1780948049},{"alt_m":3547.2,"lat":43.487627,"lon":-79.728745,"t":1780948050},{"alt_m":3541.9,"lat":43.488746,"lon":-79.727707,"t":1780948051},{"alt_m":3538.2,"lat":43.489865,"lon":-79.726669,"t":1780948052},{"alt_m":3519.2,"lat":43.490984,"lon":-79.725631,"t":1780948053},{"alt_m":3527.2,"lat":43.492103,"lon":-79.724593,"t":1780948054},{"alt_m":3512.7,"lat":43.493222,"lon":-79.723554,"t":1780948055},{"alt_m":3503.5,"lat":43.494341,"lon":-79.722516,"t":1780948056},{"alt_m":3494.3,"lat":43.49546,"lon":-79.721478,"t":1780948057},{"alt_m":3491.3,"lat":43.496579,"lon":-79.72044,"t":1780948058},{"alt_m":3477.1,"lat":43.497698,"lon":-79.719402,"t":1780948059},{"alt_m":3473.9,"lat":43.498817,"lon":-79.718363,"t":1780948060},{"alt_m":3473.8,"lat":43.499936,"lon":-79.717325,"t":1780948061},{"alt_m":3461.5,"lat":43.501055,"lon":-79.716287,"t":1780948062},{"alt_m":3465.4,"lat":43.502174,"lon":-79.715249,"t":1780948063},{"alt_m":3449.0,"lat":43.503293,"lon":-79.714211,"t":1780948064},{"alt_m":3452.3,"lat":43.504412,"lon":-79.713172,"t":1780948065},{"alt_m":3444.0,"lat":43.505531,"lon":-79.712134,"t":1780948066},{"alt_m":3422.0,"lat":43.50665,"lon":-79.711096,"t":1780948067},{"alt_m":3417.2,"lat":43.507769,"lon":-79.710058,"t":1780948068},{"alt_m":3425.7,"lat":43.508888,"lon":-79.70902,"t":1780948069},{"alt_m":3405.2,"lat":43.510007,"lon":-79.707981,"t":1780948070},{"alt_m":3394.4,"lat":43.511126,"lon":-79.706943,"t":1780948071},{"alt_m":3399.9,"lat":43.512245,"lon":-79.705905,"t":1780948072},{"alt_m":3379.4,"lat":43.513364,"lon":-79.704867,"t":1780948073},{"alt_m":3387.4,"lat":43.514483,"lon":-79.703829,"t":1780948074},{"alt_m":3380.2,"lat":43.515602,"lon":-79.70279,"t":1780948075},{"alt_m":3370.0,"lat":43.516721,"lon":-79.701752,"t":1780948076},{"alt_m":3356.1,"lat":43.51784,"lon":-79.700714,"t":1780948077},{"alt_m":3352.9,"lat":43.518959,"lon":-79.699676,"t":1780948078},{"alt_m":3337.7,"lat":43.520078,"lon":-79.698637,"t":1780948079},{"alt_m":3347.6,"lat":43.521197,"lon":-79.697599,"t":1780948080},{"alt_m":3328.3,"lat":43.522316,"lon":-79.696561,"t":1780948081},{"alt_m":3316.1,"lat":43.523435,"lon":-79.695523,"t":1780948082},{"alt_m":3316.0,"lat":43.524554,"lon":-79.694485,"t":1780948083},{"alt_m":3308.5,"lat":43.525673,"lon":-79.693446,"t":1780948084},{"alt_m":3313.1,"lat":43.526792,"lon":-79.692408,"t":1780948085},{"alt_m":3297.7,"lat":43.527911,"lon":-79.69137,"t":1780948086},{"alt_m":3291.0,"lat":43.52903,"lon":-79.690332,"t":1780948087},{"alt_m":3287.9,"lat":43.530149,"lon":-79.689294,"t":1780948088},{"alt_m":3272.5,"lat":43.531268,"lon":-79.688255,"t":1780948089},{"alt_m":3266.2,"lat":43.532387,"lon":-79.687217,"t":1780948090},{"alt_m":3270.6,"lat":43.533506,"lon":-79.686179,"t":1780948091},{"alt_m":3254.0,"lat":43.534625,"lon":-79.685141,"t":1780948092},{"alt_m":3249.9,"lat":43.535744,"lon":-79.684103,"t":1780948093},{"alt_m":3245.8,"lat":43.536863,"lon":-79.683064,"t":1780948094},{"alt_m":3230.3,"lat":43.537982,"lon":-79.682026,"t":1780948095},{"alt_m":3229.9,"lat":43.539101,"lon":-79.680988,"t":1780948096},{"alt_m":3211.1,"lat":43.54022,"lon":-79.67995,"t":1780948097},{"alt_m":3222.6,"lat":43.541339,"lon":-79.678912,"t":1780948098},{"alt_m":3206.0,"lat":43.542458,"lon":-79.677873,"t":1780948099},{"alt_m":3207.1,"lat":43.543577,"lon":-79.676835,"t":1780948100},{"alt_m":3202.1,"lat":43.544696,"lon":-79.675797,"t":1780948101},{"alt_m":3176.2,"lat":43.545815,"lon":-79.674759,"t":1780948102},{"alt_m":3174.5,"lat":43.546934,"lon":-79.673721,"t":1780948103},{"alt_m":3172.8,"lat":43.548053,"lon":-79.672682,"t":1780948104},{"alt_m":3157.2,"lat":43.549172,"lon":-79.671644,"t":1780948105},{"alt_m":3150.4,"lat":43.550291,"lon":-79.670606,"t":1780948106},{"alt_m":3159.1,"lat":43.55141,"lon":-79.669568,"t":1780948107},{"alt_m":3138.5,"lat":43.552529,"lon":-79.668529,"t":1780948108},{"alt_m":3137.4,"lat":43.553648,"lon":-79.667491,"t":1780948109},{"alt_m":3123.1,"lat":43.554767,"lon":-79.666453,"t":1780948110},{"alt_m":3123.2,"lat":43.555886,"lon":-79.665415,"t":1780948111},{"alt_m":3116.7,"lat":43.557005,"lon":-79.664377,"t":1780948112},{"alt_m":3101.0,"lat":43.558124,"lon":-79.663338,"t":1780948113},{"alt_m":3101.2,"lat":43.559243,"lon":-79.6623,"t":1780948114},{"alt_m":3101.9,"lat":43.560362,"lon":-79.661262,"t":1780948115},{"alt_m":3092.6,"lat":43.561481,"lon":-79.660224,"t":1780948116},{"alt_m":3080.6,"lat":43.5626,"lon":-79.659186,"t":1780948117},{"alt_m":3070.8,"lat":43.563719,"lon":-79.658147,"t":1780948118},{"alt_m":3059.3,"lat":43.564837,"lon":-79.657109,"t":1780948119},{"alt_m":3069.5,"lat":43.565956,"lon":-79.656071,"t":1780948120},{"alt_m":3047.9,"lat":43.567075,"lon":-79.655033,"t":1780948121},{"alt_m":3049.2,"lat":43.568194,"lon":-79.653995,"t":1780948122},{"alt_m":3044.2,"lat":43.569313,"lon":-79.652956,"t":1780948123},{"alt_m":3040.3,"lat":43.570432,"lon":-79.651918,"t":1780948124},{"alt_m":3020.6,"lat":43.571551,"lon":-79.65088,"t":1780948125},{"alt_m":3018.6,"lat":43.57267,"lon":-79.649842,"t":1780948126},{"alt_m":3007.6,"lat":43.573789,"lon":-79.648804,"t":1780948127},{"alt_m":3002.7,"lat":43.574908,"lon":-79.647765,"t":1780948128},{"alt_m":2988.4,"lat":43.576027,"lon":-79.646727,"t":1780948129},{"alt_m":2989.9,"lat":43.577146,"lon":-79.645689,"t":1780948130},{"alt_m":2987.3,"lat":43.578265,"lon":-79.644651,"t":1780948131},{"alt_m":2966.6,"lat":43.579384,"lon":-79.643613,"t":1780948132},{"alt_m":2963.8,"lat":43.580503,"lon":-79.642574,"t":1780948133},{"alt_m":2970.4,"lat":43.581622,"lon":-79.641536,"t":1780948134},{"alt_m":2950.7,"lat":43.582741,"lon":-79.640498,"t":1780948135},{"alt_m":2946.7,"lat":43.58386,"lon":-79.63946,"t":1780948136},{"alt_m":2934.6,"lat":43.584979,"lon":-79.638421,"t":1780948137},{"alt_m":2937.4,"lat":43.586098,"lon":-79.637383,"t":1780948138},{"alt_m":2925.3,"lat":43.587217,"lon":-79.636345,"t":1780948139},{"alt_m":2923.1,"lat":43.588336,"lon":-79.635307,"t":1780948140},{"alt_m":2913.4,"lat":43.589455,"lon":-79.634269,"t":1780948141},{"alt_m":2899.7,"lat":43.590574,"lon":-79.63323,"t":1780948142},{"alt_m":2892.5,"lat":43.591693,"lon":-79.632192,"t":1780948143},{"alt_m":2896.1,"lat":43.592812,"lon":-79.631154,"t":1780948144},{"alt_m":2889.5,"lat":43.593931,"lon":-79.630116,"t":1780948145},{"alt_m":2879.5,"lat":43.59505,"lon":-79.629078,"t":1780948146},{"alt_m":2867.2,"lat":43.596169,"lon":-79.628039,"t":1780948147},{"alt_m":2856.9,"lat":43.597288,"lon":-79.627001,"t":1780948148},{"alt_m":2854.6,"lat":43.598407,"lon":-79.625963,"t":1780948149},{"alt_m":2854.4,"lat":43.599526,"lon":-79.624925,"t":1780948150},{"alt_m":2851.6,"lat":43.600645,"lon":-79.623887,"t":1780948151},{"alt_m":2827.0,"lat":43.601764,"lon":-79.622848,"t":1780948152},{"alt_m":2837.7,"lat":43.602883,"lon":-79.62181,"t":1780948153},{"alt_m":2826.3,"lat":43.604002,"lon":-79.620772,"t":1780948154},{"alt_m":2822.3,"lat":43.605121,"lon":-79.619734,"t":1780948155},{"alt_m":2816.8,"lat":43.60624,"lon":-79.618696,"t":1780948156},{"alt_m":2799.3,"lat":43.607359,"lon":-79.617657,"t":1780948157},{"alt_m":2787.8,"lat":43.608478,"lon":-79.616619,"t":1780948158},{"alt_m":2785.2,"lat":43.609597,"lon":-79.615581,"t":1780948159},{"alt_m":2770.2,"lat":43.610716,"lon":-79.614543,"t":1780948160},{"alt_m":2769.9,"lat":43.611835,"lon":-79.613505,"t":1780948161},{"alt_m":2760.7,"lat":43.612954,"lon":-79.612466,"t":1780948162},{"alt_m":2751.1,"lat":43.614073,"lon":-79.611428,"t":1780948163},{"alt_m":2743.9,"lat":43.615192,"lon":-79.61039,"t":1780948164},{"alt_m":2738.3,"lat":43.616311,"lon":-79.609352,"t":1780948165},{"alt_m":2747.7,"lat":43.61743,"lon":-79.608313,"t":1780948166},{"alt_m":2731.5,"lat":43.618549,"lon":-79.607275,"t":1780948167},{"alt_m":2729.7,"lat":43.619668,"lon":-79.606237,"t":1780948168},{"alt_m":2712.2,"lat":43.620787,"lon":-79.605199,"t":1780948169},{"alt_m":2701.6,"lat":43.621906,"lon":-79.604161,"t":1780948170},{"alt_m":2706.4,"lat":43.623025,"lon":-79.603122,"t":1780948171},{"alt_m":2690.1,"lat":43.624144,"lon":-79.602084,"t":1780948172},{"alt_m":2688.4,"lat":43.625263,"lon":-79.601046,"t":1780948173},{"alt_m":2673.6,"lat":43.626382,"lon":-79.600008,"t":1780948174},{"alt_m":2670.9,"lat":43.627501,"lon":-79.59897,"t":1780948175},{"alt_m":2676.9,"lat":43.62862,"lon":-79.597931,"t":1780948176},{"alt_m":2652.4,"lat":43.629739,"lon":-79.596893,"t":1780948177},{"alt_m":2652.9,"lat":43.630858,"lon":-79.595855,"t":1780948178},{"alt_m":2639.9,"lat":43.631977,"lon":-79.594817,"t":1780948179},{"alt_m":2634.3,"lat":43.633096,"lon":-79.593779,"t":1780948180},{"alt_m":2637.7,"lat":43.634215,"lon":-79.59274,"t":1780948181},{"alt_m":2632.4,"lat":43.635334,"lon":-79.591702,"t":1780948182},{"alt_m":2614.9,"lat":43.636453,"lon":-79.590664,"t":1780948183},{"alt_m":2616.1,"lat":43.637572,"lon":-79.589626,"t":1780948184},{"alt_m":2601.2,"lat":43.638691,"lon":-79.588588,"t":1780948185},{"alt_m":2604.3,"lat":43.63981,"lon":-79.587549,"t":1780948186},{"alt_m":2589.9,"lat":43.640929,"lon":-79.586511,"t":1780948187},{"alt_m":2592.7,"lat":43.642048,"lon":-79.585473,"t":1780948188},{"alt_m":2584.6,"lat":43.643167,"lon":-79.584435,"t":1780948189},{"alt_m":2574.7,"lat":43.644286,"lon":-79.583397,"t":1780948190},{"alt_m":2557.8,"lat":43.645405,"lon":-79.582358,"t":1780948191},{"alt_m":2561.3,"lat":43.646524,"lon":-79.58132,"t":1780948192},{"alt_m":2558.8,"lat":43.647643,"lon":-79.580282,"t":1780948193},{"alt_m":2535.8,"lat":43.648762,"lon":-79.579244,"t":1780948194},{"alt_m":2526.7,"lat":43.649881,"lon":-79.578205,"t":1780948195},{"alt_m":2524.9,"lat":43.651,"lon":-79.577167,"t":1780948196},{"alt_m":2521.3,"lat":43.652119,"lon":-79.576129,"t":1780948197},{"alt_m":2517.8,"lat":43.653238,"lon":-79.575091,"t":1780948198},{"alt_m":2504.3,"lat":43.654357,"lon":-79.574053,"t":1780948199}]},{"anomaly":{"band":"strong anomaly","reasons":["heading 165° is 77° off the nearest known corridor","mean altitude 450 m deviates 2.0σ from the local baseline (8126 m)","start time 03:xx UTC has 0 prior tracks within ±2 h","signal -3.0 dBFS is 3.3σ from baseline -16.0 dBFS (unusually close/strong)","vector novelty 1.00: no similar track in RuVector memory","no callsign broadcast"],"score":0.86},"callsign":"","icao24":"deadbf","overhead":true,"points":[{"alt_m":447.7,"lat":43.550454,"lon":-79.743903,"t":1780974600},{"alt_m":455.1,"lat":43.550037,"lon":-79.743749,"t":1780974601},{"alt_m":455.6,"lat":43.54962,"lon":-79.743595,"t":1780974602},{"alt_m":441.5,"lat":43.549203,"lon":-79.743442,"t":1780974603},{"alt_m":455.8,"lat":43.548786,"lon":-79.743288,"t":1780974604},{"alt_m":447.2,"lat":43.548368,"lon":-79.743134,"t":1780974605},{"alt_m":445.3,"lat":43.547951,"lon":-79.74298,"t":1780974606},{"alt_m":449.9,"lat":43.547534,"lon":-79.742826,"t":1780974607},{"alt_m":441.5,"lat":43.547117,"lon":-79.742673,"t":1780974608},{"alt_m":458.8,"lat":43.5467,"lon":-79.742519,"t":1780974609},{"alt_m":453.3,"lat":43.546282,"lon":-79.742365,"t":1780974610},{"alt_m":449.3,"lat":43.545865,"lon":-79.742211,"t":1780974611},{"alt_m":440.4,"lat":43.545448,"lon":-79.742058,"t":1780974612},{"alt_m":451.4,"lat":43.545031,"lon":-79.741904,"t":1780974613},{"alt_m":457.4,"lat":43.544614,"lon":-79.74175,"t":1780974614},{"alt_m":459.1,"lat":43.544196,"lon":-79.741596,"t":1780974615},{"alt_m":440.7,"lat":43.543779,"lon":-79.741443,"t":1780974616},{"alt_m":458.7,"lat":43.543362,"lon":-79.741289,"t":1780974617},{"alt_m":447.2,"lat":43.542945,"lon":-79.741135,"t":1780974618},{"alt_m":444.5,"lat":43.542528,"lon":-79.740981,"t":1780974619},{"alt_m":442.4,"lat":43.54211,"lon":-79.740827,"t":1780974620},{"alt_m":441.2,"lat":43.541693,"lon":-79.740674,"t":1780974621},{"alt_m":453.4,"lat":43.541276,"lon":-79.74052,"t":1780974622},{"alt_m":448.6,"lat":43.540859,"lon":-79.740366,"t":1780974623},{"alt_m":443.7,"lat":43.540442,"lon":-79.740212,"t":1780974624},{"alt_m":443.5,"lat":43.540024,"lon":-79.740059,"t":1780974625},{"alt_m":450.2,"lat":43.539607,"lon":-79.739905,"t":1780974626},{"alt_m":454.6,"lat":43.53919,"lon":-79.739751,"t":1780974627},{"alt_m":455.0,"lat":43.538773,"lon":-79.739597,"t":1780974628},{"alt_m":456.7,"lat":43.538356,"lon":-79.739444,"t":1780974629},{"alt_m":454.6,"lat":43.537938,"lon":-79.73929,"t":1780974630},{"alt_m":458.4,"lat":43.537521,"lon":-79.739136,"t":1780974631},{"alt_m":458.6,"lat":43.537104,"lon":-79.738982,"t":1780974632},{"alt_m":448.2,"lat":43.536687,"lon":-79.738828,"t":1780974633},{"alt_m":445.2,"lat":43.53627,"lon":-79.738675,"t":1780974634},{"alt_m":447.5,"lat":43.535852,"lon":-79.738521,"t":1780974635},{"alt_m":455.5,"lat":43.535435,"lon":-79.738367,"t":1780974636},{"alt_m":448.8,"lat":43.535018,"lon":-79.738213,"t":1780974637},{"alt_m":456.6,"lat":43.534601,"lon":-79.73806,"t":1780974638},{"alt_m":443.0,"lat":43.534184,"lon":-79.737906,"t":1780974639},{"alt_m":446.3,"lat":43.533766,"lon":-79.737752,"t":1780974640},{"alt_m":452.6,"lat":43.533349,"lon":-79.737598,"t":1780974641},{"alt_m":459.5,"lat":43.532932,"lon":-79.737445,"t":1780974642},{"alt_m":442.4,"lat":43.532515,"lon":-79.737291,"t":1780974643},{"alt_m":450.3,"lat":43.532098,"lon":-79.737137,"t":1780974644},{"alt_m":452.0,"lat":43.53168,"lon":-79.736983,"t":1780974645},{"alt_m":442.2,"lat":43.531263,"lon":-79.736829,"t":1780974646},{"alt_m":448.1,"lat":43.530846,"lon":-79.736676,"t":1780974647},{"alt_m":443.8,"lat":43.530429,"lon":-79.736522,"t":1780974648},{"alt_m":441.6,"lat":43.530012,"lon":-79.736368,"t":1780974649},{"alt_m":445.7,"lat":43.529594,"lon":-79.736214,"t":1780974650},{"alt_m":450.1,"lat":43.529177,"lon":-79.736061,"t":1780974651},{"alt_m":453.2,"lat":43.52876,"lon":-79.735907,"t":1780974652},{"alt_m":451.8,"lat":43.528343,"lon":-79.735753,"t":1780974653},{"alt_m":452.7,"lat":43.527926,"lon":-79.735599,"t":1780974654},{"alt_m":441.4,"lat":43.527508,"lon":-79.735446,"t":1780974655},{"alt_m":456.0,"lat":43.527091,"lon":-79.735292,"t":1780974656},{"alt_m":441.2,"lat":43.526674,"lon":-79.735138,"t":1780974657},{"alt_m":442.3,"lat":43.526257,"lon":-79.734984,"t":1780974658},{"alt_m":457.6,"lat":43.52584,"lon":-79.73483,"t":1780974659},{"alt_m":456.9,"lat":43.525422,"lon":-79.734677,"t":1780974660},{"alt_m":450.1,"lat":43.525005,"lon":-79.734523,"t":1780974661},{"alt_m":451.8,"lat":43.524588,"lon":-79.734369,"t":1780974662},{"alt_m":455.0,"lat":43.524171,"lon":-79.734215,"t":1780974663},{"alt_m":446.9,"lat":43.523754,"lon":-79.734062,"t":1780974664},{"alt_m":452.9,"lat":43.523336,"lon":-79.733908,"t":1780974665},{"alt_m":450.7,"lat":43.522919,"lon":-79.733754,"t":1780974666},{"alt_m":451.3,"lat":43.522502,"lon":-79.7336,"t":1780974667},{"alt_m":452.6,"lat":43.522085,"lon":-79.733447,"t":1780974668},{"alt_m":451.2,"lat":43.521668,"lon":-79.733293,"t":1780974669},{"alt_m":447.7,"lat":43.52125,"lon":-79.733139,"t":1780974670},{"alt_m":456.3,"lat":43.520833,"lon":-79.732985,"t":1780974671},{"alt_m":457.1,"lat":43.520416,"lon":-79.732831,"t":1780974672},{"alt_m":459.2,"lat":43.519999,"lon":-79.732678,"t":1780974673},{"alt_m":450.3,"lat":43.519582,"lon":-79.732524,"t":1780974674},{"alt_m":454.9,"lat":43.519164,"lon":-79.73237,"t":1780974675},{"alt_m":440.4,"lat":43.518747,"lon":-79.732216,"t":1780974676},{"alt_m":452.9,"lat":43.51833,"lon":-79.732063,"t":1780974677},{"alt_m":457.6,"lat":43.517913,"lon":-79.731909,"t":1780974678},{"alt_m":450.4,"lat":43.517496,"lon":-79.731755,"t":1780974679},{"alt_m":445.9,"lat":43.517078,"lon":-79.731601,"t":1780974680},{"alt_m":448.8,"lat":43.516661,"lon":-79.731448,"t":1780974681},{"alt_m":453.4,"lat":43.516244,"lon":-79.731294,"t":1780974682},{"alt_m":441.9,"lat":43.515827,"lon":-79.73114,"t":1780974683},{"alt_m":444.4,"lat":43.51541,"lon":-79.730986,"t":1780974684},{"alt_m":447.6,"lat":43.514992,"lon":-79.730832,"t":1780974685},{"alt_m":443.9,"lat":43.514575,"lon":-79.730679,"t":1780974686},{"alt_m":450.9,"lat":43.514158,"lon":-79.730525,"t":1780974687},{"alt_m":458.9,"lat":43.513741,"lon":-79.730371,"t":1780974688},{"alt_m":443.4,"lat":43.513324,"lon":-79.730217,"t":1780974689},{"alt_m":453.0,"lat":43.512906,"lon":-79.730064,"t":1780974690},{"alt_m":443.0,"lat":43.512489,"lon":-79.72991,"t":1780974691},{"alt_m":441.1,"lat":43.512072,"lon":-79.729756,"t":1780974692},{"alt_m":443.6,"lat":43.511655,"lon":-79.729602,"t":1780974693},{"alt_m":455.1,"lat":43.511238,"lon":-79.729449,"t":1780974694},{"alt_m":452.4,"lat":43.51082,"lon":-79.729295,"t":1780974695},{"alt_m":452.3,"lat":43.510403,"lon":-79.729141,"t":1780974696},{"alt_m":445.7,"lat":43.509986,"lon":-79.728987,"t":1780974697},{"alt_m":444.6,"lat":43.509569,"lon":-79.728833,"t":1780974698},{"alt_m":458.7,"lat":43.509152,"lon":-79.72868,"t":1780974699},{"alt_m":442.6,"lat":43.508734,"lon":-79.728526,"t":1780974700},{"alt_m":446.7,"lat":43.508317,"lon":-79.728372,"t":1780974701},{"alt_m":451.0,"lat":43.5079,"lon":-79.728218,"t":1780974702},{"alt_m":442.3,"lat":43.507483,"lon":-79.728065,"t":1780974703},{"alt_m":444.2,"lat":43.507065,"lon":-79.727911,"t":1780974704},{"alt_m":456.6,"lat":43.506648,"lon":-79.727757,"t":1780974705},{"alt_m":456.2,"lat":43.506231,"lon":-79.727603,"t":1780974706},{"alt_m":444.0,"lat":43.505814,"lon":-79.72745,"t":1780974707},{"alt_m":459.4,"lat":43.505397,"lon":-79.727296,"t":1780974708},{"alt_m":455.2,"lat":43.504979,"lon":-79.727142,"t":1780974709},{"alt_m":455.2,"lat":43.504562,"lon":-79.726988,"t":1780974710},{"alt_m":458.6,"lat":43.504145,"lon":-79.726835,"t":1780974711},{"alt_m":451.1,"lat":43.503728,"lon":-79.726681,"t":1780974712},{"alt_m":456.6,"lat":43.503311,"lon":-79.726527,"t":1780974713},{"alt_m":446.1,"lat":43.502893,"lon":-79.726373,"t":1780974714},{"alt_m":446.3,"lat":43.502476,"lon":-79.726219,"t":1780974715},{"alt_m":444.5,"lat":43.502059,"lon":-79.726066,"t":1780974716},{"alt_m":455.7,"lat":43.501642,"lon":-79.725912,"t":1780974717},{"alt_m":451.4,"lat":43.501225,"lon":-79.725758,"t":1780974718},{"alt_m":456.3,"lat":43.500807,"lon":-79.725604,"t":1780974719},{"alt_m":444.9,"lat":43.50039,"lon":-79.725451,"t":1780974720},{"alt_m":454.1,"lat":43.499973,"lon":-79.725297,"t":1780974721},{"alt_m":458.2,"lat":43.499556,"lon":-79.725143,"t":1780974722},{"alt_m":454.1,"lat":43.499139,"lon":-79.724989,"t":1780974723},{"alt_m":451.9,"lat":43.498721,"lon":-79.724836,"t":1780974724},{"alt_m":454.8,"lat":43.498304,"lon":-79.724682,"t":1780974725},{"alt_m":457.2,"lat":43.497887,"lon":-79.724528,"t":1780974726},{"alt_m":442.3,"lat":43.49747,"lon":-79.724374,"t":1780974727},{"alt_m":449.4,"lat":43.497053,"lon":-79.72422,"t":1780974728},{"alt_m":458.5,"lat":43.496635,"lon":-79.724067,"t":1780974729},{"alt_m":449.0,"lat":43.496218,"lon":-79.723913,"t":1780974730},{"alt_m":447.1,"lat":43.495801,"lon":-79.723759,"t":1780974731},{"alt_m":459.4,"lat":43.495384,"lon":-79.723605,"t":1780974732},{"alt_m":457.9,"lat":43.494967,"lon":-79.723452,"t":1780974733},{"alt_m":448.3,"lat":43.494549,"lon":-79.723298,"t":1780974734},{"alt_m":447.1,"lat":43.494132,"lon":-79.723144,"t":1780974735},{"alt_m":442.0,"lat":43.493715,"lon":-79.72299,"t":1780974736},{"alt_m":446.1,"lat":43.493298,"lon":-79.722837,"t":1780974737},{"alt_m":450.4,"lat":43.492881,"lon":-79.722683,"t":1780974738},{"alt_m":459.8,"lat":43.492463,"lon":-79.722529,"t":1780974739},{"alt_m":455.7,"lat":43.492046,"lon":-79.722375,"t":1780974740},{"alt_m":447.9,"lat":43.491629,"lon":-79.722221,"t":1780974741},{"alt_m":457.7,"lat":43.491212,"lon":-79.722068,"t":1780974742},{"alt_m":440.2,"lat":43.490795,"lon":-79.721914,"t":1780974743},{"alt_m":451.9,"lat":43.490377,"lon":-79.72176,"t":1780974744},{"alt_m":454.9,"lat":43.48996,"lon":-79.721606,"t":1780974745},{"alt_m":441.1,"lat":43.489543,"lon":-79.721453,"t":1780974746},{"alt_m":449.9,"lat":43.489126,"lon":-79.721299,"t":1780974747},{"alt_m":446.5,"lat":43.488709,"lon":-79.721145,"t":1780974748},{"alt_m":447.7,"lat":43.488291,"lon":-79.720991,"t":1780974749},{"alt_m":458.9,"lat":43.487874,"lon":-79.720838,"t":1780974750},{"alt_m":457.8,"lat":43.487457,"lon":-79.720684,"t":1780974751},{"alt_m":444.9,"lat":43.48704,"lon":-79.72053,"t":1780974752},{"alt_m":453.6,"lat":43.486623,"lon":-79.720376,"t":1780974753},{"alt_m":445.1,"lat":43.486205,"lon":-79.720222,"t":1780974754},{"alt_m":440.4,"lat":43.485788,"lon":-79.720069,"t":1780974755},{"alt_m":448.0,"lat":43.485371,"lon":-79.719915,"t":1780974756},{"alt_m":445.7,"lat":43.484954,"lon":-79.719761,"t":1780974757},{"alt_m":442.0,"lat":43.484537,"lon":-79.719607,"t":1780974758},{"alt_m":447.8,"lat":43.484119,"lon":-79.719454,"t":1780974759},{"alt_m":451.1,"lat":43.483702,"lon":-79.7193,"t":1780974760},{"alt_m":443.6,"lat":43.483285,"lon":-79.719146,"t":1780974761},{"alt_m":453.8,"lat":43.482868,"lon":-79.718992,"t":1780974762},{"alt_m":444.7,"lat":43.482451,"lon":-79.718839,"t":1780974763},{"alt_m":443.4,"lat":43.482033,"lon":-79.718685,"t":1780974764},{"alt_m":448.3,"lat":43.481616,"lon":-79.718531,"t":1780974765},{"alt_m":446.2,"lat":43.481199,"lon":-79.718377,"t":1780974766},{"alt_m":458.2,"lat":43.480782,"lon":-79.718223,"t":1780974767},{"alt_m":451.9,"lat":43.480365,"lon":-79.71807,"t":1780974768},{"alt_m":450.9,"lat":43.479947,"lon":-79.717916,"t":1780974769},{"alt_m":446.4,"lat":43.47953,"lon":-79.717762,"t":1780974770},{"alt_m":449.9,"lat":43.479113,"lon":-79.717608,"t":1780974771},{"alt_m":448.2,"lat":43.478696,"lon":-79.717455,"t":1780974772},{"alt_m":457.8,"lat":43.478279,"lon":-79.717301,"t":1780974773},{"alt_m":445.4,"lat":43.477861,"lon":-79.717147,"t":1780974774},{"alt_m":458.4,"lat":43.477444,"lon":-79.716993,"t":1780974775},{"alt_m":455.7,"lat":43.477027,"lon":-79.71684,"t":1780974776},{"alt_m":458.0,"lat":43.47661,"lon":-79.716686,"t":1780974777},{"alt_m":446.9,"lat":43.476193,"lon":-79.716532,"t":1780974778},{"alt_m":443.5,"lat":43.475775,"lon":-79.716378,"t":1780974779},{"alt_m":455.8,"lat":43.475358,"lon":-79.716224,"t":1780974780},{"alt_m":453.4,"lat":43.474941,"lon":-79.716071,"t":1780974781},{"alt_m":448.9,"lat":43.474524,"lon":-79.715917,"t":1780974782},{"alt_m":456.0,"lat":43.474107,"lon":-79.715763,"t":1780974783},{"alt_m":449.5,"lat":43.473689,"lon":-79.715609,"t":1780974784},{"alt_m":440.0,"lat":43.473272,"lon":-79.715456,"t":1780974785},{"alt_m":443.8,"lat":43.472855,"lon":-79.715302,"t":1780974786},{"alt_m":447.7,"lat":43.472438,"lon":-79.715148,"t":1780974787},{"alt_m":446.0,"lat":43.472021,"lon":-79.714994,"t":1780974788},{"alt_m":450.2,"lat":43.471603,"lon":-79.714841,"t":1780974789},{"alt_m":456.0,"lat":43.471186,"lon":-79.714687,"t":1780974790},{"alt_m":444.8,"lat":43.470769,"lon":-79.714533,"t":1780974791},{"alt_m":444.8,"lat":43.470352,"lon":-79.714379,"t":1780974792},{"alt_m":451.2,"lat":43.469935,"lon":-79.714225,"t":1780974793},{"alt_m":440.4,"lat":43.469517,"lon":-79.714072,"t":1780974794},{"alt_m":446.6,"lat":43.4691,"lon":-79.713918,"t":1780974795},{"alt_m":457.3,"lat":43.468683,"lon":-79.713764,"t":1780974796},{"alt_m":455.2,"lat":43.468266,"lon":-79.71361,"t":1780974797},{"alt_m":441.4,"lat":43.467849,"lon":-79.713457,"t":1780974798},{"alt_m":458.7,"lat":43.467431,"lon":-79.713303,"t":1780974799},{"alt_m":456.8,"lat":43.467014,"lon":-79.713149,"t":1780974800},{"alt_m":459.6,"lat":43.466597,"lon":-79.712995,"t":1780974801},{"alt_m":444.0,"lat":43.46618,"lon":-79.712842,"t":1780974802},{"alt_m":447.8,"lat":43.465763,"lon":-79.712688,"t":1780974803},{"alt_m":444.2,"lat":43.465345,"lon":-79.712534,"t":1780974804},{"alt_m":452.9,"lat":43.464928,"lon":-79.71238,"t":1780974805},{"alt_m":458.5,"lat":43.464511,"lon":-79.712226,"t":1780974806},{"alt_m":443.7,"lat":43.464094,"lon":-79.712073,"t":1780974807},{"alt_m":457.9,"lat":43.463677,"lon":-79.711919,"t":1780974808},{"alt_m":451.2,"lat":43.463259,"lon":-79.711765,"t":1780974809},{"alt_m":458.0,"lat":43.462842,"lon":-79.711611,"t":1780974810},{"alt_m":450.1,"lat":43.462425,"lon":-79.711458,"t":1780974811},{"alt_m":442.9,"lat":43.462008,"lon":-79.711304,"t":1780974812},{"alt_m":459.9,"lat":43.461591,"lon":-79.71115,"t":1780974813},{"alt_m":440.8,"lat":43.461173,"lon":-79.710996,"t":1780974814},{"alt_m":442.0,"lat":43.460756,"lon":-79.710843,"t":1780974815},{"alt_m":457.1,"lat":43.460339,"lon":-79.710689,"t":1780974816},{"alt_m":457.8,"lat":43.459922,"lon":-79.710535,"t":1780974817},{"alt_m":448.7,"lat":43.459505,"lon":-79.710381,"t":1780974818},{"alt_m":451.4,"lat":43.459087,"lon":-79.710227,"t":1780974819},{"alt_m":445.8,"lat":43.45867,"lon":-79.710074,"t":1780974820},{"alt_m":448.8,"lat":43.458253,"lon":-79.70992,"t":1780974821},{"alt_m":453.9,"lat":43.457836,"lon":-79.709766,"t":1780974822},{"alt_m":445.7,"lat":43.457419,"lon":-79.709612,"t":1780974823},{"alt_m":449.4,"lat":43.457001,"lon":-79.709459,"t":1780974824},{"alt_m":459.4,"lat":43.456584,"lon":-79.709305,"t":1780974825},{"alt_m":444.2,"lat":43.456167,"lon":-79.709151,"t":1780974826},{"alt_m":453.9,"lat":43.45575,"lon":-79.708997,"t":1780974827},{"alt_m":444.1,"lat":43.455333,"lon":-79.708844,"t":1780974828},{"alt_m":446.0,"lat":43.454915,"lon":-79.70869,"t":1780974829},{"alt_m":458.5,"lat":43.454498,"lon":-79.708536,"t":1780974830},{"alt_m":443.2,"lat":43.454081,"lon":-79.708382,"t":1780974831},{"alt_m":449.4,"lat":43.453664,"lon":-79.708228,"t":1780974832},{"alt_m":445.4,"lat":43.453246,"lon":-79.708075,"t":1780974833},{"alt_m":455.4,"lat":43.452829,"lon":-79.707921,"t":1780974834},{"alt_m":446.4,"lat":43.452412,"lon":-79.707767,"t":1780974835},{"alt_m":458.5,"lat":43.451995,"lon":-79.707613,"t":1780974836},{"alt_m":457.7,"lat":43.451578,"lon":-79.70746,"t":1780974837},{"alt_m":445.5,"lat":43.45116,"lon":-79.707306,"t":1780974838},{"alt_m":457.3,"lat":43.450743,"lon":-79.707152,"t":1780974839},{"alt_m":441.1,"lat":43.450326,"lon":-79.706998,"t":1780974840},{"alt_m":452.0,"lat":43.449909,"lon":-79.706845,"t":1780974841},{"alt_m":453.1,"lat":43.449492,"lon":-79.706691,"t":1780974842},{"alt_m":451.0,"lat":43.449074,"lon":-79.706537,"t":1780974843},{"alt_m":457.1,"lat":43.448657,"lon":-79.706383,"t":1780974844},{"alt_m":445.3,"lat":43.44824,"lon":-79.706229,"t":1780974845},{"alt_m":444.4,"lat":43.447823,"lon":-79.706076,"t":1780974846},{"alt_m":457.1,"lat":43.447406,"lon":-79.705922,"t":1780974847},{"alt_m":458.3,"lat":43.446988,"lon":-79.705768,"t":1780974848},{"alt_m":442.4,"lat":43.446571,"lon":-79.705614,"t":1780974849},{"alt_m":446.2,"lat":43.446154,"lon":-79.705461,"t":1780974850},{"alt_m":454.9,"lat":43.445737,"lon":-79.705307,"t":1780974851},{"alt_m":453.9,"lat":43.44532,"lon":-79.705153,"t":1780974852},{"alt_m":457.6,"lat":43.444902,"lon":-79.704999,"t":1780974853},{"alt_m":452.7,"lat":43.444485,"lon":-79.704846,"t":1780974854},{"alt_m":442.3,"lat":43.444068,"lon":-79.704692,"t":1780974855},{"alt_m":453.3,"lat":43.443651,"lon":-79.704538,"t":1780974856},{"alt_m":441.3,"lat":43.443234,"lon":-79.704384,"t":1780974857},{"alt_m":452.5,"lat":43.442816,"lon":-79.70423,"t":1780974858},{"alt_m":447.7,"lat":43.442399,"lon":-79.704077,"t":1780974859},{"alt_m":441.4,"lat":43.441982,"lon":-79.703923,"t":1780974860},{"alt_m":452.7,"lat":43.441565,"lon":-79.703769,"t":1780974861},{"alt_m":442.9,"lat":43.441148,"lon":-79.703615,"t":1780974862},{"alt_m":458.2,"lat":43.44073,"lon":-79.703462,"t":1780974863},{"alt_m":449.2,"lat":43.440313,"lon":-79.703308,"t":1780974864},{"alt_m":456.1,"lat":43.439896,"lon":-79.703154,"t":1780974865},{"alt_m":450.5,"lat":43.439479,"lon":-79.703,"t":1780974866},{"alt_m":448.7,"lat":43.439062,"lon":-79.702847,"t":1780974867},{"alt_m":455.5,"lat":43.438644,"lon":-79.702693,"t":1780974868},{"alt_m":454.5,"lat":43.438227,"lon":-79.702539,"t":1780974869},{"alt_m":448.2,"lat":43.43781,"lon":-79.702385,"t":1780974870},{"alt_m":442.1,"lat":43.437393,"lon":-79.702231,"t":1780974871},{"alt_m":459.1,"lat":43.436976,"lon":-79.702078,"t":1780974872},{"alt_m":445.7,"lat":43.436558,"lon":-79.701924,"t":1780974873},{"alt_m":448.4,"lat":43.436141,"lon":-79.70177,"t":1780974874},{"alt_m":453.4,"lat":43.435724,"lon":-79.701616,"t":1780974875},{"alt_m":450.5,"lat":43.435307,"lon":-79.701463,"t":1780974876},{"alt_m":445.0,"lat":43.43489,"lon":-79.701309,"t":1780974877},{"alt_m":447.3,"lat":43.434472,"lon":-79.701155,"t":1780974878},{"alt_m":451.3,"lat":43.434055,"lon":-79.701001,"t":1780974879},{"alt_m":453.4,"lat":43.433638,"lon":-79.700848,"t":1780974880},{"alt_m":448.2,"lat":43.433221,"lon":-79.700694,"t":1780974881},{"alt_m":448.2,"lat":43.432804,"lon":-79.70054,"t":1780974882},{"alt_m":449.1,"lat":43.432386,"lon":-79.700386,"t":1780974883},{"alt_m":452.4,"lat":43.431969,"lon":-79.700232,"t":1780974884},{"alt_m":454.7,"lat":43.431552,"lon":-79.700079,"t":1780974885},{"alt_m":444.6,"lat":43.431135,"lon":-79.699925,"t":1780974886},{"alt_m":449.9,"lat":43.430718,"lon":-79.699771,"t":1780974887},{"alt_m":440.9,"lat":43.4303,"lon":-79.699617,"t":1780974888},{"alt_m":452.6,"lat":43.429883,"lon":-79.699464,"t":1780974889},{"alt_m":441.7,"lat":43.429466,"lon":-79.69931,"t":1780974890},{"alt_m":450.2,"lat":43.429049,"lon":-79.699156,"t":1780974891},{"alt_m":442.8,"lat":43.428632,"lon":-79.699002,"t":1780974892},{"alt_m":442.9,"lat":43.428214,"lon":-79.698849,"t":1780974893},{"alt_m":444.1,"lat":43.427797,"lon":-79.698695,"t":1780974894},{"alt_m":447.9,"lat":43.42738,"lon":-79.698541,"t":1780974895},{"alt_m":447.7,"lat":43.426963,"lon":-79.698387,"t":1780974896},{"alt_m":443.2,"lat":43.426546,"lon":-79.698233,"t":1780974897},{"alt_m":453.3,"lat":43.426128,"lon":-79.69808,"t":1780974898},{"alt_m":453.0,"lat":43.425711,"lon":-79.697926,"t":1780974899},{"alt_m":446.1,"lat":43.425294,"lon":-79.697772,"t":1780974900},{"alt_m":459.9,"lat":43.424877,"lon":-79.697618,"t":1780974901},{"alt_m":455.2,"lat":43.42446,"lon":-79.697465,"t":1780974902},{"alt_m":455.8,"lat":43.424042,"lon":-79.697311,"t":1780974903},{"alt_m":445.8,"lat":43.423625,"lon":-79.697157,"t":1780974904},{"alt_m":450.6,"lat":43.423208,"lon":-79.697003,"t":1780974905},{"alt_m":455.4,"lat":43.422791,"lon":-79.69685,"t":1780974906},{"alt_m":454.7,"lat":43.422374,"lon":-79.696696,"t":1780974907},{"alt_m":444.7,"lat":43.421956,"lon":-79.696542,"t":1780974908},{"alt_m":446.7,"lat":43.421539,"lon":-79.696388,"t":1780974909},{"alt_m":449.5,"lat":43.421122,"lon":-79.696234,"t":1780974910},{"alt_m":458.5,"lat":43.420705,"lon":-79.696081,"t":1780974911},{"alt_m":454.7,"lat":43.420288,"lon":-79.695927,"t":1780974912},{"alt_m":440.0,"lat":43.41987,"lon":-79.695773,"t":1780974913},{"alt_m":452.7,"lat":43.419453,"lon":-79.695619,"t":1780974914},{"alt_m":442.2,"lat":43.419036,"lon":-79.695466,"t":1780974915},{"alt_m":449.2,"lat":43.418619,"lon":-79.695312,"t":1780974916},{"alt_m":454.6,"lat":43.418202,"lon":-79.695158,"t":1780974917},{"alt_m":450.2,"lat":43.417784,"lon":-79.695004,"t":1780974918},{"alt_m":457.8,"lat":43.417367,"lon":-79.694851,"t":1780974919},{"alt_m":452.9,"lat":43.41695,"lon":-79.694697,"t":1780974920},{"alt_m":452.6,"lat":43.416533,"lon":-79.694543,"t":1780974921},{"alt_m":446.1,"lat":43.416116,"lon":-79.694389,"t":1780974922},{"alt_m":459.8,"lat":43.415698,"lon":-79.694236,"t":1780974923},{"alt_m":456.7,"lat":43.415281,"lon":-79.694082,"t":1780974924},{"alt_m":441.7,"lat":43.414864,"lon":-79.693928,"t":1780974925},{"alt_m":447.9,"lat":43.414447,"lon":-79.693774,"t":1780974926},{"alt_m":442.0,"lat":43.41403,"lon":-79.69362,"t":1780974927},{"alt_m":444.6,"lat":43.413612,"lon":-79.693467,"t":1780974928},{"alt_m":450.2,"lat":43.413195,"lon":-79.693313,"t":1780974929},{"alt_m":458.6,"lat":43.412778,"lon":-79.693159,"t":1780974930},{"alt_m":444.0,"lat":43.412361,"lon":-79.693005,"t":1780974931},{"alt_m":440.9,"lat":43.411944,"lon":-79.692852,"t":1780974932},{"alt_m":448.9,"lat":43.411526,"lon":-79.692698,"t":1780974933},{"alt_m":440.8,"lat":43.411109,"lon":-79.692544,"t":1780974934},{"alt_m":459.6,"lat":43.410692,"lon":-79.69239,"t":1780974935},{"alt_m":455.4,"lat":43.410275,"lon":-79.692237,"t":1780974936},{"alt_m":450.9,"lat":43.409858,"lon":-79.692083,"t":1780974937},{"alt_m":455.5,"lat":43.40944,"lon":-79.691929,"t":1780974938},{"alt_m":454.7,"lat":43.409023,"lon":-79.691775,"t":1780974939},{"alt_m":453.1,"lat":43.408606,"lon":-79.691621,"t":1780974940},{"alt_m":447.2,"lat":43.408189,"lon":-79.691468,"t":1780974941},{"alt_m":458.4,"lat":43.407772,"lon":-79.691314,"t":1780974942},{"alt_m":441.6,"lat":43.407354,"lon":-79.69116,"t":1780974943},{"alt_m":456.3,"lat":43.406937,"lon":-79.691006,"t":1780974944},{"alt_m":452.9,"lat":43.40652,"lon":-79.690853,"t":1780974945},{"alt_m":458.9,"lat":43.406103,"lon":-79.690699,"t":1780974946},{"alt_m":449.4,"lat":43.405686,"lon":-79.690545,"t":1780974947},{"alt_m":458.2,"lat":43.405268,"lon":-79.690391,"t":1780974948},{"alt_m":445.6,"lat":43.404851,"lon":-79.690238,"t":1780974949},{"alt_m":449.4,"lat":43.404434,"lon":-79.690084,"t":1780974950},{"alt_m":457.6,"lat":43.404017,"lon":-79.68993,"t":1780974951},{"alt_m":455.2,"lat":43.4036,"lon":-79.689776,"t":1780974952},{"alt_m":454.8,"lat":43.403182,"lon":-79.689622,"t":1780974953},{"alt_m":442.3,"lat":43.402765,"lon":-79.689469,"t":1780974954},{"alt_m":451.3,"lat":43.402348,"lon":-79.689315,"t":1780974955},{"alt_m":459.5,"lat":43.401931,"lon":-79.689161,"t":1780974956},{"alt_m":449.5,"lat":43.401514,"lon":-79.689007,"t":1780974957},{"alt_m":453.4,"lat":43.401096,"lon":-79.688854,"t":1780974958},{"alt_m":440.0,"lat":43.400679,"lon":-79.6887,"t":1780974959},{"alt_m":443.2,"lat":43.400262,"lon":-79.688546,"t":1780974960},{"alt_m":457.8,"lat":43.399845,"lon":-79.688392,"t":1780974961},{"alt_m":451.0,"lat":43.399428,"lon":-79.688239,"t":1780974962},{"alt_m":451.1,"lat":43.39901,"lon":-79.688085,"t":1780974963},{"alt_m":457.1,"lat":43.398593,"lon":-79.687931,"t":1780974964},{"alt_m":458.3,"lat":43.398176,"lon":-79.687777,"t":1780974965},{"alt_m":455.1,"lat":43.397759,"lon":-79.687623,"t":1780974966},{"alt_m":458.7,"lat":43.397341,"lon":-79.68747,"t":1780974967},{"alt_m":444.4,"lat":43.396924,"lon":-79.687316,"t":1780974968},{"alt_m":459.6,"lat":43.396507,"lon":-79.687162,"t":1780974969},{"alt_m":442.9,"lat":43.39609,"lon":-79.687008,"t":1780974970},{"alt_m":458.6,"lat":43.395673,"lon":-79.686855,"t":1780974971},{"alt_m":456.3,"lat":43.395255,"lon":-79.686701,"t":1780974972},{"alt_m":445.1,"lat":43.394838,"lon":-79.686547,"t":1780974973},{"alt_m":444.2,"lat":43.394421,"lon":-79.686393,"t":1780974974},{"alt_m":454.8,"lat":43.394004,"lon":-79.68624,"t":1780974975},{"alt_m":457.8,"lat":43.393587,"lon":-79.686086,"t":1780974976},{"alt_m":442.3,"lat":43.393169,"lon":-79.685932,"t":1780974977},{"alt_m":454.1,"lat":43.392752,"lon":-79.685778,"t":1780974978},{"alt_m":447.2,"lat":43.392335,"lon":-79.685624,"t":1780974979},{"alt_m":456.0,"lat":43.391918,"lon":-79.685471,"t":1780974980},{"alt_m":454.2,"lat":43.391501,"lon":-79.685317,"t":1780974981},{"alt_m":448.2,"lat":43.391083,"lon":-79.685163,"t":1780974982},{"alt_m":447.5,"lat":43.390666,"lon":-79.685009,"t":1780974983},{"alt_m":457.1,"lat":43.390249,"lon":-79.684856,"t":1780974984},{"alt_m":458.6,"lat":43.389832,"lon":-79.684702,"t":1780974985},{"alt_m":447.2,"lat":43.389415,"lon":-79.684548,"t":1780974986},{"alt_m":450.8,"lat":43.388997,"lon":-79.684394,"t":1780974987},{"alt_m":444.4,"lat":43.38858,"lon":-79.684241,"t":1780974988},{"alt_m":443.6,"lat":43.388163,"lon":-79.684087,"t":1780974989},{"alt_m":440.1,"lat":43.387746,"lon":-79.683933,"t":1780974990},{"alt_m":452.5,"lat":43.387329,"lon":-79.683779,"t":1780974991},{"alt_m":443.3,"lat":43.386911,"lon":-79.683625,"t":1780974992},{"alt_m":440.2,"lat":43.386494,"lon":-79.683472,"t":1780974993},{"alt_m":451.1,"lat":43.386077,"lon":-79.683318,"t":1780974994},{"alt_m":456.5,"lat":43.38566,"lon":-79.683164,"t":1780974995},{"alt_m":451.7,"lat":43.385243,"lon":-79.68301,"t":1780974996},{"alt_m":449.9,"lat":43.384825,"lon":-79.682857,"t":1780974997},{"alt_m":458.9,"lat":43.384408,"lon":-79.682703,"t":1780974998},{"alt_m":458.8,"lat":43.383991,"lon":-79.682549,"t":1780974999},{"alt_m":458.1,"lat":43.383574,"lon":-79.682395,"t":1780975000},{"alt_m":440.5,"lat":43.383157,"lon":-79.682242,"t":1780975001},{"alt_m":455.7,"lat":43.382739,"lon":-79.682088,"t":1780975002},{"alt_m":441.0,"lat":43.382322,"lon":-79.681934,"t":1780975003},{"alt_m":457.3,"lat":43.381905,"lon":-79.68178,"t":1780975004},{"alt_m":446.0,"lat":43.381488,"lon":-79.681626,"t":1780975005},{"alt_m":457.2,"lat":43.381071,"lon":-79.681473,"t":1780975006},{"alt_m":450.2,"lat":43.380653,"lon":-79.681319,"t":1780975007},{"alt_m":441.6,"lat":43.380236,"lon":-79.681165,"t":1780975008},{"alt_m":456.3,"lat":43.379819,"lon":-79.681011,"t":1780975009},{"alt_m":458.1,"lat":43.379402,"lon":-79.680858,"t":1780975010},{"alt_m":445.3,"lat":43.378985,"lon":-79.680704,"t":1780975011},{"alt_m":455.4,"lat":43.378567,"lon":-79.68055,"t":1780975012},{"alt_m":452.7,"lat":43.37815,"lon":-79.680396,"t":1780975013},{"alt_m":458.6,"lat":43.377733,"lon":-79.680243,"t":1780975014},{"alt_m":448.0,"lat":43.377316,"lon":-79.680089,"t":1780975015},{"alt_m":451.0,"lat":43.376899,"lon":-79.679935,"t":1780975016},{"alt_m":440.7,"lat":43.376481,"lon":-79.679781,"t":1780975017},{"alt_m":444.5,"lat":43.376064,"lon":-79.679627,"t":1780975018},{"alt_m":444.2,"lat":43.375647,"lon":-79.679474,"t":1780975019}]}]}; diff --git a/examples/sky-monitor/ui/dashboard/sky.js b/examples/sky-monitor/ui/dashboard/sky.js index 3a7b7126ec..dce49e1285 100644 --- a/examples/sky-monitor/ui/dashboard/sky.js +++ b/examples/sky-monitor/ui/dashboard/sky.js @@ -1,327 +1,350 @@ -// RuView SkyGraph dashboard (ADR-199 presentation plane, "dashboard first"). +// RuView SkyGraph dashboard (ADR-199 presentation plane) — realtime, with +// recorded replay of real traffic. // -// Renders the embedded deterministic scenario (sky-demo-data.js, generated by -// `cargo run -p sky-monitor --release -- --emit-json ui/dashboard/sky-demo-data.js`) -// on an all-sky polar plot with replay, trails, and anomaly badges. -// -// Projection math: the JS functions below mirror coords.rs (WGS-84 geodetic -> -// ECEF -> ENU -> az/el/range) so the page works standalone. When the wasm-pack -// output is present at ./pkg/sky_monitor_wasm.js (see README.md) it is loaded -// automatically and its SkyProjector.project_batch replaces the JS math. +// 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. -/* global SKY_DATA */ +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"; -// --------------------------------------------------------------------------- -// Projection (JS mirror of examples/sky-monitor/src/coords.rs — replaced by -// the wasm module when built). -// --------------------------------------------------------------------------- +// Reference observer (matches src/config.rs ObserverConfig defaults). +const OBSERVER = { name: "oakville_node", lat: 43.4675, lon: -79.6877, alt_m: 100.0 }; -const WGS84_A = 6378137.0; -const WGS84_F = 1.0 / 298.257223563; -const WGS84_E2 = WGS84_F * (2.0 - WGS84_F); -const DEG = Math.PI / 180.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)`; -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, - ]; -} + // 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)"; -function normalizeDeg(d) { - const r = d % 360.0; - return r < 0.0 ? r + 360.0 : r; -} + 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(); -// Full WGS-84 -> observer az/el/range/bearing projection (coords.rs -// observer_frame). Returns [azDeg, elDeg, rangeM, bearingDeg]. -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; - // Great-circle initial bearing observer -> target. - const phi2 = lat * DEG, dl = (lon - obs.lon) * DEG; - const by = Math.sin(dl) * Math.cos(phi2); - const bx = Math.cos(la) * Math.sin(phi2) - Math.sin(la) * Math.cos(phi2) * Math.cos(dl); - const bearing = normalizeDeg(Math.atan2(by, bx) / DEG); - return [az, el, range, bearing]; -} + // 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(); -// Polar "fisheye" all-sky mapping (mirror of wasm/src/screen.rs): zenith at -// the centre, horizon on the inscribed circle, azimuth 0 = North = up. -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]; -} + // --- 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"; + } + } -// --------------------------------------------------------------------------- -// Optional wasm engine (preferred when ./pkg exists). -// --------------------------------------------------------------------------- + // --- 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; + } -async function loadWasmProjector(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), version: mod.version() }; - } catch (_e) { - return null; // pkg not built — JS fallback stays active + 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); -// --------------------------------------------------------------------------- -// Scene preparation: project every track point once, cache az/el/range. -// --------------------------------------------------------------------------- + // 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; + } -const BAND_COLORS = { - "normal": "#3ddc84", - "mildly unusual": "#e8d44d", - "interesting": "#ff9f43", - "strong anomaly": "#ff5252", - "rare": "#d05aff", - "baseline": "#5a6378", -}; -const TRAIL_SECS = 150; // trail length behind the dot -const LINGER_SECS = 20; // dot stays this long after the last sample -const PLAY_SPEED = 60; // replay seconds per wall-clock second + 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); } + } + } -function projectAll(tracks, projectBatch, obs, obsEcef) { - for (const tr of tracks) { - if (projectBatch) { - const flat = new Float64Array(tr.points.length * 3); - tr.points.forEach((p, i) => { flat[i * 3] = p.lat; flat[i * 3 + 1] = p.lon; flat[i * 3 + 2] = p.alt_m; }); - const out = projectBatch(flat); // [az, el, range, bearing] * N - tr.points.forEach((p, i) => { p.az = out[i * 4]; p.el = out[i * 4 + 1]; p.range = out[i * 4 + 2]; }); + // 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 tr.points) { - const [az, el, range] = observerFrameJs(obs, obsEcef, p.lat, p.lon, p.alt_m); + 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.band = tr.anomaly ? tr.anomaly.band : "baseline"; - tr.color = BAND_COLORS[tr.band] || BAND_COLORS.baseline; tr.label = tr.callsign || tr.icao24; } -} -// Last point index with p.t <= t (binary search; points are 1 Hz ordered). -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; + // 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; } - return lo; -} -// --------------------------------------------------------------------------- -// Rendering. -// --------------------------------------------------------------------------- - -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); + // 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) }; } - // 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"; -} -function drawTrack(ctx, tr, t, w, h, selected) { - const i = indexAt(tr, t); - if (i < 0 || t > tr.t1 + LINGER_SECS) return false; - // Fading trail. - ctx.lineWidth = 1.5; - for (let j = Math.max(1, i - TRAIL_SECS); 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) / TRAIL_SECS; - 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. - const p = tr.points[i]; - const [x, y, visible] = polarScreenXY(p.az, p.el, w, h); - if (!visible) return false; - const gone = t > tr.t1; // lingering after last sample - ctx.globalAlpha = gone ? Math.max(0, 1 - (t - tr.t1) / LINGER_SECS) : 1; - ctx.fillStyle = tr.color; - ctx.beginPath(); ctx.arc(x, y, selected ? 5 : 3.5, 0, Math.PI * 2); ctx.fill(); - // Overhead-candidate highlight ring. - if (tr.overhead) { - ctx.strokeStyle = "#5aa9ff"; - ctx.lineWidth = 1.2; - ctx.beginPath(); ctx.arc(x, y, 9, 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(); + // 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(); + }); } - // Callsign + altitude label. - ctx.fillStyle = "#c7d2e8"; - ctx.font = "11px monospace"; - ctx.fillText(`${tr.label} ${Math.round(p.alt_m)}m`, x + 12, y - 6); - ctx.globalAlpha = 1; - return true; -} -// --------------------------------------------------------------------------- -// App. -// --------------------------------------------------------------------------- - -async function main() { - const obs = SKY_DATA.observer; - const tracks = SKY_DATA.tracks; - const canvas = document.getElementById("sky"); - const ctx = canvas.getContext("2d"); - const scrubber = document.getElementById("scrubber"); - const clock = document.getElementById("clock"); - const playBtn = document.getElementById("play"); - const engineLabel = document.getElementById("engine"); - document.getElementById("observer-label").textContent = - `observer: ${obs.name} (${obs.lat.toFixed(4)}, ${obs.lon.toFixed(4)}, ${obs.alt_m} m)`; - - // Prefer wasm projection when ./pkg is present; otherwise mirror in JS. - const wasm = await loadWasmProjector(obs); - engineLabel.textContent = wasm - ? `projection: wasm (sky-monitor-wasm ${wasm.version})` - : "projection: JS fallback (build ./pkg for wasm)"; - projectAll(tracks, wasm && wasm.projectBatch, obs, geodeticToEcef(obs.lat, obs.lon, obs.alt_m)); + function showDetails() { + renderDetails({ + details, selected, selectedSat, satsAbove, satNames, sun, + feed, spaceWx, noveltySize: novelty.size(), conflicts, requestRoute, + }); + } - const tMin = Math.min(...tracks.map((tr) => tr.t0)); - const tMax = Math.max(...tracks.map((tr) => tr.t1)) + LINGER_SECS; - let t = tMin; - let playing = false; - let selected = null; - let lastFrame = performance.now(); + function syncSatTable() { + renderSatTable({ satTbody, satsAbove, satNames, selectedSat }, (i) => { + selectedSat = selectedSat === i ? -1 : i; + selected = null; // aircraft and satellite selection are exclusive + showDetails(); + }); + } - // --- Side panel ----------------------------------------------------------- - const tbody = document.querySelector("#track-table tbody"); - const reasons = document.getElementById("reasons"); - const rows = new Map(); - for (const tr of tracks) { - const row = document.createElement("tr"); - row.className = "track-row"; - const score = tr.anomaly ? tr.anomaly.score.toFixed(3) : "—"; - const last = tr.points[tr.points.length - 1]; - row.innerHTML = - `${tr.label}${tr.overhead ? " ◎" : ""}` + - `${Math.round(last.alt_m)}` + - `${headingOf(tr)}°` + - `${tr.band}` + - `${score}`; - row.addEventListener("click", () => { + function syncTable() { + syncLiveTable(feed, tbody, liveRows, (tr) => { selected = selected === tr ? null : tr; - if (selected) t = Math.max(tMin, tr.t0); // jump replay to the track - showReasons(); - syncScrubber(); - render(); + selectedSat = -1; // aircraft and satellite selection are exclusive + showDetails(); }); - tbody.appendChild(row); - rows.set(tr, row); } - function headingOf(tr) { - // Circular mean over the projected ground path (display only). - let s = 0, c = 0; - for (let i = 1; i < tr.points.length; i += 10) { - const a = tr.points[i - 1], b = tr.points[i]; - const brg = Math.atan2(b.lon - a.lon, b.lat - a.lat); - s += Math.sin(brg); c += Math.cos(brg); - } - return Math.round(normalizeDeg(Math.atan2(s, c) / DEG)); + // --- 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 showReasons() { - if (!selected) { - reasons.innerHTML = '
Select a track below.
'; - return; + 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; } - const who = `
${selected.label} (icao24 ${selected.icao24})` + - `${selected.overhead ? " — overhead candidate" : ""}
`; - if (!selected.anomaly) { - reasons.innerHTML = `${who}
baseline track — unscored (ADR §26: baseline before alerting)
`; - return; + 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 } - reasons.innerHTML = who + selected.anomaly.reasons - .map((r) => `
${r}
`) - .join(""); + syncTable(); + showDetails(); } - // --- Replay / scrubber ---------------------------------------------------- - function syncScrubber() { - scrubber.value = String(Math.round(((t - tMin) / (tMax - tMin)) * 1000)); + 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…"; } - scrubber.addEventListener("input", () => { - t = tMin + (Number(scrubber.value) / 1000) * (tMax - tMin); - render(); - }); - playBtn.addEventListener("click", () => { - playing = !playing; - playBtn.textContent = playing ? "Pause" : "Play"; - lastFrame = performance.now(); - if (playing) requestAnimationFrame(tick); - }); - function tick(now) { - if (!playing) return; - t += ((now - lastFrame) / 1000) * PLAY_SPEED; - lastFrame = now; - if (t >= tMax) { t = tMin; } // loop the day - syncScrubber(); - render(); - requestAnimationFrame(tick); + function exitReplay() { + replay.active = false; + replay.tracks = []; + replayBtn.textContent = "⏪ replay"; + document.body.classList.remove("replaying"); } - // --- Render ---------------------------------------------------------------- + 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; @@ -331,17 +354,75 @@ async function main() { ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, w, h); drawSkyDome(ctx, w, h); - for (const tr of tracks) { - const live = drawTrack(ctx, tr, t, w, h, tr === selected); - rows.get(tr).classList.toggle("live", live); - rows.get(tr).classList.toggle("active", tr === selected); + 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) + " UTC"; + 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); - syncScrubber(); - 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 index 1e6ee70c19..aae6ea766f 100644 --- a/examples/sky-monitor/wasm/Cargo.toml +++ b/examples/sky-monitor/wasm/Cargo.toml @@ -20,6 +20,11 @@ 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. 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 index 3af2ddb8d6..e2442d0147 100644 --- a/examples/sky-monitor/wasm/src/lib.rs +++ b/examples/sky-monitor/wasm/src/lib.rs @@ -14,6 +14,11 @@ //! * [`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. @@ -22,8 +27,12 @@ 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()) } diff --git a/examples/sky-monitor/wasm/src/sat.rs b/examples/sky-monitor/wasm/src/sat.rs new file mode 100644 index 0000000000..36fb780238 --- /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; + for k in 0..=steps { + 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; + } + let (dir, dark) = &sun[k]; + 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]); + } + } +} From 84b2169383a6866540b08e03ba516ac72234a6c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 15:59:23 +0000 Subject: [PATCH 8/8] fix(sky-monitor-wasm): clippy needless_range_loop in satellite pass prediction Enumerate the precomputed per-step sun samples instead of indexing them with the loop counter; fixes the deny-warnings Clippy CI failure on PR #549. No behavior change (13/13 wasm crate tests pass, wasm32 release build clean). https://claude.ai/code/session_013Nh9Naw8gim75DGY9LBvK7 --- examples/sky-monitor/wasm/src/sat.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/sky-monitor/wasm/src/sat.rs b/examples/sky-monitor/wasm/src/sat.rs index 36fb780238..3a611c6dab 100644 --- a/examples/sky-monitor/wasm/src/sat.rs +++ b/examples/sky-monitor/wasm/src/sat.rs @@ -216,7 +216,8 @@ impl SatPropagator { let mut open: Option = None; let mut prev_el = f64::NEG_INFINITY; let mut prev_t = start_unix; - for k in 0..=steps { + // `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 @@ -250,7 +251,6 @@ impl SatPropagator { p.az_culm = az; p.t_culm = t; } - let (dir, dark) = &sun[k]; if *dark && sat_sunlit(lat, lon, alt_m, dir) { p.visible = true; }