feat: add more personas instead of having only the 'best' score as the main score
This commit is contained in:
parent
9158ed3ba2
commit
da95e90ba2
10 changed files with 129 additions and 54 deletions
13
README.md
13
README.md
|
|
@ -129,7 +129,10 @@ 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
|
||||||
|
|
||||||
|
|
@ -138,10 +141,10 @@ All scores are precomputed at ingest time for every combination of threshold (5
|
||||||
Each subcategory *i* contributes a proximity score based on travel time `t` and threshold `T` (both in seconds) using exponential decay:
|
Each subcategory *i* contributes a proximity score based on travel time `t` and threshold `T` (both in seconds) using exponential decay:
|
||||||
|
|
||||||
```
|
```
|
||||||
score(t, T) = exp(−6 × t / T)
|
score(t, T) = exp(−3 × t / T)
|
||||||
```
|
```
|
||||||
|
|
||||||
At t = 0 the score is 1.0. At the threshold it is exp(−6) ≈ 0.002 — essentially zero. Close proximity strongly dominates: a sixth of the threshold away scores ~0.37, a third scores ~0.14. This ensures that only genuinely nearby POIs contribute meaningfully to the score.
|
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 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]`:
|
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]`:
|
||||||
|
|
||||||
|
|
@ -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) |
|
||||||
|
|
|
||||||
|
|
@ -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<{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,12 @@ ALTER TABLE cities ADD COLUMN IF NOT EXISTS resolution_m INTEGER NOT NULL DEFAUL
|
||||||
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;
|
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);
|
||||||
|
|
||||||
|
|
@ -78,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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -180,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
|
||||||
|
|
@ -205,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
|
||||||
|
|
@ -248,7 +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 EXP(-6.0 * s.travel_time_s / (t.threshold_min * 60.0))
|
ELSE EXP(-3.0 * s.travel_time_s / (t.threshold_min * 60.0))
|
||||||
END,
|
END,
|
||||||
1e-10
|
1e-10
|
||||||
))
|
))
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue