Compare commits

..

3 commits

15 changed files with 165 additions and 85 deletions

View file

@ -129,27 +129,30 @@ Travel times are obtained from [Valhalla](https://github.com/valhalla/valhalla),
- **Walking, cycling, driving** — Valhalla's `sources_to_targets` matrix endpoint. For each grid point the 6 spatially nearest POIs in the category are sent as targets; the resulting travel-time matrix gives the exact routed time to each. The nearest POI *per subcategory* is retained. - **Walking, cycling, driving** — Valhalla's `sources_to_targets` matrix endpoint. For each grid point the 6 spatially nearest POIs in the category are sent as targets; the resulting travel-time matrix gives the exact routed time to each. The nearest POI *per subcategory* is retained.
- **Transit** — Valhalla's matrix endpoint does not support transit. Instead, a multimodal isochrone is computed per grid point at contour intervals of 5, 10, 15, 20, and 30 minutes (fixed departure: next Tuesday 08:00 for reproducible GTFS results). PostGIS `ST_Within` then classifies every POI in the city into the smallest contour it falls within, giving estimated times of 300 / 600 / 900 / 1200 / 1800 seconds. Grid points outside the transit network are silently skipped — they receive no transit score. - **Transit** — Valhalla's matrix endpoint does not support transit. Instead, a multimodal isochrone is computed per grid point at contour intervals of 5, 10, 15, 20, and 30 minutes (fixed departure: next Tuesday 08:00 for reproducible GTFS results). PostGIS `ST_Within` then classifies every POI in the city into the smallest contour it falls within, giving estimated times of 300 / 600 / 900 / 1200 / 1800 seconds. Grid points outside the transit network are silently skipped — they receive no transit score.
- **Best mode (`fifteen`)** — a synthetic mode computed during score aggregation: for each (grid point, subcategory) the minimum travel time across walking, cycling, and transit is used. Driving is excluded intentionally. No extra routing calls are needed. - **Cyclist (`cyclist`)** — synthetic persona: `MIN(walking, cycling, transit)` per POI. Represents someone who cycles and also uses transit when faster. No extra routing calls needed.
- **Cyclist, no transit (`cycling_walk`)** — synthetic persona: `MIN(walking, cycling)`. Represents someone who cycles but avoids public transit. No extra routing calls needed.
- **Transit + Walk (`transit_walk`)** — synthetic persona: `MIN(walking, transit)`. Represents someone who does not cycle. No extra routing calls needed.
- **Walker** — the raw `walking` mode also serves as a fourth persona: someone who only walks.
### Scoring formula ### Scoring formula
All scores are precomputed at ingest time for every combination of threshold (5 / 10 / 15 / 20 / 30 min), travel mode, and profile, so interactive queries hit only the database. All scores are precomputed at ingest time for every combination of threshold (5 / 10 / 15 / 20 / 30 min), travel mode, and profile, so interactive queries hit only the database.
Each subcategory *i* contributes a sigmoid score based on travel time `t` and threshold `T` (both in seconds): Each subcategory *i* contributes a proximity score based on travel time `t` and threshold `T` (both in seconds) using exponential decay:
``` ```
sigmoid(t, T) = 1 / (1 + exp(4 × (t T) / T)) score(t, T) = exp(3 × t / T)
``` ```
The sigmoid equals 0.5 exactly at the threshold, approaches 1 for very short times, and approaches 0 for very long times. It is continuous — a 14-minute trip still contributes almost as much as a 10-minute trip under a 15-minute threshold. At t = 0 the score is 1.0. At the threshold it is exp(3) ≈ 0.05 — a POI reachable in exactly the threshold time barely contributes. Close proximity dominates: a third of the threshold away scores ~0.37, halfway scores ~0.22. This ensures that genuinely nearby POIs are rated much more highly than merely reachable ones.
The category score aggregates all subcategories via a **complement product** weighted by profile-specific importance weights `w_i ∈ [0, 1]`: The category score aggregates across subcategories **and** across multiple nearby POIs of the same subcategory via a **complement product** weighted by profile-specific importance weights `w_i ∈ [0, 1]`:
``` ```
category_score = 1 ∏ (1 w_i × sigmoid(t_i, T)) category_score = 1 ∏ (1 w_i × score(t_i, T))
``` ```
This captures coverage diversity: one nearby supermarket already yields a high score, but also having a pharmacy and a bakery pushes it higher. Subcategories with no POI found are omitted from the product and do not penalise the score. This captures both subcategory coverage (a pharmacy and a supermarket together score higher than either alone) and within-subcategory diversity (a second nearby park still improves the score, with strongly diminishing returns). Subcategories with no POI found contribute nothing and do not penalise the score.
The **composite score** shown on the heatmap is a weighted average of all five category scores. Category weights come from the selected profile but can be adjusted freely with the UI sliders. Changing the profile, threshold, or travel mode re-queries the database; adjusting the sliders re-blends client-side with no server round-trip. The **composite score** shown on the heatmap is a weighted average of all five category scores. Category weights come from the selected profile but can be adjusted freely with the UI sliders. Changing the profile, threshold, or travel mode re-queries the database; adjusting the sliders re-blends client-side with no server round-trip.
@ -257,7 +260,9 @@ A single SQL CTE chain inside PostgreSQL computes all scores without streaming d
| Mode | Key | Source | | Mode | Key | Source |
|------|-----|--------| |------|-----|--------|
| Best mode | `fifteen` | Synthetic — `MIN(travel_time_s)` across walking, cycling, transit per subcategory during Phase 2. No extra routing calls. | | Cyclist | `cyclist` | Synthetic — `MIN(walking, cycling, transit)` per POI. Persona: cycles + uses transit. |
| Cyclist, no transit | `cycling_walk` | Synthetic — `MIN(walking, cycling)` per POI. Persona: cycles, avoids transit. |
| Transit + Walk | `transit_walk` | Synthetic — `MIN(walking, transit)` per POI. Persona: does not cycle. |
| Walking | `walking` | Valhalla pedestrian matrix, exact seconds | | Walking | `walking` | Valhalla pedestrian matrix, exact seconds |
| Cycling | `cycling` | Valhalla bicycle matrix, exact seconds | | Cycling | `cycling` | Valhalla bicycle matrix, exact seconds |
| Transit | `transit` | Valhalla multimodal isochrone, quantised to 5-min bands (requires GTFS feed) | | Transit | `transit` | Valhalla multimodal isochrone, quantised to 5-min bands (requires GTFS feed) |

View file

@ -27,8 +27,11 @@ export async function POST(
return NextResponse.json({ error: "City not found" }, { status: 404 }); return NextResponse.json({ error: "City not found" }, { status: 404 });
} }
await Promise.resolve(sql` const [{ iter }] = await Promise.resolve(sql<{ iter: number }[]>`
UPDATE cities SET status = 'pending', error_message = NULL WHERE slug = ${slug} UPDATE cities SET status = 'pending', error_message = NULL,
refresh_iter = refresh_iter + 1
WHERE slug = ${slug}
RETURNING refresh_iter AS iter
`); `);
const queue = getPipelineQueue(); const queue = getPipelineQueue();
@ -45,7 +48,7 @@ export async function POST(
attempts: 1, attempts: 1,
removeOnComplete: { age: 86400 * 7 }, removeOnComplete: { age: 86400 * 7 },
removeOnFail: { age: 86400 * 30 }, removeOnFail: { age: 86400 * 30 },
jobId: `compute-scores.${slug}`, jobId: `compute-scores.${slug}.${iter}`,
}, },
); );

