feat: real fire on the boards, the LA office as a twin, and a night sky worth reading
The world stops being a simulation of California and starts being California. **THE PROMOTION GATE WAS THE FIRST COMMIT, BEFORE ANY ORANGE PIXEL EXISTED.** On today's live store the SoCal board contains 22 incidents. Every one has NULL acreage and fifteen are nameless LA County dispatch numbers. Drawn naively that is 22 orange marks over Los Angeles on a day nothing is burning — in a frame that contains no other warm colour, so one glyph would be the most salient object on the board and twenty-two would spend its credibility permanently. `acres >= 10 AND contained < 80 AND type != 'RX' AND last_seen = max(last_seen)` returns 0 on SoCal, exactly 5 on California, 0 on the Bay — same body, same day, three correct answers. The empty board is a deliverable, not a fallback: it says "No active fire on this board — CAL FIRE and WFIGS, just now", states that 21 records were gated and why, lists the largest fires burning OUTSIDE the frame with distances, and counts the hot pixels it is deliberately not drawing. **The privacy leak is structurally impossible rather than carefully avoided.** cloud-1 serves a projection; the four home-relative columns never leave that box. `observations.threat` was the one that nearly got through — it is `(16/distance)^2 x log10(acres) x momentum x containment x wind-alignment`, so with acreage and containment public it inverts to a distance circle around a house and three fires give an intersection. A grep of the built bundle for distance_km, bearing_deg, threat, 7762 and the street name returns nothing. **Deliberately not used, and both would have produced a confident wrong answer:** the store's `air` table retains only the last parameter of each poll, so all 78 rows read "Good" while the live feed reports ozone 101 "Unhealthy for Sensitive Groups" — haze driven off it would clear the sky during a smoke event. And `weather` is written only inside the NWS alerts loop, so a quiet day stores no wind at all. Tera's own per-region NWS wind is already correct and already what the clouds drift on. Satellite detections are drawn as evidence and never as incidents. The permanent industrial heat source 4.7 km from the owner's house is flagged persistent and dropped, asserted by a test that first proves it is present in the fixture. MODIS integer confidence and VIIRS string confidence are branched on `sat`. **The LA office is a twin.** Its entire authored second storey — Model Loft, Model Bay, The Materials Room, 430 lines nobody had ever stood in — is reachable on foot: a walker crosses level-1 to level-2 in 73 fixed steps, floorY 0 to 5, verified against the real pack rather than a synthetic plan. Its two studio devices read real hardware through a field-allowlisted bridge: mute, volume and reachability only. Never level, because there is no passive level upstream and obtaining one would record a room with people in it. Never dB, because upstream is gainPct across four different native scales. The bridge refuses all writes. Fixed at its root: an anonymous visitor was getting permanently at-rest instruments backing off against a 401. The tier moves into `createDeviceSource`, so anon gets the living simulator three file headers already promised. **Item 8 is closed, not fixed, and the correction is the point.** The Bay Area "stutter" was GPU power management — the card sat at 500 MHz of 2725 through every run that reproduced it, 4096/2048/1024/256 shadow maps all render in 1.21-1.31 ms, and two consecutive runs over a byte-identical dist gave 33.4 then 16.7. The allowance is removed and the cell is back to 16.7. Geometry is the gate; frame time is advisory. Item 7 was re-scoped after measuring: 1,069,006 of the Bay Area's 2,265,056 triangles were the second submission of the same buildings into the shadow pass. Mobile now has its own triangle caps and bay-area mobile draws 1,266,096. Also: bridges and the freeway corridor light up at night as emission, not lights — 1,614 deck lamps and 18 tower heads on the Bay in two draw calls. The single change that made US-101 legible was moving its edge lines from the lit material to the unlit one: retroreflective paint, the argument the SFO night frame already makes. California went 21,991 lamps to 4,051, clustered at the 17 town districts, because a rural interurban corridor genuinely is unlit. Tests 1137 -> 1340, server 280. All ten budget cells pass on first attempt with no cap raised. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* Which fires may be drawn — and, far more often, which may not.
|
||||
*
|
||||
* This is the most important module in the fire feed and it is the one with the
|
||||
* least code in it. Everything else moves bytes; this decides whether a board
|
||||
* that looks calm is telling the truth.
|
||||
*
|
||||
* ### The quiet day is the dangerous one
|
||||
*
|
||||
* On the day this was written the SoCal board's bounds contained **twenty-two
|
||||
* live incident rows**. Every single one had `acres: null`. Fifteen were
|
||||
* nameless LA County dispatch numbers — `LAC-297933`, `LAC-298861` — records
|
||||
* that open when an engine rolls and close when it turns round. Nothing was
|
||||
* burning in Los Angeles. Drawn without a gate, that is twenty-two orange marks
|
||||
* over a city on a day nothing happened, in a frame whose palette was checked by
|
||||
* eye and contains no other warm colour at all: pale sand basin, white-blue
|
||||
* buildings, green ridges, blue ocean. One orange glyph is the most salient
|
||||
* object on that board. Twenty-two of them, wrong, spends the board's
|
||||
* credibility permanently, and no later polish buys it back.
|
||||
*
|
||||
* The same gate, on the same data, on the same day, returns **exactly five**
|
||||
* fires on the California board: Timber (7,591 ac, 29% contained), Alpaugh
|
||||
* (3,600 ac, 30%), Carrizo (268 ac), Amber (10 ac) and GREEN (10 ac). Both
|
||||
* answers are correct. That is the whole argument for this file: an honest empty
|
||||
* board and a truthful full one have to come out of one rule.
|
||||
*
|
||||
* ### Four independent reasons a row is not a fire
|
||||
*
|
||||
* They are independent, which is why they are four conditions and not one score:
|
||||
*
|
||||
* 1. **No acreage, or under ten.** A dispatch record with `acres: null` is not
|
||||
* an event, it is a radio call. Ten is a judgement and is stated as one — see
|
||||
* `FIRE_TIER_MIN_ACRES`.
|
||||
* 2. **Eighty percent contained or more.** A contained fire is news that has
|
||||
* finished happening.
|
||||
* 3. **`type === "RX"` — a prescribed burn.** Deliberate, scheduled, frequently
|
||||
* adjacent to real fire ground, and completely indistinguishable from a
|
||||
* wildfire under a distance filter. Drawing one as a wildfire raises an alarm
|
||||
* about a planned event.
|
||||
* 4. **A stale `lastSeen`.** The upstream collector writes every row it is
|
||||
* handed and never deletes, and it de-duplicates two agencies' copies of one
|
||||
* fire only in the list it *returns*. So the loser keeps its old `lastSeen`
|
||||
* forever: twenty-one of ninety-five rows were ghosts, including a second
|
||||
* Timber Fire 850 m from the live one with different acreage. A reader that
|
||||
* skips this both double-counts and under-reports the same fire at once.
|
||||
*
|
||||
* A fifth filter — a name that reads as a drill — is applied for the same reason
|
||||
* the first one is: an exercise is not an event.
|
||||
*
|
||||
* ### Tiers, not a score
|
||||
*
|
||||
* Tier 0 is not drawn. Tier 1 is a mark and nothing else. Tier 2 (a hundred
|
||||
* acres and up) additionally earns a plume, because a plume is the layer most
|
||||
* able to overstate: at board altitude the eye has no scale reference, and a
|
||||
* 268-acre fire under a forty-kilometre smoke column is a lie told in a medium
|
||||
* that reads as truthful.
|
||||
*
|
||||
* ### Detections are evidence, and are separated here rather than downstream
|
||||
*
|
||||
* A satellite hot pixel is a pixel that was hot on one overpass. There is a
|
||||
* permanent industrial heat source 4.7 km from the upstream operator's house
|
||||
* that appears on every pass at FRP ~1.0 with no matching incident, on both days
|
||||
* the store holds, and it will be there tomorrow. `persistent` pixels are split
|
||||
* out of the drawn set here — counted, never discarded silently — so that no
|
||||
* renderer has to remember to do it.
|
||||
*
|
||||
* ### Why this file is here and not in `engine/`
|
||||
*
|
||||
* It is pure, it imports nothing but types, and it touches neither three.js nor
|
||||
* the DOM, so the renderer and the test suite can both have it. The gate is a
|
||||
* statement about data, and a statement about data that lives inside a mesh
|
||||
* builder is a statement nobody can test without a WebGL context.
|
||||
*/
|
||||
|
||||
import type { FireDetection, FireIncident, FiresBody, FiresSourceId } from "./wire.ts";
|
||||
|
||||
// ---- The ladder -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The acreage below which a row is not drawn at all.
|
||||
*
|
||||
* **An untested judgement, and it is worth saying so out loud.** The upstream
|
||||
* store has never held a SoCal fire between 1 and 100 acres, so this boundary
|
||||
* has never been exercised against the case it exists for: a genuinely dangerous
|
||||
* five-acre fire in Griffith Park would be invisible under it. It is kept
|
||||
* because the agencies' own LA County records make the false-positive rate below
|
||||
* ten acres overwhelming — fifteen nameless dispatch numbers on an ordinary
|
||||
* Friday — and because the cost of the two errors is not symmetric. A board that
|
||||
* cries wolf is never believed again; a board that is one tier slow on a small
|
||||
* fire is a board that catches up in ten minutes. If it bites, it is one
|
||||
* constant.
|
||||
*/
|
||||
export const FIRE_TIER_MIN_ACRES = 10;
|
||||
|
||||
/** At and above this, a fire has earned a plume as well as a mark. */
|
||||
export const FIRE_TIER_PLUME_ACRES = 100;
|
||||
|
||||
/** Containment at or above which a fire is no longer news. Percent. */
|
||||
export const FIRE_MAX_CONTAINED_PCT = 80;
|
||||
|
||||
/**
|
||||
* The most fires one board will draw.
|
||||
*
|
||||
* Not a data claim — the state has produced five — but a bound on what an
|
||||
* upstream that has gone strange can do to a fixed instance buffer downstream.
|
||||
* The set is sorted by acreage first, so the cap drops the smallest.
|
||||
*/
|
||||
export const FIRE_DRAW_LIMIT = 64;
|
||||
|
||||
/** The most off-board fires worth naming in a caption. */
|
||||
export const FIRE_OFF_BOARD_LIMIT = 3;
|
||||
|
||||
/**
|
||||
* Names that describe an exercise rather than an event.
|
||||
*
|
||||
* Anchored on word boundaries, because "Drill Creek Fire" and "TEST FIRE" are
|
||||
* different things and only one of them is furniture. Matched case-insensitively
|
||||
* against the trimmed name.
|
||||
*/
|
||||
const EXERCISE_NAME = /\b(training|exercise|drill|simulation|test\s*fire|do\s*not\s*use)\b/i;
|
||||
|
||||
// ---- Shapes ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A rectangle in degrees. Structurally the `bounds` a city pack declares,
|
||||
* restated rather than imported for the reason `wire.ts` restates `SimRoute`:
|
||||
* a city pack is three thousand lines of coastline that pulls in three.js, and
|
||||
* this module is meant to be importable by a test with no renderer in it.
|
||||
*/
|
||||
export interface FireBounds {
|
||||
minLat: number;
|
||||
maxLat: number;
|
||||
minLng: number;
|
||||
maxLng: number;
|
||||
}
|
||||
|
||||
/** What tier 1 and tier 2 mean, as a number a renderer can switch on. */
|
||||
export type FireTier = 1 | 2;
|
||||
|
||||
/**
|
||||
* One fire that survived the gate, flattened for drawing.
|
||||
*
|
||||
* `acres` is a `number` here where `FireIncident.acres` is `number | null`,
|
||||
* which is the whole point of the type existing: past this gate, acreage is a
|
||||
* fact. A renderer sizing a glyph never has to ask.
|
||||
*/
|
||||
export interface DrawnFire {
|
||||
id: string;
|
||||
/** `null` where the agency published none. Never defaulted to the id. */
|
||||
name: string | null;
|
||||
lat: number;
|
||||
lon: number;
|
||||
county: string | null;
|
||||
source: string;
|
||||
url: string | null;
|
||||
acres: number;
|
||||
/** `null` means "the agency has not said", which is not zero. */
|
||||
pctContained: number | null;
|
||||
tier: FireTier;
|
||||
/** ISO-8601 of the observation `acres` came from. */
|
||||
observedAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything a board needs to draw fire, and everything it needs to explain an
|
||||
* empty one.
|
||||
*
|
||||
* `suppressed`, `offBoard` and `fetchedAt` are the three fields that make the
|
||||
* empty case honest rather than merely blank. "Nothing is burning here" is a
|
||||
* finding; "I have not heard from the feed since Tuesday" is a fault; and
|
||||
* "ninety-three thousand acres are burning a hundred and ninety kilometres north
|
||||
* of this frame's edge" is neither. A board that cannot say which one it is in
|
||||
* is a board that is guessed at.
|
||||
*/
|
||||
export interface FirePromotion {
|
||||
source: FiresSourceId;
|
||||
/** ISO-8601 of the last successful upstream fetch. Epoch zero when never. */
|
||||
fetchedAt: string;
|
||||
/** Milliseconds since `fetchedAt`, or `null` when nothing has ever answered. */
|
||||
ageMs: number | null;
|
||||
/** The de-duplication watermark every drawn row matched. */
|
||||
latestSeen: string | null;
|
||||
/** Fires inside `bounds`, worst first. At most `FIRE_DRAW_LIMIT`. */
|
||||
drawn: DrawnFire[];
|
||||
/** Fires that passed the gate but fall outside `bounds`, largest first. */
|
||||
offBoard: DrawnFire[];
|
||||
/** Live rows inside `bounds` the gate refused. The number behind a calm board. */
|
||||
suppressed: number;
|
||||
/** Hot pixels inside `bounds` that are not known furniture. */
|
||||
detections: FireDetection[];
|
||||
/** Hot pixels inside `bounds` that are. Counted so a caption can say so. */
|
||||
persistentDetections: number;
|
||||
/** How many hours of overpasses `detections` covers. */
|
||||
detectionWindowHours: number;
|
||||
}
|
||||
|
||||
/** The answer for a board with no feed behind it at all. */
|
||||
export function emptyPromotion(): FirePromotion {
|
||||
return {
|
||||
source: "none",
|
||||
fetchedAt: new Date(0).toISOString(),
|
||||
ageMs: null,
|
||||
latestSeen: null,
|
||||
drawn: [],
|
||||
offBoard: [],
|
||||
suppressed: 0,
|
||||
detections: [],
|
||||
persistentDetections: 0,
|
||||
detectionWindowHours: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- The gate -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Apply the ladder to one body, for one board.
|
||||
*
|
||||
* Pure and total: a malformed body, a body from a server one version behind, or
|
||||
* `null` all produce an empty promotion rather than an exception. That is the
|
||||
* same posture every adapter in this repo takes toward a shape it did not build,
|
||||
* and it matters more here than elsewhere — the consumer is a render loop.
|
||||
*
|
||||
* `nowMs` is injected so a test can assert on `ageMs` without owning the clock.
|
||||
*/
|
||||
export function promote(
|
||||
body: FiresBody | null | undefined,
|
||||
bounds: FireBounds,
|
||||
nowMs: number = Date.now(),
|
||||
): FirePromotion {
|
||||
const empty = emptyPromotion();
|
||||
if (body === null || body === undefined || typeof body !== "object") return empty;
|
||||
|
||||
const fetchedAt = typeof body.fetchedAt === "string" ? body.fetchedAt : empty.fetchedAt;
|
||||
const fetchedMs = Date.parse(fetchedAt);
|
||||
const ageMs = Number.isFinite(fetchedMs) && fetchedMs > 0 ? Math.max(0, nowMs - fetchedMs) : null;
|
||||
|
||||
const incidents = Array.isArray(body.incidents) ? body.incidents : [];
|
||||
// The watermark comes from the body where the server stated one, and is
|
||||
// otherwise recomputed here from the rows in hand. Recomputing is the
|
||||
// fallback and not the primary: the server sees the whole table and this sees
|
||||
// only what it was sent, so a body clipped by anything at all would move its
|
||||
// own watermark and re-admit exactly the ghosts this exists to drop.
|
||||
const latestSeen =
|
||||
typeof body.latestSeen === "string" && body.latestSeen !== ""
|
||||
? body.latestSeen
|
||||
: latestOf(incidents);
|
||||
|
||||
const drawn: DrawnFire[] = [];
|
||||
const offBoard: DrawnFire[] = [];
|
||||
let suppressed = 0;
|
||||
|
||||
for (const incident of incidents) {
|
||||
if (incident === null || typeof incident !== "object") continue;
|
||||
const lat = finite(incident.lat);
|
||||
const lon = finite(incident.lon);
|
||||
if (lat === null || lon === null) continue;
|
||||
|
||||
const inside = within(lat, lon, bounds);
|
||||
const fire = admit(incident, lat, lon, latestSeen);
|
||||
if (fire === null) {
|
||||
// Counted only for the board being drawn. A refusal in Humboldt is not
|
||||
// something a viewer looking at Los Angeles is owed a number for.
|
||||
if (inside) suppressed += 1;
|
||||
continue;
|
||||
}
|
||||
(inside ? drawn : offBoard).push(fire);
|
||||
}
|
||||
|
||||
drawn.sort(bySeverity);
|
||||
offBoard.sort(bySeverity);
|
||||
|
||||
const detections: FireDetection[] = [];
|
||||
let persistentDetections = 0;
|
||||
for (const detection of Array.isArray(body.detections) ? body.detections : []) {
|
||||
if (detection === null || typeof detection !== "object") continue;
|
||||
const lat = finite(detection.lat);
|
||||
const lon = finite(detection.lon);
|
||||
if (lat === null || lon === null) continue;
|
||||
if (!within(lat, lon, bounds)) continue;
|
||||
// Split, not filtered. The industrial flare on the I-15 corridor is a real
|
||||
// measurement of a real hot object; it is simply not news, and a layer that
|
||||
// silently dropped it would have no way to say how much of the board's
|
||||
// thermal activity is furniture.
|
||||
if (detection.persistent === true) {
|
||||
persistentDetections += 1;
|
||||
continue;
|
||||
}
|
||||
detections.push(detection);
|
||||
}
|
||||
|
||||
return {
|
||||
source: isSourceId(body.source) ? body.source : "none",
|
||||
fetchedAt,
|
||||
ageMs,
|
||||
latestSeen,
|
||||
drawn: drawn.slice(0, FIRE_DRAW_LIMIT),
|
||||
offBoard: offBoard.slice(0, FIRE_OFF_BOARD_LIMIT),
|
||||
suppressed,
|
||||
detections,
|
||||
persistentDetections,
|
||||
detectionWindowHours: finite(body.detectionWindowHours) ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The four conditions plus the exercise name, in one place, returning the
|
||||
* flattened row or `null`.
|
||||
*
|
||||
* Ordered cheapest-first only by accident; they are independent and the order
|
||||
* carries no meaning, which is deliberate. A scoring function would have an
|
||||
* order and would therefore have a tuning knob, and a tuning knob is how "does
|
||||
* this get drawn" stops being answerable in a sentence.
|
||||
*/
|
||||
function admit(
|
||||
incident: FireIncident,
|
||||
lat: number,
|
||||
lon: number,
|
||||
latestSeen: string | null,
|
||||
): DrawnFire | null {
|
||||
// 4. A row the collector has stopped seeing but never deleted.
|
||||
if (latestSeen !== null && incident.lastSeen !== latestSeen) return null;
|
||||
|
||||
// 3. A prescribed burn is not a wildfire.
|
||||
const type = typeof incident.type === "string" ? incident.type.trim().toUpperCase() : "";
|
||||
if (type === "RX") return null;
|
||||
|
||||
// 1. No acreage is not "small". It is "nobody has said this is a fire".
|
||||
const acres = finite(incident.acres);
|
||||
if (acres === null || acres < FIRE_TIER_MIN_ACRES) return null;
|
||||
|
||||
// 2. Contained is finished.
|
||||
const pctContained = finite(incident.pctContained);
|
||||
if ((pctContained ?? 0) >= FIRE_MAX_CONTAINED_PCT) return null;
|
||||
|
||||
const name = typeof incident.name === "string" ? incident.name.trim() : "";
|
||||
if (name !== "" && EXERCISE_NAME.test(name)) return null;
|
||||
|
||||
return {
|
||||
id: typeof incident.id === "string" ? incident.id : "",
|
||||
name: name === "" ? null : name,
|
||||
lat,
|
||||
lon,
|
||||
county: typeof incident.county === "string" && incident.county !== "" ? incident.county : null,
|
||||
source: typeof incident.source === "string" ? incident.source : "",
|
||||
url: typeof incident.url === "string" && incident.url !== "" ? incident.url : null,
|
||||
acres,
|
||||
pctContained,
|
||||
tier: acres >= FIRE_TIER_PLUME_ACRES ? 2 : 1,
|
||||
observedAt: typeof incident.observedAt === "string" ? incident.observedAt : null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Detections -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* One hot pixel's confidence as a fraction, or `null` where it cannot be read.
|
||||
*
|
||||
* **The branch that has to exist.** MODIS publishes an integer 0–100 in this
|
||||
* column and VIIRS publishes `low`/`nominal`/`high` in the same one, so a
|
||||
* consumer that maps the raw value to an opacity is wrong for one of the two on
|
||||
* every frame — and wrong in the direction that matters, because `"nominal"`
|
||||
* parses to `NaN` and `NaN` reaches a shader as a hole.
|
||||
*
|
||||
* The VIIRS steps are the product's own three-way split and are placed at the
|
||||
* middles of the thirds rather than at 0/0.5/1, because `low` is not "no
|
||||
* confidence" — it is the bottom band of a detection that was still published.
|
||||
*/
|
||||
export function detectionConfidence(detection: FireDetection): number | null {
|
||||
const raw = detection.confidence;
|
||||
if (typeof raw !== "string" || raw.trim() === "") return null;
|
||||
const value = raw.trim().toLowerCase();
|
||||
|
||||
if (typeof detection.sat === "string" && detection.sat.toUpperCase().startsWith("VIIRS")) {
|
||||
if (value === "low" || value === "l") return 1 / 6;
|
||||
if (value === "nominal" || value === "n") return 0.5;
|
||||
if (value === "high" || value === "h") return 5 / 6;
|
||||
return null;
|
||||
}
|
||||
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return null;
|
||||
return Math.min(1, Math.max(0, numeric / 100));
|
||||
}
|
||||
|
||||
// ---- Small helpers --------------------------------------------------------
|
||||
|
||||
function within(lat: number, lon: number, bounds: FireBounds): boolean {
|
||||
return (
|
||||
lat >= bounds.minLat && lat <= bounds.maxLat && lon >= bounds.minLng && lon <= bounds.maxLng
|
||||
);
|
||||
}
|
||||
|
||||
/** Worst first: acreage descending, then least-contained, then id for stability. */
|
||||
function bySeverity(a: DrawnFire, b: DrawnFire): number {
|
||||
if (b.acres !== a.acres) return b.acres - a.acres;
|
||||
const ca = a.pctContained ?? 0;
|
||||
const cb = b.pctContained ?? 0;
|
||||
if (ca !== cb) return ca - cb;
|
||||
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
||||
}
|
||||
|
||||
function latestOf(incidents: readonly FireIncident[]): string | null {
|
||||
let latest: string | null = null;
|
||||
for (const incident of incidents) {
|
||||
const seen = incident?.lastSeen;
|
||||
if (typeof seen !== "string" || seen === "") continue;
|
||||
if (latest === null || seen > latest) latest = seen;
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
function finite(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function isSourceId(value: unknown): value is FiresSourceId {
|
||||
return value === "none" || value === "cloud1";
|
||||
}
|
||||
+206
-5
@@ -23,6 +23,7 @@
|
||||
* | `GET /health` | `HealthBody` | no |
|
||||
* | `GET /flights` | `FlightsBody` | yes |
|
||||
* | `GET /satellites` | `SatellitesBody` | yes |
|
||||
* | `GET /fires` | `FiresBody` | yes |
|
||||
* | `GET /weather` | `WeatherBody` | yes |
|
||||
* | `GET /markers` | `MarkersBody` | yes |
|
||||
* | `GET /offices/:id` | `OfficeDoc` | public offices only |
|
||||
@@ -72,6 +73,26 @@ export interface ErrorBody {
|
||||
export type WeatherSourceId = "none" | "nws" | "metno" | "openmeteo";
|
||||
export type FlightsSourceId = "sim" | "adsb" | "dump1090";
|
||||
export type SatellitesSourceId = "none" | "celestrak";
|
||||
/**
|
||||
* Where fire data comes from.
|
||||
*
|
||||
* `none` is the default and serves a real, empty body — never an invented fire.
|
||||
* The asymmetry with `FlightsBody`'s simulated plan is deliberate and is the
|
||||
* same one `SatellitesBody` draws: an invented aeroplane is a plausible
|
||||
* aeroplane, and an invented wildfire is a claim that a named place is burning.
|
||||
*
|
||||
* `cloud1` is a **projection served by another machine**, which is the whole
|
||||
* design of this feed rather than an implementation detail. The upstream store
|
||||
* is centred on a private home and carries four columns computed from the
|
||||
* distance to it — `observations.distance_km`, `bearing_deg`, `threat` and
|
||||
* `detections.distance_km`. `threat` is the subtle one: it is
|
||||
* `(16/distance)^2 x log10(acres) x momentum x containment x wind-alignment`,
|
||||
* so with acreage and containment public it inverts to a circle around the
|
||||
* house and three fires give an intersection. None of those columns appears in
|
||||
* any type below, and the reason they cannot appear is not care — it is that
|
||||
* the machine holding them never sends them. See `server/src/fires/cloud1.ts`.
|
||||
*/
|
||||
export type FiresSourceId = "none" | "cloud1";
|
||||
export type MarkersSourceId = "none" | "file";
|
||||
/**
|
||||
* Where device readings come from.
|
||||
@@ -80,12 +101,21 @@ export type MarkersSourceId = "none" | "file";
|
||||
* any hardware has no hardware, which renders as a studio whose panels say so
|
||||
* rather than as an error. `sim` is the deterministic state machine in
|
||||
* `src/devices/sim.ts`, the same module the arena wraps, and it is what this
|
||||
* build ships. `homeassistant` is named here and implemented nowhere: it is the
|
||||
* door a `first-party-sensor` provenance comes through, and naming it in the
|
||||
* union now is what stops the next person from adding a second, differently
|
||||
* shaped source field when they build it.
|
||||
* build ships.
|
||||
*
|
||||
* `first-party` is an operator's own bridge to their own hardware, reached over
|
||||
* their own network — `server/src/devices/firstParty.ts`. It is deliberately
|
||||
* **not** called `la-studio`: the first one of these happens to read Lumbridge's
|
||||
* studio, but a self-hoster pointing `TERA_STUDIO_URL` at their own box is the
|
||||
* same source and should not have to name somebody else's room to use it.
|
||||
*
|
||||
* `homeassistant` is named here and implemented nowhere, and stays that way: it
|
||||
* is one layer further out than `first-party` (a first-party bridge may itself
|
||||
* talk to Home Assistant), and naming it in the union is what stops the next
|
||||
* person from adding a second, differently shaped source field when they build
|
||||
* it. `config.ts` demotes it loudly.
|
||||
*/
|
||||
export type DevicesSourceId = "none" | "sim" | "homeassistant";
|
||||
export type DevicesSourceId = "none" | "sim" | "homeassistant" | "first-party";
|
||||
export type AuthMode = "none" | "sso" | "jwt";
|
||||
|
||||
/**
|
||||
@@ -130,6 +160,16 @@ export interface HealthBody {
|
||||
* pointless before it opens a watch that will 404 forever.
|
||||
*/
|
||||
devices: DevicesSourceId;
|
||||
/**
|
||||
* Newer again, and read the same defensive way `devices` is: a browser
|
||||
* meeting a server one version behind this one sees `undefined` and must
|
||||
* conclude the box serves no fires, which is the safe direction. A board
|
||||
* that draws nothing because nobody answered and a board that draws nothing
|
||||
* because nothing is burning are the same picture, and only the fetch age
|
||||
* beside it tells them apart — which is why `FiresBody.fetchedAt` is not
|
||||
* optional.
|
||||
*/
|
||||
fires?: FiresSourceId;
|
||||
};
|
||||
auth: {
|
||||
mode: AuthMode;
|
||||
@@ -390,6 +430,167 @@ export interface SatellitesBody {
|
||||
attribution?: string[];
|
||||
}
|
||||
|
||||
// ---- Fires ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* One active wildfire incident, as CAL FIRE and WFIGS describe it — and as a
|
||||
* projection served by cloud-1 is willing to say.
|
||||
*
|
||||
* ### What is not here, and why that is structural
|
||||
*
|
||||
* The upstream store carries `distance_km`, `bearing_deg` and `threat` on every
|
||||
* observation, all three measured from a private home. They are absent from this
|
||||
* type, but the type is not what keeps them off the wire: the machine that holds
|
||||
* them never puts them in a response. `server/src/fires/cloud1.ts` reads a
|
||||
* projection over HTTP and has no database to be careless with. That ordering is
|
||||
* the point — a filter downstream of a copy is a filter somebody can forget.
|
||||
*
|
||||
* ### Identity is split from observation upstream, and rejoined here
|
||||
*
|
||||
* `incidents` holds the name and the coordinate; `observations` holds acreage
|
||||
* and containment, one row per ten-minute poll. The projection joins each
|
||||
* incident to its **latest** observation, so `acres` and `pctContained` describe
|
||||
* the same instant `observedAt` names. Both are `null` where the agency has not
|
||||
* said, and null acreage is common: on the SoCal board today every live incident
|
||||
* has it, because fifteen of them are LA County dispatch numbers that will never
|
||||
* become fires. `promote()` in `src/server/fires.ts` is what refuses to draw
|
||||
* them, and it is the reason this type exposes the raw nulls rather than
|
||||
* defaulting them to zero — a zero would pass a `>= 0` test somewhere.
|
||||
*/
|
||||
export interface FireIncident {
|
||||
/** IrwinID (WFIGS) or UniqueId (CAL FIRE). Stable for the incident's life. */
|
||||
id: string;
|
||||
/** `calfire`, `wfigs`. Which agency's record this row came from. */
|
||||
source: string;
|
||||
/**
|
||||
* The incident name, trimmed, or `null` where the agency published none.
|
||||
*
|
||||
* Not defaulted to the id. Fifteen of today's live SoCal rows are named
|
||||
* `LAC-297933` and similar — a dispatch number, not a fire — and a renderer
|
||||
* that printed one under a flame glyph would be inventing an event.
|
||||
*/
|
||||
name: string | null;
|
||||
lat: number;
|
||||
lon: number;
|
||||
/** Where the coordinate came from. `us-gov` for both agencies. CONTRACT.md §8. */
|
||||
provenance: CoordinateProvenance;
|
||||
county: string | null;
|
||||
/**
|
||||
* `WF` for a wildfire, `RX` for a **prescribed burn**, `""` where unstated.
|
||||
*
|
||||
* Carried rather than filtered upstream because it is the client's tier gate
|
||||
* that must refuse it, and refusing it silently at the endpoint would leave no
|
||||
* way to say how many were refused. An RX is deliberate, scheduled and
|
||||
* frequently adjacent to a real fire; under a distance filter it is
|
||||
* indistinguishable from one, and drawing it as a wildfire is a false alarm
|
||||
* about a planned event.
|
||||
*/
|
||||
type: string;
|
||||
url: string | null;
|
||||
/** ISO-8601. When this incident was first written to the store. */
|
||||
firstSeen: string;
|
||||
/**
|
||||
* ISO-8601. The last poll that saw this incident in an agency feed.
|
||||
*
|
||||
* **Load-bearing, and the reason a body-level `latestSeen` sits beside it.**
|
||||
* The collector writes every row it is handed and never deletes, and it
|
||||
* de-duplicates two agencies' copies of one fire only in the list it returns —
|
||||
* so the loser of a de-duplication keeps its old `lastSeen` forever. Twenty-one
|
||||
* of ninety-five rows were ghosts when this was designed, including a second
|
||||
* Timber Fire 850 m away with different acreage, which means a naive reader
|
||||
* both double-counts and under-reports the same fire at once. Anything drawn
|
||||
* must have `lastSeen === FiresBody.latestSeen`.
|
||||
*/
|
||||
lastSeen: string;
|
||||
/** ISO-8601 of the joined observation, or `null` when there is none at all. */
|
||||
observedAt: string | null;
|
||||
/** Acres burned, as last reported. `null` is "the agency has not said". */
|
||||
acres: number | null;
|
||||
/** Percent contained, 0–100. `null` is "not said", which is not zero. */
|
||||
pctContained: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One satellite hot pixel. **Evidence, never an incident.**
|
||||
*
|
||||
* A thermal anomaly in a NASA FIRMS product is a pixel that was hotter than its
|
||||
* neighbours on one overpass. Most of them are not fires: there is a permanent
|
||||
* industrial heat source 4.7 km from the upstream operator's house that appears
|
||||
* on every pass at FRP ~1.0 with no matching incident, and it will be there
|
||||
* tomorrow. Anything that renders this layer must make it visually weaker than
|
||||
* and separate from the incident layer, and must never promote one to a fire
|
||||
* client-side. `promote()` drops `persistent` pixels from the drawn set for
|
||||
* exactly that reason.
|
||||
*/
|
||||
export interface FireDetection {
|
||||
/** `MODIS`, `VIIRS-NOAA20`, `VIIRS-SNPP` — the instrument, verbatim. */
|
||||
sat: string;
|
||||
/** ISO-8601, normalised from the archive's `YYYY-MM-DDTHHMMZ`. */
|
||||
acquiredAt: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
/** Fire radiative power, megawatts. `null` where the product did not report. */
|
||||
frp: number | null;
|
||||
/**
|
||||
* The product's own confidence string, **unconverted**.
|
||||
*
|
||||
* MODIS reports an integer 0–100 and VIIRS reports `low`/`nominal`/`high`, in
|
||||
* the same column, and a consumer that maps this to an opacity without
|
||||
* branching on `sat` is wrong for one of the two on every frame. Use
|
||||
* `detectionConfidence()` in `src/server/fires.ts`, which does the branch once.
|
||||
*/
|
||||
confidence: string | null;
|
||||
/**
|
||||
* Has a low-power pixel appeared in this cell on more than one day?
|
||||
*
|
||||
* The learned ignore-list for industrial heat, computed at the endpoint over a
|
||||
* fourteen-day window: distinct days on which a sub-5-MW pixel landed within
|
||||
* 0.02° of here. `true` means furniture — a flare stack, a kiln, a landfill —
|
||||
* not news. It is deliberately computed from the store rather than from a
|
||||
* hand-written exclusion list, because the list nobody maintains is the list
|
||||
* that gets a real fire suppressed.
|
||||
*/
|
||||
persistent: boolean;
|
||||
/** How many distinct days fed `persistent`. Carried so a caption can say it. */
|
||||
persistentDays: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every fire this deployment knows about, and when it last managed to ask.
|
||||
*
|
||||
* One body for the whole state and one cache key, exactly as `SatellitesBody`
|
||||
* argues: the boards this build draws are rectangles inside California, the
|
||||
* incident set is small (five drawable fires state-wide on the day this was
|
||||
* written), and a server that filtered by board would be doing a worse job of a
|
||||
* clip the client has to do anyway. `promote()` is the clip.
|
||||
*
|
||||
* `fetchedAt` is **not optional and not the response time**. A board with
|
||||
* nothing on it is the commonest correct answer this feed will ever give, and a
|
||||
* silent empty board is indistinguishable from a dead feed without an age beside
|
||||
* it. That is the same argument `HealthBody.degraded` makes, applied to a
|
||||
* picture instead of a log.
|
||||
*/
|
||||
export interface FiresBody {
|
||||
source: FiresSourceId;
|
||||
/** ISO-8601, the last time a fetch **succeeded**. Epoch zero when never. */
|
||||
fetchedAt: string;
|
||||
/**
|
||||
* The newest `lastSeen` in the upstream incident table, or `null` when it is
|
||||
* empty.
|
||||
*
|
||||
* The de-duplication watermark. Every drawn incident must match it exactly;
|
||||
* see `FireIncident.lastSeen` for what happens to a reader that does not
|
||||
* check.
|
||||
*/
|
||||
latestSeen: string | null;
|
||||
incidents: FireIncident[];
|
||||
detections: FireDetection[];
|
||||
/** How many hours of overpasses `detections` covers. */
|
||||
detectionWindowHours: number;
|
||||
ttlSeconds: number;
|
||||
attribution?: string[];
|
||||
}
|
||||
|
||||
// ---- Weather --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user