The city points at its own buildings, and the sky stops depending on an API
**Clouds were invisible to everyone who had not wired up NWS.** The layer
took `currentWeather()?.cloudCover ?? 0`, and `currentWeather()` is null on
any deployment without a weather source — which is the default, and the
exact configuration this repo is held to: a stranger clones it, runs one
command, and gets a city with no account and no key. Their sky was
permanently, silently empty. `atmosphere.ts` already models a sky when
nobody has observed one; it now models cover too, an observed reading
still wins outright, and the clouds are there on a bare clone.
**Both offices are pins on the city, and clicking one walks you in.** Each
pack has carried a real `site` since the sun needed one, and that
coordinate was known to the lighting and to nothing else — a visitor
looking at the board had no way to tell that two of those buildings are
ones they can go inside. The coordinates move to a tiny eagerly-imported
`offices/sites.ts` that the packs import *from*, because a pack is a 25 kB
lazy chunk and the board wants its pins long before anybody opens a door.
A test asserts the pack and the table hold the **same object**, not merely
equal values: a drifted coordinate would put the marker on one building
and the sun on another and both would look entirely plausible.
**Aircraft bank into their turns.** The roll channel existed and was never
written, so every turn was flat. Bank comes from the coordinated-turn
relation against the measured turn rate, damped by a first-order lag so it
settles rather than oscillates, and clamped at 30° like a real limiter.
Six regression tests, because roll is the one channel that feeds itself —
position and heading are recomputed from the last two observations and
wash out a bad value, while a NaN in the roll would persist for the life
of the track.
That fed straight into a real defect: `AdsbFlights` substituted
`heading: 0` for records with no `track` field, which is harmless for a
symmetrical dart and is a **sustained full-scale artefact** once aircraft
bank — a target whose real heading is 200° reported as 0° reads as a 160°
turn and pins the roll at its limiter for as long as it is in the feed.
Those records are dropped now. An aeroplane the feed will not give a
heading for is one this layer cannot draw honestly.
**The office empties out overnight.** A full complement of seated people
at one in the morning, under house lights that came on because the sun is
down, was the least believable thing left in the room once the clock
became real. A live roster always wins — an API that says the building is
empty is telling the truth about the building.
**Robots go somewhere.** They pick real addresses — a seat, a room — and
turn to face the seat when they arrive, rather than stopping at a random
angle. Godmode gets an office section: house lights forced on or off or
following the sun, robots and ceilings toggled, with a readout.
**The bundle is split.** Entry chunk 758 kB to 208 kB, with three.js and
satellite.js in a vendor chunk that survives an app deploy instead of
being re-downloaded on every one. Rollup's 500 kB warning still fires and
should — it now points at three.js, where it is true, instead of at our
code, where it was pointing at three.js all along.
Reviewers caught two false geography claims in the new prose ("both
shipped buildings stand in San Francisco" — one is across the estuary at
Alameda Point) and several miscounted figures. Fixed. In a codebase where
the comments are the design record, those are defects.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+263
-1
@@ -27,6 +27,14 @@
|
||||
* that goes flat grey the moment the wifi drops would fail that in the most
|
||||
* visible way possible.
|
||||
*
|
||||
* There is a second output alongside the rig, `cloudCover`, and it is here for
|
||||
* the same reason. It answers "how much sky has cloud in it" rather than "what
|
||||
* does that cloud do to the light" — the number a cloud layer draws from, not a
|
||||
* light — and it falls back to the same local climatology when nothing was
|
||||
* observed. See `modelledCloudCover`: a caller that reads
|
||||
* `weather?.cloudCover ?? 0` instead gets an empty sky on every deployment
|
||||
* without a weather API, which is the default one.
|
||||
*
|
||||
* Wiring one to a city, in full:
|
||||
*
|
||||
* ```ts
|
||||
@@ -465,6 +473,15 @@ export interface AtmosphereOptions {
|
||||
export interface Atmosphere {
|
||||
/** The rig this observation implies. Pure; the caller applies the result. */
|
||||
apply(env: Environment): LightingState;
|
||||
/**
|
||||
* How much of the sky has cloud in it, 0..1 — observed if anyone observed it,
|
||||
* modelled from this place and this instant if nobody did. Pure, like `apply`.
|
||||
*
|
||||
* The cause, not the consequence: what a cloud layer needs in order to draw
|
||||
* the right amount of cloud, as distinct from everything in `LightingState`,
|
||||
* which is what that cloud does to the light once it is there.
|
||||
*/
|
||||
cloudCover(env: Environment): number;
|
||||
}
|
||||
|
||||
// ---- Constants ------------------------------------------------------------
|
||||
@@ -868,7 +885,40 @@ export function createAtmosphere(options: AtmosphereOptions): Atmosphere {
|
||||
};
|
||||
}
|
||||
|
||||
return { apply };
|
||||
/**
|
||||
* The sky's own cover, 0..1, for whatever wants to draw it.
|
||||
*
|
||||
* **A method on `Atmosphere` rather than a field of `LightingState` or a
|
||||
* second return from `apply`.** The rig is a set of consequences — three
|
||||
* lights, a gradient and a fog — and CONTRACT.md §4's one-way rule survives
|
||||
* only while causes travel in an `Environment` and consequences travel in a
|
||||
* `LightingState`. A cover parked on the rig is an invitation for the next
|
||||
* module along to read a cause back out of a light, which is the shape this
|
||||
* file exists to prevent. It is not a field of `Environment` either: an
|
||||
* `Environment` is what was *observed*, `observe()` is handed nothing but a
|
||||
* place and an instant, and a modelled number sitting in the observation is
|
||||
* exactly the confusion that `WeatherObservation`'s `null`-means-unreported
|
||||
* rule is careful about.
|
||||
*
|
||||
* Which leaves a method, and the closure is the reason it is a good one: the
|
||||
* answer needs `lng`, because the marine layer's clock runs on apparent solar
|
||||
* time, and it needs this city's `marineLayer`, because the fog is a fact
|
||||
* about one coast. Both are already held here. A free function would have to
|
||||
* be handed both at every call site, and the call site that matters already
|
||||
* has an `Atmosphere` in scope.
|
||||
*/
|
||||
function cloudCover(env: Environment): number {
|
||||
// **An observation wins outright, and nothing below is allowed to argue
|
||||
// with it.** `WeatherObservation.cloudCover` is a measured fraction — never
|
||||
// `null`, unlike the fields that can go unreported — so the presence of an
|
||||
// observation at all is the whole test. A *reported* clear sky ends the
|
||||
// argument here exactly as it does for obscuration in `apply`: the model
|
||||
// below is what to do when nobody was asked, not a second opinion.
|
||||
if (env.weather) return clamp(env.weather.cloudCover, 0, 1);
|
||||
return modelledCloudCover(marineOptions, env, lng);
|
||||
}
|
||||
|
||||
return { apply, cloudCover };
|
||||
}
|
||||
|
||||
// ---- The sun's direction, and the shadow camera ---------------------------
|
||||
@@ -1377,6 +1427,183 @@ function applyObscuration(
|
||||
return fogColor;
|
||||
}
|
||||
|
||||
// ---- Modelled cloud cover -------------------------------------------------
|
||||
|
||||
/**
|
||||
* The most sky the generic term is ever allowed to cover.
|
||||
*
|
||||
* Scattered to broken, never overcast, and the cap is a statement about what
|
||||
* this module is entitled to claim. It knows one climate in detail — the coast
|
||||
* `MarineLayerOptions` describes — and for every other city it has a longitude
|
||||
* and a sun angle. That is enough to say "there is usually some cloud about,
|
||||
* more of it in the afternoon"; it is not enough to close a stranger's sky over
|
||||
* a city it has never been told anything about. An overcast is a real event with
|
||||
* a real cause, and if a deployment wants one rendered it can report one, at
|
||||
* which point `cloudCover` hands the report straight through.
|
||||
*/
|
||||
const SYNOPTIC_MAX_COVER = 0.55;
|
||||
|
||||
/**
|
||||
* The slow term: `[period in days, amplitude, phase in turns]`.
|
||||
*
|
||||
* Weather systems arrive, cover the sky for a day or two and leave, and that is
|
||||
* the variation a viewer notices across a week. Three cosines of deliberately
|
||||
* incommensurate period are the cheapest thing that produces it while staying
|
||||
* *smooth* — every requirement on this number at once. Continuous in time, and
|
||||
* in every derivative, so a clock that scrubs forward never steps. Deterministic
|
||||
* from the instant alone, so two people looking at the same city at the same
|
||||
* moment on different machines see the same sky, with no seed to agree on and
|
||||
* nothing stored. And non-repeating on any timescale anyone will watch: as
|
||||
* tenths of a day the three periods are 29, 67 and 151, all prime, so the
|
||||
* combined pattern closes after 29 × 67 × 151 tenths — 29,339.3 days, a little
|
||||
* over eighty years. A single period would come back around inside a fortnight
|
||||
* and be recognised.
|
||||
*
|
||||
* The phases are there only so the three do not all start aligned at the Unix
|
||||
* epoch, which is a real instant the clock can be scrubbed to.
|
||||
*
|
||||
* The amplitudes sum to 1, which is what lets `synopticCover` rescale without a
|
||||
* second constant to keep in step.
|
||||
*/
|
||||
const SYNOPTIC_WAVES: readonly (readonly [number, number, number])[] = [
|
||||
[2.9, 0.5, 0.13],
|
||||
[6.7, 0.32, 0.61],
|
||||
[15.1, 0.18, 0.29],
|
||||
];
|
||||
|
||||
/**
|
||||
* Exponent leaning the slow term back toward a clear sky.
|
||||
*
|
||||
* Three cosines summed and rescaled pile up around their own midpoint. Over a
|
||||
* year of hourly samples the unshaped term runs 0.20 at the tenth percentile,
|
||||
* 0.50 at the median and 0.80 at the ninetieth — a sky that is half covered
|
||||
* half the time, which is not weather, it is a permanent haze the eye stops
|
||||
* seeing after a minute. Raising it moves the middle down and leaves both ends
|
||||
* alone: the shaped term still reaches its cap on the days all three waves
|
||||
* agree and still reaches zero, but the same samples now run 0.11 / 0.38 /
|
||||
* 0.73. Multiplied out through `SYNOPTIC_MAX_COVER` and `cumulusDiurnal`, a
|
||||
* city with no marine layer spends a year at a median cover of 0.14, a
|
||||
* ninetieth percentile of 0.30 and a maximum of 0.53 — some cloud up there
|
||||
* nearly always, a busy sky now and again, and never a lid.
|
||||
*/
|
||||
const SYNOPTIC_SHAPE = 1.4;
|
||||
|
||||
/**
|
||||
* Apparent solar hour the diurnal term peaks at, and how far it falls overnight.
|
||||
*
|
||||
* Cumulus over land is built by the ground under it: the surface heats, the
|
||||
* heat takes time to get into the air above it, and the cloud that results
|
||||
* peaks well after noon and thins out overnight. Mid-afternoon here rather than
|
||||
* noon is that lag — and it is the reason this cannot be driven off the sun's
|
||||
* elevation, which is the obvious idea and is symmetric about noon. Elevation
|
||||
* alone makes nine in the morning and three in the afternoon the same sky, and
|
||||
* they are not the same sky. Apparent solar hours are asymmetric about noon and
|
||||
* are what `DIURNAL` already runs the marine layer's clock on; see `solarHours`
|
||||
* for why that is also the only clock available offline.
|
||||
*
|
||||
* A floor rather than zero because not all cloud is convective. A sky that
|
||||
* emptied completely every night and refilled every morning would be a stronger
|
||||
* claim than this module has any way to support, and the marine layer — which
|
||||
* does exactly the opposite, being thickest before dawn — is the standing proof
|
||||
* that it would be wrong somewhere. Where it sits is a compromise between that
|
||||
* and the requirement that a day not be flat: 0.4 leaves the afternoon two and
|
||||
* a half times the pre-dawn sky at most, which is a shape you can watch arrive
|
||||
* without it ever emptying.
|
||||
*/
|
||||
const CUMULUS_PEAK_SOLAR_HOUR = 15;
|
||||
const CUMULUS_NIGHT_FLOOR = 0.4;
|
||||
|
||||
/**
|
||||
* Cloud cover with nobody to ask: 0..1, from the calendar, the clock and the
|
||||
* coast.
|
||||
*
|
||||
* This is the offline path and the offline path is the *default* one. A city
|
||||
* with no weather API configured — no account, no key, no network, which is the
|
||||
* case this whole engine is written around — has no observation to draw a sky
|
||||
* from, and a caller that reads a cover of 0 out of that draws no cloud, ever.
|
||||
* The fix belongs in this file because this file already models a sky nobody
|
||||
* observed: it is where the marine layer lives, and the marine layer is the same
|
||||
* argument already won once for fog.
|
||||
*
|
||||
* Two terms, and the split is the point:
|
||||
*
|
||||
* - **The coast, when there is one.** `marineStrength` is season × clock ×
|
||||
* wind and is already what the fog is computed from; it is reused rather
|
||||
* than paraphrased, so the deck a viewer sees and the grey the rig goes are
|
||||
* the same event and cannot drift apart. Its strength reads directly as a
|
||||
* cover because that is what it physically is — an advected stratus deck at
|
||||
* full strength is a covered sky. It carries the season and the burn-off
|
||||
* clock with it, which is why a June morning here comes out closed in and a
|
||||
* December one does not.
|
||||
* - **Everywhere else.** A slow synoptic drift over days, modulated by the
|
||||
* afternoon build of cumulus over warm ground. Capped well short of
|
||||
* overcast — see `SYNOPTIC_MAX_COVER` — because unlike the marine layer it
|
||||
* is not a fact about anywhere in particular.
|
||||
*
|
||||
* The two combine by random overlap: two decks placed independently of one
|
||||
* another leave `(1 - a)(1 - b)` of the sky clear between them. Plain `max` was
|
||||
* the alternative and swallows the weaker layer whole, so a summer morning in
|
||||
* San Francisco would render identically whether or not there was anything else
|
||||
* in the sky that week.
|
||||
*
|
||||
* **Nothing here reaches the light rig, deliberately.** `apply` still reads its
|
||||
* `cloud` from `env.weather` alone and every keyframe, curve and constant above
|
||||
* is untouched — the rig is tuned and deployed and this is an output, not a new
|
||||
* input. It would also be wrong twice over: the marine half of this number
|
||||
* already reaches the rig as `obscuration`, so feeding it back in as `cloud`
|
||||
* would count the same deck against the sun twice.
|
||||
*/
|
||||
function modelledCloudCover(
|
||||
layer: MarineLayerOptions | null,
|
||||
env: Environment,
|
||||
lng: number,
|
||||
): number {
|
||||
const marine = layer ? marineStrength(layer, env, lng) : 0;
|
||||
|
||||
// UTC milliseconds, so the phase is an absolute instant rather than anything
|
||||
// to do with the viewer's timezone — two people in different zones are
|
||||
// looking at the same sky and must be given the same number for it.
|
||||
const days = env.time.getTime() / MS_PER_DAY;
|
||||
const background =
|
||||
SYNOPTIC_MAX_COVER *
|
||||
synopticCover(days) *
|
||||
cumulusDiurnal(solarHours(env.time, lng, env.sun.equationOfTime));
|
||||
|
||||
return clamp(1 - (1 - marine) * (1 - background), 0, 1);
|
||||
}
|
||||
|
||||
/** The slow term, 0..1. See `SYNOPTIC_WAVES` and `SYNOPTIC_SHAPE`. */
|
||||
function synopticCover(days: number): number {
|
||||
let sum = 0;
|
||||
for (const [period, amplitude, phase] of SYNOPTIC_WAVES) {
|
||||
sum += amplitude * Math.cos(2 * Math.PI * (days / period + phase));
|
||||
}
|
||||
// The amplitudes sum to 1, so `sum` lands in -1..1 and the base in 0..1.
|
||||
// Clamped even so, and the reason is the exponent: it is fractional, and a
|
||||
// base a single float error *below* zero raised to a fractional power is
|
||||
// `NaN` rather than a small number. Neither 0.32 nor 0.18 is exact in binary,
|
||||
// so "the amplitudes sum to 1" is true of the decimals and not quite of the
|
||||
// doubles. One `NaN` leaving here is a cloud layer that silently stops
|
||||
// drawing at one instant on one machine, which is the least debuggable
|
||||
// failure available to a function this small.
|
||||
return clamp((sum + 1) / 2, 0, 1) ** SYNOPTIC_SHAPE;
|
||||
}
|
||||
|
||||
/**
|
||||
* The afternoon build, as a multiplier on the slow term rather than a term of
|
||||
* its own: a cloudy week is cloudier in the afternoon, and a clear week is
|
||||
* still clear at four o'clock.
|
||||
*
|
||||
* A cosine rather than a table like `DIURNAL`, because this one has to close the
|
||||
* loop at midnight and a table has to be trusted to. Periodic by construction
|
||||
* means there is no midnight seam to get wrong later.
|
||||
*/
|
||||
function cumulusDiurnal(hours: number): number {
|
||||
const turns = mod(hours - CUMULUS_PEAK_SOLAR_HOUR, 24) / 24;
|
||||
const bump = 0.5 * (1 + Math.cos(2 * Math.PI * turns));
|
||||
return CUMULUS_NIGHT_FLOOR + (1 - CUMULUS_NIGHT_FLOOR) * bump;
|
||||
}
|
||||
|
||||
// ---- Time -----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -1603,4 +1830,39 @@ function wrapSigned(x: number, period: number): number {
|
||||
* them; by -7.6° it is 0.572 and #8ea0d3 from the east. The shadows swing
|
||||
* across the city over about half an hour, which is not an artefact — it is
|
||||
* what actually happens, and on the one night a month it happens on.
|
||||
*
|
||||
* **`cloudCover`, with no weather at all**, which is the case it exists for. San
|
||||
* Francisco with `PACIFIC_MARINE_LAYER`, 21 June 2026, midnight to 23:00 PDT:
|
||||
*
|
||||
* ```
|
||||
* 0.83 0.83 0.83 0.83 0.84 0.85 0.86 0.84 0.82 0.73 0.57 0.46
|
||||
* 0.28 0.27 0.25 0.23 0.30 0.43 0.54 0.72 0.76 0.81 0.81 0.82
|
||||
* ```
|
||||
*
|
||||
* A June night closed in at over four fifths, burning back to under a quarter
|
||||
* by mid-afternoon and shut again by nine — which is the marine layer's own
|
||||
* diurnal curve arriving in the sky as well as in the fog, and is the day that
|
||||
* city actually has in June. The 08:00 in that row is 0.82; the same hour four
|
||||
* months later, on 21 October, is 0.12, and solar noon on 21 December 0.04:
|
||||
* out of season the layer is
|
||||
* not there, and neither is the cloud — the same October the fog notes above
|
||||
* are careful about, arriving here for the same reason and out of the same
|
||||
* curve. Los Angeles, same 24 hours and no marine layer, runs 0.15 down
|
||||
* to 0.09 before dawn and back to 0.14 through the afternoon — the generic term
|
||||
* alone, which is a few clouds about and a slight afternoon build, and never
|
||||
* pretends to be more than that.
|
||||
*
|
||||
* Over a full year sampled hourly, San Francisco's modelled cover runs a median
|
||||
* of 0.27 and a ninetieth percentile of 0.76, and touches 1.00 at the peak of
|
||||
* the fog season; Los Angeles runs 0.14 and 0.30 against a maximum of 0.53,
|
||||
* which is `SYNOPTIC_MAX_COVER` very nearly reached. Neither produces a
|
||||
* non-finite value or leaves 0..1 anywhere in that year, including at 69.65 N,
|
||||
* where the sun never sets and `solarHours` is doing the only clock there is.
|
||||
*
|
||||
* The largest change in one minute anywhere in that year is 0.0065, at San
|
||||
* Francisco's steepest burn-off. There is nothing in this to step on: a scrubbed
|
||||
* clock moves the sky the way an advancing one does.
|
||||
*
|
||||
* And with a station reporting, the model does not get a vote: the same June
|
||||
* morning that models 0.82 returns exactly 0.05 when the observation says 0.05.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user