From c48dc5b94ca998c05da18c8a4115b3ad291b8a71 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:26:30 +0100 Subject: [PATCH 1/2] fix(api): rank nearest stations by true ground distance /api/stations/nearest measured distance in planar degrees times 111.32 and never scaled longitude by cos(lat). At 52 N every east-west distance came out ~1.64x too long, so a closer station could rank behind a farther one and the search radius was too narrow east-west. Measure distance and radius on geography in metres instead; the filter uses stations_geom_geography_idx. --- .../nearest/nearest.integration.test.ts | 104 ++++++++++++++++++ src/app/api/stations/nearest/route.test.ts | 15 ++- src/app/api/stations/nearest/route.ts | 23 ++-- 3 files changed, 131 insertions(+), 11 deletions(-) create mode 100644 src/app/api/stations/nearest/nearest.integration.test.ts diff --git a/src/app/api/stations/nearest/nearest.integration.test.ts b/src/app/api/stations/nearest/nearest.integration.test.ts new file mode 100644 index 0000000..0ecda75 --- /dev/null +++ b/src/app/api/stations/nearest/nearest.integration.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from "vitest"; +import { PostgreSqlContainer, type StartedPostgreSqlContainer } from "@testcontainers/postgresql"; +import { Client } from "pg"; + +// Drives the real GET handler against PostGIS. At 52° N a degree of longitude +// is only ~68 km, so a query that measures in planar degrees overstates every +// east-west distance by ~1.64x: it ranks a station 0.8 km east behind one +// 1.0 km north, and drops a station 9 km east from a 10 km radius. +const hasDocker = process.env.SKIP_INTEGRATION !== "1"; +const d = hasDocker ? describe : describe.skip; + +const ORIGIN = { lat: 52.37, lon: 4.89 }; +const KM_PER_DEG_LON = 111.32 * Math.cos((ORIGIN.lat * Math.PI) / 180); + +const STATIONS = [ + { name: "East 0.8 km", lon: ORIGIN.lon + 0.8 / KM_PER_DEG_LON, lat: ORIGIN.lat, km: 0.8 }, + { name: "North 1.0 km", lon: ORIGIN.lon, lat: ORIGIN.lat + 0.009, km: 1.0 }, + { name: "East 9 km", lon: ORIGIN.lon + 9 / KM_PER_DEG_LON, lat: ORIGIN.lat, km: 9 }, + { name: "North 11 km", lon: ORIGIN.lon, lat: ORIGIN.lat + 0.099, km: 11 }, +]; + +function makeRequest(params: Record) { + const url = new URL("http://localhost/api/stations/nearest"); + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); + return { nextUrl: url }; +} + +d("stations/nearest GET handler against PostGIS (integration)", () => { + let container: StartedPostgreSqlContainer; + let client: Client; + let GET: (req: unknown) => Promise; + + beforeAll(async () => { + container = await new PostgreSqlContainer("postgis/postgis:17-3.4").start(); + client = new Client({ connectionString: container.getConnectionUri() }); + await client.connect(); + await client.query("CREATE EXTENSION IF NOT EXISTS postgis"); + await client.query(` + CREATE TABLE stations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + external_id VARCHAR NOT NULL, + country VARCHAR NOT NULL, + name VARCHAR NOT NULL, + brand VARCHAR, + address TEXT NOT NULL, + city VARCHAR NOT NULL, + geom geometry(Point, 4326) NOT NULL, + station_type VARCHAR NOT NULL DEFAULT 'fuel', + max_power_kw DECIMAL + ) + `); + await client.query("CREATE INDEX stations_geom_geography_idx ON stations USING GIST ((geom::geography))"); + await client.query(` + CREATE TABLE fuel_prices ( + id BIGSERIAL PRIMARY KEY, + station_id UUID NOT NULL REFERENCES stations(id), + fuel_type VARCHAR NOT NULL, + price DECIMAL(6,3) NOT NULL, + currency VARCHAR(3) NOT NULL DEFAULT 'EUR', + reported_at TIMESTAMPTZ NOT NULL DEFAULT now(), + ev_fees JSONB + ) + `); + + for (const [i, s] of STATIONS.entries()) { + const row = await client.query<{ id: string }>( + `INSERT INTO stations (external_id, country, name, address, city, geom) + VALUES ($1, 'NL', $2, 'Test 1', 'Amsterdam', ST_SetSRID(ST_MakePoint($3, $4), 4326)) + RETURNING id`, + [`t-${i}`, s.name, s.lon, s.lat], + ); + await client.query( + `INSERT INTO fuel_prices (station_id, fuel_type, price) VALUES ($1, 'E10', 2.0)`, + [row.rows[0].id], + ); + } + + process.env.DATABASE_URL = container.getConnectionUri(); + vi.resetModules(); + const mod = await import("./route"); + GET = mod.GET as unknown as (req: unknown) => Promise; + }); + + afterAll(async () => { + await client?.end(); + await container?.stop(); + }); + + it("ranks and filters by true ground distance", async () => { + const res = await GET( + makeRequest({ lat: String(ORIGIN.lat), lon: String(ORIGIN.lon), radius_km: "10", fuel: "E10", limit: "10" }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + features: Array<{ properties: { name: string; distanceKm: number } }>; + }; + + expect(body.features.map((f) => f.properties.name)).toEqual(["East 0.8 km", "North 1.0 km", "East 9 km"]); + for (const f of body.features) { + const want = STATIONS.find((s) => s.name === f.properties.name)!.km; + expect(Math.abs(f.properties.distanceKm - want) / want).toBeLessThan(0.02); + } + }); +}); diff --git a/src/app/api/stations/nearest/route.test.ts b/src/app/api/stations/nearest/route.test.ts index 78ae0b5..06448bd 100644 --- a/src/app/api/stations/nearest/route.test.ts +++ b/src/app/api/stations/nearest/route.test.ts @@ -89,10 +89,23 @@ describe("stations/nearest API", () => { await GET(makeRequest(validParams) as any); const callArgs = vi.mocked(prisma.$queryRawUnsafe).mock.calls[0]; - // Non-EV query: (sql, lon, lat, radiusDeg, limit, fuel) + // Non-EV query: (sql, lon, lat, radiusM, limit, fuel) expect(callArgs[4]).toBe(5); }); + it("measures distance and radius on the sphere, in metres", async () => { + vi.mocked(prisma.$queryRawUnsafe).mockResolvedValue([]); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await GET(makeRequest(validParams) as any); + + const [sql, , , radius] = vi.mocked(prisma.$queryRawUnsafe).mock.calls[0]; + expect(radius).toBe(10000); + expect(sql).toContain("ST_Distance(s.geom::geography"); + expect(sql).toContain("ST_DWithin(\n s.geom::geography"); + expect(sql).not.toContain("111.32"); + }); + it("accepts custom limit parameter", async () => { vi.mocked(prisma.$queryRawUnsafe).mockResolvedValue([]); diff --git a/src/app/api/stations/nearest/route.ts b/src/app/api/stations/nearest/route.ts index 536820b..865506d 100644 --- a/src/app/api/stations/nearest/route.ts +++ b/src/app/api/stations/nearest/route.ts @@ -76,8 +76,11 @@ export async function GET(request: NextRequest) { try { const isEV = fuel === "EV"; - // Approximate degree conversion for ST_DWithin on geometry (not geography) - const radiusDeg = radius_km / 111.32; + // Distance and radius are measured on the sphere (geography, metres), so a + // kilometre east-west counts the same as a kilometre north-south. Planar + // degrees * 111.32 overstated east-west distance by 1/cos(lat). The + // filter uses the functional GiST stations_geom_geography_idx. + const radiusM = radius_km * 1000; const rows: NearestStationRow[] = isEV ? await prisma.$queryRawUnsafe( @@ -97,7 +100,7 @@ export async function GET(request: NextRequest) { fp.reported_at, fp.ev_fees, s.max_power_kw::int AS max_power_kw, - (ST_Distance(s.geom, ST_SetSRID(ST_MakePoint($1, $2), 4326)) * 111.32)::float AS distance_km + (ST_Distance(s.geom::geography, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography) / 1000)::float AS distance_km FROM stations s LEFT JOIN LATERAL ( SELECT price, currency, reported_at, ev_fees @@ -109,8 +112,8 @@ export async function GET(request: NextRequest) { ) fp ON true WHERE s.station_type IN ('ev_charger', 'both') AND ST_DWithin( - s.geom, - ST_SetSRID(ST_MakePoint($1, $2), 4326), + s.geom::geography, + ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography, $3 ) ORDER BY distance_km ASC @@ -118,7 +121,7 @@ export async function GET(request: NextRequest) { `, lon, lat, - radiusDeg, + radiusM, limit, ) : await prisma.$queryRawUnsafe( @@ -136,7 +139,7 @@ export async function GET(request: NextRequest) { fp.price::float AS price, COALESCE(fp.currency, 'EUR') AS currency, fp.reported_at, - (ST_Distance(s.geom, ST_SetSRID(ST_MakePoint($1, $2), 4326)) * 111.32)::float AS distance_km + (ST_Distance(s.geom::geography, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography) / 1000)::float AS distance_km FROM stations s JOIN LATERAL ( SELECT price, currency, reported_at @@ -147,8 +150,8 @@ export async function GET(request: NextRequest) { LIMIT 1 ) fp ON true WHERE ST_DWithin( - s.geom, - ST_SetSRID(ST_MakePoint($1, $2), 4326), + s.geom::geography, + ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography, $3 ) ORDER BY distance_km ASC @@ -156,7 +159,7 @@ export async function GET(request: NextRequest) { `, lon, lat, - radiusDeg, + radiusM, limit, fuel, ); From 5bbdf39f306772a5b1ceb5028b96b6e62c08232c Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:31:46 +0100 Subject: [PATCH 2/2] test(api): check the EV nearest query uses ground distance too --- src/app/api/stations/nearest/route.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/api/stations/nearest/route.test.ts b/src/app/api/stations/nearest/route.test.ts index 06448bd..b754306 100644 --- a/src/app/api/stations/nearest/route.test.ts +++ b/src/app/api/stations/nearest/route.test.ts @@ -93,16 +93,16 @@ describe("stations/nearest API", () => { expect(callArgs[4]).toBe(5); }); - it("measures distance and radius on the sphere, in metres", async () => { + it.each(["B7", "EV"])("measures %s distance and radius on the sphere, in metres", async (fuel) => { vi.mocked(prisma.$queryRawUnsafe).mockResolvedValue([]); // eslint-disable-next-line @typescript-eslint/no-explicit-any - await GET(makeRequest(validParams) as any); + await GET(makeRequest({ ...validParams, fuel }) as any); - const [sql, , , radius] = vi.mocked(prisma.$queryRawUnsafe).mock.calls[0]; + const [sql, , , radius] = vi.mocked(prisma.$queryRawUnsafe).mock.calls[0] as [string, ...unknown[]]; expect(radius).toBe(10000); expect(sql).toContain("ST_Distance(s.geom::geography"); - expect(sql).toContain("ST_DWithin(\n s.geom::geography"); + expect(sql).toMatch(/ST_DWithin\(\s*s\.geom::geography,/); expect(sql).not.toContain("111.32"); });