diff --git a/pipeline/normalize.py b/pipeline/normalize.py index 8f7dd7f..732796e 100644 --- a/pipeline/normalize.py +++ b/pipeline/normalize.py @@ -15,10 +15,21 @@ name, id, forest, district, surface, maintlevel, symbol_name, miles, kind classes ",passenger,motorcycle,atv," (comma-delimited; substring-filterable) - season "yearlong" | "seasonal" + season "yearlong" | "seasonal" (any permitted class has a bounded window) open_start day-of-year 1..366 (representative window start) open_end day-of-year 1..366 (representative window end) - window_text "01/01-12/31" (human-readable representative window) + window_text "01/01-12/31" (human-readable representative window; suffixed + " (varies by class)" when permitted classes' + parsed windows genuinely differ) + os_ day-of-year 1..365 (start of THAT class's bounded window) — only + present when the class is permitted AND its window is bounded + (i.e. not year-round). E-bike classes never get os_/oe_ fields. + oe_ day-of-year 1..365 (end of that class's bounded window) — paired + with os_; a class with no os_/oe_ fields is yearlong. + +`open_start`/`open_end`/`season` remain a representative (Counter-derived) +summary for display/back-compat; the per-class `os_/oe_` fields are the +authoritative source for per-vehicle-profile filtering in the frontend. Run: uv run python -m pipeline.normalize """ @@ -109,13 +120,13 @@ def parse_window(raw) -> tuple[int, int] | None: def normalize_feature(props: dict) -> dict | None: """Map one raw MVUM properties dict to the compact schema. None to drop.""" allowed: list[str] = [] - windows: list[tuple[int, int]] = [] + class_windows: dict[str, tuple[int, int]] = {} for key, field in CLASS_DATEFIELD.items(): win = parse_window(props.get(field)) if win is not None: allowed.append(key) - windows.append(win) + class_windows[key] = win for key, field in EBIKE_FIELDS.items(): v = props.get(field) @@ -125,9 +136,12 @@ def normalize_feature(props: dict) -> dict | None: if not allowed: return None # no motorized class permitted -> not useful on this map + windows = list(class_windows.values()) + # Representative window = the most common bounded window among permitted # classes; fall back to year-round. Seasonal closures typically gate the - # whole segment, so classes usually share one window. + # whole segment, so classes usually share one window. (Display/back-compat + # summary only — per-class os_/oe_ fields below are authoritative.) bounded = [w for w in windows if w != (1, 365)] if bounded: start, end = Counter(bounded).most_common(1)[0][0] @@ -136,7 +150,7 @@ def normalize_feature(props: dict) -> dict | None: start, end = 1, 365 season = "yearlong" - return { + out = { "name": (props.get("name") or "").strip() or None, "id": (props.get("id") or "").strip() or None, "forest": props.get("forestname"), @@ -150,14 +164,25 @@ def normalize_feature(props: dict) -> dict | None: "season": season, "open_start": start, "open_end": end, - "window_text": _window_text(start, end), + "window_text": _window_text(start, end, varies=len(set(windows)) > 1), } + # Per-class window fields: emitted only for permitted, non-ebike classes + # whose parsed window is bounded (not year-round). A class with no + # os_/oe_ fields is yearlong by convention (frontend coalesces to 1..365). + for key, win in class_windows.items(): + if win != (1, 365): + out[f"os_{key}"] = win[0] + out[f"oe_{key}"] = win[1] + + return out + -def _window_text(start: int, end: int) -> str: +def _window_text(start: int, end: int, varies: bool = False) -> str: if (start, end) == (1, 365): return "Yearlong" - return f"{_doy_to_md(start)}-{_doy_to_md(end)}" + text = f"{_doy_to_md(start)}-{_doy_to_md(end)}" + return f"{text} (varies by class)" if varies else text def _doy_to_md(doy: int) -> str: diff --git a/tests/test_normalize.py b/tests/test_normalize.py index 75ae41d..eca916e 100644 --- a/tests/test_normalize.py +++ b/tests/test_normalize.py @@ -77,10 +77,9 @@ def test_single_yearlong_class(self): assert (result["open_start"], result["open_end"]) == (1, 365) def test_multi_class_representative_window_collapse(self): - # Characterizes the representative-window collapse: a bounded window - # on ANY permitted class makes the whole route read "seasonal", even - # though the passenger class itself is yearlong-permitted here. - # Plan 005 changes this per-class behavior; update this test then. + # Plan 005: per-class windows are now emitted so divergent classes + # read correctly per vehicle profile, even though the route still + # carries a representative season/open_start/open_end for display. result = normalize_feature( { "passengervehicle_datesopen": "open", @@ -93,6 +92,42 @@ def test_multi_class_representative_window_collapse(self): assert result["season"] == "seasonal" assert result["open_start"] == 121 assert result["open_end"] == 319 + assert result["os_motorcycle"] == 121 + assert result["oe_motorcycle"] == 319 + assert "os_passenger" not in result + assert "oe_passenger" not in result + assert result["window_text"].endswith("(varies by class)") + + def test_shared_window_across_classes_no_variance_suffix(self): + result = normalize_feature( + { + "passengervehicle_datesopen": "05/01-11/15", + "motorcycle_datesopen": "05/01-11/15", + } + ) + assert result is not None + assert result["os_passenger"] == 121 + assert result["oe_passenger"] == 319 + assert result["os_motorcycle"] == 121 + assert result["oe_motorcycle"] == 319 + assert result["window_text"] == "05/01-11/15" + + def test_all_yearlong_route_has_no_window_fields(self): + result = normalize_feature( + { + "passengervehicle_datesopen": "open", + "motorcycle_datesopen": "open", + } + ) + assert result is not None + assert result["season"] == "yearlong" + assert not any(k.startswith(("os_", "oe_")) for k in result) + + def test_ebike_only_route_has_no_window_fields(self): + result = normalize_feature({"e_bike_class1": "yes"}) + assert result is not None + assert result["classes"] == ",e_bike1," + assert not any(k.startswith(("os_", "oe_")) for k in result) def test_ebike_gate_yes(self): result = normalize_feature( diff --git a/web/src/legal.test.ts b/web/src/legal.test.ts index 8f56717..dd524af 100644 --- a/web/src/legal.test.ts +++ b/web/src/legal.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { expression } from "@maplibre/maplibre-gl-style-spec"; -import { dayOfYear, classPermitted, dateInSeason, isOpen } from "./legal"; +import { dayOfYear, isOpen } from "./legal"; import type { ExpressionSpecification } from "maplibre-gl"; /** Evaluate a MapLibre expression against a synthetic feature's properties. */ @@ -45,90 +45,78 @@ describe("dayOfYear", () => { }); }); -describe("classPermitted", () => { +describe("isOpen: class permission (delimiter safety)", () => { it("matches when the token is present as a delimited entry", () => { - const result = evalExpr(classPermitted(["passenger"]), { classes: ",passenger," }); - expect(result).toBe(true); + expect(evalOpen(["passenger"], 1, { classes: ",passenger," })).toBe(true); }); it("does not match when the token is absent", () => { - const result = evalExpr(classPermitted(["passenger"]), { classes: ",atv," }); - expect(result).toBe(false); + expect(evalOpen(["passenger"], 1, { classes: ",atv," })).toBe(false); }); it("matches a multi-token profile against a route with only one of the tokens", () => { - const result = evalExpr(classPermitted(["passenger", "high_clearance"]), { - classes: ",high_clearance,", - }); - expect(result).toBe(true); + expect( + evalOpen(["passenger", "high_clearance"], 1, { classes: ",high_clearance," }), + ).toBe(true); }); it("delimiters prevent bare-substring hits (,atv, does not match ,utv_atv_x,)", () => { - const result = evalExpr(classPermitted(["atv"]), { classes: ",utv_atv_x," }); - expect(result).toBe(false); + expect(evalOpen(["atv"], 1, { classes: ",utv_atv_x," })).toBe(false); }); }); -describe("dateInSeason", () => { - it("yearlong season is open regardless of window fields, at doy 1", () => { - const result = evalExpr(dateInSeason(1), { - season: "yearlong", - open_start: 999, - open_end: -999, - }); - expect(result).toBe(true); - }); - - it("yearlong season is open regardless of window fields, at doy 366", () => { - const result = evalExpr(dateInSeason(366), { - season: "yearlong", - open_start: 999, - open_end: -999, - }); - expect(result).toBe(true); +describe("isOpen: per-token season window", () => { + it("missing os_/oe_ fields mean yearlong: open at doy 1, 200, 365", () => { + const props = { classes: ",atv," }; + expect(evalOpen(["atv"], 1, props)).toBe(true); + expect(evalOpen(["atv"], 200, props)).toBe(true); + expect(evalOpen(["atv"], 365, props)).toBe(true); }); - describe("normal (non-wrapping) window: open_start=121, open_end=319", () => { - const props = { season: "seasonal", open_start: 121, open_end: 319 }; + describe("normal (non-wrapping) window: os_atv=121, oe_atv=319", () => { + const props = { classes: ",atv,", os_atv: 121, oe_atv: 319 }; it("open at the start boundary (121)", () => { - expect(evalExpr(dateInSeason(121), props)).toBe(true); + expect(evalOpen(["atv"], 121, props)).toBe(true); }); it("open in the middle (200)", () => { - expect(evalExpr(dateInSeason(200), props)).toBe(true); + expect(evalOpen(["atv"], 200, props)).toBe(true); }); it("open at the end boundary (319)", () => { - expect(evalExpr(dateInSeason(319), props)).toBe(true); + expect(evalOpen(["atv"], 319, props)).toBe(true); }); it("closed just before the start (120)", () => { - expect(evalExpr(dateInSeason(120), props)).toBe(false); + expect(evalOpen(["atv"], 120, props)).toBe(false); }); it("closed just after the end (320)", () => { - expect(evalExpr(dateInSeason(320), props)).toBe(false); + expect(evalOpen(["atv"], 320, props)).toBe(false); }); }); - describe("wrapping (winter) window: open_start=305, open_end=90", () => { - const props = { season: "seasonal", open_start: 305, open_end: 90 }; + describe("wrapping (winter) per-token window: os_atv=305, oe_atv=90", () => { + const props = { classes: ",atv,", os_atv: 305, oe_atv: 90 }; it("open just after the start (306)", () => { - expect(evalExpr(dateInSeason(306), props)).toBe(true); + expect(evalOpen(["atv"], 306, props)).toBe(true); }); it("open at year end (366)", () => { - expect(evalExpr(dateInSeason(366), props)).toBe(true); + expect(evalOpen(["atv"], 366, props)).toBe(true); }); it("open at year start (1)", () => { - expect(evalExpr(dateInSeason(1), props)).toBe(true); + expect(evalOpen(["atv"], 1, props)).toBe(true); }); it("open just before the end (89)", () => { - expect(evalExpr(dateInSeason(89), props)).toBe(true); + expect(evalOpen(["atv"], 89, props)).toBe(true); + }); + it("open at 320 (this plan's test vector)", () => { + expect(evalOpen(["atv"], 320, props)).toBe(true); }); it("closed in the middle of the off-season (200)", () => { - expect(evalExpr(dateInSeason(200), props)).toBe(false); + expect(evalOpen(["atv"], 200, props)).toBe(false); }); }); }); describe("isOpen (combined)", () => { - const props = { classes: ",passenger,", season: "seasonal", open_start: 121, open_end: 319 }; + const props = { classes: ",passenger,", os_passenger: 121, oe_passenger: 319 }; it("permitted class but out of season -> false", () => { expect(evalOpen(["passenger"], 1, props)).toBe(false); @@ -142,8 +130,34 @@ describe("isOpen (combined)", () => { expect(evalOpen(["passenger"], 200, props)).toBe(true); }); - it("a window ending 12/31 (open_end=365) is OPEN on Dec 31 of a leap year (the leap-year bug this plan fixes)", () => { - const leapProps = { classes: ",passenger,", season: "seasonal", open_start: 121, open_end: 365 }; + it("a window ending 12/31 (oe_passenger=365) is OPEN on Dec 31 of a leap year (the leap-year convention this plan preserves)", () => { + const leapProps = { classes: ",passenger,", os_passenger: 121, oe_passenger: 365 }; expect(evalOpen(["passenger"], dayOfYear(new Date(2028, 11, 31)), leapProps)).toBe(true); }); }); + +describe("isOpen: per-class divergent windows (the HAY FLAT bug this plan fixes)", () => { + const props = { + classes: ",passenger,high_clearance,", + os_high_clearance: 91, + oe_high_clearance: 365, + }; + + it("passenger is yearlong (no os_/oe_ fields) -> open at doy 50", () => { + expect(evalOpen(["passenger"], 50, props)).toBe(true); + }); + + it("high_clearance's own window excludes doy 50 -> closed", () => { + expect(evalOpen(["high_clearance"], 50, props)).toBe(false); + }); + + it("ANY semantics: a profile passing both tokens is open at doy 50 because passenger is", () => { + expect(evalOpen(["passenger", "high_clearance"], 50, props)).toBe(true); + }); + + it("at doy 200 (inside high_clearance's window too) all three checks are open", () => { + expect(evalOpen(["passenger"], 200, props)).toBe(true); + expect(evalOpen(["high_clearance"], 200, props)).toBe(true); + expect(evalOpen(["passenger", "high_clearance"], 200, props)).toBe(true); + }); +}); diff --git a/web/src/legal.ts b/web/src/legal.ts index 35788d1..09f3be6 100644 --- a/web/src/legal.ts +++ b/web/src/legal.ts @@ -2,10 +2,21 @@ // selected vehicle class on the selected date?" // // Mirrors the data model from pipeline/normalize.py: -// classes ",passenger,motorcycle," (comma-delimited token list) -// season "yearlong" | "seasonal" -// open_start day-of-year 1..365 (non-leap convention) -// open_end day-of-year 1..365 (non-leap convention) (may be < open_start for winter-wrapping) +// classes ",passenger,motorcycle," (comma-delimited token list) +// season "yearlong" | "seasonal" (representative summary; display only) +// open_start day-of-year 1..365 (non-leap convention, representative — display only) +// open_end day-of-year 1..365 (non-leap convention, representative — display only) +// os_ day-of-year 1..365 — start of THAT class's bounded window. +// Present only when the class is permitted and its window is +// bounded (not year-round). Missing means yearlong for that class. +// oe_ day-of-year 1..365 — end of that class's bounded window, +// paired with os_ (may be < os_ for winter-wrapping). +// +// Per-class windows are the authoritative source for per-vehicle-profile +// filtering: two classes on the same route can have genuinely different +// season windows (e.g. passenger yearlong, motorcycle seasonal), so `isOpen` +// evaluates each requested class token against ITS OWN os_/oe_ fields rather +// than the route-level representative window. import type { ExpressionSpecification } from "maplibre-gl"; @@ -20,35 +31,30 @@ export function dayOfYear(d: Date): number { return MONTH_START[d.getMonth() + 1] + d.getDate(); } -/** True-expression: the route permits ANY of the profile's class tokens. - * A street-legal profile passes several tokens (highway-legal roads + its OHV - * trails); an OHV-only profile passes one. */ -export function classPermitted(tokens: string[]): ExpressionSpecification { - const anyOf = tokens.map( - (t) => ["in", `,${t},`, ["get", "classes"]] as ExpressionSpecification, - ); - return ["any", ...anyOf] as ExpressionSpecification; -} - -/** True-expression: the route's season window contains `doy`. */ -export function dateInSeason(doy: number): ExpressionSpecification { +/** True-expression: the route permits `token` AND `token`'s own season + * window (os_/oe_, defaulting to yearlong when absent) + * contains `doy`. */ +function openForToken(token: string, doy: number): ExpressionSpecification { + const start = ["coalesce", ["get", `os_${token}`], 1] as ExpressionSpecification; + const end = ["coalesce", ["get", `oe_${token}`], 365] as ExpressionSpecification; return [ - "case", - ["==", ["get", "season"], "yearlong"], - true, - // normal window: start <= end - ["<=", ["get", "open_start"], ["get", "open_end"]], + "all", + ["in", `,${token},`, ["get", "classes"]], [ - "all", - [">=", doy, ["get", "open_start"]], - ["<=", doy, ["get", "open_end"]], + "case", + // normal window: start <= end + ["<=", start, end], + ["all", [">=", doy, start], ["<=", doy, end]], + // wrapping window (e.g. winter): open if after start OR before end + ["any", [">=", doy, start], ["<=", doy, end]], ], - // wrapping window (e.g. winter): open if after start OR before end - ["any", [">=", doy, ["get", "open_start"]], ["<=", doy, ["get", "open_end"]]], ] as unknown as ExpressionSpecification; } -/** Combined: permitted for the profile AND in-season on the date. */ +/** True-expression: the route is open to ANY of the profile's class tokens + * on `doy`, each evaluated against its own per-class window (ANY semantics — + * a street-legal profile passes several tokens; an OHV-only profile passes + * one). */ export function isOpen(tokens: string[], doy: number): ExpressionSpecification { - return ["all", classPermitted(tokens), dateInSeason(doy)] as ExpressionSpecification; + return ["any", ...tokens.map((t) => openForToken(t, doy))] as ExpressionSpecification; }