/** * The vessel gate: three AIS sentinels, one berth, and the position that must * never exist. * * Every assertion in this file is about a defect that **typechecks, throws * nothing and renders a perfectly plausible harbour**. That is why they are * here rather than left to a picture: a fleet of ships all facing due north * looks like a design choice, a hull three kilometres out to sea looks like a * hull three kilometres out to sea, and a ship cutting the corner of a * breakwater looks like a ship. None of the three is visible in a still frame * and all three are wrong. * * The load-bearing one is `cog 358.7 survives`. `cog % 360` is the obvious * normalisation, it is what anybody would write, and it silently converts the * "not available" sentinel — exactly 360.0 — into a course of zero, due north. * Real course over ground reaches 358.7 in the store behind this feed, so a * range check cannot separate them either: the value, and only the value, can. */ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { AIS_COURSE_UNAVAILABLE, AIS_HEADING_UNAVAILABLE, AIS_SOG_UNAVAILABLE_KN, KNOTS_TO_MPS, VESSEL_MAKING_WAY_MPS, aisCourse, aisHeading, aisSpeedMps, berthAnchors, isMakingWay, metresBetween, modelHarbour, MODELLED_INTERVAL_SECONDS, promoteVessels, reckonVessel, resolveBearing, vesselStatus, vesselSummary, type BerthAnchor, type VesselBounds, } from "../../server/vessels.ts"; import type { Port } from "../../engine/types.ts"; import type { VesselsBody, WireVessel } from "../../server/wire.ts"; // ---- Fixtures -------------------------------------------------------------- const SAN_PEDRO: VesselBounds = { minLat: 33.55, maxLat: 33.85, minLng: -118.4, maxLng: -118.0, }; /** * One berth on Pier 400, bearing 118° — a hull lying alongside it points * east-south-east. Hand-typed like everything else on these boards; the point of * it here is that the number is *authored* and therefore known before any ship * arrives. */ const BERTH: BerthAnchor = { id: "uslax-p400-a", lat: 33.72, lng: -118.24, bearing: 118 }; function wire(overrides: Partial = {}): WireVessel { return { id: "w-1", kind: "container", lat: 33.72, lon: -118.24, speed: 0, course: null, heading: null, navStatus: 5, length: 300, beam: 45, ageSeconds: 0, ...overrides, }; } function body(vessels: WireVessel[], source: VesselsBody["source"] = "cloud1"): VesselsBody { return { source, fetchedAt: "2026-08-22T02:00:00.000Z", vessels, intervalSeconds: 900, ttlSeconds: 900, }; } // ---- The three sentinels --------------------------------------------------- describe("the AIS sentinels, none of which is ever NULL", () => { it("rejects sog 102.3, which is 'not available' and 52.6 m/s", () => { assert.equal(aisSpeedMps(AIS_SOG_UNAVAILABLE_KN), null); assert.equal(aisSpeedMps(102.3), null); // Sixty seconds of it is 3.2 km — eight SoCal scene units — which is the // whole reason this is a rejection and not a clamp. assert.ok(AIS_SOG_UNAVAILABLE_KN * KNOTS_TO_MPS * 60 > 3_000); }); it("keeps a real speed, including the zero half the fleet reports", () => { assert.equal(aisSpeedMps(0), 0); assert.ok(Math.abs((aisSpeedMps(12) ?? 0) - 12 * KNOTS_TO_MPS) < 1e-9); // Zero is an answer and not an absence: 554 of 1,138 fixes in the store are // exactly 0.0, and conflating them with "unknown" would stop every moored // ship in the product being drawn. assert.notEqual(aisSpeedMps(0), null); }); it("rejects heading 511, which 40% of fixes carry", () => { assert.equal(aisHeading(AIS_HEADING_UNAVAILABLE), null); assert.equal(aisHeading(511), null); assert.equal(aisHeading(0), 0); assert.equal(aisHeading(359.9), 359.9); }); it("rejects cog exactly 360.0", () => { assert.equal(aisCourse(AIS_COURSE_UNAVAILABLE), null); assert.equal(aisCourse(360), null); assert.equal(aisCourse(360.0), null); }); it("ACCEPTS cog 358.7 — the assertion this whole file exists for", () => { // Real course over ground reaches 358.7, and 355.0, 355.4, 355.7, 356.3, // 356.9 and 357.0 all occur in the same store. A naive range check near // north, or a `% 360`, eats every one of them or turns the sentinel into a // course. Neither may happen. assert.equal(aisCourse(358.7), 358.7); for (const cog of [355.0, 355.4, 355.7, 356.3, 356.9, 357.0, 358.7, 359.99]) { assert.equal(aisCourse(cog), cog, `real course ${cog} was eaten by the gate`); } // And the trap itself, stated so nobody reintroduces it: `% 360` maps the // sentinel onto a perfectly good course. assert.equal(AIS_COURSE_UNAVAILABLE % 360, 0); assert.notEqual(aisCourse(AIS_COURSE_UNAVAILABLE), 0); }); it("survives garbage without throwing", () => { for (const bad of [null, undefined, Number.NaN, Infinity, -1]) { assert.equal(aisSpeedMps(bad as number), null); assert.equal(aisHeading(bad as number), null); assert.equal(aisCourse(bad as number), null); } }); }); // ---- Motion is gated on speed, never on nav_status ------------------------- describe("speed gates the motion; nav_status only labels it", () => { it("treats nav_status 0 at 0.2 kn as stopped", () => { // 83 of 197 vessels reporting "under way using engine" are under half a // knot. The status is a word on a card; the speed is the fact. const speed = aisSpeedMps(0.2); assert.notEqual(speed, null); assert.equal(isMakingWay(speed), false); assert.equal(vesselStatus(0), "under-way"); const promotion = promoteVessels( body([wire({ lat: 33.7, lon: -118.2, speed: 0.2 * KNOTS_TO_MPS, navStatus: 0, course: 90 })]), SAN_PEDRO, [], ); const drawn = promotion.drawn[0]; assert.ok(drawn, "the hull was dropped rather than drawn stopped"); assert.equal(drawn.status, "under-way", "the label must survive"); assert.equal(drawn.speed, 0, "the motion must not"); assert.equal(promotion.makingWay, 0); }); it("lets a hull at 6 kn make way", () => { const promotion = promoteVessels( body([wire({ lat: 33.7, lon: -118.2, speed: 6 * KNOTS_TO_MPS, navStatus: 0, course: 210 })]), SAN_PEDRO, [], ); assert.equal(promotion.makingWay, 1); assert.ok((promotion.drawn[0]?.speed ?? 0) >= VESSEL_MAKING_WAY_MPS); }); it("refuses a wire speed that is the sentinel in metres per second", () => { // The upstream half of this feed is in another repo on another box and does // not exist yet, so "already stripped" is a promise nobody can keep today. const promotion = promoteVessels( body([wire({ speed: AIS_SOG_UNAVAILABLE_KN * KNOTS_TO_MPS })]), SAN_PEDRO, [BERTH], ); assert.equal(promotion.drawn.length, 0); assert.equal(promotion.suppressed, 1); }); }); // ---- Orientation comes from the berth -------------------------------------- describe("a berthed hull points the way its quay does", () => { it("gives a stopped hull with no heading and no cog its berth's bearing", () => { // 21 of 150 stopped vessels have neither heading nor cog, and only 75 of the // 150 have a heading at all. This is the case the layer must not be spun by. const promotion = promoteVessels( body([wire({ lat: BERTH.lat, lon: BERTH.lng, heading: null, course: null, speed: 0 })]), SAN_PEDRO, [BERTH], ); const drawn = promotion.drawn[0]; assert.ok(drawn, "a hull with no orientation from the wire was dropped"); assert.equal(drawn.bearing, BERTH.bearing); assert.equal(drawn.berthId, BERTH.id); assert.equal(promotion.alongside, 1); }); it("lets the wire nudge a berthed hull, and never swing it", () => { const nudged = resolveBearing({ heading: 121, course: null, speedMps: 0, berthBearing: 118 }); assert.equal(nudged, 121); // A heading 180 degrees off the quay is an AIS unit that is wrong about // which end is the bow, not a ship moored backwards. The concrete wins. const absurd = resolveBearing({ heading: 298, course: null, speedMps: 0, berthBearing: 118 }); assert.ok(absurd !== null && Math.abs(absurd - 118) <= 3, `berth bearing was swung to ${absurd}`); }); it("uses the course for a hull making way, and the heading for one at anchor", () => { assert.equal(resolveBearing({ heading: 30, course: 210, speedMps: 5, berthBearing: null }), 210); assert.equal(resolveBearing({ heading: 30, course: 210, speedMps: 0, berthBearing: null }), 30); }); it("withholds a hull nothing will orient, rather than inventing an angle", () => { const promotion = promoteVessels( body([wire({ lat: 33.6, lon: -118.1, heading: null, course: null, speed: 0 })]), SAN_PEDRO, [BERTH], ); assert.equal(promotion.drawn.length, 0); assert.equal(promotion.withoutOrientation, 1); assert.equal(promotion.suppressed, 0, "an unoriented hull is not the same as an unreadable one"); assert.match(vesselSummary(promotion), /which way/); }); it("does not reach across the harbour for a berth", () => { // A berth is one authored point and the reach is 400 m; a hull a kilometre // away is not lying on it. assert.ok(metresBetween(BERTH.lat, BERTH.lng, 33.73, -118.24) > 400); const promotion = promoteVessels( body([wire({ lat: 33.73, lon: -118.24, heading: 44, course: null, speed: 0 })]), SAN_PEDRO, [BERTH], ); assert.equal(promotion.drawn[0]?.bearing, 44, "the wire heading should stand off the berth"); assert.equal(promotion.drawn[0]?.berthId, undefined); }); }); // ---- Dead reckoning, and the position that must not exist ------------------ describe("motion is along the reported course and never between two fixes", () => { /** * Two fixes fifteen minutes apart, on a hull that turned. * * `A` reports a course of 090 — due east — and eight knots. Fifteen minutes * later it is reported at `B`, which is to the *south* east, because it came * round the breakwater in between. The chord from A to B is therefore a line * nothing sailed, and every point on it except the ends is a place the ship * never was. */ const A = { lat: 33.72, lng: -118.28, speed: 8 * KNOTS_TO_MPS, course: 90 }; const B = { lat: 33.69, lng: -118.2 }; it("advances along the course at the speed", () => { const after = reckonVessel(A, 450); const metres = metresBetween(A.lat, A.lng, after.lat, after.lng); assert.ok( Math.abs(metres - A.speed * 450) < 1, `advanced ${metres.toFixed(1)} m instead of ${(A.speed * 450).toFixed(1)}`, ); // Due east means the latitude does not move. assert.ok(Math.abs(after.lat - A.lat) < 1e-9, "a course of 090 changed the latitude"); assert.ok(after.lng > A.lng, "a course of 090 went west"); }); it("does not arrive at the second fix, which is what an interpolator does", () => { // The crispest statement of the rule. An interpolator hands back exactly `B` // at the end of the interval; a dead-reckoner hands back wherever the // reported course took the ship, which here is five kilometres away because // the ship turned and the course did not say so until the next fix. const atInterval = reckonVessel(A, 900); const missBy = metresBetween(atInterval.lat, atInterval.lng, B.lat, B.lng); assert.ok(missBy > 3_000, `reckoning landed ${missBy.toFixed(0)} m from the second fix`); }); it("never lands on the chord between two fixes", () => { // The geometric statement of "no interpolation": walk the interval and // assert every reckoned point stays clear of the segment A->B. It cannot be // otherwise, because `reckonVessel` is handed one fix and has no second // point to reach toward — but that is the property being pinned. // // From 150 s rather than from zero, because the first fix *is* an endpoint // of the chord and the ship genuinely was there: a clearance test that // started at t=0 would be asserting the ship was never at its own reported // position. let closest = Infinity; for (let t = 150; t <= 900; t += 30) { const at = reckonVessel(A, t); closest = Math.min(closest, metresToSegment(at, A, B)); } assert.ok( closest > 200, `a reckoned position came within ${closest.toFixed(0)} m of the chord between two fixes`, ); }); it("stops rather than sailing on for ever once the next fix is overdue", () => { const atLimit = reckonVessel(A, 900); const wayPast = reckonVessel(A, 4_000); assert.deepEqual(wayPast, atLimit); }); it("does not move a hull with no course, whatever its speed says", () => { const still = reckonVessel({ lat: 33.72, lng: -118.28, speed: 6, course: null }, 600); assert.deepEqual(still, { lat: 33.72, lng: -118.28 }); }); it("does not move a stopped hull", () => { const still = reckonVessel({ lat: 33.72, lng: -118.28, speed: 0, course: 90 }, 600); assert.deepEqual(still, { lat: 33.72, lng: -118.28 }); }); }); // ---- The empty state, designed first --------------------------------------- describe("the harbour with nothing in it says which kind of nothing it is", () => { it("distinguishes an unconfigured feed from an empty board", () => { const unconfigured = promoteVessels(null, SAN_PEDRO, []); assert.equal(unconfigured.source, "none"); assert.match(vesselSummary(unconfigured), /No vessel feed is configured/i); const answered = promoteVessels(body([]), SAN_PEDRO, []); assert.equal(answered.source, "cloud1"); assert.match(vesselSummary(answered), /answered/i); }); it("counts what it withheld rather than going blank", () => { const promotion = promoteVessels( body([ wire({ id: "off", lat: 30.0, lon: -118.2, heading: 10 }), wire({ id: "bad", lat: Number.NaN, heading: 10 }), wire({ id: "blind", lat: 33.6, lon: -118.1, heading: null, course: null }), ]), SAN_PEDRO, [], ); assert.equal(promotion.drawn.length, 0); assert.equal(promotion.offBoard, 1); assert.equal(promotion.suppressed, 1); assert.equal(promotion.withoutOrientation, 1); const summary = vesselSummary(promotion); assert.match(summary, /outside the frame/); assert.match(summary, /unreadable/); }); it("never labels a hull laden or in ballast", () => { const promotion = promoteVessels( body([wire({ lat: BERTH.lat, lon: BERTH.lng })]), SAN_PEDRO, [BERTH], ); // The owner asked "whether they are empty or full". The honest answer is a // port figure — 348,691 of 460,467 boxes left Los Angeles empty in July // 2026 — and it is never attached to a hull, because `vessels` carries no // draught column and the static AIS message is absent for most ships. assert.match(vesselSummary(promotion), /not a ship one/); assert.equal("draught" in (promotion.drawn[0] ?? {}), false); }); }); // ---- The modelled harbour -------------------------------------------------- const PORT: Port = { id: "USLAX", name: "Port of Los Angeles", lat: 33.73, lng: -118.26, harborType: "CB", channel: [ [33.705, -118.26], [33.72, -118.255], [33.74, -118.25], ], berths: [ { id: "a", lat: 33.735, lng: -118.262, bearing: 118, maxLength: 400 }, { id: "b", lat: 33.737, lng: -118.259, bearing: 118, maxLength: 400 }, { id: "c", lat: 33.739, lng: -118.256, bearing: 118, maxLength: 340 }, { id: "d", lat: 33.741, lng: -118.253, bearing: 296, maxLength: 120 }, ], }; describe("the modelled harbour, which is what runs this round", () => { it("is deterministic — two people see the same ships", () => { const a = modelHarbour([PORT], { seed: 115, atMs: 1_000_000 }); const b = modelHarbour([PORT], { seed: 115, atMs: 1_000_000 }); assert.deepEqual(a, b); const other = modelHarbour([PORT], { seed: 116, atMs: 1_000_000 }); assert.notDeepEqual(other.vessels.map((v) => v.id), []); }); it("carries no name, no MMSI, no callsign and no destination", () => { // The store has CSCL INDIAN OCEAN and EVER LOVELY in it right now, and // hardcoding them would be the fire layer's twenty-two orange marks in a // nicer costume. Identity arrives with a licence entry or not at all. for (const vessel of modelHarbour([PORT], { seed: 115 }).vessels) { for (const forbidden of ["name", "mmsi", "callsign", "destination", "draught", "laden"]) { assert.equal(forbidden in vessel, false, `a modelled vessel carried ${forbidden}`); } } }); it("says it is modelled, and the panel says so too", () => { const modelled = modelHarbour([PORT], { seed: 115 }); assert.equal(modelled.source, "modelled"); /** * A minute, not the fifteen a real AIS listener declares. * * `intervalSeconds` is a property of the *source*, and this source is a * closed form of the clock that can be asked for any instant — see * `MODELLED_INTERVAL_SECONDS`. Fifteen minutes was mimicry, and it drew * arriving ships over Terminal Island, because a consumer dead-reckons along * a straight course and the Main Channel bends. A real feed still arrives * declaring its own 900 and is still dead-reckoned for 900. */ assert.equal(modelled.intervalSeconds, MODELLED_INTERVAL_SECONDS); const promotion = promoteVessels(modelled, SAN_PEDRO, berthAnchors([PORT])); assert.match(vesselSummary(promotion), /Modelled/); assert.match(vesselSummary(promotion), /no names and no MMSIs/); }); it("volunteers no heading, so the berths have to do the work", () => { const modelled = modelHarbour([PORT], { seed: 115 }); assert.ok(modelled.vessels.length > 0); for (const vessel of modelled.vessels) assert.equal(vessel.heading, null); const promotion = promoteVessels(modelled, SAN_PEDRO, berthAnchors([PORT])); assert.ok(promotion.alongside > 0, "no modelled hull found its berth"); for (const drawn of promotion.drawn) { if (!drawn.berthId) continue; const berth = PORT.berths?.find((b) => b.id === drawn.berthId); assert.equal(drawn.bearing, berth?.bearing); } }); it("puts a handful under way on the channel and the rest alongside", () => { const promotion = promoteVessels( modelHarbour([PORT], { seed: 115, underWayPerPort: 3 }), SAN_PEDRO, berthAnchors([PORT]), ); /** * A band, not a number, and the band is the honest assertion. * * `underWayPerPort` sizes the berth cycle so that the transits add up to the * target, but the legs differ in length and each arriving or departing ship * may have a tug attending her, so the count breathes. What must hold is * that the channel is neither empty nor a traffic jam. */ assert.ok(promotion.makingWay >= 2, `only ${promotion.makingWay} under way`); assert.ok(promotion.makingWay <= 8, `${promotion.makingWay} under way is a jam`); assert.ok(promotion.alongside >= 1, "the quays came out empty"); // Every moving hull has a course, or the layer could not reckon it and would // not draw a wake — which is the one thing that reads at board scale. for (const drawn of promotion.drawn) { if (drawn.speed > 0) assert.notEqual(drawn.course, null); } }); it("draws nothing at all for a board with no ports", () => { const modelled = modelHarbour(undefined, { seed: 115 }); assert.deepEqual(modelled.vessels, []); const promotion = promoteVessels(modelled, SAN_PEDRO, []); assert.deepEqual(promotion.drawn, []); }); }); // ---- Geometry helper ------------------------------------------------------- /** Metres from a point to the segment `a`-`b`, in the flat local approximation. */ function metresToSegment( p: { lat: number; lng: number }, a: { lat: number; lng: number }, b: { lat: number; lng: number }, ): number { const scale = Math.cos((a.lat * Math.PI) / 180); const px = (p.lng - a.lng) * scale; const py = p.lat - a.lat; const bx = (b.lng - a.lng) * scale; const by = b.lat - a.lat; const denominator = bx * bx + by * by; const t = denominator > 0 ? Math.max(0, Math.min(1, (px * bx + py * by) / denominator)) : 0; const dx = px - bx * t; const dy = py - by * t; return Math.hypot(dx, dy) * 111_320; }