Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
dc58aed
chore: untrack .planning/ROADMAP.md
Pascal-ZeGerman Jul 27, 2026
4ce72ef
chore: untrack remaining legacy .planning/ files
Pascal-ZeGerman Jul 27, 2026
dfa1a5a
Merge remote-tracking branch 'origin/main'
Pascal-ZeGerman Jul 28, 2026
a9dcf0f
feat(41-03): add static demo page shell with pinned+SRI Leaflet
Pascal-ZeGerman Jul 29, 2026
c634ad0
test(41-01): add failing offline tests for demo dataset dumper
Pascal-ZeGerman Jul 29, 2026
2805f3e
feat(41-03): add dark-theme demo stylesheet
Pascal-ZeGerman Jul 29, 2026
0567df2
feat(41-01): implement offline demo dataset dumper
Pascal-ZeGerman Jul 29, 2026
a06f131
chore: merge executor worktree (worktree-agent-afefd087bfb078b8c)
Pascal-ZeGerman Jul 29, 2026
fb2df93
chore: merge executor worktree (worktree-agent-a25e28975eba6b2ba)
Pascal-ZeGerman Jul 29, 2026
c5b328d
feat(41-04): add demo client controller (map, HA card, next-move cale…
Pascal-ZeGerman Jul 29, 2026
72c155a
feat(41-02): generate committed demo dataset snapshot
Pascal-ZeGerman Jul 29, 2026
df047bc
chore: merge executor worktree (worktree-agent-ad81734d3ae05f2d3)
Pascal-ZeGerman Jul 29, 2026
337afd7
chore: merge executor worktree (worktree-agent-a7f086f09bb90af19)
Pascal-ZeGerman Jul 29, 2026
7a8b08e
feat(41-05): add GitHub Pages workflow to publish docs/ demo
Pascal-ZeGerman Jul 29, 2026
573243b
docs(41-05): document the live demo in README
Pascal-ZeGerman Jul 29, 2026
2c70052
chore: merge executor worktree (worktree-agent-a43ac62e0f908aecd)
Pascal-ZeGerman Jul 29, 2026
0629ee0
fix(41-02): replace unresolvable demo points and populate ASPActiveNo…
Pascal-ZeGerman Jul 30, 2026
9a154fd
fix(41-02): populate cleaning_days/schedule_summary for asp_active_no…
Pascal-ZeGerman Jul 30, 2026
76f1e4e
feat(42-03): add coverage explorer page shell (index.html)
Pascal-ZeGerman Jul 31, 2026
450df5c
test(42-01): add failing grouping-key + tier-partition tests
Pascal-ZeGerman Jul 31, 2026
d6c9fa4
feat(42-03): add coverage explorer stylesheet (styles.css)
Pascal-ZeGerman Jul 31, 2026
fe7e383
feat(42-01): implement coverage-dataset dumper deterministic core
Pascal-ZeGerman Jul 31, 2026
5f02b19
chore: merge executor worktree (worktree-agent-a2c4ae53fd75ddfa0)
Pascal-ZeGerman Aug 1, 2026
c567247
chore: merge executor worktree (worktree-agent-af0b6c0c17beb5ada)
Pascal-ZeGerman Aug 1, 2026
992581f
feat(42-04): canvas map init, dataset load/error, freshness, tier col…
Pascal-ZeGerman Aug 1, 2026
514ee45
feat(42-04): popup builder — fields, link-outs, explicit unresolved s…
Pascal-ZeGerman Aug 1, 2026
554230c
test(42-02): add grouped-resolve, per-segment-entry, and zero-record …
Pascal-ZeGerman Aug 1, 2026
9fedff3
feat(42-04): four AND filters + no-results state + GeoJSON export
Pascal-ZeGerman Aug 1, 2026
4183c9b
feat(42-02): grouped SODA resolve + worst-case-side pick (R1)
Pascal-ZeGerman Aug 1, 2026
39162b6
style(42-02): wrap long expected_count line to satisfy ruff format
Pascal-ZeGerman Aug 1, 2026
8094aa9
test(42-02): coverage.json writer + token-safety guards (R1, T-42-01)
Pascal-ZeGerman Aug 1, 2026
087017e
chore: merge executor worktree (worktree-agent-ade246ca4d940074e)
Pascal-ZeGerman Aug 1, 2026
26df1db
chore: merge executor worktree (worktree-agent-ae627b39b20642574)
Pascal-ZeGerman Aug 1, 2026
c31e808
feat(42-05): commit whole-index coverage.json (R1)
Pascal-ZeGerman Aug 1, 2026
251edf4
fix(dataset-dumpers): correct tier misclassification, SODA fallback, …
Pascal-ZeGerman Aug 2, 2026
85c78fc
data(coverage): regenerate coverage.json with fixed dumper
Pascal-ZeGerman Aug 2, 2026
851f12f
fix(demo+explorer): resolve code-review findings on docs pages and du…
Pascal-ZeGerman Aug 2, 2026
bb927c0
fix(demo+coverage): resolve xhigh code-review findings
Pascal-ZeGerman Aug 2, 2026
490682c
fix(demo+explorer+coverage): resolve xhigh code-review findings
Pascal-ZeGerman Aug 2, 2026
b4d06f6
fix(demo+explorer+coverage): resolve xhigh code-review findings
Pascal-ZeGerman Aug 3, 2026
8cc097f
fix(260802-uk5): add weekly_schedule to ASPActiveNow to preserve full…
Pascal-ZeGerman Aug 3, 2026
c1575c6
fix(260802-uk5): serialize full weekly schedule in demo/coverage dumpers
Pascal-ZeGerman Aug 3, 2026
412f736
fix(demo+explorer+coverage): resolve xhigh code-review findings
Pascal-ZeGerman Aug 3, 2026
0f174a1
chore(vendor): sync spatial_index.py vendored copy with src
Pascal-ZeGerman Aug 3, 2026
61eb3e9
fix(sensor+demo+coverage+explorer): resolve xhigh code-review findings
Pascal-ZeGerman Aug 3, 2026
63a94a2
test(sensor): add regression test for ASPActiveNow full weekly_schedule
Pascal-ZeGerman Aug 3, 2026
1f748c3
fix(vendor+tests): sync dataset_common.py mirror, fix stale ASPActive…
Pascal-ZeGerman Aug 3, 2026
b09ac64
fix(coverage-dataset): query every group's raw street spelling, fix -…
Pascal-ZeGerman Aug 3, 2026
960b95e
fix(demo+coverage+sensor): resolve xhigh code-review findings
Pascal-ZeGerman Aug 3, 2026
f2533c1
fix(coverage-dataset): remove dead query_count initializer
Pascal-ZeGerman Aug 4, 2026
1eedb25
fix(dataset-common): dedupe BOROUGH_NAMES/SIDE_LABELS/TIER_BOUNDS con…
Pascal-ZeGerman Aug 4, 2026
2a8cda4
fix(dataset-common+ha-tests): dedupe index-path resolution and SIDE_L…
Pascal-ZeGerman Aug 4, 2026
8938537
fix(coverage-dataset+vendor-mirror): normalize internal whitespace in…
Pascal-ZeGerman Aug 4, 2026
e22c81f
fix(dataset-common+coverage-dataset): apply ruff formatting, split ru…
Pascal-ZeGerman Aug 4, 2026
01c9f0b
fix(signs-normalize+ha-tests): resync vendored mirror, use lightweigh…
Pascal-ZeGerman Aug 4, 2026
a441103
fix(build-demo-dataset): use is-not-None check for cross_streets gate
Pascal-ZeGerman Aug 4, 2026
9b81676
fix(explorer+coverage-dataset): correct missing-borocode string coerc…
Pascal-ZeGerman Aug 4, 2026
321135d
fix(demo+coverage-dataset+sensor): dedupe vendored logic, sync datase…
Pascal-ZeGerman Aug 4, 2026
299257f
fix(demo+explorer): resolve xhigh code-review findings
Pascal-ZeGerman Aug 4, 2026
67c30f8
fix(coverage-dataset+demo): resolve xhigh code-review findings
Pascal-ZeGerman Aug 5, 2026
107ee57
chore(hooks): track vendor-sync pre-commit hook via .githooks
Pascal-ZeGerman Aug 5, 2026
be11d1a
fix(lint+silent-failures): enable BLE001/TRY400/RUF059 ruff rules, re…
Pascal-ZeGerman Aug 5, 2026
f6b7f5f
fix(scripts): dedupe geocoding logic in generate_random_fixtures
Pascal-ZeGerman Aug 5, 2026
f6de280
Merge origin/main (PR #39 iCloud CalDAV fix) into local main
Pascal-ZeGerman Aug 5, 2026
a0c681b
fix(build-coverage+fixtures): stop silent-failure modes found in PR #…
Pascal-ZeGerman Aug 5, 2026
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
17 changes: 17 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Auto-resync the vendored gps2asp/ mirror before commit, so the CI
# vendor-guard (.github/workflows/vendor-guard.yml) never has to catch
# drift after the fact. Runs scripts/sync_vendored.py in write mode when
# the commit touches src/gps2asp/, then stages the resynced output.
#
# Tracked at .githooks/pre-commit — enable once per clone with:
# git config core.hooksPath .githooks
set -euo pipefail

repo_root="$(git rev-parse --show-toplevel)"
cd "$repo_root"

if git diff --cached --name-only --diff-filter=ACMR | grep -q '^src/gps2asp/'; then
.venv/bin/python scripts/sync_vendored.py
git add -A -- custom_components/asp_parking/gps2asp/
fi
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ It's a plain HTML/CSS/JS page — no build step, no install, no server-side code

**Where the data comes from.** The demo does **not** call any live API from the browser.
It reads a **dated snapshot** committed to the repo at
[`docs/demo/data/demo.json`](docs/demo/data/demo.json) (snapshot date: **2026-07-28**),
[`docs/demo/data/demo.json`](docs/demo/data/demo.json) (snapshot date: **2026-08-02**),
plus the matched segment geometries in `docs/demo/data/demo-segments.geojson`. The snapshot
stores *weekly recurring patterns* rather than absolute datetimes, and the page recomputes the
next move time in your browser at NYC time — so the "next move" stays correct even though the
Expand All @@ -52,20 +52,21 @@ needs a token.

This requires two things the demo page itself does not: the **spatial index** must be present
locally, and the script needs **network access to the NYC Open Data (SODA) API**. The index is
gitignored (~95 MB), so build it once with `python scripts/build_index.py` **or** download the
gitignored (~95 MB), so build it once with `.venv/bin/python scripts/build_index.py` **or** download the
released `index-v1` asset from the [Releases page](https://github.com/Pascal-ZeGerman/GPS2ASP-Resolver/releases).
Setting a `NYC_OPEN_DATA_APP_TOKEN` environment variable is optional but helps avoid SODA rate
limiting. The generated `demo.json`/`demo-segments.geojson` are the only files committed — the
index never is.

**Running it locally.** Serve the folder over HTTP (opening `index.html` via `file://` won't
let the page `fetch()` its JSON):
**Running it locally.** Serve the whole `docs/` tree over HTTP (opening `index.html` via `file://`
won't let the page `fetch()` its JSON, and serving `docs/demo` alone 404s on the shared
`../common.js`/`../common.css` the page loads):

```bash
python -m http.server --directory docs/demo 8000
.venv/bin/python -m http.server --directory docs 8000
```

Then visit <http://localhost:8000/>.
Then visit <http://localhost:8000/demo/>.

**Hosting it.** The repo ships a [`.github/workflows/pages.yml`](.github/workflows/pages.yml)
workflow that publishes the committed `docs/` tree to **GitHub Pages** on every push to `docs/`
Expand Down
6 changes: 3 additions & 3 deletions custom_components/asp_parking/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ async def _async_download_index(hass: HomeAssistant) -> None:
title="ASP Parking: Setup Error",
notification_id="asp_parking_index_error",
)
logger.error("ASP Parking: index download failed: %s", err)
logger.exception("ASP Parking: index download failed: %s", err)
raise


Expand Down Expand Up @@ -240,7 +240,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
try:
coordinator = ASPParkingCoordinator(hass, entry)
except ImportError as err:
logger.error(
logger.exception(
"ASP Parking: gps2asp vendored package is incomplete -- "
"reinstall via HACS. (%s)",
err,
Expand All @@ -265,7 +265,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
try:
await coordinator.async_start()
except Exception as err: # noqa: BLE001 — defence-in-depth: catch all startup failures
logger.error(
logger.exception(
"ASP Parking: coordinator.async_start() raised %s: %s; "
"see HA logs for traceback",
type(err).__name__,
Expand Down
2 changes: 1 addition & 1 deletion custom_components/asp_parking/caldav_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ async def __aexit__(self, *exc_info: Any) -> None:
try:
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, client.close)
except Exception:
except Exception: # noqa: BLE001 — close() must never mask the original error
# Use DEBUG when we're already inside exception handling (e.g.
# CancelledError during HA shutdown) — close failures in that
# context are expected and not user-actionable. Use WARNING
Expand Down
9 changes: 1 addition & 8 deletions custom_components/asp_parking/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@

from zoneinfo import ZoneInfo

from .gps2asp.dataset_labels import BOROUGH_NAMES as _BOROUGH_NAMES
from .gps2asp.resolver import convert, resolve
from .gps2asp.resolver.exceptions import (
AmbiguousResolutionError,
Expand Down Expand Up @@ -130,14 +131,6 @@

_METRES_TO_FEET = 3.28084 # 1 metre = 3.28084 US survey feet

_BOROUGH_NAMES: dict[str, str] = {
"1": "Manhattan",
"2": "Bronx",
"3": "Brooklyn",
"4": "Queens",
"5": "Staten Island",
}


class RebuildPath(Enum):
"""Phase 38 (IDX-05): which executor strategy services a rebuild request.
Expand Down
109 changes: 109 additions & 0 deletions custom_components/asp_parking/gps2asp/dataset_common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""Shared constants for the offline dataset dumper scripts.

``build_coverage_dataset.py`` and ``build_demo_dataset.py`` are both
presentation-layer snapshot dumpers that reproject EPSG:2263 geometry to
WGS84 and label CSCL borough codes. This module is the single source for
that shared, non-resolver-logic plumbing so the two dumpers never drift.
"""

from __future__ import annotations

import asyncio
import json
from collections.abc import Awaitable, Callable, Iterable
from pathlib import Path
from typing import TypeVar

from pyproj import Transformer

from .dataset_labels import (
BOROUGH_NAMES,
SIDE_LABELS,
borough_name,
cleaning_day_names,
)
from .resolver.spatial_index import SpatialIndex

__all__ = [
"BOROUGH_NAMES",
"SIDE_LABELS",
"TO_WGS84",
"bounded_gather",
"borough_name",
"cleaning_day_names",
"load_segment_records_with_raw_count",
]

_T = TypeVar("_T")
_R = TypeVar("_R")

# Reverse of resolver/converter.py's forward transform: EPSG:2263 -> WGS84.
# always_xy=True yields (lon, lat) — exactly GeoJSON coordinate order.
TO_WGS84 = Transformer.from_crs("EPSG:2263", "EPSG:4326", always_xy=True)


def _default_segments_path() -> Path:
"""Default ``segments.json`` location, honoring ``GPS2ASP_INDEX_DIR``.

Reuses ``SpatialIndex.resolve_index_dir``'s own precedence (env var, then
the package-bundled default) so this dumper never reads a different
index than the live resolver path ``build_demo_dataset.py`` uses via
``SpatialIndex.get()``.
"""
return SpatialIndex.resolve_index_dir() / "segments.json"


def _load_raw_segments(path: Path | None = None) -> dict:
return json.loads((path or _default_segments_path()).read_text())


def load_segment_records_with_raw_count(
path: Path | None = None,
) -> tuple[dict[str, dict], int]:
"""Load ``segments.json`` into a filtered record map, plus the pre-filter
raw count, from a single parse of ``segments.json``.

Filters to dict records carrying ``geometry_wkt`` — the only records
either dumper can use (for a map midpoint/render or a schedule resolve).
Callers that need both the filtered records and the raw count (e.g. a
"never omit a segment" self-check) should use this rather than parsing
the (multi-MB) index file twice.
"""
raw = _load_raw_segments(path)
filtered = {
str(seg_id): rec
for seg_id, rec in raw.items()
if isinstance(rec, dict) and "geometry_wkt" in rec
}
return filtered, len(raw)


async def bounded_gather(
items: Iterable[_T],
worker: Callable[[_T], Awaitable[_R]],
concurrency: int,
) -> list[_R]:
"""Run ``worker`` over every item concurrently, bounded by ``concurrency``.

Both offline dataset dumpers need the same semaphore-bounded-gather
idiom (each item is an independent I/O-bound resolve: one point resolve
for the demo dumper, one SODA group fetch for the coverage dumper).
Factored here so a future fix to the bounded-concurrency pattern (e.g.
propagating the first exception, adding a timeout) doesn't have to be
hand-ported between the two dumper scripts.

Args:
items: The items to process; result order matches ``items`` order.
worker: Async callable applied to each item under the semaphore.
concurrency: Maximum number of ``worker`` calls in flight at once.

Returns:
Results in the same order as ``items``.
"""
semaphore = asyncio.Semaphore(concurrency)

async def _bounded(item: _T) -> _R:
async with semaphore:
return await worker(item)

return await asyncio.gather(*(_bounded(item) for item in items))
60 changes: 60 additions & 0 deletions custom_components/asp_parking/gps2asp/dataset_labels.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Presentation-label constants shared by the live HA integration and the
offline dataset dumper scripts.

Split out of ``dataset_common`` so importing these plain dicts (from
``coordinator.py`` and ``sensor.py``) doesn't also pull in that module's
pyproj/asyncio dumper plumbing, which HA never uses.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
from .schedule.models import TimeWindow

# CSCL borough code -> human name. Single source of truth shared by
# coordinator.py and both offline dataset dumpers so the three never drift.
BOROUGH_NAMES: dict[str, str] = {
"1": "Manhattan",
"2": "Bronx",
"3": "Brooklyn",
"4": "Queens",
"5": "Staten Island",
}


def borough_name(borocode: str | None) -> str | None:
"""Map a CSCL borough code to its human name, or None when unknown."""
if borocode is None:
return None
return BOROUGH_NAMES.get(str(borocode))


# side_of_street letter -> display label. Single source of truth shared by
# sensor.py and build_demo_dataset.py so the two never drift.
SIDE_LABELS: dict[str, str] = {
"N": "North side",
"S": "South side",
"E": "East side",
"W": "West side",
}


def cleaning_day_names(
windows: "list[TimeWindow] | tuple[TimeWindow, ...]",
) -> list[str]:
"""Ordered-unique cleaning-day display names from a schedule's windows.

``windows`` is expected pre-sorted by day then start_time (the
``WeeklySchedule.windows`` contract), so deduplicating by first
appearance yields the same day order as sorting the unique day values.
Single source of truth shared by sensor.py, build_demo_dataset.py, and
the HA-independent test mirror so the three never drift.
"""
seen: list[str] = []
for window in windows:
name = window.day.name.title()
if name not in seen:
seen.append(name)
return seen
45 changes: 38 additions & 7 deletions custom_components/asp_parking/gps2asp/resolver/spatial_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,23 @@ class SpatialIndex:
def __init__(self, index_dir: str | None = None) -> None:
self._index = None
self._segments = None
self._index_dir = self.resolve_index_dir(index_dir)

@staticmethod
def resolve_index_dir(index_dir: str | None = None) -> Path:
"""Resolve the index directory using the constructor/env/default precedence.

Shared with ``dataset_common._default_segments_path`` so the offline
dataset dumpers always resolve ``segments.json`` from the same
directory the live resolver reads its index from.
"""
if index_dir is not None:
self._index_dir = Path(index_dir)
elif env_dir := os.environ.get("GPS2ASP_INDEX_DIR"):
self._index_dir = Path(env_dir)
else:
# Default: data/index/ relative to the gps2asp package
package_dir = Path(__file__).parent.parent
self._index_dir = package_dir / "data" / "index"
return Path(index_dir)
if env_dir := os.environ.get("GPS2ASP_INDEX_DIR"):
return Path(env_dir)
# Default: data/index/ relative to the gps2asp package
package_dir = Path(__file__).parent.parent
return package_dir / "data" / "index"

@classmethod
async def get(cls, index_dir: str | None = None) -> SpatialIndex:
Expand Down Expand Up @@ -312,3 +320,26 @@ def query_radius(
results.sort(key=lambda c: c.distance_ft)

return results

def get_segment_geometry_wkt(self, segment_id: object) -> str | None:
"""Return the raw ``geometry_wkt`` for one segment id, or ``None``.

Reuses the already-loaded ``_segments`` metadata (the same dict
``nearest()``/``query_radius()`` read) instead of re-parsing
segments.json. Callers that already resolved a point through this
singleton (e.g. dataset dumpers deriving a matched segment's display
geometry) get the lookup for free rather than paying a second,
independent full parse of the multi-megabyte index file.

Args:
segment_id: A segment id as returned on ``SegmentCandidate.segment_id``
(int or str; looked up via ``str(segment_id)``, matching the
string keys segments.json is loaded into).

Returns:
The segment's ``geometry_wkt``, or ``None`` if unloaded or not found.
"""
if self._segments is None:
return None
seg_data = self._segments.get(str(segment_id))
return seg_data.get("geometry_wkt") if seg_data else None
1 change: 1 addition & 0 deletions custom_components/asp_parking/gps2asp/schedule/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ def compute_schedule(
return ASPActiveNow(
status="asp_active_now",
active_window=active,
weekly_schedule=merged_schedule,
on_street=on_street,
from_street=from_street,
to_street=to_street,
Expand Down
5 changes: 5 additions & 0 deletions custom_components/asp_parking/gps2asp/schedule/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,10 @@ class ASPActiveNow:
Attributes:
status: Discriminator literal "asp_active_now".
active_window: The currently active cleaning window.
weekly_schedule: Full merged weekly schedule (all cleaning days/windows),
mirroring ScheduleFound.weekly_schedule. Preserves every cleaning day
even while one window is in progress, so downstream consumers (demo /
coverage dumpers, calendars) can render the complete pattern.
on_street: Street name in CSCL format.
from_street: Cross street at one end.
to_street: Cross street at the other end.
Expand All @@ -191,6 +195,7 @@ class ASPActiveNow:

status: Literal["asp_active_now"]
active_window: CleaningWindow
weekly_schedule: WeeklySchedule
on_street: str
from_street: str
to_street: str
Expand Down
7 changes: 4 additions & 3 deletions custom_components/asp_parking/gps2asp/signs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
__all__ = [
"retrieve_signs",
"materialize_cached_records",
"normalize_street",
"SignRetrievalResult",
"SignRetrievalSuccess",
"NoASPSigns",
Expand Down Expand Up @@ -130,7 +131,7 @@ def materialize_cached_records(
)


def _normalize_street(name: str) -> str:
def normalize_street(name: str) -> str:
"""Normalize a street name for comparison (uppercase, strip, expand)."""
return normalize_to_soda(name.upper().strip())

Expand Down Expand Up @@ -165,8 +166,8 @@ def _cross_streets_match(
return False

# Normalize the raw SODA record fields (uppercase, strip, expand abbreviations)
record_from = _normalize_street(record_from_raw)
record_to = _normalize_street(record_to_raw)
record_from = normalize_street(record_from_raw)
record_to = normalize_street(record_to_raw)

# Generate all known variants of the CSCL cross-street names for matching
# (name_variants expands abbreviations like AVE→AVENUE, PL→PLACE, etc.)
Expand Down
Loading
Loading