Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 34 additions & 9 deletions pipeline/normalize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_<class> 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_<class> day-of-year 1..365 (end of that class's bounded window) — paired
with os_<class>; 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
"""
Expand Down Expand Up @@ -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)
Expand All @@ -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]
Expand All @@ -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"),
Expand All @@ -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:
Expand Down
43 changes: 39 additions & 4 deletions tests/test_normalize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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(
Expand Down
106 changes: 60 additions & 46 deletions web/src/legal.test.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down Expand Up @@ -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);
Expand All @@ -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);
});
});
62 changes: 34 additions & 28 deletions web/src/legal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_<class> 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_<class> day-of-year 1..365 — end of that class's bounded window,
// paired with os_<class> (may be < os_<class> 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";

Expand All @@ -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_<token>/oe_<token>, 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;
}
Loading