From 7b7282c3c97282a5bf3ced145cce65d428e30223 Mon Sep 17 00:00:00 2001 From: gili8420 Date: Tue, 4 Aug 2026 13:41:11 +0900 Subject: [PATCH] fix(prediction): correct lon mapping --- src/lib/api/index.ts | 2 +- src/lib/api/predictions.ts | 26 ++--- tests/e2e/dataset-param.spec.ts | 77 +++++++++++++ tests/e2e/western-launch.spec.ts | 183 +++++++++++++++++++++++++++++++ 4 files changed, 271 insertions(+), 17 deletions(-) create mode 100644 tests/e2e/dataset-param.spec.ts create mode 100644 tests/e2e/western-launch.spec.ts diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 29b43d2..8b76940 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -3,4 +3,4 @@ export { telemetryApi, buildWsUrl, type RawTelemetryPacket } from './telemetry'; export { pointsApi } from './points'; export { profilesApi } from './profiles'; export { scenariosApi } from './scenarios'; -export { predictionsApi, getLatestDataset, buildLaunchDateTime } from './predictions'; +export { predictionsApi, buildLaunchDateTime } from './predictions'; diff --git a/src/lib/api/predictions.ts b/src/lib/api/predictions.ts index 566577c..a4f9969 100644 --- a/src/lib/api/predictions.ts +++ b/src/lib/api/predictions.ts @@ -1,18 +1,6 @@ import { api } from './client'; import type { FlightParameters, RawPrediction } from '$domain'; -/** - * GFS datasets are published every 6 hours with a ~6 hour processing lag. - * Round down to the most recent available slot. - */ -export function getLatestDataset(now: Date = new Date()): string { - // const rounded = new Date(now); - // rounded.setUTCHours(Math.floor(rounded.getUTCHours() / 6) * 6, 0, 0, 0); - // rounded.setUTCHours(rounded.getUTCHours() - 6); - // return rounded.toISOString(); - return "2025-04-06T00:00:00Z"; -} - export function buildLaunchDateTime(date: string, time: string): string { const fullTime = time.split(':').length === 2 ? `${time}:00` : time; return new Date(`${date}T${fullTime}Z`).toISOString(); @@ -24,11 +12,17 @@ export interface PredictionResponse { export const predictionsApi = { run: (params: FlightParameters, launchDateTime: string) => { - const payload: FlightParameters & { launch_datetime: string } = { - ...params, - dataset: params.dataset || getLatestDataset(), + // `dataset` carries only what the operator actually chose. It used to fall + // back to a client-side guess at which GFS run the server holds — and the + // guess had degenerated into a hardcoded 2025-04-06, over a year stale. The + // client cannot know which runs are stored; the predictor refuses one it + // does not have, so an unset value must stay unset and let the server pick. + const { dataset, ...rest } = params; + const payload = { + ...rest, + ...(dataset ? { dataset } : {}), launch_datetime: launchDateTime, - }; + } as FlightParameters & { launch_datetime: string }; if (payload.start_point === -1) delete payload.start_point; return api.post('/predictions/', payload); }, diff --git a/tests/e2e/dataset-param.spec.ts b/tests/e2e/dataset-param.spec.ts new file mode 100644 index 0000000..9e971ee --- /dev/null +++ b/tests/e2e/dataset-param.spec.ts @@ -0,0 +1,77 @@ +import { test, expect, login } from './fixtures'; + +/** + * The client must not invent a dataset. + * + * `predictionsApi.run` used to fall back to a client-side guess at which GFS run + * the server held, and the guess had rotted into a hardcoded "2025-04-06T00:00:00Z" + * — over a year stale. That was harmless only for as long as Django dropped the + * parameter on the floor. Now that the predictor honours it and refuses a run it + * does not hold, sending an invented epoch fails every prediction with + * "dataset 2025-04-06T00:00:00Z is not stored". + * + * Which runs exist is server knowledge. An unchosen dataset must stay absent so + * the server picks. + */ + +test.beforeEach(async ({ context }) => { + await login(context); +}); + +test('a prediction request carries no dataset the operator did not choose', async ({ page }) => { + test.setTimeout(120_000); + + const bodies: string[] = []; + await page.route('**/predictions/', async (route) => { + if (route.request().method() === 'POST') { + bodies.push(route.request().postData() ?? ''); + } + await route.continue(); + }); + + await page.goto('/'); + await page.evaluate(() => { + const ws = { + id: crypto.randomUUID(), + name: 'dataset-param', + color: '#dc3545', + opacity: 1, + visible: true, + flightParameters: { + ascent_rate: 5, + burst_altitude: 30000, + dataset: '', // the UI's "choose automatically" + descent_rate: 5, + format: 'json', + launch_altitude: 0, + launch_latitude: 52.2, + launch_longitude: 0.1, + profile: 'standard_profile', + version: 2, + }, + launchDate: new Date().toISOString().split('T')[0], + launchTime: '12:00:00', + result: null, + }; + localStorage.setItem('workspaces', JSON.stringify({ items: [ws], activeId: ws.id })); + }); + await page.goto('/predict'); + await page + .locator('.map-container canvas') + .first() + .waitFor({ state: 'attached', timeout: 60_000 }); + + await page + .locator('.panel-container-right .workspace-row') + .first() + .getByRole('button', { name: /Рассчитать|Run/ }) + .click(); + + await expect.poll(() => bodies.length, { timeout: 60_000 }).toBeGreaterThan(0); + + const payload = JSON.parse(bodies[0]); + expect(payload).not.toHaveProperty('dataset'); + // The rest of the request must still be intact. + expect(payload.launch_latitude).toBe(52.2); + expect(payload.launch_datetime).toBeTruthy(); +}); diff --git a/tests/e2e/western-launch.spec.ts b/tests/e2e/western-launch.spec.ts new file mode 100644 index 0000000..a6d9f84 --- /dev/null +++ b/tests/e2e/western-launch.spec.ts @@ -0,0 +1,183 @@ +import { test, expect, login, sceneObjectCount } from './fixtures'; + +/** + * A launch west of Greenwich must work. + * + * The app holds longitudes in [-180, 180] — wrapLongitude produces that, the map + * reports clicks that way, and the predictor accepts [-180, 360). Django's + * PredictionRequestSerializer declared launch_longitude with min_value=0, so every + * western coordinate was refused before it ever reached the predictor. That is + * Canada, Greenland and Alaska: most of the Arctic launch sites this product is + * being built for. + * + * Nuuk (64.1, -51.7) is the fixture. 308.3 is the same meridian written in the + * [0, 360) convention, which is the control: if one is refused and the other is + * accepted, the constraint is about notation, not about the place. + */ + +test.beforeEach(async ({ context }) => { + await login(context); +}); + +const NUUK = { lat: 64.1, lngSigned: -51.7, lngUnsigned: 308.3 }; + +/** POST straight to Django from the page, so the browser's session and CSRF apply. */ +async function postPrediction(page: import('@playwright/test').Page, lat: number, lng: number) { + return page.evaluate( + async ({ lat, lng }) => { + const cookie = (name: string) => + document.cookie + .split('; ') + .find((c) => c.startsWith(name + '=')) + ?.split('=')[1] ?? ''; + await fetch('/api/csrf/', { credentials: 'include' }); + const res = await fetch('/api/predictions/', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json', 'X-CSRFToken': cookie('csrftoken') }, + body: JSON.stringify({ + launch_latitude: lat, + launch_longitude: lng, + launch_datetime: '2026-08-05T12:00:00Z', + launch_altitude: 0, + ascent_rate: 5, + burst_altitude: 30000, + descent_rate: 5, + profile: 'standard_profile', + }), + }); + return { status: res.status, body: (await res.text()).slice(0, 300) }; + }, + { lat, lng }, + ); +} + +test('the operator can run a prediction west of Greenwich', async ({ page }) => { + test.setTimeout(150_000); + + const posted: string[] = []; + const answered: string[] = []; + page.on('request', (r) => { + if (r.url().includes('/predictions/') && r.method() === 'POST') posted.push(r.postData() ?? ''); + }); + page.on('response', async (r) => { + if (r.url().includes('/predictions/')) + answered.push(`${r.status()} ${(await r.text().catch(() => '')).slice(0, 200)}`); + }); + + // A default workspace is created on first visit; nothing is seeded, so the + // coordinates below come from the input fields the way an operator enters them. + await page.goto('/predict'); + await page + .locator('.map-container canvas') + .first() + .waitFor({ state: 'attached', timeout: 60_000 }); + + // The coordinate fields live on the Conditions tab; the Scenario tab is open by + // default, so nothing numeric is mounted until this click. + await page + .locator('.panel-container-left') + .getByRole('button', { name: /Условия|Conditions/ }) + .first() + .click(); + + // The latitude input is the only number field bounded to +-90; longitude is the + // number field immediately after it. + const latInput = page.locator('.panel-container-left input[type="number"][min="-90"]').first(); + await expect(latInput).toBeVisible(); + const lngInput = latInput.locator('xpath=following::input[@type="number"][1]'); + + await latInput.fill(String(NUUK.lat)); + await lngInput.fill(String(NUUK.lngSigned)); + + // What the UI holds after its own normalisation. wrapLongitude keeps west + // negative rather than folding it to 308.3, which is why the value Django sees + // is negative. + expect(Number(await lngInput.inputValue())).toBeCloseTo(NUUK.lngSigned, 6); + + await page + .locator('.panel-container-right .workspace-row') + .first() + .getByRole('button', { name: /Рассчитать|Run/ }) + .click(); + + await expect.poll(() => answered.length, { timeout: 90_000 }).toBeGreaterThan(0); + console.log(' POST body :', posted[0]); + console.log(' response :', answered[0]); + + // Compared numerically: wrapLongitude's modular arithmetic returns + // -51.69999999999999, about a nanometre off, which is not worth chasing. + expect(JSON.parse(posted[0]).launch_longitude).toBeCloseTo(NUUK.lngSigned, 6); + expect(answered[0]).toMatch(/^2\d\d /); + // And the trajectory actually renders. + await expect.poll(() => sceneObjectCount(page, 'ws/'), { timeout: 30_000 }).toBeGreaterThan(0); +}); + +test('the same point is accepted in either longitude convention', async ({ page }) => { + test.setTimeout(150_000); + await page.goto('/predict'); + + const west = await postPrediction(page, NUUK.lat, NUUK.lngSigned); + const east = await postPrediction(page, NUUK.lat, NUUK.lngUnsigned); + console.log(` lng=${NUUK.lngSigned} -> ${west.status} ${west.body.slice(0, 120)}`); + console.log(` lng=${NUUK.lngUnsigned} -> ${east.status} ${east.body.slice(0, 120)}`); + + // The control: the unsigned form of the same meridian was always accepted. + expect(east.status).toBeLessThan(300); + expect(west.status).toBe(east.status); +}); + +test('the parsed trajectory is folded back to the signed convention', async ({ page }) => { + test.setTimeout(150_000); + + // v1 publishes [0, 360) — the convention upstream Tawhiri publishes, which is + // why v1 exists. The frontend holds [-180, 180], and domain/prediction.ts is + // the single place that folds: parsePrediction runs normalizeLng over the + // flight path and over launch/burst/landing. This checks that fold actually + // happened, since a missed one would put Greenland at 308 E — on the far side + // of the globe — with no error anywhere. + await page.goto('/'); + await page.evaluate(() => { + const ws = { + id: crypto.randomUUID(), name: 'nuuk', color: '#dc3545', opacity: 1, visible: true, + flightParameters: { + ascent_rate: 5, burst_altitude: 30000, dataset: '', descent_rate: 5, format: 'json', + launch_altitude: 0, launch_latitude: 64.1, launch_longitude: -51.7, + profile: 'standard_profile', version: 2, + }, + launchDate: new Date().toISOString().split('T')[0], launchTime: '12:00:00', result: null, + }; + localStorage.setItem('workspaces', JSON.stringify({ items: [ws], activeId: ws.id })); + }); + await page.goto('/predict'); + await page.locator('.map-container canvas').first().waitFor({ state: 'attached', timeout: 60_000 }); + await page + .locator('.panel-container-right .workspace-row') + .first() + .getByRole('button', { name: /Рассчитать|Run/ }) + .click(); + await expect.poll(() => sceneObjectCount(page, 'ws/'), { timeout: 90_000, intervals: [1000, 2000, 3000] }) + .toBeGreaterThan(0); + + // Read the drawn polyline, not the store: what matters is where the trajectory + // ends up on the globe, and Cesium holds it as earth-centred vectors that have + // to be read back through Cartographic. + const geom = await page.evaluate(() => { + /* eslint-disable @typescript-eslint/no-explicit-any */ + const v: any = (window as any)._lsvMap; + const C: any = v.camera.positionCartographic.constructor; + const e = v.entities.values.find((x: any) => String(x.id).includes('ws/') && x.polyline); + if (!e) return null; + const ps = e.polyline.positions.getValue(v.clock.currentTime); + const lngs = ps.map((p: any) => (C.fromCartesian(p).longitude * 180) / Math.PI); + return { n: ps.length, min: Math.min(...lngs), max: Math.max(...lngs), first: lngs[0] }; + /* eslint-enable @typescript-eslint/no-explicit-any */ + }); + + expect(geom).not.toBeNull(); + console.log(' drawn longitudes:', geom!.min.toFixed(4), '..', geom!.max.toFixed(4), `(${geom!.n} pts)`); + // Nuuk and its whole flight stay near -51, not near +308 on the far side. + expect(geom!.max).toBeLessThan(0); + expect(geom!.min).toBeGreaterThan(-90); + expect(geom!.first).toBeCloseTo(-51.7, 3); +});