Compare commits
3 commits
25c67b2536
...
da95e90ba2
| Author | SHA1 | Date | |
|---|---|---|---|
| da95e90ba2 | |||
| 9158ed3ba2 | |||
| faa9e48234 |
15 changed files with 165 additions and 85 deletions
21
README.md
21
README.md
|
|
@ -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.
|
||||
- **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
|
||||
|
||||
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.
|
||||
|
||||
|
|
@ -257,7 +260,9 @@ A single SQL CTE chain inside PostgreSQL computes all scores without streaming d
|
|||
|
||||
| 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 |
|
||||
| Cycling | `cycling` | Valhalla bicycle matrix, exact seconds |
|
||||
| Transit | `transit` | Valhalla multimodal isochrone, quantised to 5-min bands (requires GTFS feed) |
|
||||
|
|
|
|||
|
|
@ -27,8 +27,11 @@ export async function POST(
|
|||
return NextResponse.json({ error: "City not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
await Promise.resolve(sql`
|
||||
UPDATE cities SET status = 'pending', error_message = NULL WHERE slug = ${slug}
|
||||
const [{ iter }] = await Promise.resolve(sql<{ iter: number }[]>`
|
||||
UPDATE cities SET status = 'pending', error_message = NULL,
|
||||
refresh_iter = refresh_iter + 1
|
||||
WHERE slug = ${slug}
|
||||
RETURNING refresh_iter AS iter
|
||||
`);
|
||||
|
||||
const queue = getPipelineQueue();
|
||||
|
|
@ -45,7 +48,7 @@ export async function POST(
|
|||
attempts: 1,
|
||||
removeOnComplete: { age: 86400 * 7 },
|
||||
removeOnFail: { age: 86400 * 30 },
|
||||
jobId: `compute-scores.${slug}`,
|
||||
jobId: `compute-scores.${slug}.${iter}`,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 job = await queue.add(
|
||||
"refresh-city",
|
||||
|
|
@ -164,8 +169,9 @@ export async function POST(req: NextRequest) {
|
|||
citySlug: slug as string,
|
||||
geofabrikUrl,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -20,16 +20,18 @@ export async function POST(
|
|||
|
||||
const { geofabrik_url: geofabrikUrl } = rows[0];
|
||||
|
||||
// Reset status
|
||||
await Promise.resolve(sql`
|
||||
UPDATE cities SET status = 'pending', error_message = NULL WHERE slug = ${slug}
|
||||
const [{ iter }] = await Promise.resolve(sql<{ iter: number }[]>`
|
||||
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 job = await queue.add(
|
||||
"refresh-city",
|
||||
{ type: "refresh-city", citySlug: slug, geofabrikUrl },
|
||||
{ ...JOB_OPTIONS["refresh-city"], jobId: `refresh-city.${slug}` },
|
||||
{ type: "refresh-city", citySlug: slug, geofabrikUrl, iter },
|
||||
{ ...JOB_OPTIONS["refresh-city"], jobId: `refresh-city.${slug}.${iter}` },
|
||||
);
|
||||
|
||||
return NextResponse.json({ citySlug: slug, jobId: job.id }, { status: 202 });
|
||||
|
|
|
|||
|
|
@ -122,10 +122,12 @@ export async function GET(req: NextRequest) {
|
|||
recreation: r.time_recreation ?? undefined,
|
||||
};
|
||||
|
||||
// "fifteen" is synthetic — grid_poi_details only stores the raw modes
|
||||
// (walking, cycling, transit, driving). For fifteen we query all three
|
||||
// contributing modes and pick the best (minimum travel_time_s) per subcategory.
|
||||
const detailModes = mode === "fifteen" ? ["walking", "cycling", "transit"] : [mode];
|
||||
// Synthetic modes query multiple raw modes and pick the best per subcategory.
|
||||
const detailModes =
|
||||
mode === "cyclist" ? ["walking", "cycling", "transit"] :
|
||||
mode === "cycling_walk" ? ["walking", "cycling"] :
|
||||
mode === "transit_walk" ? ["walking", "transit"] :
|
||||
[mode];
|
||||
|
||||
// Fetch nearest POI per subcategory for this grid point and mode.
|
||||
const detailRows = await Promise.resolve(sql<{
|
||||
|
|
|
|||
|
|
@ -21,10 +21,13 @@ export async function GET(req: NextRequest) {
|
|||
return NextResponse.json({ error: "Missing required params", code: "INVALID_PARAMS" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Map modes the same way the display isochrone route does.
|
||||
// "fifteen" uses cycling as the representative isochrone mode.
|
||||
// "transit" uses its own multimodal isochrone.
|
||||
const isoMode = mode === "fifteen" ? "cycling" : mode;
|
||||
// Synthetic modes map to a single isochrone mode for the spatial POI lookup.
|
||||
// "cyclist" uses cycling (largest catchment); "transit_walk" uses transit.
|
||||
const isoMode =
|
||||
mode === "cyclist" ? "cycling" :
|
||||
mode === "cycling_walk" ? "cycling" :
|
||||
mode === "transit_walk" ? "transit" :
|
||||
mode;
|
||||
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export default function HomePage() {
|
|||
const [cities, setCities] = useState<City[]>([]);
|
||||
const [selectedCity, setSelectedCity] = useState<string | null>(null);
|
||||
const [profile, setProfile] = useState<ProfileId>("universal");
|
||||
const [mode, setMode] = useState<TravelMode>("fifteen");
|
||||
const [mode, setMode] = useState<TravelMode>("cyclist");
|
||||
const [threshold, setThreshold] = useState(15);
|
||||
const [weights, setWeights] = useState({ ...PROFILES["universal"].categoryWeights });
|
||||
const [activeCategory, setActiveCategory] = useState<CategoryId | "composite">("composite");
|
||||
|
|
@ -218,10 +218,13 @@ export default function HomePage() {
|
|||
body: JSON.stringify({
|
||||
lng: pinLocation.lng,
|
||||
lat: pinLocation.lat,
|
||||
// "fifteen" uses cycling as the representative display mode (largest
|
||||
// reliable non-time-dependent coverage). "transit" uses the real
|
||||
// multimodal costing supported by fetchIsochrone.
|
||||
travelMode: mode === "fifteen" ? "cycling" : mode,
|
||||
// Synthetic modes map to a single Valhalla costing for the isochrone display.
|
||||
// "cyclist" uses cycling (largest catchment); "transit_walk" uses transit.
|
||||
travelMode:
|
||||
mode === "cyclist" ? "cycling" :
|
||||
mode === "cycling_walk" ? "cycling" :
|
||||
mode === "transit_walk" ? "transit" :
|
||||
mode,
|
||||
contourMinutes: isochroneContours(threshold),
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,13 +3,15 @@
|
|||
import { CATEGORIES, PROFILES, PROFILE_IDS, VALID_THRESHOLDS } 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: "walking", label: "Walking", icon: "🚶" },
|
||||
{ value: "cycling", label: "Cycling", icon: "🚲" },
|
||||
{ value: "transit", label: "Transit", icon: "🚌" },
|
||||
{ value: "driving", label: "Driving", icon: "🚗" },
|
||||
{ value: "cyclist", label: "Cyclist", icon: "🚶🚲🚌", description: "Best of walking, cycling & transit — for people who cycle and use public transport" },
|
||||
{ value: "cycling_walk", label: "Cyclist (no transit)", icon: "🚶🚲", description: "Best of walking & cycling only — for cyclists who avoid public transport" },
|
||||
{ value: "transit_walk", label: "Transit + Walk", icon: "🚶🚌", description: "Best of walking & transit — for people without a bike" },
|
||||
{ value: "walking", label: "Walker", icon: "🚶", description: "Walking only — for people who rely solely on foot travel" },
|
||||
{ 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,11 +94,13 @@ export function ControlPanel({
|
|||
Travel Mode
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{TRAVEL_MODES.map((m) => (
|
||||
{TRAVEL_MODES.map((m, i) => {
|
||||
const isRightCol = i % 2 === 1;
|
||||
return (
|
||||
<div key={m.value} className="relative group">
|
||||
<button
|
||||
key={m.value}
|
||||
onClick={() => onModeChange(m.value)}
|
||||
className={`flex flex-col items-center gap-1 py-2 rounded-md text-xs border transition-colors ${
|
||||
className={`w-full flex flex-col items-center gap-1 py-2 rounded-md text-xs border transition-colors ${
|
||||
mode === m.value
|
||||
? "border-brand-500 bg-brand-50 text-brand-700 font-medium"
|
||||
: "border-gray-200 text-gray-600 hover:border-gray-300"
|
||||
|
|
@ -105,7 +109,13 @@ export function ControlPanel({
|
|||
<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>
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,13 @@ CREATE TABLE IF NOT EXISTS cities (
|
|||
-- 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 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_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 (
|
||||
grid_point_id BIGINT NOT NULL REFERENCES grid_points(id) ON DELETE CASCADE,
|
||||
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,
|
||||
profile TEXT NOT NULL DEFAULT 'universal',
|
||||
nearest_poi_id BIGINT,
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ export type CategoryId =
|
|||
|
||||
/** Modes that produce real routing data (matrix or isochrone calls). */
|
||||
export type RoutingMode = "walking" | "cycling" | "driving" | "transit";
|
||||
/** All display modes, including the synthetic "fifteen" (best-of walking+cycling+transit). */
|
||||
export type TravelMode = RoutingMode | "fifteen";
|
||||
/** All display modes, including synthetic best-of modes per persona. */
|
||||
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 interface TagFilter {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,9 @@ export interface RefreshCityJobData {
|
|||
citySlug: string;
|
||||
geofabrikUrl: string;
|
||||
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(). */
|
||||
computeScoresJobId?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,8 @@ export type ComputeRoutingData = {
|
|||
category: string;
|
||||
};
|
||||
|
||||
/** Number of nearest POI candidates per grid point (across all subcategories).
|
||||
* Higher K means more diversity candidates for the complement-product formula. */
|
||||
const K = 15;
|
||||
/** Number of nearest POI candidates per grid point (across all subcategories). */
|
||||
const K = 6;
|
||||
/** Grid points per Valhalla matrix call. */
|
||||
const BATCH_SIZE = 5;
|
||||
/** Concurrent Valhalla calls within this job. */
|
||||
|
|
|
|||
|
|
@ -73,7 +73,6 @@ export async function handleComputeScores(
|
|||
attempts: 1,
|
||||
removeOnComplete: { age: 86400 * 7 },
|
||||
removeOnFail: { age: 86400 * 30 },
|
||||
jobId: `compute-transit.${citySlug}`,
|
||||
parent: { id: job.id!, queue: queue.qualifiedName },
|
||||
ignoreDependencyOnFailure: true,
|
||||
},
|
||||
|
|
@ -91,7 +90,6 @@ export async function handleComputeScores(
|
|||
backoff: { type: "fixed", delay: 3000 },
|
||||
removeOnComplete: { age: 86400 * 7 },
|
||||
removeOnFail: { age: 86400 * 30 },
|
||||
jobId: `compute-routing.${citySlug}.${mode}.${category}`,
|
||||
parent: {
|
||||
id: job.id!,
|
||||
queue: queue.qualifiedName,
|
||||
|
|
@ -111,7 +109,6 @@ export async function handleComputeScores(
|
|||
backoff: { type: "fixed", delay: 5000 },
|
||||
removeOnComplete: { age: 86400 * 7 },
|
||||
removeOnFail: { age: 86400 * 30 },
|
||||
jobId: `ingest-boris-ni.${citySlug}`,
|
||||
parent: { id: job.id!, queue: queue.qualifiedName },
|
||||
ignoreDependencyOnFailure: true,
|
||||
},
|
||||
|
|
@ -128,7 +125,6 @@ export async function handleComputeScores(
|
|||
backoff: { type: "fixed", delay: 5000 },
|
||||
removeOnComplete: { age: 86400 * 7 },
|
||||
removeOnFail: { age: 86400 * 30 },
|
||||
jobId: `ingest-boris-hb.${citySlug}`,
|
||||
parent: { id: job.id!, queue: queue.qualifiedName },
|
||||
ignoreDependencyOnFailure: true,
|
||||
},
|
||||
|
|
@ -184,22 +180,44 @@ export async function handleComputeScores(
|
|||
JOIN grid_points gp ON gp.id = gpd.grid_point_id
|
||||
WHERE gp.city_slug = ${citySlug}
|
||||
),
|
||||
fifteen_subcat AS (
|
||||
-- "fifteen" mode: for each unique POI, take the best time across
|
||||
-- walking / cycling / transit so each POI contributes independently
|
||||
-- to the complement-product formula (preserving diversity).
|
||||
cyclist_subcat AS (
|
||||
-- "cyclist" persona: best time per POI across walking / cycling / transit
|
||||
SELECT
|
||||
grid_point_id, category, subcategory,
|
||||
'fifteen'::text AS travel_mode,
|
||||
'cyclist'::text AS travel_mode,
|
||||
MIN(travel_time_s) AS travel_time_s
|
||||
FROM base
|
||||
WHERE travel_mode IN ('walking', 'cycling', 'transit')
|
||||
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 (
|
||||
SELECT grid_point_id, category, subcategory, travel_mode, travel_time_s FROM base
|
||||
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 (
|
||||
-- Nearest POI per (grid_point, category, mode) by distance
|
||||
|
|
@ -209,21 +227,44 @@ export async function handleComputeScores(
|
|||
WHERE nearest_poi_id IS NOT NULL
|
||||
ORDER BY grid_point_id, category, travel_mode, distance_m
|
||||
),
|
||||
fifteen_nearest AS (
|
||||
-- Nearest POI for "fifteen": closest across walking / cycling / transit
|
||||
cyclist_nearest AS (
|
||||
SELECT DISTINCT ON (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
|
||||
FROM base
|
||||
WHERE travel_mode IN ('walking', 'cycling', 'transit')
|
||||
AND nearest_poi_id IS NOT NULL
|
||||
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 (
|
||||
SELECT * FROM road_nearest
|
||||
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 (
|
||||
SELECT profile_id, subcategory, weight
|
||||
|
|
@ -252,10 +293,7 @@ export async function handleComputeScores(
|
|||
1.0 - COALESCE(pw.weight, ${DEFAULT_SUBCATEGORY_WEIGHT}::float8)
|
||||
* CASE
|
||||
WHEN s.travel_time_s IS NULL THEN 0.0
|
||||
ELSE 1.0 / (1.0 + EXP(
|
||||
(s.travel_time_s - t.threshold_min * 60.0)
|
||||
/ (t.threshold_min * 10.0)
|
||||
))
|
||||
ELSE EXP(-3.0 * s.travel_time_s / (t.threshold_min * 60.0))
|
||||
END,
|
||||
1e-10
|
||||
))
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@
|
|||
*
|
||||
* 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
|
||||
* nothing to that grid point's "fifteen" score, which then falls back to
|
||||
* walking/cycling only.
|
||||
* nothing to that grid point's "cyclist" / "transit_walk" scores, which then
|
||||
* fall back to walking/cycling only.
|
||||
*
|
||||
* Transit scores are computed ONCE per city (not per category) since the isochrone
|
||||
* polygon covers all categories in a single PostGIS spatial join.
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export type RefreshCityData = {
|
|||
citySlug: string;
|
||||
geofabrikUrl: string;
|
||||
resolutionM?: number;
|
||||
iter: number;
|
||||
/** Set after flow.add() — the ID of the enqueued compute-scores job. */
|
||||
computeScoresJobId?: string;
|
||||
};
|
||||
|
|
@ -35,7 +36,7 @@ function isInBremen(minLng: number, minLat: number, maxLng: number, maxLat: numb
|
|||
export async function handleRefreshCity(
|
||||
job: Job<RefreshCityData>,
|
||||
): Promise<void> {
|
||||
const { citySlug, geofabrikUrl, resolutionM = 200 } = job.data;
|
||||
const { citySlug, geofabrikUrl, resolutionM = 200, iter = 0 } = job.data;
|
||||
const sql = getSql();
|
||||
|
||||
const pbfPath = `${OSM_DATA_DIR}/${citySlug}-latest.osm.pbf`;
|
||||
|
|
@ -135,16 +136,14 @@ export async function handleRefreshCity(
|
|||
ingestBorisNi: niApplicable,
|
||||
ingestBorisHb: hbApplicable,
|
||||
},
|
||||
opts: { ...JOB_OPTIONS["compute-scores"], jobId: `compute-scores.${citySlug}` },
|
||||
opts: { ...JOB_OPTIONS["compute-scores"], jobId: `compute-scores.${citySlug}.${iter}` },
|
||||
children: [
|
||||
{
|
||||
name: "generate-grid",
|
||||
queueName: "pipeline",
|
||||
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: [
|
||||
// Three parallel branches — all share a single download-pbf job via
|
||||
// the deterministic jobId; BullMQ deduplicates them to one download.
|
||||
{
|
||||
name: "extract-pois",
|
||||
queueName: "pipeline",
|
||||
|
|
@ -154,7 +153,7 @@ export async function handleRefreshCity(
|
|||
pbfPath,
|
||||
...(bbox ? { bbox } : {}),
|
||||
},
|
||||
opts: { ...JOB_OPTIONS["extract-pois"], jobId: `extract-pois.${citySlug}` },
|
||||
opts: { ...JOB_OPTIONS["extract-pois"], jobId: `extract-pois.${citySlug}.${iter}` },
|
||||
children: [downloadNode()],
|
||||
},
|
||||
// Road-only Valhalla build — no GTFS, produces clean tiles without
|
||||
|
|
@ -168,7 +167,7 @@ export async function handleRefreshCity(
|
|||
pbfPath,
|
||||
...(bbox ? { bbox } : {}),
|
||||
},
|
||||
opts: { ...JOB_OPTIONS["build-valhalla"], jobId: `build-valhalla.${citySlug}` },
|
||||
opts: { ...JOB_OPTIONS["build-valhalla"], jobId: `build-valhalla.${citySlug}.${iter}` },
|
||||
children: [downloadNode()],
|
||||
},
|
||||
// Transit Valhalla build — depends on GTFS download. Produces tiles with
|
||||
|
|
@ -182,7 +181,7 @@ export async function handleRefreshCity(
|
|||
pbfPath,
|
||||
...(bbox ? { bbox } : {}),
|
||||
},
|
||||
opts: { ...JOB_OPTIONS["build-valhalla"], jobId: `build-valhalla-transit.${citySlug}` },
|
||||
opts: { ...JOB_OPTIONS["build-valhalla"], jobId: `build-valhalla-transit.${citySlug}.${iter}` },
|
||||
children: [
|
||||
downloadNode(),
|
||||
// Download GTFS feed before building transit tiles. Idempotent —
|
||||
|
|
|
|||
Loading…
Reference in a new issue