Spaces:
Configuration error
Configuration error
File size: 7,065 Bytes
9f21d0a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 | // Where a satellite's passes come from.
//
// The same shape as sampleSource, and for the same reasons: one interface, two
// implementations chosen once, so PassPredictor never handles a transport and the
// tests never need a worker. The differences are all consequences of pass
// prediction being a slow batch job rather than a fast stream.
//
// There is no inline fallback for a dead worker. A failed sample request stops
// satellites moving, which is worth degrading for; a failed pass request costs a
// table that stays empty, and running 40 s of prediction on the main thread to
// avoid an empty table is a worse outcome than the empty table. So a broken
// worker is reported once and every request resolves to nothing.
import type { SwathExtents } from "../../config/satelliteMetadata";
import type { GpRecord } from "./gp";
import { OrbitCache, runPassCommand, type PassCommand, type PassRequest, type PassResponse, type PassStation, type WorkerPass } from "./passWorker";
export type { PassStation, WorkerPass };
export interface PassQuery {
mode: string;
stations: PassStation[];
startEpochMs: number;
endEpochMs: number;
swath: SwathExtents;
}
/**
* One satellite's pass prediction, bound to its satnum and element set — so
* PassPredictor asks about a window and gets passes.
*/
export interface PassPredictorSource {
passes(query: PassQuery): Promise<WorkerPass[] | undefined>;
}
export interface PassSource {
predictorFor(satnum: string, record: GpRecord): PassPredictorSource;
}
/**
* Commands per message.
*
* Small, unlike the sampling source's sixty-four. Each command is milliseconds of
* propagation rather than microseconds, so a big batch would hold every result
* back until the slowest one finished. Eight is about 64 ms a reply, which is what
* makes a station's table fill in progressively instead of arriving whole.
*/
const MAX_COMMANDS_PER_MESSAGE = 8;
/** Predicts on the calling thread. The tests' implementation. */
export class InlinePassSource implements PassSource {
readonly #cache = new OrbitCache();
predictorFor(satnum: string, record: GpRecord): PassPredictorSource {
return {
passes: (query) => {
const reply = runPassCommand(this.#cache, { kind: "passes", satnum, record, ...query });
if (reply.kind !== "passes") {
return Promise.resolve(undefined);
}
return Promise.resolve(reply.passes);
},
};
}
}
interface Pending {
resolve: (passes: WorkerPass[] | undefined) => void;
satnum: string;
query: PassQuery;
record: GpRecord;
sendRecord: boolean;
}
export class WorkerPassSource implements PassSource {
#worker: Worker | undefined;
/** Set once the worker is given up on; every request resolves to nothing after. */
#broken = false;
#nextBatchId = 1;
#queued: Pending[] = [];
#flushScheduled = false;
#inFlight = new Map<number, Pending[]>();
/** Satnums whose record has been sent at least once. */
#recordSent = new Set<string>();
constructor() {
if (typeof Worker !== "function") {
this.#giveUp("this environment has no Worker");
return;
}
try {
this.#worker = new Worker(new URL("./passWorker.ts", import.meta.url), { type: "module" });
this.#worker.addEventListener("message", (event: MessageEvent<PassResponse>) => this.#accept(event.data));
this.#worker.addEventListener("error", (event) => this.#giveUp(event.message || "worker error"));
} catch (error) {
this.#giveUp(error instanceof Error ? error.message : String(error));
}
}
predictorFor(satnum: string, record: GpRecord): PassPredictorSource {
return {
passes: (query) => {
if (this.#broken) {
return Promise.resolve(undefined);
}
const sendRecord = !this.#recordSent.has(satnum);
this.#recordSent.add(satnum);
return new Promise<WorkerPass[] | undefined>((resolve) => {
this.#queued.push({ resolve, satnum, query, record, sendRecord });
this.#schedule();
});
},
};
}
/**
* Coalesce onto messages once per turn.
*
* No deadline timer, unlike the sampling source. A batch here legitimately
* takes a second, and there is no answer to "it is taking too long" that beats
* waiting — the fallback would be to do the same work on the thread this exists
* to keep free.
*/
#schedule(): void {
if (this.#flushScheduled) {
return;
}
this.#flushScheduled = true;
queueMicrotask(() => {
this.#flushScheduled = false;
this.#flush();
});
}
#flush(): void {
const queued = this.#queued;
this.#queued = [];
if (queued.length === 0 || !this.#worker) {
return;
}
for (let offset = 0; offset < queued.length; offset += MAX_COMMANDS_PER_MESSAGE) {
const pending = queued.slice(offset, offset + MAX_COMMANDS_PER_MESSAGE);
const batchId = this.#nextBatchId++;
const commands: PassCommand[] = pending.map((item) =>
item.sendRecord ? { kind: "passes", satnum: item.satnum, record: item.record, ...item.query } : { kind: "passes", satnum: item.satnum, ...item.query },
);
this.#inFlight.set(batchId, pending);
// A transfer list, not a target origin — see sampleSource.
// eslint-disable-next-line unicorn/require-post-message-target-origin
this.#worker.postMessage({ batchId, commands } satisfies PassRequest);
}
}
#accept(response: PassResponse): void {
const pending = this.#inFlight.get(response.batchId);
if (!pending) {
return;
}
this.#inFlight.delete(response.batchId);
response.replies.forEach((reply, index) => {
const item = pending[index];
if (!item) {
return;
}
if (reply.kind === "passes") {
item.resolve(reply.passes);
return;
}
if (reply.kind === "unknown") {
// The orbit was evicted, or a batch raced ahead of the one that would
// have created it. Re-queue with the record attached this time.
this.#recordSent.delete(reply.satnum);
this.#queued.push({ ...item, sendRecord: true });
this.#schedule();
return;
}
item.resolve(undefined);
});
}
/**
* Stop using the worker. Loud, and once — a worker that fails to construct or
* throws on load would otherwise look like a ground station with nothing
* overhead, which is a plausible enough answer to go unnoticed.
*/
#giveUp(reason: string): void {
if (this.#broken) {
return;
}
this.#broken = true;
console.error(`Pass prediction worker unavailable (${reason}); pass tables will stay empty`);
this.#worker?.terminate();
this.#worker = undefined;
for (const pending of this.#inFlight.values()) {
for (const item of pending) {
item.resolve(undefined);
}
}
this.#inFlight.clear();
const queued = this.#queued;
this.#queued = [];
for (const item of queued) {
item.resolve(undefined);
}
}
}
|