View file

@ -156,6 +156,11 @@ export async function POST(req: NextRequest) {
`); `);
} }
const [{ iter }] = await Promise.resolve(sql<{ iter: number }[]>`
UPDATE cities SET refresh_iter = refresh_iter + 1 WHERE slug = ${slug as string}
RETURNING refresh_iter AS iter
`);
const queue = getPipelineQueue(); const queue = getPipelineQueue();
const job = await queue.add( const job = await queue.add(
"refresh-city", "refresh-city",
@ -164,8 +169,9 @@ export async function POST(req: NextRequest) {
citySlug: slug as string, citySlug: slug as string,
geofabrikUrl, geofabrikUrl,
resolutionM: resolutionM as number, resolutionM: resolutionM as number,
iter,
}, },
{ ...JOB_OPTIONS["refresh-city"], jobId: `refresh-city.${slug}` }, { ...JOB_OPTIONS["refresh-city"], jobId: `refresh-city.${slug}.${iter}` },
); );
// Invalidate city list cache // Invalidate city list cache

View file

@ -20,16 +20,18 @@ export async function POST(
const { geofabrik_url: geofabrikUrl } = rows[0]; const { geofabrik_url: geofabrikUrl } = rows[0];
// Reset status const [{ iter }] = await Promise.resolve(sql<{ iter: number }[]>`
await Promise.resolve(sql` UPDATE cities SET status = 'pending', error_message = NULL,
UPDATE cities SET status = 'pending', error_message = NULL WHERE slug = ${slug} refresh_iter = refresh_iter + 1
WHERE slug = ${slug}
RETURNING refresh_iter AS iter
`); `);
const queue = getPipelineQueue(); const queue = getPipelineQueue();
const job = await queue.add( const job = await queue.add(
"refresh-city", "refresh-city",
{ type: "refresh-city", citySlug: slug, geofabrikUrl }, { type: "refresh-city", citySlug: slug, geofabrikUrl, iter },
{ ...JOB_OPTIONS["refresh-city"], jobId: `refresh-city.${slug}` }, { ...JOB_OPTIONS["refresh-city"], jobId: `refresh-city.${slug}.${iter}` },
); );
return NextResponse.json({ citySlug: slug, jobId: job.id }, { status: 202 }); return NextResponse.json({ citySlug: slug, jobId: job.id }, { status: 202 });

View file

@ -122,10 +122,12 @@ export async function GET(req: NextRequest) {
recreation: r.time_recreation ?? undefined, recreation: r.time_recreation ?? undefined,
}; };
// "fifteen" is synthetic — grid_poi_details only stores the raw modes // Synthetic modes query multiple raw modes and pick the best per subcategory.
// (walking, cycling, transit, driving). For fifteen we query all three const detailModes =
// contributing modes and pick the best (minimum travel_time_s) per subcategory. mode === "cyclist" ? ["walking", "cycling", "transit"] :
const detailModes = mode === "fifteen" ? ["walking", "cycling", "transit"] : [mode]; mode === "cycling_walk" ? ["walking", "cycling"] :
mode === "transit_walk" ? ["walking", "transit"] :
[mode];
// Fetch nearest POI per subcategory for this grid point and mode. // Fetch nearest POI per subcategory for this grid point and mode.
const detailRows = await Promise.resolve(sql<{ const detailRows = await Promise.resolve(sql<{

View file

@ -21,10 +21,13 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: "Missing required params", code: "INVALID_PARAMS" }, { status: 400 }); return NextResponse.json({ error: "Missing required params", code: "INVALID_PARAMS" }, { status: 400 });
} }
// Map modes the same way the display isochrone route does. // Synthetic modes map to a single isochrone mode for the spatial POI lookup.
// "fifteen" uses cycling as the representative isochrone mode. // "cyclist" uses cycling (largest catchment); "transit_walk" uses transit.
// "transit" uses its own multimodal isochrone. const isoMode =
const isoMode = mode === "fifteen" ? "cycling" : mode; mode === "cyclist" ? "cycling" :
mode === "cycling_walk" ? "cycling" :
mode === "transit_walk" ? "transit" :
mode;
// Use the identical contours array that the display isochrone route caches with, // Use the identical contours array that the display isochrone route caches with,
// so the exact-match lookup is guaranteed to find the entry once isochroneData // so the exact-match lookup is guaranteed to find the entry once isochroneData

View file

@ -31,7 +31,7 @@ export default function HomePage() {
const [cities, setCities] = useState<City[]>([]); const [cities, setCities] = useState<City[]>([]);
const [selectedCity, setSelectedCity] = useState<string | null>(null); const [selectedCity, setSelectedCity] = useState<string | null>(null);
const [profile, setProfile] = useState<ProfileId>("universal"); const [profile, setProfile] = useState<ProfileId>("universal");
const [mode, setMode] = useState<TravelMode>("fifteen"); const [mode, setMode] = useState<TravelMode>("cyclist");
const [threshold, setThreshold] = useState(15); const [threshold, setThreshold] = useState(15);
const [weights, setWeights] = useState({ ...PROFILES["universal"].categoryWeights }); const [weights, setWeights] = useState({ ...PROFILES["universal"].categoryWeights });
const [activeCategory, setActiveCategory] = useState<CategoryId | "composite">("composite"); const [activeCategory, setActiveCategory] = useState<CategoryId | "composite">("composite");
@ -218,10 +218,13 @@ export default function HomePage() {
body: JSON.stringify({ body: JSON.stringify({
lng: pinLocation.lng, lng: pinLocation.lng,
lat: pinLocation.lat, lat: pinLocation.lat,
// "fifteen" uses cycling as the representative display mode (largest // Synthetic modes map to a single Valhalla costing for the isochrone display.
// reliable non-time-dependent coverage). "transit" uses the real // "cyclist" uses cycling (largest catchment); "transit_walk" uses transit.
// multimodal costing supported by fetchIsochrone. travelMode:
travelMode: mode === "fifteen" ? "cycling" : mode, mode === "cyclist" ? "cycling" :
mode === "cycling_walk" ? "cycling" :
mode === "transit_walk" ? "transit" :
mode,
contourMinutes: isochroneContours(threshold), contourMinutes: isochroneContours(threshold),
}), }),
}) })

View file

@ -3,13 +3,15 @@
import { CATEGORIES, PROFILES, PROFILE_IDS, VALID_THRESHOLDS } from "@transportationer/shared"; import { CATEGORIES, PROFILES, PROFILE_IDS, VALID_THRESHOLDS } from "@transportationer/shared";
import type { CategoryId, TravelMode, ProfileId } from "@transportationer/shared"; import type { CategoryId, TravelMode, ProfileId } from "@transportationer/shared";
const TRAVEL_MODES: Array<{ value: TravelMode; label: string; icon: string }> = const TRAVEL_MODES: Array<{ value: TravelMode; label: string; icon: string; description: string }> =
[ [
{ value: "fifteen", label: "Best mode", icon: "🏆" }, { value: "cyclist", label: "Cyclist", icon: "🚶🚲🚌", description: "Best of walking, cycling & transit — for people who cycle and use public transport" },
{ value: "walking", label: "Walking", icon: "🚶" }, { value: "cycling_walk", label: "Cyclist (no transit)", icon: "🚶🚲", description: "Best of walking & cycling only — for cyclists who avoid public transport" },
{ value: "cycling", label: "Cycling", icon: "🚲" }, { value: "transit_walk", label: "Transit + Walk", icon: "🚶🚌", description: "Best of walking & transit — for people without a bike" },
{ value: "transit", label: "Transit", icon: "🚌" }, { value: "walking", label: "Walker", icon: "🚶", description: "Walking only — for people who rely solely on foot travel" },
{ value: "driving", label: "Driving", icon: "🚗" }, { value: "cycling", label: "Cycling only", icon: "🚲", description: "Raw cycling travel times, unblended" },
{ value: "transit", label: "Transit only", icon: "🚌", description: "Raw transit travel times, unblended" },
{ value: "driving", label: "Driving only", icon: "🚗", description: "Raw driving travel times, unblended" },
]; ];
@ -92,20 +94,28 @@ export function ControlPanel({
Travel Mode Travel Mode
</p> </p>
<div className="grid grid-cols-2 gap-1"> <div className="grid grid-cols-2 gap-1">
{TRAVEL_MODES.map((m) => ( {TRAVEL_MODES.map((m, i) => {
<button const isRightCol = i % 2 === 1;
key={m.value} return (
onClick={() => onModeChange(m.value)} <div key={m.value} className="relative group">
className={`flex flex-col items-center gap-1 py-2 rounded-md text-xs border transition-colors ${ <button
mode === m.value onClick={() => onModeChange(m.value)}
? "border-brand-500 bg-brand-50 text-brand-700 font-medium" className={`w-full flex flex-col items-center gap-1 py-2 rounded-md text-xs border transition-colors ${
: "border-gray-200 text-gray-600 hover:border-gray-300" mode === m.value
}`} ? "border-brand-500 bg-brand-50 text-brand-700 font-medium"
> : "border-gray-200 text-gray-600 hover:border-gray-300"
<span className="text-lg">{m.icon}</span> }`}
{m.label} >
</button> <span className="text-lg">{m.icon}</span>
))} {m.label}
</button>
<div className={`pointer-events-none absolute bottom-full mb-2 w-48 rounded-md bg-gray-900 px-2.5 py-1.5 text-[11px] leading-snug text-white opacity-0 group-hover:opacity-100 transition-opacity z-50 shadow-lg ${isRightCol ? "right-0" : "left-0"}`}>
{m.description}
<div className={`absolute top-full border-4 border-transparent border-t-gray-900 ${isRightCol ? "right-4" : "left-4"}`} />
</div>
</div>
);
})}
</div> </div>
</div> </div>

View file

@ -22,6 +22,13 @@ CREATE TABLE IF NOT EXISTS cities (
-- Migration for existing databases -- Migration for existing databases
ALTER TABLE cities ADD COLUMN IF NOT EXISTS resolution_m INTEGER NOT NULL DEFAULT 200; ALTER TABLE cities ADD COLUMN IF NOT EXISTS resolution_m INTEGER NOT NULL DEFAULT 200;
ALTER TABLE cities ADD COLUMN IF NOT EXISTS boundary geometry(MultiPolygon, 4326); ALTER TABLE cities ADD COLUMN IF NOT EXISTS boundary geometry(MultiPolygon, 4326);
ALTER TABLE cities ADD COLUMN IF NOT EXISTS refresh_iter SMALLINT NOT NULL DEFAULT 0;
-- Migration: rename 'fifteen' mode to 'cyclist' and update CHECK constraint
UPDATE grid_scores SET travel_mode = 'cyclist' WHERE travel_mode = 'fifteen';
ALTER TABLE grid_scores DROP CONSTRAINT IF EXISTS grid_scores_travel_mode_check;
ALTER TABLE grid_scores ADD CONSTRAINT grid_scores_travel_mode_check
CHECK (travel_mode IN ('walking','cycling','driving','transit','cyclist','cycling_walk','transit_walk'));
CREATE INDEX IF NOT EXISTS idx_cities_bbox ON cities USING GIST (bbox); CREATE INDEX IF NOT EXISTS idx_cities_bbox ON cities USING GIST (bbox);
CREATE INDEX IF NOT EXISTS idx_cities_boundary ON cities USING GIST (boundary); CREATE INDEX IF NOT EXISTS idx_cities_boundary ON cities USING GIST (boundary);
@ -77,7 +84,7 @@ CREATE INDEX IF NOT EXISTS idx_grid_hidden_gem
CREATE TABLE IF NOT EXISTS grid_scores ( CREATE TABLE IF NOT EXISTS grid_scores (
grid_point_id BIGINT NOT NULL REFERENCES grid_points(id) ON DELETE CASCADE, grid_point_id BIGINT NOT NULL REFERENCES grid_points(id) ON DELETE CASCADE,
category TEXT NOT NULL, category TEXT NOT NULL,
travel_mode TEXT NOT NULL CHECK (travel_mode IN ('walking','cycling','driving','transit','fifteen')), travel_mode TEXT NOT NULL CHECK (travel_mode IN ('walking','cycling','driving','transit','cyclist','cycling_walk','transit_walk')),
threshold_min INTEGER NOT NULL, threshold_min INTEGER NOT NULL,
profile TEXT NOT NULL DEFAULT 'universal', profile TEXT NOT NULL DEFAULT 'universal',
nearest_poi_id BIGINT, nearest_poi_id BIGINT,

View file

@ -7,10 +7,10 @@ export type CategoryId =
/** Modes that produce real routing data (matrix or isochrone calls). */ /** Modes that produce real routing data (matrix or isochrone calls). */
export type RoutingMode = "walking" | "cycling" | "driving" | "transit"; export type RoutingMode = "walking" | "cycling" | "driving" | "transit";
/** All display modes, including the synthetic "fifteen" (best-of walking+cycling+transit). */ /** All display modes, including synthetic best-of modes per persona. */
export type TravelMode = RoutingMode | "fifteen"; export type TravelMode = RoutingMode | "cyclist" | "cycling_walk" | "transit_walk";
export const TRAVEL_MODES = ["walking", "cycling", "driving", "transit", "fifteen"] as const satisfies TravelMode[]; export const TRAVEL_MODES = ["walking", "cycling", "driving", "transit", "cyclist", "cycling_walk", "transit_walk"] as const satisfies TravelMode[];
export const VALID_THRESHOLDS = [5, 10, 15, 20, 30] as const; export const VALID_THRESHOLDS = [5, 10, 15, 20, 30] as const;
export interface TagFilter { export interface TagFilter {

View file

@ -64,6 +64,9 @@ export interface RefreshCityJobData {
citySlug: string; citySlug: string;
geofabrikUrl: string; geofabrikUrl: string;
resolutionM?: number; resolutionM?: number;
/** Monotonically increasing counter incremented at each trigger; used in jobIds
* to prevent completed-job deduplication on re-runs. */
iter: number;
/** ID of the compute-scores job enqueued for this refresh; set after flow.add(). */ /** ID of the compute-scores job enqueued for this refresh; set after flow.add(). */
computeScoresJobId?: string; computeScoresJobId?: string;
} }

View file

@ -10,9 +10,8 @@ export type ComputeRoutingData = {
category: string; category: string;
}; };
/** Number of nearest POI candidates per grid point (across all subcategories). /** Number of nearest POI candidates per grid point (across all subcategories). */
* Higher K means more diversity candidates for the complement-product formula. */ const K = 6;
const K = 15;
/** Grid points per Valhalla matrix call. */ /** Grid points per Valhalla matrix call. */
const BATCH_SIZE = 5; const BATCH_SIZE = 5;
/** Concurrent Valhalla calls within this job. */ /** Concurrent Valhalla calls within this job. */

View file

@ -73,7 +73,6 @@ export async function handleComputeScores(
attempts: 1, attempts: 1,
removeOnComplete: { age: 86400 * 7 }, removeOnComplete: { age: 86400 * 7 },
removeOnFail: { age: 86400 * 30 }, removeOnFail: { age: 86400 * 30 },
jobId: `compute-transit.${citySlug}`,
parent: { id: job.id!, queue: queue.qualifiedName }, parent: { id: job.id!, queue: queue.qualifiedName },
ignoreDependencyOnFailure: true, ignoreDependencyOnFailure: true,
}, },
@ -91,7 +90,6 @@ export async function handleComputeScores(
backoff: { type: "fixed", delay: 3000 }, backoff: { type: "fixed", delay: 3000 },
removeOnComplete: { age: 86400 * 7 }, removeOnComplete: { age: 86400 * 7 },
removeOnFail: { age: 86400 * 30 }, removeOnFail: { age: 86400 * 30 },
jobId: `compute-routing.${citySlug}.${mode}.${category}`,
parent: { parent: {
id: job.id!, id: job.id!,
queue: queue.qualifiedName, queue: queue.qualifiedName,
@ -111,7 +109,6 @@ export async function handleComputeScores(
backoff: { type: "fixed", delay: 5000 }, backoff: { type: "fixed", delay: 5000 },
removeOnComplete: { age: 86400 * 7 }, removeOnComplete: { age: 86400 * 7 },
removeOnFail: { age: 86400 * 30 }, removeOnFail: { age: 86400 * 30 },
jobId: `ingest-boris-ni.${citySlug}`,
parent: { id: job.id!, queue: queue.qualifiedName }, parent: { id: job.id!, queue: queue.qualifiedName },
ignoreDependencyOnFailure: true, ignoreDependencyOnFailure: true,
}, },
@ -128,7 +125,6 @@ export async function handleComputeScores(
backoff: { type: "fixed", delay: 5000 }, backoff: { type: "fixed", delay: 5000 },
removeOnComplete: { age: 86400 * 7 }, removeOnComplete: { age: 86400 * 7 },
removeOnFail: { age: 86400 * 30 }, removeOnFail: { age: 86400 * 30 },
jobId: `ingest-boris-hb.${citySlug}`,
parent: { id: job.id!, queue: queue.qualifiedName }, parent: { id: job.id!, queue: queue.qualifiedName },
ignoreDependencyOnFailure: true, ignoreDependencyOnFailure: true,
}, },
@ -184,22 +180,44 @@ export async function handleComputeScores(
JOIN grid_points gp ON gp.id = gpd.grid_point_id JOIN grid_points gp ON gp.id = gpd.grid_point_id
WHERE gp.city_slug = ${citySlug} WHERE gp.city_slug = ${citySlug}
), ),
fifteen_subcat AS ( cyclist_subcat AS (
-- "fifteen" mode: for each unique POI, take the best time across -- "cyclist" persona: best time per POI across walking / cycling / transit
-- walking / cycling / transit so each POI contributes independently
-- to the complement-product formula (preserving diversity).
SELECT SELECT
grid_point_id, category, subcategory, grid_point_id, category, subcategory,
'fifteen'::text AS travel_mode, 'cyclist'::text AS travel_mode,
MIN(travel_time_s) AS travel_time_s MIN(travel_time_s) AS travel_time_s
FROM base FROM base
WHERE travel_mode IN ('walking', 'cycling', 'transit') WHERE travel_mode IN ('walking', 'cycling', 'transit')
GROUP BY grid_point_id, category, subcategory, nearest_poi_id GROUP BY grid_point_id, category, subcategory, nearest_poi_id
), ),
cycling_walk_subcat AS (
-- "cycling_walk" persona: best time per POI across walking / cycling only (no transit)
SELECT
grid_point_id, category, subcategory,
'cycling_walk'::text AS travel_mode,
MIN(travel_time_s) AS travel_time_s
FROM base
WHERE travel_mode IN ('walking', 'cycling')
GROUP BY grid_point_id, category, subcategory, nearest_poi_id
),
transit_walk_subcat AS (
-- "transit_walk" persona: best time per POI across walking / transit only
SELECT
grid_point_id, category, subcategory,
'transit_walk'::text AS travel_mode,
MIN(travel_time_s) AS travel_time_s
FROM base
WHERE travel_mode IN ('walking', 'transit')
GROUP BY grid_point_id, category, subcategory, nearest_poi_id
),
all_subcat AS ( all_subcat AS (
SELECT grid_point_id, category, subcategory, travel_mode, travel_time_s FROM base SELECT grid_point_id, category, subcategory, travel_mode, travel_time_s FROM base
UNION ALL UNION ALL
SELECT grid_point_id, category, subcategory, travel_mode, travel_time_s FROM fifteen_subcat SELECT grid_point_id, category, subcategory, travel_mode, travel_time_s FROM cyclist_subcat
UNION ALL
SELECT grid_point_id, category, subcategory, travel_mode, travel_time_s FROM cycling_walk_subcat
UNION ALL
SELECT grid_point_id, category, subcategory, travel_mode, travel_time_s FROM transit_walk_subcat
), ),
road_nearest AS ( road_nearest AS (
-- Nearest POI per (grid_point, category, mode) by distance -- Nearest POI per (grid_point, category, mode) by distance
@ -209,21 +227,44 @@ export async function handleComputeScores(
WHERE nearest_poi_id IS NOT NULL WHERE nearest_poi_id IS NOT NULL
ORDER BY grid_point_id, category, travel_mode, distance_m ORDER BY grid_point_id, category, travel_mode, distance_m
), ),
fifteen_nearest AS ( cyclist_nearest AS (
-- Nearest POI for "fifteen": closest across walking / cycling / transit
SELECT DISTINCT ON (grid_point_id, category) SELECT DISTINCT ON (grid_point_id, category)
grid_point_id, category, grid_point_id, category,
'fifteen'::text AS travel_mode, 'cyclist'::text AS travel_mode,
nearest_poi_id, distance_m, travel_time_s nearest_poi_id, distance_m, travel_time_s
FROM base FROM base
WHERE travel_mode IN ('walking', 'cycling', 'transit') WHERE travel_mode IN ('walking', 'cycling', 'transit')
AND nearest_poi_id IS NOT NULL AND nearest_poi_id IS NOT NULL
ORDER BY grid_point_id, category, distance_m ORDER BY grid_point_id, category, distance_m
), ),
cycling_walk_nearest AS (
SELECT DISTINCT ON (grid_point_id, category)
grid_point_id, category,
'cycling_walk'::text AS travel_mode,
nearest_poi_id, distance_m, travel_time_s
FROM base
WHERE travel_mode IN ('walking', 'cycling')
AND nearest_poi_id IS NOT NULL
ORDER BY grid_point_id, category, distance_m
),
transit_walk_nearest AS (
SELECT DISTINCT ON (grid_point_id, category)
grid_point_id, category,
'transit_walk'::text AS travel_mode,
nearest_poi_id, distance_m, travel_time_s
FROM base
WHERE travel_mode IN ('walking', 'transit')
AND nearest_poi_id IS NOT NULL
ORDER BY grid_point_id, category, distance_m
),
all_nearest AS ( all_nearest AS (
SELECT * FROM road_nearest SELECT * FROM road_nearest
UNION ALL UNION ALL
SELECT * FROM fifteen_nearest SELECT * FROM cyclist_nearest
UNION ALL
SELECT * FROM cycling_walk_nearest
UNION ALL
SELECT * FROM transit_walk_nearest
), ),
profile_weights AS ( profile_weights AS (
SELECT profile_id, subcategory, weight SELECT profile_id, subcategory, weight
@ -252,10 +293,7 @@ export async function handleComputeScores(
1.0 - COALESCE(pw.weight, ${DEFAULT_SUBCATEGORY_WEIGHT}::float8) 1.0 - COALESCE(pw.weight, ${DEFAULT_SUBCATEGORY_WEIGHT}::float8)
* CASE * CASE
WHEN s.travel_time_s IS NULL THEN 0.0 WHEN s.travel_time_s IS NULL THEN 0.0
ELSE 1.0 / (1.0 + EXP( ELSE EXP(-3.0 * s.travel_time_s / (t.threshold_min * 60.0))
(s.travel_time_s - t.threshold_min * 60.0)
/ (t.threshold_min * 10.0)
))
END, END,
1e-10 1e-10
)) ))

View file

@ -12,8 +12,8 @@
* *
* If transit routing fails for a grid point (e.g. no GTFS data, or the point is * If transit routing fails for a grid point (e.g. no GTFS data, or the point is
* outside the transit network), it is silently skipped transit contributes * outside the transit network), it is silently skipped transit contributes
* nothing to that grid point's "fifteen" score, which then falls back to * nothing to that grid point's "cyclist" / "transit_walk" scores, which then
* walking/cycling only. * fall back to walking/cycling only.
* *
* Transit scores are computed ONCE per city (not per category) since the isochrone * Transit scores are computed ONCE per city (not per category) since the isochrone
* polygon covers all categories in a single PostGIS spatial join. * polygon covers all categories in a single PostGIS spatial join.

View file

@ -10,6 +10,7 @@ export type RefreshCityData = {
citySlug: string; citySlug: string;
geofabrikUrl: string; geofabrikUrl: string;
resolutionM?: number; resolutionM?: number;
iter: number;
/** Set after flow.add() — the ID of the enqueued compute-scores job. */ /** Set after flow.add() — the ID of the enqueued compute-scores job. */
computeScoresJobId?: string; computeScoresJobId?: string;
}; };
@ -35,7 +36,7 @@ function isInBremen(minLng: number, minLat: number, maxLng: number, maxLat: numb
export async function handleRefreshCity( export async function handleRefreshCity(
job: Job<RefreshCityData>, job: Job<RefreshCityData>,
): Promise<void> { ): Promise<void> {
const { citySlug, geofabrikUrl, resolutionM = 200 } = job.data; const { citySlug, geofabrikUrl, resolutionM = 200, iter = 0 } = job.data;
const sql = getSql(); const sql = getSql();
const pbfPath = `${OSM_DATA_DIR}/${citySlug}-latest.osm.pbf`; const pbfPath = `${OSM_DATA_DIR}/${citySlug}-latest.osm.pbf`;
@ -135,16 +136,14 @@ export async function handleRefreshCity(
ingestBorisNi: niApplicable, ingestBorisNi: niApplicable,
ingestBorisHb: hbApplicable, ingestBorisHb: hbApplicable,
}, },
opts: { ...JOB_OPTIONS["compute-scores"], jobId: `compute-scores.${citySlug}` }, opts: { ...JOB_OPTIONS["compute-scores"], jobId: `compute-scores.${citySlug}.${iter}` },
children: [ children: [
{ {
name: "generate-grid", name: "generate-grid",
queueName: "pipeline", queueName: "pipeline",
data: { type: "generate-grid" as const, citySlug, resolutionM }, data: { type: "generate-grid" as const, citySlug, resolutionM },
opts: { ...JOB_OPTIONS["generate-grid"], jobId: `generate-grid.${citySlug}` }, opts: { ...JOB_OPTIONS["generate-grid"], jobId: `generate-grid.${citySlug}.${iter}` },
children: [ children: [
// Three parallel branches — all share a single download-pbf job via
// the deterministic jobId; BullMQ deduplicates them to one download.
{ {
name: "extract-pois", name: "extract-pois",
queueName: "pipeline", queueName: "pipeline",
@ -154,7 +153,7 @@ export async function handleRefreshCity(
pbfPath, pbfPath,
...(bbox ? { bbox } : {}), ...(bbox ? { bbox } : {}),
}, },
opts: { ...JOB_OPTIONS["extract-pois"], jobId: `extract-pois.${citySlug}` }, opts: { ...JOB_OPTIONS["extract-pois"], jobId: `extract-pois.${citySlug}.${iter}` },
children: [downloadNode()], children: [downloadNode()],
}, },
// Road-only Valhalla build — no GTFS, produces clean tiles without // Road-only Valhalla build — no GTFS, produces clean tiles without
@ -168,7 +167,7 @@ export async function handleRefreshCity(
pbfPath, pbfPath,
...(bbox ? { bbox } : {}), ...(bbox ? { bbox } : {}),
}, },
opts: { ...JOB_OPTIONS["build-valhalla"], jobId: `build-valhalla.${citySlug}` }, opts: { ...JOB_OPTIONS["build-valhalla"], jobId: `build-valhalla.${citySlug}.${iter}` },
children: [downloadNode()], children: [downloadNode()],
}, },
// Transit Valhalla build — depends on GTFS download. Produces tiles with // Transit Valhalla build — depends on GTFS download. Produces tiles with
@ -182,7 +181,7 @@ export async function handleRefreshCity(
pbfPath, pbfPath,
...(bbox ? { bbox } : {}), ...(bbox ? { bbox } : {}),
}, },
opts: { ...JOB_OPTIONS["build-valhalla"], jobId: `build-valhalla-transit.${citySlug}` }, opts: { ...JOB_OPTIONS["build-valhalla"], jobId: `build-valhalla-transit.${citySlug}.${iter}` },
children: [ children: [
downloadNode(), downloadNode(),
// Download GTFS feed before building transit tiles. Idempotent — // Download GTFS feed before building transit tiles. Idempotent —