diff --git a/.env.example b/.env.example index 02534f0..526c356 100644 --- a/.env.example +++ b/.env.example @@ -4,8 +4,17 @@ RAW_DATA_ROOT=/srv/lab-data/dados-tp # This is where the actual bronze layer parquets will be stored, below is the default BRONZE_ROOT=/data/bronze -# Postgres+PostGIS silver layer, provisioned via `docker compose up -d` -SILVER_DB_USER=opa -SILVER_DB_PASSWORD=opa -SILVER_DB_NAME=opa -SILVER_DSN=postgresql://opa:opa@localhost:5432/opa +# Postgres+PostGIS database, provisioned via `docker compose up -d`. +# Shared by every schema (silver, ml, ...), not just silver. +DB_USER=opa +DB_PASSWORD=opa +DB_NAME=opa +DB_DSN=postgresql://opa:opa@localhost:5432/opa + +# Which network interface Postgres/Adminer bind to. Defaults to +# 127.0.0.1 (localhost-only) if left unset - only set this if you need +# remote access, e.g. to your Tailscale IP (`tailscale ip -4`) to reach +# them over Tailscale without exposing them to the wider network. Must +# be a literal IP, not a hostname - Docker doesn't resolve DNS names for +# port binding. +# BIND_HOST=127.0.0.1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14ae939..a1a4da9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: with: version: ${{ env.UV_VERSION }} enable-cache: true - - run: uv sync + - run: uv sync --group ml - run: uv run ruff format --check . - run: uv run ruff check . - run: uv run ty check @@ -51,7 +51,7 @@ jobs: with: version: ${{ env.UV_VERSION }} enable-cache: true - - run: uv sync + - run: uv sync --group ml - name: Run tests (tolerate "no tests collected") run: | set +e diff --git a/.gitignore b/.gitignore index 0e25770..387588f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,12 @@ dist/ wheels/ *.egg-info .ruff_cache +.pytest_cache +.coverage +htmlcov/ + +# Jupyter +.ipynb_checkpoints/ # Virtual environments .venv @@ -13,14 +19,15 @@ wheels/ # Environment variables .env -# Local bronze/gold layer output +# Local bronze/silver layer output data/ -# dbt-generated artifacts -gold/target/ -gold/logs/ -gold/dbt_packages/ -gold/.user.yml +# Regenerable per-date feature checkpoints (ml/bus_matching_model) +ml/bus_matching_model/artifacts/features/ +ml/bus_matching_model/artifacts/features_v2/ + +# Claude Code local state +.claude/ # macOS .DS_Store diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d53aeed..6e0f27a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,6 +13,17 @@ repos: language: python types: [python] pass_filenames: true + # ml/bus_matching_model/app and ml/trip_validity_model/app both + # have same-named sibling modules (features.py, db.py, ...). A + # single shared pyproject.toml `environment.extra-paths` list + # makes bare `from features import X` ambiguous between the two + # -- confirmed live, ty silently resolves it to whichever tree is + # listed first, breaking the other tree's own sibling imports. + # Excluded here rather than added to that shared list; matches + # the standing call to leave this app's lint/format debt for + # later too. `scripts/` is excluded for the same reason -- it + # imports those same sibling modules by bare name. + exclude: ^ml/bus_matching_model/(app|scripts)/ - repo: https://github.com/astral-sh/uv-pre-commit rev: 0.11.22 hooks: diff --git a/CLAUDE.md b/CLAUDE.md index ba948e3..0bcbe92 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,16 +19,26 @@ uv run opa-database ingest-reference vehicle_dictionary uv run opa-database load-silver {avl,afc,gtfs} --year Y --month M uv run opa-database load-silver-reference vehicle_dictionary -# Gold layer (dbt project lives in gold/, not under src/) -uv run dbt run --project-dir gold --profiles-dir gold -uv run dbt test --project-dir gold --profiles-dir gold - uv run prek run --all-files # dry-run every pre-commit hook before committing ``` There's no single-test invocation documented yet since the pytest suite is empty; use standard `pytest path::test_name` once tests exist. +**Before considering any code change done** (this includes notebooks), +run `uv run prek run --all-files` and fix everything it reports — don't +stop at a clean `ruff check` in isolation; `prek` also runs `ruff +format`, `ty check`, and the `requirements*.txt`/`uv.lock` sync hooks. +Two gotchas learned the hard way: + +- `prek run --all-files` only checks files **git already tracks**. A + freshly created, still-untracked file (e.g. a new notebook) is + silently skipped — `git add` it first, or run `ruff + check`/`ruff format --check`/`ty check` directly against the new + path(s), before trusting a clean `prek` result. +- The `ruff-check`/`ruff-format` hooks cover `.ipynb` files, not just + `.py` — don't assume notebooks are exempt from linting. + Always invoke Python through `uv run` (`uv run `, `uv run pytest`, ...) rather than calling `python`/`python3` directly, so it runs against this project's synced environment and pinned Python version. @@ -44,21 +54,13 @@ needed). See `src/opa_database/config.py` or convention to match. **Python version is pinned to 3.12** (`pyproject.toml`, `.python-version`). -This is a hard requirement, not a preference. `dbt-core`'s dependency -`mashumaro` fails to import under Python 3.14 (`UnserializableField` on a -plain `Optional[str]`, verified across the entire mashumaro version range -dbt allows) and hits a different incompatibility under 3.13. If dbt starts -throwing `mashumaro`/`typing_extensions` errors, check whether 3.13/3.14 -crept back in before assuming it's a config problem. Don't try pinning -mashumaro/typing_extensions instead, that was already tried and failed. ## Architecture -This is a three-layer ("medallion") pipeline turning Fortaleza, Brazil's +This is a two-layer ("medallion") pipeline turning Fortaleza, Brazil's raw public transit data into a queryable PostgreSQL+PostGIS database: **bronze** (raw files -> typed Parquet) -> **silver** (Parquet -> per-source -normalized Postgres tables) -> **gold** (dbt models joining across sources). -Full rationale in `docs/architecture.md` and `docs/gold-layer.md`; the +normalized Postgres tables). Full rationale in `docs/architecture.md`; the essential cross-file structure is: ### Bronze (`src/opa_database/{adapters,contracts}/`, `loaders/bronze.py`) @@ -93,8 +95,7 @@ its bronze partition key. Silver is **strictly per-source and stays flat/denormalized** (e.g. `silver.afc_boardings` repeats every trip/line/vehicle/company column on every boarding row, ~16.4x redundancy, measured). This is deliberate, not -unfinished. Fact/dimension splitting and any cross-source join belongs in -gold, not silver. +unfinished. Timezones: AFC's raw timestamps are naive Fortaleza local time (UTC-3, no DST since 2008) and are converted to UTC during the silver load; AVL/GPS is @@ -105,40 +106,6 @@ PostGIS `geometry(Point, 4326)` columns are Postgres `GENERATED ALWAYS AS ... STORED` columns computed from lat/lon by Postgres itself, not written directly; bulk `COPY` only ever carries the plain lat/lon columns. -### Gold (`gold/`, a self-contained dbt-core project) - -Not under `src/opa_database/`, a separate SQL toolchain. Builds on -`silver` via `{{ source(...) }}`, materializes into schema `gold`. Full -model inventory and testing conventions in `docs/gold-layer.md`; key -patterns that apply to any new gold model: - -- **GTFS/AFC ids repeat across snapshots**, so every model's real key is a - composite `(feed_version_date, entity_id)` (or the AFC equivalent), never - a bare id. dbt's built-in single-column `relationships`/`unique` tests - would silently pass broken references in this shape, so referential - integrity and uniqueness are enforced with hand-written singular tests in - `gold/tests/*_exists.sql` / `*_unique_per_*.sql` that join/group on the - full composite key. -- **Surrogate keys** for entities with no natural id (AFC has no trip id) - use `md5(concat_ws('|', ...))` over the full natural-key column set, - factored into a shared macro (`gold/macros/afc_trip_key.sql`) rather than - repeated inline. -- **Conformed dimension pattern**: `dim_vehicle_master` reconciles AFC - vehicle identity (`vehicle_number`) and GPS vehicle identity - (`vehicle_id`), which are independently assigned by each source. It's a - deterministic function of the latest `vehicle_dictionary` snapshot (not - a persisted/stateful registry). Every vehicle from every source gets - exactly one `master_vehicle_id`, synthesizing a source-prefixed id where - no confirmed match exists, tagged via `match_status` rather than dropped. -- **Don't duplicate an id reachable through an existing relationship**: - fact tables carry `master_vehicle_id` only at the grain it canonically - belongs to (e.g. `dim_afc_trip`, not `fact_afc_boarding`), and never keep - a raw per-source id alongside the conformed one once it's recoverable via - a dimension's crosswalk column. -- Referential integrity in gold is dbt tests, not enforced Postgres - `FOREIGN KEY`/`PRIMARY KEY` constraints (dbt model contracts aren't - turned on yet). - ## CI / release `.github/workflows/ci.yml`: PR titles are enforced as Conventional Commits; diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ef3853e..b8b9e59 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,9 +10,8 @@ uv run prek install # or: pre-commit install ``` See the [README](README.md) for environment variables and running the -pipeline end to end, and [`docs/architecture.md`](docs/architecture.md) / -[`docs/gold-layer.md`](docs/gold-layer.md) for how the codebase is -organized. +pipeline end to end, and [`docs/architecture.md`](docs/architecture.md) +for how the codebase is organized. ## Dependencies @@ -100,7 +99,7 @@ The end-to-end flow: [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `perf:`, `test:`, `ci:`, `build:`, `style:`, `revert:`, optionally scoped like - `feat(gold): ...`), `validate` runs formatting/lint/type checks, and + `feat(silver): ...`), `validate` runs formatting/lint/type checks, and `test` runs the pytest suite. All three must pass before merging. 2. Once merged into `develop`, that push triggers `prep-release-pr`: CI automatically opens (or updates, if one is already open) a diff --git a/README.md b/README.md index 6157c52..628a2b8 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,14 @@ queryable, analysis-ready PostgreSQL+PostGIS database. ## Overview -The pipeline follows a three-layer ("medallion") architecture: +The pipeline follows a two-layer ("medallion") architecture: ```bash -raw files (CSV/XML/zip) bronze silver gold ------------------------- --> -------------------- --> -------------------- --> -------------------- -On disk, agency-supplied Typed, validated, Per-source, normalized Cross-source, -formats, one file per day/ Hive-partitioned Parquet PostgreSQL+PostGIS dimensional models -export/snapshot (data/bronze/...) tables (schema `silver`) (dbt, schema `gold`) +raw files (CSV/XML/zip) bronze silver +------------------------ --> -------------------- --> -------------------- +On disk, agency-supplied Typed, validated, Per-source, normalized +formats, one file per day/ Hive-partitioned Parquet PostgreSQL+PostGIS +export/snapshot (data/bronze/...) tables (schema `silver`) ``` - **Bronze**: raw agency exports (CSV, nested XML, zipped GTFS feeds) are @@ -25,16 +25,9 @@ export/snapshot (data/bronze/...) tables (schema `silver PostgreSQL+PostGIS (schema `silver`). Still strictly per-source (no AFC-to-GPS vehicle reconciliation, no GTFS-to-ridership joins here) but now typed, deduplicated, indexed, and queryable with SQL/PostGIS. -- **Gold**: a [dbt](https://docs.getdbt.com/) project (schema `gold`) that - builds normalized fact/dimension models on top of silver: a proper - star-schema split of the flat AFC feed, a conformed vehicle dimension - that reconciles AFC and GPS vehicle identities, and dimensional models - for every GTFS table. This is where cross-source joins and analytical - modeling happen. See [`docs/architecture.md`](docs/architecture.md) for the full design -rationale, and [`docs/gold-layer.md`](docs/gold-layer.md) for the dbt -project specifically. +rationale. ## Data sources @@ -53,7 +46,7 @@ conventions, known data-quality issues) are in ```text src/opa_database/ - config.py Settings (env-driven: raw_data_root, bronze_root, silver_dsn) + config.py Settings (env-driven: raw_data_root, bronze_root, db_dsn) cli.py Click CLI: ingest, ingest-reference, load-silver, load-silver-reference contracts/ Pandera schemas for each raw source (bronze validation) adapters/ Raw file -> validated bronze Parquet, one module per source @@ -62,12 +55,7 @@ src/opa_database/ silver.py Generic idempotent "replace a period" loader for silver tables silver/ Bronze Parquet -> silver PostgreSQL+PostGIS, one module per source -gold/ dbt project: fact/dimension models on top of silver (schema `gold`) - models/{afc,gtfs,vehicle}/ - macros/ - tests/ Hand-written composite-key uniqueness/referential-integrity tests - -docs/ Architecture, gold-layer reference, remote access +docs/ Architecture reference, remote access docker-compose.yml Postgres+PostGIS and Adminer (web SQL UI) ``` @@ -75,10 +63,9 @@ docker-compose.yml Postgres+PostGIS and Adminer (web SQL UI) ### Prerequisites -- Python 3.12 (see [`docs/gold-layer.md`](docs/gold-layer.md) for why not - 3.13/3.14) +- Python 3.12 - [uv](https://docs.astral.sh/uv/) -- Docker + Docker Compose (for the Postgres+PostGIS silver/gold database) +- Docker + Docker Compose (for the Postgres+PostGIS silver database) ### Setup @@ -94,8 +81,9 @@ docker compose up -d # starts Postgres+PostGIS on :5432 and Adminer on :8080 | --- | --- | | `RAW_DATA_ROOT` | Path to the raw agency data on disk | | `BRONZE_ROOT` | Where bronze Parquet files are written | -| `SILVER_DB_USER` / `SILVER_DB_PASSWORD` / `SILVER_DB_NAME` | Postgres credentials, used both by `docker compose` and by the app | -| `SILVER_DSN` | Full connection string the pipeline uses to reach Postgres | +| `DB_USER` / `DB_PASSWORD` / `DB_NAME` | Postgres credentials, used both by `docker compose` and by the app | +| `DB_DSN` | Full connection string the pipeline uses to reach Postgres. Shared by every schema (`silver`, `ml`, ...), not silver-specific | +| `BIND_HOST` | Optional. Network interface Postgres/Adminer bind to, defaults to `127.0.0.1` (localhost-only). See [`docs/remote-access.md`](docs/remote-access.md) to expose them over Tailscale instead | ### Running the pipeline @@ -111,10 +99,6 @@ uv run opa-database load-silver avl --year 2023 --month 11 uv run opa-database load-silver afc --year 2023 --month 11 uv run opa-database load-silver gtfs --year 2023 --month 11 uv run opa-database load-silver-reference vehicle_dictionary - -# Gold: build the dbt models on top of silver -uv run dbt run --project-dir gold --profiles-dir gold -uv run dbt test --project-dir gold --profiles-dir gold ``` Each `load-silver`/`load-silver-reference` run is idempotent: re-running it diff --git a/docker-compose.yml b/docker-compose.yml index d0ae4cf..9745177 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,18 +2,18 @@ services: postgres: image: postgis/postgis:16-3.4 environment: - POSTGRES_USER: ${SILVER_DB_USER:-opa} - POSTGRES_PASSWORD: ${SILVER_DB_PASSWORD:-opa} - POSTGRES_DB: ${SILVER_DB_NAME:-opa} + POSTGRES_USER: ${DB_USER:-opa} + POSTGRES_PASSWORD: ${DB_PASSWORD:-opa} + POSTGRES_DB: ${DB_NAME:-opa} ports: - - "5432:5432" + - "${BIND_HOST:-127.0.0.1}:5432:5432" volumes: - opa_postgres_data:/var/lib/postgresql/data adminer: image: adminer ports: - - "8080:8080" + - "${BIND_HOST:-127.0.0.1}:8080:8080" depends_on: - postgres diff --git a/docs/architecture.md b/docs/architecture.md index afac0a2..05f6781 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,12 +1,11 @@ # Architecture OPA Database ingests four raw sources describing Fortaleza's public transit -system and moves them through three layers: **bronze** (typed raw data), -**silver** (per-source normalized SQL tables), and **gold** (cross-source -dimensional models). Each layer has a different job and deliberately does -not do the next layer's work. +system and moves them through two layers: **bronze** (typed raw data) and +**silver** (per-source normalized SQL tables). Each layer has a different +job and deliberately does not do the next layer's work. -## Why three layers +## Why two layers - **Bronze** exists to absorb the raw data's format problems once: inconsistent folder naming, headerless CSVs, deeply nested XML, GTFS's @@ -16,11 +15,6 @@ not do the next layer's work. columns, PostGIS geometries, indexes, deduplication, and timezone correctness. It stays flat and per-source on purpose (see below): no cross-source joins, no denormalization removal. -- **Gold** exists for everything that requires combining or reshaping - data: fact/dimension splits, surrogate keys, cross-source identity - resolution, and schedule-validity logic. It's a dbt project because that - kind of modeling benefits from being expressed, tested, and iterated on - as SQL rather than as pipeline code. ## Bronze layer @@ -59,18 +53,33 @@ Source-specific notes worth knowing before touching an adapter: clearing elements as it goes to keep memory bounded on ~15M rows/month. Only the `V{YYYYMMDD}.zip` filename convention (used since 2020) is supported; pre-2020 formats are out of scope for now. -- **GTFS** (`adapters/gtfs.py`): only the `exportacao_YYYY-MM-DD.zip` - naming convention (used since 2020) is supported. GTFS ids (`route_id`, - `stop_id`, etc.) sometimes carry meaningful leading zeros, so raw CSVs - are read with `infer_schema_length=0` (every column as a string) before - Pandera coerces each into its declared type; letting Polars infer types - itself would silently strip those zeros. +- **GTFS** (`adapters/gtfs.py`): the `exportacao_YYYY-MM-DD.zip` naming + convention (used since 2020, 72 canonical exports) plus three 2015-2019 + legacy naming schemes (`exportacaoDDMMYYYY.zip`, an optional stray space + before the date, and `exportacao_DD-MM-YYYY.zip`) are all supported — + `_EXPORT_NAME_PATTERNS` tries each in turn. One legacy filename has a + data-entry typo in the year (`2818` for `2018`), corrected explicitly via + `_FILENAME_YEAR_CORRECTIONS`. GTFS ids (`route_id`, `stop_id`, etc.) + sometimes carry meaningful leading zeros, so raw CSVs are read with + `infer_schema_length=0` (every column as a string) before Pandera + coerces each into its declared type; letting Polars infer types itself + would silently strip those zeros. A handful of exports (five of the 72 + canonical ones, plus several legacy ones) are missing an entire raw + table file inside their zip (`calendar_dates.txt` or `stop_times.txt` — + a verified one-off data-quality issue, not an ongoing pattern). For just + these two tables, `ingest()` falls back to the nearest other export (by date, + ties preferring the earlier export) that actually has the file, and + tags every borrowed row with a non-null `copied_from_feed_version_date` + column holding that export's date; every other row of these two tables + carries `null`. Unlike this section's other raw-format quirks, this one + is deliberately *not* made fully invisible: the fact that data was + borrowed is preserved as a real column through silver + (`copied_from_feed_version_date`), not swallowed at bronze. - **Vehicle dictionary** (`adapters/vehicle_dictionary.py`): maps AFC's `cod_veiculo` to GPS's `id_veiculo`. `cod_veiculo` is not a reliable unique key even within one snapshot: buses get reassigned, so ~2% of codes map to more than one `id_veiculo`. Bronze keeps this as-is; - reconciling which mapping is current is deferred to gold - (`dim_vehicle_master`, see [`gold-layer.md`](gold-layer.md)). + reconciling which mapping is current is left to downstream consumers. ## Silver layer @@ -78,23 +87,30 @@ Location: `src/opa_database/silver/`, `src/opa_database/loaders/silver.py`. Backing store: PostgreSQL 16 + PostGIS 3.4 (`docker-compose.yml`), schema `silver`. -Every silver loader follows the same **idempotent "replace a period"** -pattern, implemented once in `loaders/silver.py::replace_period`: - -1. Bootstrap the table (`CREATE TABLE IF NOT EXISTS`) if it doesn't exist. -2. Inside one transaction: drop the table's indexes, `DELETE` any existing - rows in `[start, end)` of the period being loaded, bulk-load the new +Every silver table is a native Postgres `PARTITION BY RANGE` parent, one +partition per load period (monthly for AVL/AFC, daily for GTFS/vehicle +dictionary — matching each source's own load granularity exactly). Every +silver loader follows the same **idempotent "replace a period"** pattern, +implemented once in `loaders/silver.py::replace_period`: + +1. Bootstrap the parent table (`CREATE TABLE IF NOT EXISTS ... PARTITION + BY RANGE (...)`) if it doesn't exist. +2. Inside one transaction: `DROP TABLE IF EXISTS` the target partition + (if this period was already loaded — this removes its rows *and* its + indexes in one metadata operation), `CREATE TABLE ... PARTITION OF + ... FOR VALUES FROM (...) TO (...)` a fresh one, bulk-load the new data via `COPY` (a single vectorized CSV write, not a Python - per-row loop), then recreate the indexes. + per-row loop), then create that partition's indexes. -Indexes are dropped and rebuilt in bulk rather than maintained +Indexes are created after the bulk load rather than maintained incrementally during the `COPY`, since incremental index maintenance (especially the GiST spatial index) is dramatically slower than one batch -rebuild for a multi-million-row load; this is Postgres's own documented -recommendation. The tradeoff: every load rebuilds indexes for the *whole* -table, not just the period changed, so cost scales with total table size. -Fine for the current handful of months of history; worth revisiting (e.g. -native monthly partitioning) if it stops being fine. +build for a multi-million-row load; this is Postgres's own documented +recommendation. Scoping the drop/rebuild to a single partition (rather +than the whole table, as an earlier version of this design did) means +that cost scales with one period's size, not the table's total +accumulated history — reloading any one month/day costs the same +regardless of how many other months/days already exist. This makes "reload November" a safe, repeatable operation: run it twice and you get the same rows, not duplicates. @@ -104,15 +120,23 @@ Each silver table's period key matches its bronze partition key for GTFS, `snapshot_date` for the vehicle dictionary): silver reprocesses "the same period bronze uses," not a recomputed one. +One accepted tradeoff from partitioning: AFC's `event_id` unique index +used to guarantee uniqueness across the table's entire history (a single +unpartitioned index); it's now per-partition (per-month), so it only +catches a duplicate within the same month. A duplicate `event_id` landing +in two different months would no longer be caught automatically. Postgres +has no native way to enforce true cross-partition uniqueness on a +non-partition-key column; the empirical finding backing this index (zero +overlap across all 435 day-pairs in November 2023) still stands as +evidence about the real data, this only weakens the automatic safety net +for a hypothetical future violation. + Silver stays **flat and per-source on purpose**. For example, `silver.afc_boardings` repeats every trip/line/vehicle/company attribute on every boarding row (measured ~16.4x redundancy) instead of being split into -a trip/boarding fact-dimension pair. That split, along with any -cross-source join (AFC vehicle identity vs. GPS vehicle identity, GTFS -schedule validity, etc.), is gold's job. Dimensional modeling is much -easier to iterate on as dbt SQL than as pipeline Python, and keeping silver -a thin, obviously-correct typed mirror of the raw data makes it a stable -foundation to model on top of. +a trip/boarding fact-dimension pair. Keeping silver a thin, +obviously-correct typed mirror of the raw data makes it a stable +foundation for any downstream consumer to build on. ### Timezone handling @@ -122,7 +146,7 @@ Both are localized/converted to proper `timestamptz` values during the silver load (`silver/afc.py`, `silver/avl.py`): a relabeling for AVL and a real timezone conversion for AFC. Reconciling AFC and AVL events against each other happens at silver time (each is independently correct -in UTC) or later in gold, never in bronze. +in UTC), never in bronze. ### PostGIS geometry columns @@ -134,23 +158,11 @@ ever writes the plain lat/lon columns; Postgres computes and indexes the geometry itself, so there's no staging-table step needed to populate a generated column via `COPY`. -## Gold layer - -Location: `gold/` (a self-contained dbt project). See -[`gold-layer.md`](gold-layer.md) for the full model inventory, testing -conventions, and how to run it. In short: gold is where the fact/dimension -split of AFC happens, where a conformed vehicle dimension unifies AFC and -GPS vehicle identities, and where every GTFS table gets a gold-layer -counterpart so nothing needs to fall back to silver directly. - ## Known gaps -- No fact table yet joins AFC + AVL + GTFS together in one place (each - pairwise piece exists independently). -- Referential integrity in gold is enforced via dbt tests, not actual - Postgres `FOREIGN KEY`/`PRIMARY KEY` constraints. dbt supports enforced - DDL-level constraints via "model contracts," not yet turned on. -- No CI job runs the bronze/silver/gold pipeline end-to-end (would need a +- No cross-source fact table yet joins AFC + AVL + GTFS together in one + place (each pairwise piece exists independently). +- No CI job runs the bronze/silver pipeline end-to-end (would need a Postgres+PostGIS service container and sample data in GitHub Actions); CI currently only lints, type-checks, and runs the (currently empty) Python test suite. diff --git a/docs/gold-layer.md b/docs/gold-layer.md deleted file mode 100644 index a8ff331..0000000 --- a/docs/gold-layer.md +++ /dev/null @@ -1,188 +0,0 @@ -# Gold layer (dbt) - -The gold layer is a [dbt-core](https://docs.getdbt.com/) project in `gold/` -at the repo root (`dbt_project.yml`, `profiles.yml`, `models/`, `macros/`, -`tests/`), a separate SQL-based toolchain from `src/opa_database/`, not -Python package code. It builds normalized, cross-source models on top of -the `silver` schema, materialized into a `gold` schema on the same -Postgres+PostGIS instance. - -## Running it - -```bash -uv run dbt debug --project-dir gold --profiles-dir gold # verify the connection -uv run dbt run --project-dir gold --profiles-dir gold -uv run dbt test --project-dir gold --profiles-dir gold -``` - -Connection settings (`gold/profiles.yml`) read from the same -`SILVER_DB_USER`/`SILVER_DB_PASSWORD`/`SILVER_DB_NAME` environment -variables as the rest of the project (see `.env.example`), plus -`SILVER_DB_HOST` (defaults to `localhost`). - -### Why Python 3.12, not 3.13/3.14 - -This project's Python version is pinned to **3.12** in `pyproject.toml` -and `.python-version` specifically because of dbt. `dbt-core` cannot even -import under Python 3.14: its dependency `mashumaro` (JSON-schema -serialization) raises `UnserializableField` on a plain `Optional[str]` -field at class-definition time, on every invocation. This was verified -systemic: the entire `mashumaro` version range `dbt-core` allows (3.9 -through 3.14) fails identically under Python 3.14. Python 3.13 hits a -different but related `mashumaro`/`typing_extensions` incompatibility. -**Python 3.12 is confirmed clean.** If this error resurfaces, check -`.python-version`/`requires-python` before assuming it's a real dbt config -problem. Don't try to pin `mashumaro`/`typing_extensions` versions within -dbt-core's allowed range instead, that was tried first and doesn't work. - -## Design conventions - -- **Composite natural keys everywhere.** Every GTFS and AFC gold table is - keyed by `(feed_version_date, entity_id)` or similar, never a bare id, - because ids repeat across different snapshots (the same GTFS `route_id` - is a *different* real entity in the Nov 10 vs. Nov 20 feed export). - Because of this, dbt's built-in single-column `relationships` test would - falsely pass a broken reference as long as the id existed in *any* - snapshot of the parent table. Referential integrity is instead enforced - with hand-written singular tests in `gold/tests/*_exists.sql` that join - on the full composite key. Composite uniqueness is similarly checked - with hand-written `gold/tests/*_unique_per_*.sql` tests rather than dbt's - single-column `unique` test. -- **Surrogate keys for entities with no natural id.** The raw AFC feed has - no trip identifier at all, so `dim_afc_trip.trip_id` is a deterministic - `md5(concat_ws('|', ...))` hash over the full ancestor chain (service - date, category, company, vehicle, line, trip timing/turnstiles), - generated once by the shared macro `macros/afc_trip_key.sql` and reused - everywhere that key is needed (with an optional table-alias parameter for - use inside joins). -- **Don't duplicate an id that's reachable through an existing - relationship.** Fact tables carry the conformed `master_vehicle_id` - where it belongs at that table's own grain and nowhere else, e.g. - `fact_afc_boarding` doesn't carry vehicle identity directly, since it's - already on `dim_afc_trip` and reachable via `trip_id`. `fact_avl_ping` - omits the raw `vehicle_id` entirely since it's fully recoverable via - `dim_vehicle_master.gps_vehicle_id`. -- **Ephemeral models for internal-only building blocks.** A model with - exactly one consumer and no reason to be queried directly (e.g. - `int_vehicle_dictionary`, prefixed `int_` rather than `dim_`) is - materialized as `ephemeral` (inlined as a CTE, no table/view object ever - created) instead of a real table. -- **Referential integrity is dbt tests, not DDL constraints.** No actual - Postgres `FOREIGN KEY`/`PRIMARY KEY` constraints exist in gold yet. dbt - supports enforced "model contracts" for this, but it hasn't been turned - on for anything. Integrity is checked by tests run after a model builds, - not enforced by the database itself. - -## Model inventory - -### AFC (`models/afc/`) - -Splits silver's flat, ~16.4x-redundant `afc_boardings` into a proper -fact/dimension pair: - -- **`dim_afc_trip`**: one row per real trip instance (one `Viagem` in the - raw XML). Carries `master_vehicle_id` (joined from `dim_vehicle_master`) - instead of the raw `vehicle_number`, since vehicle identity is a - trip-level attribute. Also carries `boarding_count`. -- **`fact_afc_boarding`**: one row per boarding event, referencing its - trip via `trip_id` instead of repeating trip/line/vehicle/company - columns. Does not carry vehicle identity directly (reachable via - `trip_id` -> `dim_afc_trip.master_vehicle_id`). - -### Vehicle identity (`models/vehicle/`) - -Reconciles AFC vehicle identity (`vehicle_number`) and GPS vehicle identity -(`vehicle_id`) into one conformed dimension, since the two sources use -different, independently-assigned ids for the same physical vehicles: - -- **`int_vehicle_dictionary`** (ephemeral): a plain crosswalk between - AFC's `cod_veiculo` and GPS's `id_veiculo` from the *latest* ingested - `vehicle_dictionary` snapshot only. Flags `is_cod_veiculo_ambiguous` for - codes reassigned to more than one `id_veiculo` (buses get reassigned - over time; the raw dictionary itself is not a clean 1:1 mapping). - Internal-only: nothing outside `dim_vehicle_master` should query this. -- **`dim_vehicle_master`**: one row per distinct physical vehicle seen in - *either* source. Where the dictionary confirms a non-ambiguous match, - both sides collapse into one row (`master_vehicle_id` = GPS's - `id_veiculo`, the side that's actually unique). Where there's no - confirmed match (missing from the dictionary, or only present via an - ambiguous code), the vehicle still gets a master id (a synthesized, - source-prefixed id such as `AFC-`, guaranteed never to - collide with a real `id_veiculo`) and is tagged via `match_status` - (`matched` / `afc_only` / `avl_only`). Every vehicle from every source - resolves to exactly one `master_vehicle_id`, never left out. This is a - **deterministic function of the current `vehicle_dictionary` snapshot**, - not a persisted/stateful surrogate-key registry: if the dictionary later - confirms a mapping for a currently-unmatched vehicle, re-running gold - merges it going forward, with no manual remap step. -- **`fact_avl_ping`**: all of `silver.avl_pings`, keyed by - `master_vehicle_id` instead of the raw `vehicle_id` (fully recoverable - via `dim_vehicle_master.gps_vehicle_id`, so not duplicated here). - -**Deliberately not done yet**: reconciling `fact_avl_ping.route_code` -against `dim_gtfs_route.route_id`. They might correspond (`route_code` is a -bare int like `4`; `route_id` is a zero-padded string like `"0004"`) but -this is genuinely unverified and risks matching wrong data silently, so -it's left as a known gap rather than guessed at. - -### GTFS (`models/gtfs/`) - -Every one of the 10 silver GTFS tables has a gold counterpart, so no query -needs to fall back to silver directly: - -- **`dim_gtfs_agency`**, **`dim_gtfs_route`**, **`dim_gtfs_trip`**, - **`dim_gtfs_stop`**, **`dim_gtfs_stop_time`**, **`dim_gtfs_fare`**, - **`dim_gtfs_fare_rule`**: straightforward per-table pass-throughs from - their silver sources, keyed by `feed_version_date` plus the table's - natural id(s). `dim_gtfs_stop_time` is keyed by - `(feed_version_date, trip_id, stop_sequence)`, not `stop_id`, since a - trip can revisit the same stop (e.g. a loop route); it's the largest of - these tables, so it has an index on `(feed_version_date, trip_id)`. - `dim_gtfs_trip` is named to avoid confusion with `dim_afc_trip` (a real - observed vehicle run, not a GTFS schedule definition). -- **`dim_shape`**: GTFS shape points aggregated into a single - `LINESTRING` per `(feed_version_date, shape_id)` via - `ST_MakeLine(geom ORDER BY shape_pt_sequence)`. Point-level granularity - stays in `silver.gtfs_shapes`. -- **`dim_gtfs_feed_version`**: one row per feed export, with the - real-world date range (`valid_from`/`valid_to`) it was actually in - effect for (`valid_to` is `null` for the current export). Answers "which - `feed_version_date` applied on real date X" via - `event_date BETWEEN valid_from AND valid_to` (or `valid_to IS NULL`), so - nothing has to guess or hardcode a snapshot. -- **`dim_gtfs_service_date`**: one row per - `(feed_version_date, service_id, calendar_date)` a service actually - operated on: `gtfs_calendar`'s weekly day-of-week pattern expanded via - `generate_series` + a lateral join over its date range, then corrected - by `gtfs_calendar_dates`' exceptions (`exception_type 1` adds a date, - `2` removes one). Precomputed once so nothing downstream has to redo the - day-of-week/exception logic itself. Describes one feed snapshot's own - calendar; pair with `dim_gtfs_feed_version` to know which snapshot - applied on a given real date. - -## Testing - -Run `uv run dbt test --project-dir gold --profiles-dir gold`. Two kinds of -tests exist: - -- **Schema tests** (`models/*/_*.yml`): `not_null`, `accepted_values`, and - dbt's built-in `relationships`/`unique` where a bare single-column check - is actually sufficient (i.e. not one of the composite-key GTFS/AFC - cases above). -- **Singular composite-key tests** (`tests/*.sql`): hand-written, - returning zero rows on success. Two shapes recur: - - `*_unique_per_feed.sql` / `*_unique_per_day.sql`: composite - uniqueness (e.g. `dim_gtfs_route_unique_per_feed.sql` checks - `(feed_version_date, route_id)` is unique, since `route_id` alone - isn't). - - `*_exists.sql`: composite referential integrity (e.g. - `dim_gtfs_trip_route_exists.sql`: a `LEFT JOIN` on - `(feed_version_date, route_id)` together, asserting no child row's FK - is non-null while the matching parent is missing). - -## Known gaps - -See the "Known gaps" section of [`architecture.md`](architecture.md). The -gold-specific ones (no AFC+AVL+GTFS combined fact table, no enforced -DDL-level constraints, no dbt run in CI) live there to avoid duplicating -the list in two places. diff --git a/docs/remote-access.md b/docs/remote-access.md index 341effd..698b30b 100644 --- a/docs/remote-access.md +++ b/docs/remote-access.md @@ -1,23 +1,30 @@ # Remote database access -The `docker-compose.yml` Postgres+PostGIS instance binds to `0.0.0.0`, so -it's reachable from any device on the same network, not just the machine -running Docker. This project accesses it remotely over -[Tailscale](https://tailscale.com/), using MagicDNS names rather than raw -IPs. +The `docker-compose.yml` Postgres+PostGIS instance and Adminer bind to +`BIND_HOST`, a `.env` variable defaulting to `127.0.0.1` (localhost-only, +the right default for a fresh local dev setup with no remote access +needed). To reach them over [Tailscale](https://tailscale.com/) instead, +set `BIND_HOST` to the Docker host's own Tailscale IP (`tailscale ip +-4`). Docker's port publishing needs a literal IP here; it can't resolve +a Tailscale MagicDNS hostname directly, only raw IPs. That IP is stable +for the life of the device (it doesn't change on reconnect or reboot), +so it's safe to set once. Either way, connecting *to* it still uses the +Tailscale MagicDNS hostname (e.g. `opa-server`), not the raw IP; only +the bind side needs the IP. ## Direct Postgres connection For `psql`, DBeaver, Postico, TablePlus, or any other Postgres client: ```text -postgresql://opa:opa@:5432/opa +postgresql://:@:5432/opa ``` Replace `` with the Docker host's Tailscale MagicDNS -name (e.g. `opa-server`), and the credentials with whatever -`SILVER_DB_USER`/`SILVER_DB_PASSWORD`/`SILVER_DB_NAME` are set to in -`.env` (defaults shown above). +name (e.g. `opa-server`), and ``/`` with whatever +`DB_USER`/`DB_PASSWORD` are actually set to in `.env` - there's no +universal default account to fall back on; the original `opa` superuser +was retired (`NOLOGIN`) in favor of a per-deployment admin account. ## Browser-based access (Adminer) @@ -25,17 +32,16 @@ name (e.g. `opa-server`), and the credentials with whatever lightweight web-based SQL client, on port 8080, so no local Postgres client install is needed. -- **On the host machine, or over plain Tailscale IP/hostname**: - `http://:8080` -- **Clean HTTPS URL, no port** (via `tailscale serve`): once configured - (see below), the same UI is reachable at - `https://..ts.net`. +- **Primary way, for now**: `http://:8080`. +- **Optional, not currently set up**: a clean HTTPS URL with no port, + via `tailscale serve` (see below) - once configured, the same UI is + reachable at `https://..ts.net`. Adminer login: System **PostgreSQL**, Server `postgres` (the `docker-compose.yml` service name), then the same user/password/database as above. -### Setting up `tailscale serve` for a clean URL +### Setting up `tailscale serve` for a clean URL (optional, not in use) `tailscale serve --bg 8080` reverse-proxies Adminer behind a proper HTTPS URL instead of `host:8080`. This requires two one-time steps on the Docker diff --git a/gold/dbt_project.yml b/gold/dbt_project.yml deleted file mode 100644 index 4e2f032..0000000 --- a/gold/dbt_project.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: "opa_gold" -version: "1.0.0" -config-version: 2 - -profile: "opa_gold" - -model-paths: ["models"] -macro-paths: ["macros"] - -target-path: "target" -clean-targets: - - "target" - - "dbt_packages" - -models: - opa_gold: - +materialized: table diff --git a/gold/macros/afc_trip_key.sql b/gold/macros/afc_trip_key.sql deleted file mode 100644 index 64025f3..0000000 --- a/gold/macros/afc_trip_key.sql +++ /dev/null @@ -1,38 +0,0 @@ -{# - Surrogate key for one AFC trip instance (one Viagem in the raw XML): - the natural key is the whole ancestor chain (service date, category, - company, vehicle, line, trip timing/turnstiles), since there's no - single natural id for a trip in the raw feed. All inputs are declared - NOT NULL in silver.afc_boardings, so concat_ws needs no null handling. - - relation_alias: optional table alias/prefix (e.g. 'b' for 'b.column'), - used when the columns are qualified in a join. -#} -{% macro afc_trip_key(relation_alias='') %} -{%- set prefix = relation_alias ~ '.' if relation_alias else '' -%} -md5( - concat_ws( - '|', - {{ prefix }}dump_date, - {{ prefix }}service_date, - {{ prefix }}category_type, - {{ prefix }}company_code, - {{ prefix }}company_modality, - {{ prefix }}vehicle_number, - {{ prefix }}validator_id, - {{ prefix }}line_number, - {{ prefix }}line_shift, - {{ prefix }}line_operator_number, - {{ prefix }}line_fare_table, - {{ prefix }}line_opened_at, - {{ prefix }}line_closed_at, - {{ prefix }}trip_opened_at, - {{ prefix }}trip_closed_at, - {{ prefix }}turnstile_start, - {{ prefix }}turnstile_end, - {{ prefix }}direction, - {{ prefix }}stop_open, - {{ prefix }}stop_close - ) -) -{% endmacro %} diff --git a/gold/models/afc/_afc__models.yml b/gold/models/afc/_afc__models.yml deleted file mode 100644 index 458272b..0000000 --- a/gold/models/afc/_afc__models.yml +++ /dev/null @@ -1,50 +0,0 @@ -version: 2 - -models: - - name: dim_afc_trip - description: > - One row per real AFC trip instance. trip_id is a surrogate key - (md5 hash of the full ancestor chain: service date, category, - company, vehicle, line, trip timing/turnstiles), since the raw - feed has no single natural id for a trip. master_vehicle_id - resolves this trip's vehicle to the conformed identity in - dim_vehicle_master. - columns: - - name: trip_id - tests: - - not_null - - unique - - name: dump_date - tests: - - not_null - - name: master_vehicle_id - tests: - - not_null - - relationships: - arguments: - to: ref('dim_vehicle_master') - field: master_vehicle_id - - name: boarding_count - tests: - - not_null - - - name: fact_afc_boarding - description: > - One row per boarding event, referencing its trip via trip_id - instead of repeating trip/line/vehicle/company columns per row. - Vehicle identity is reachable via trip_id -> dim_afc_trip -> - master_vehicle_id, not duplicated onto this table directly. - columns: - - name: event_id - tests: - - not_null - - name: trip_id - tests: - - not_null - - relationships: - arguments: - to: ref('dim_afc_trip') - field: trip_id - - name: boarding_at - tests: - - not_null diff --git a/gold/models/afc/_afc__sources.yml b/gold/models/afc/_afc__sources.yml deleted file mode 100644 index 8499296..0000000 --- a/gold/models/afc/_afc__sources.yml +++ /dev/null @@ -1,18 +0,0 @@ -version: 2 - -sources: - - name: silver - schema: silver - tables: - - name: afc_boardings - description: > - Flattened AFC/bilhetagem boarding events, one row per Passageiro - in the raw feed. Heavily denormalized on purpose: every row - repeats its trip/line/vehicle/company columns (measured ~16.4x - redundancy). See silver/afc.py in the Python pipeline. - columns: - - name: event_id - description: > - Transaction identifier. Not a reliable unique key: "0" is a - sentinel for records with no real transaction reference and - legitimately repeats. diff --git a/gold/models/afc/dim_afc_trip.sql b/gold/models/afc/dim_afc_trip.sql deleted file mode 100644 index 483acb3..0000000 --- a/gold/models/afc/dim_afc_trip.sql +++ /dev/null @@ -1,85 +0,0 @@ --- One row per real AFC trip instance (one Viagem in the raw XML), instead --- of the silver layer's one row per boarding on that trip. This is the --- fact/dimension split explicitly deferred from silver: see --- silver_gold_normalization_boundary in project memory for why. --- --- master_vehicle_id replaces the raw vehicle_number: vehicle identity is --- a trip-level attribute (one vehicle per trip), so it belongs here, not --- duplicated onto every row of fact_afc_boarding. The raw vehicle_number --- is fully recoverable via dim_vehicle_master.afc_vehicle_number for any --- master_vehicle_id, so nothing is lost, just relocated to where it --- canonically belongs. -with trips as ( - select - {{ afc_trip_key() }} as trip_id, - dump_date, - service_date, - category_type, - company_code, - company_modality, - vehicle_number, - validator_id, - line_number, - line_shift, - line_operator_number, - line_fare_table, - line_opened_at, - line_closed_at, - trip_opened_at, - trip_closed_at, - turnstile_start, - turnstile_end, - direction, - stop_open, - stop_close, - count(*) as boarding_count - from {{ source('silver', 'afc_boardings') }} - group by - dump_date, - service_date, - category_type, - company_code, - company_modality, - vehicle_number, - validator_id, - line_number, - line_shift, - line_operator_number, - line_fare_table, - line_opened_at, - line_closed_at, - trip_opened_at, - trip_closed_at, - turnstile_start, - turnstile_end, - direction, - stop_open, - stop_close -) - -select - t.trip_id, - t.dump_date, - t.service_date, - t.category_type, - t.company_code, - t.company_modality, - dvm.master_vehicle_id, - t.validator_id, - t.line_number, - t.line_shift, - t.line_operator_number, - t.line_fare_table, - t.line_opened_at, - t.line_closed_at, - t.trip_opened_at, - t.trip_closed_at, - t.turnstile_start, - t.turnstile_end, - t.direction, - t.stop_open, - t.stop_close, - t.boarding_count -from trips as t -left join {{ ref('dim_vehicle_master') }} as dvm - on dvm.afc_vehicle_number = t.vehicle_number diff --git a/gold/models/afc/fact_afc_boarding.sql b/gold/models/afc/fact_afc_boarding.sql deleted file mode 100644 index 1360ede..0000000 --- a/gold/models/afc/fact_afc_boarding.sql +++ /dev/null @@ -1,25 +0,0 @@ --- One row per boarding event, referencing its trip via trip_id instead of --- repeating the trip/line/vehicle/company columns on every row. Pairs --- with dim_afc_trip; together they replace silver.afc_boardings's ~16.4x --- redundant flat shape with a proper fact/dimension split. --- --- Does not carry master_vehicle_id directly: vehicle identity is a --- trip-level attribute, already on dim_afc_trip, reachable via trip_id. --- Duplicating it here too would be exactly the kind of redundancy the --- trip/boarding split exists to remove. -select - b.event_id, - {{ afc_trip_key(relation_alias='b') }} as trip_id, - b.boarding_at, - b.integration_bum, - b.integration_type, - b.sigben, - b.passenger_type, - b.card_id, - b.fare_paid, - b.subsidy_value, - b.metro_transfer_value, - b.latitude, - b.longitude, - b.geom -from {{ source('silver', 'afc_boardings') }} as b diff --git a/gold/models/gtfs/_gtfs__models.yml b/gold/models/gtfs/_gtfs__models.yml deleted file mode 100644 index d7916eb..0000000 --- a/gold/models/gtfs/_gtfs__models.yml +++ /dev/null @@ -1,158 +0,0 @@ -version: 2 - -models: - - name: dim_shape - description: > - One row per (feed_version_date, shape_id): GTFS shape points - aggregated into a single LineString, ordered by shape_pt_sequence. - columns: - - name: feed_version_date - tests: - - not_null - - name: shape_id - tests: - - not_null - - name: geom - tests: - - not_null - - - name: dim_gtfs_feed_version - description: > - One row per GTFS feed export, with the real-world calendar date - range (valid_from, valid_to) it was actually in effect for. - valid_to is null for the latest export, meaning still current. - columns: - - name: feed_version_date - tests: - - not_null - - unique - - name: valid_from - tests: - - not_null - - - name: dim_gtfs_service_date - description: > - One row per (feed_version_date, service_id, calendar_date) the - service actually operated on: gtfs_calendar's weekly pattern - expanded into real dates, corrected by gtfs_calendar_dates' - exceptions. - columns: - - name: feed_version_date - tests: - - not_null - - name: service_id - tests: - - not_null - - name: calendar_date - tests: - - not_null - - - name: dim_gtfs_agency - description: One row per transit agency, per feed export. - columns: - - name: feed_version_date - tests: - - not_null - - name: agency_name - tests: - - not_null - - - name: dim_gtfs_route - description: > - One row per route, per feed export. agency_id references - dim_gtfs_agency within the same feed_version_date. - columns: - - name: feed_version_date - tests: - - not_null - - name: route_id - tests: - - not_null - - name: route_type - tests: - - not_null - - - name: dim_gtfs_trip - description: > - One row per scheduled trip, per feed export. Not to be confused - with dim_afc_trip (a real observed vehicle run, not a GTFS - schedule definition). route_id references dim_gtfs_route, - shape_id references dim_shape, both within the same - feed_version_date. - columns: - - name: feed_version_date - tests: - - not_null - - name: trip_id - tests: - - not_null - - name: route_id - tests: - - not_null - - name: service_id - tests: - - not_null - - - name: dim_gtfs_stop - description: > - One row per stop/station, per feed export. parent_station - self-references stop_id within the same feed_version_date. - columns: - - name: feed_version_date - tests: - - not_null - - name: stop_id - tests: - - not_null - - name: stop_name - tests: - - not_null - - name: geom - tests: - - not_null - - - name: dim_gtfs_stop_time - description: > - One row per (feed_version_date, trip_id, stop_sequence). trip_id - references dim_gtfs_trip, stop_id references dim_gtfs_stop, both - within the same feed_version_date. - columns: - - name: feed_version_date - tests: - - not_null - - name: trip_id - tests: - - not_null - - name: stop_id - tests: - - not_null - - name: stop_sequence - tests: - - not_null - - - name: dim_gtfs_fare - description: One row per fare, per feed export. - columns: - - name: feed_version_date - tests: - - not_null - - name: fare_id - tests: - - not_null - - name: price - tests: - - not_null - - - name: dim_gtfs_fare_rule - description: > - One row per fare/route/zone rule, per feed export. A bridge table - by design, no simple natural key. fare_id references dim_gtfs_fare, - route_id (nullable) references dim_gtfs_route, both within the - same feed_version_date. - columns: - - name: feed_version_date - tests: - - not_null - - name: fare_id - tests: - - not_null diff --git a/gold/models/gtfs/_gtfs__sources.yml b/gold/models/gtfs/_gtfs__sources.yml deleted file mode 100644 index 5fb9622..0000000 --- a/gold/models/gtfs/_gtfs__sources.yml +++ /dev/null @@ -1,51 +0,0 @@ -version: 2 - -sources: - - name: silver - schema: silver - tables: - - name: gtfs_shapes - description: > - GTFS shape points, one row per (feed_version_date, shape_id, - shape_pt_sequence). See silver/gtfs.py in the Python pipeline. - columns: - - name: feed_version_date - description: Date of the GTFS feed export this row belongs to. - - name: shape_id - description: GTFS shape identifier. - - name: shape_pt_sequence - description: Order of this point within its shape. - - name: gtfs_calendar - description: > - Weekly service patterns (day-of-week flags plus a validity date - range) per service_id, per feed_version_date. See - silver/gtfs.py. - - name: gtfs_calendar_dates - description: > - Exceptions to gtfs_calendar's weekly pattern for specific - dates: exception_type 1 adds service on a date the weekly - pattern wouldn't otherwise include, 2 removes it on a date the - pattern would otherwise include. See silver/gtfs.py. - - name: gtfs_agency - description: Transit agencies operating the feed. See silver/gtfs.py. - - name: gtfs_routes - description: Routes. References agency_id. See silver/gtfs.py. - - name: gtfs_trips - description: > - Scheduled trips. References route_id, service_id, shape_id. - See silver/gtfs.py. - - name: gtfs_stops - description: > - Stop/station locations. parent_station self-references - stop_id for stops that belong to a larger station. See - silver/gtfs.py. - - name: gtfs_stop_times - description: > - Per-trip, per-stop scheduled arrival/departure. References - trip_id, stop_id. See silver/gtfs.py. - - name: gtfs_fare_attributes - description: Fare prices and transfer rules. See silver/gtfs.py. - - name: gtfs_fare_rules - description: > - Maps fares to routes/zones. References fare_id, optionally - route_id. See silver/gtfs.py. diff --git a/gold/models/gtfs/dim_gtfs_agency.sql b/gold/models/gtfs/dim_gtfs_agency.sql deleted file mode 100644 index d339c38..0000000 --- a/gold/models/gtfs/dim_gtfs_agency.sql +++ /dev/null @@ -1,11 +0,0 @@ --- One row per transit agency, per feed export. -select - feed_version_date, - agency_id, - agency_name, - agency_url, - agency_timezone, - agency_lang, - agency_phone, - agency_fare_url -from {{ source('silver', 'gtfs_agency') }} diff --git a/gold/models/gtfs/dim_gtfs_fare.sql b/gold/models/gtfs/dim_gtfs_fare.sql deleted file mode 100644 index 0d7a644..0000000 --- a/gold/models/gtfs/dim_gtfs_fare.sql +++ /dev/null @@ -1,10 +0,0 @@ --- One row per fare, per feed export. -select - feed_version_date, - fare_id, - price, - currency_type, - payment_method, - transfers, - transfer_duration -from {{ source('silver', 'gtfs_fare_attributes') }} diff --git a/gold/models/gtfs/dim_gtfs_fare_rule.sql b/gold/models/gtfs/dim_gtfs_fare_rule.sql deleted file mode 100644 index 90b51be..0000000 --- a/gold/models/gtfs/dim_gtfs_fare_rule.sql +++ /dev/null @@ -1,12 +0,0 @@ --- One row per fare/route/zone rule, per feed export. A bridge table by --- design (a fare can apply to several routes, and vice versa), so there --- is no single-column or simple composite natural key to test uniqueness --- on here, unlike the other GTFS gold tables. -select - feed_version_date, - fare_id, - route_id, - origin_id, - destination_id, - contains_id -from {{ source('silver', 'gtfs_fare_rules') }} diff --git a/gold/models/gtfs/dim_gtfs_feed_version.sql b/gold/models/gtfs/dim_gtfs_feed_version.sql deleted file mode 100644 index 671f32b..0000000 --- a/gold/models/gtfs/dim_gtfs_feed_version.sql +++ /dev/null @@ -1,22 +0,0 @@ --- One row per GTFS feed export, with the real-world calendar date range --- it was actually in effect for. A schedule snapshot stays valid until --- the next export supersedes it, so valid_to is just the next --- feed_version_date; the latest export has no upper bound (valid_to is --- null, meaning "still current"). --- --- Any fact that needs "which schedule applied on date X" should join on --- `event_date >= valid_from and (valid_to is null or event_date < valid_to)` --- rather than assuming a specific feed_version_date. --- --- gtfs_calendar is used only to enumerate the distinct feed_version_dates --- that exist; every GTFS table shares the same set by construction (one --- bronze/silver load stamps the same export date across all of them), so --- any one of them would do equally well here. -select - feed_version_date as valid_from, - lead(feed_version_date) over (order by feed_version_date) as valid_to, - feed_version_date -from ( - select distinct feed_version_date - from {{ source('silver', 'gtfs_calendar') }} -) as feed_versions diff --git a/gold/models/gtfs/dim_gtfs_route.sql b/gold/models/gtfs/dim_gtfs_route.sql deleted file mode 100644 index 65e3e2f..0000000 --- a/gold/models/gtfs/dim_gtfs_route.sql +++ /dev/null @@ -1,14 +0,0 @@ --- One row per route, per feed export. References agency_id (both keyed --- by the same feed_version_date). -select - feed_version_date, - route_id, - agency_id, - route_short_name, - route_long_name, - route_desc, - route_type, - route_url, - route_color, - route_text_color -from {{ source('silver', 'gtfs_routes') }} diff --git a/gold/models/gtfs/dim_gtfs_service_date.sql b/gold/models/gtfs/dim_gtfs_service_date.sql deleted file mode 100644 index ce91844..0000000 --- a/gold/models/gtfs/dim_gtfs_service_date.sql +++ /dev/null @@ -1,53 +0,0 @@ --- One row per (feed_version_date, service_id, calendar_date) that the --- service actually operated on: gtfs_calendar's weekly pattern (day of --- week flags over a date range), expanded into real calendar dates, then --- corrected by gtfs_calendar_dates' exceptions (exception_type 1 adds a --- date the weekly pattern wouldn't otherwise include, 2 removes one it --- would). Precomputed once here so nothing downstream has to redo the --- day-of-week and exception logic itself. --- --- This describes one feed snapshot's own calendar; it says nothing about --- which feed_version_date was valid on a given real date. Pair with --- dim_gtfs_feed_version for that. -with weekly_pattern_expanded as ( - select - c.feed_version_date, - c.service_id, - gs.calendar_date::date as calendar_date - from {{ source('silver', 'gtfs_calendar') }} as c - cross join lateral generate_series(c.start_date, c.end_date, interval '1 day') as gs (calendar_date) - where - (extract(dow from gs.calendar_date) = 0 and c.sunday = 1) - or (extract(dow from gs.calendar_date) = 1 and c.monday = 1) - or (extract(dow from gs.calendar_date) = 2 and c.tuesday = 1) - or (extract(dow from gs.calendar_date) = 3 and c.wednesday = 1) - or (extract(dow from gs.calendar_date) = 4 and c.thursday = 1) - or (extract(dow from gs.calendar_date) = 5 and c.friday = 1) - or (extract(dow from gs.calendar_date) = 6 and c.saturday = 1) -), - -added as ( - select feed_version_date, service_id, date as calendar_date - from {{ source('silver', 'gtfs_calendar_dates') }} - where exception_type = 1 -), - -removed as ( - select feed_version_date, service_id, date as calendar_date - from {{ source('silver', 'gtfs_calendar_dates') }} - where exception_type = 2 -), - -corrected as ( - select feed_version_date, service_id, calendar_date - from weekly_pattern_expanded - except - select feed_version_date, service_id, calendar_date - from removed -) - -select feed_version_date, service_id, calendar_date -from corrected -union -select feed_version_date, service_id, calendar_date -from added diff --git a/gold/models/gtfs/dim_gtfs_stop.sql b/gold/models/gtfs/dim_gtfs_stop.sql deleted file mode 100644 index 48f3369..0000000 --- a/gold/models/gtfs/dim_gtfs_stop.sql +++ /dev/null @@ -1,21 +0,0 @@ --- One row per stop/station, per feed export. parent_station --- self-references stop_id (within the same feed_version_date) for stops --- that belong to a larger station; nullable, and empty in the current --- data. geom is carried through as-is from silver, already generated --- there from stop_lat/stop_lon. -select - feed_version_date, - stop_id, - stop_code, - stop_name, - stop_desc, - stop_lat, - stop_lon, - zone_id, - stop_url, - location_type, - parent_station, - stop_timezone, - wheelchair_boarding, - geom -from {{ source('silver', 'gtfs_stops') }} diff --git a/gold/models/gtfs/dim_gtfs_stop_time.sql b/gold/models/gtfs/dim_gtfs_stop_time.sql deleted file mode 100644 index a6958a3..0000000 --- a/gold/models/gtfs/dim_gtfs_stop_time.sql +++ /dev/null @@ -1,21 +0,0 @@ -{{ config(indexes=[{'columns': ['feed_version_date', 'trip_id']}]) }} - --- One row per (feed_version_date, trip_id, stop_sequence): the natural --- key here is trip_id + stop_sequence, not trip_id + stop_id, since --- stop_sequence is what disambiguates a trip visiting the same stop more --- than once (e.g. a loop route). References trip_id and stop_id, both --- within the same feed_version_date. Biggest of the GTFS gold tables --- (a few million rows), hence the index for the join it will most --- commonly be used in (by trip). -select - feed_version_date, - trip_id, - arrival_time, - departure_time, - stop_id, - stop_sequence, - stop_headsign, - pickup_type, - drop_off_type, - shape_dist_traveled -from {{ source('silver', 'gtfs_stop_times') }} diff --git a/gold/models/gtfs/dim_gtfs_trip.sql b/gold/models/gtfs/dim_gtfs_trip.sql deleted file mode 100644 index 7ef18ad..0000000 --- a/gold/models/gtfs/dim_gtfs_trip.sql +++ /dev/null @@ -1,17 +0,0 @@ --- One row per scheduled trip, per feed export. References route_id, --- service_id (see dim_gtfs_service_date for actual operating dates), --- and shape_id (see dim_shape), all within the same feed_version_date. --- Not to be confused with dim_afc_trip, which is a real observed vehicle --- run from the fare-collection system, not a GTFS schedule definition. -select - feed_version_date, - route_id, - service_id, - trip_id, - trip_headsign, - trip_short_name, - direction_id, - block_id, - shape_id, - wheelchair_accessible -from {{ source('silver', 'gtfs_trips') }} diff --git a/gold/models/gtfs/dim_shape.sql b/gold/models/gtfs/dim_shape.sql deleted file mode 100644 index 2fed8fd..0000000 --- a/gold/models/gtfs/dim_shape.sql +++ /dev/null @@ -1,11 +0,0 @@ --- One row per (feed_version_date, shape_id): the GTFS shape's individual --- points aggregated into a single LineString, ordered by shape_pt_sequence. --- Point-level granularity stays in silver.gtfs_shapes; this is the --- dimensional/aggregated view gold is for. -select - feed_version_date, - shape_id, - count(*) as point_count, - st_makeline(geom order by shape_pt_sequence) as geom -from {{ source('silver', 'gtfs_shapes') }} -group by feed_version_date, shape_id diff --git a/gold/models/vehicle/_vehicle__models.yml b/gold/models/vehicle/_vehicle__models.yml deleted file mode 100644 index ae371b6..0000000 --- a/gold/models/vehicle/_vehicle__models.yml +++ /dev/null @@ -1,48 +0,0 @@ -version: 2 - -models: - # int_vehicle_dictionary is not listed here: it is an ephemeral model - # (see its own file for why), has exactly one consumer - # (dim_vehicle_master), and is never materialized as its own object, so - # there's nothing in the database to test directly. Its logic is - # exercised indirectly through dim_vehicle_master's tests below. - - - name: dim_vehicle_master - description: > - Conformed vehicle identity across AFC and GPS. One row per distinct - physical vehicle ever seen in either source. master_vehicle_id is - the GPS id_veiculo when a confirmed dictionary match exists, - otherwise a synthesized, source-prefixed id so every vehicle still - gets one. match_status tags provenance: matched, afc_only, or - avl_only. Deliberately not testing uniqueness of afc_vehicle_number - or gps_vehicle_id directly: both are nullable (populated only when - that source has this vehicle), and dbt's unique test treats - multiple nulls as a single duplicated group, which would be a false - positive here. master_vehicle_id is the true, always-populated key. - columns: - - name: master_vehicle_id - tests: - - not_null - - unique - - name: match_status - tests: - - not_null - - accepted_values: - arguments: - values: ["matched", "afc_only", "avl_only"] - - - name: fact_avl_ping - description: > - All of silver.avl_pings plus master_vehicle_id, resolving each - ping's vehicle to the conformed identity in dim_vehicle_master. - columns: - - name: master_vehicle_id - tests: - - not_null - - relationships: - arguments: - to: ref('dim_vehicle_master') - field: master_vehicle_id - - name: metric_timestamp - tests: - - not_null diff --git a/gold/models/vehicle/_vehicle__sources.yml b/gold/models/vehicle/_vehicle__sources.yml deleted file mode 100644 index 9629cd1..0000000 --- a/gold/models/vehicle/_vehicle__sources.yml +++ /dev/null @@ -1,16 +0,0 @@ -version: 2 - -sources: - - name: silver - schema: silver - tables: - - name: vehicle_dictionary - description: > - Snapshotted AFC-code (cod_veiculo) to GPS-id (id_veiculo) - vehicle mapping. cod_veiculo is not unique even within one - snapshot: buses get reassigned, so some codes map to more than - one id_veiculo. See silver/vehicle_dictionary.py. - - name: avl_pings - description: > - Raw AVL/GPS pings, one row per vehicle position report. See - silver/avl.py. diff --git a/gold/models/vehicle/dim_vehicle_master.sql b/gold/models/vehicle/dim_vehicle_master.sql deleted file mode 100644 index ba37bff..0000000 --- a/gold/models/vehicle/dim_vehicle_master.sql +++ /dev/null @@ -1,58 +0,0 @@ --- Conformed vehicle identity across AFC and GPS: one row per distinct --- physical vehicle we have ever seen in either source. Where --- int_vehicle_dictionary confirms a non-ambiguous match, both sides --- collapse into a single row, using GPS's id_veiculo as the master id --- since it is the side that is actually unique. Where there is no --- confirmed match (missing from the dictionary entirely, or only present --- via an ambiguous cod_veiculo), the vehicle still gets a master id --- (synthesized, prefixed so it can never collide with a real --- id_veiculo/vehicle_id value) and is tagged via match_status, rather --- than being left out or guessed at. --- --- This is a deterministic function of the current vehicle_dictionary --- snapshot, not a persisted/stateful surrogate key registry: if the --- dictionary later gets a confirmed mapping for a vehicle that is --- currently afc_only or avl_only, simply re-running this model merges --- the two into one row going forward. No manual remapping step needed. --- --- afc_only reads vehicle_number from the raw silver.afc_boardings source --- rather than from dim_afc_trip on purpose: dim_afc_trip's own --- master_vehicle_id column is resolved via this very model, so going --- through dim_afc_trip here would be a circular dependency. -with matched as ( - select - cod_veiculo as afc_vehicle_number, - id_veiculo as gps_vehicle_id, - id_veiculo::text as master_vehicle_id, - 'matched' as match_status - from {{ ref('int_vehicle_dictionary') }} - where not is_cod_veiculo_ambiguous -), - -afc_only as ( - select distinct - b.vehicle_number as afc_vehicle_number, - cast(null as integer) as gps_vehicle_id, - 'AFC-' || b.vehicle_number as master_vehicle_id, - 'afc_only' as match_status - from {{ source('silver', 'afc_boardings') }} as b - left join matched as m on m.afc_vehicle_number = b.vehicle_number - where m.afc_vehicle_number is null -), - -avl_only as ( - select distinct - cast(null as text) as afc_vehicle_number, - a.vehicle_id as gps_vehicle_id, - a.vehicle_id::text as master_vehicle_id, - 'avl_only' as match_status - from {{ source('silver', 'avl_pings') }} as a - left join matched as m on m.gps_vehicle_id = a.vehicle_id - where m.gps_vehicle_id is null -) - -select * from matched -union all -select * from afc_only -union all -select * from avl_only diff --git a/gold/models/vehicle/fact_avl_ping.sql b/gold/models/vehicle/fact_avl_ping.sql deleted file mode 100644 index 1a8ad12..0000000 --- a/gold/models/vehicle/fact_avl_ping.sql +++ /dev/null @@ -1,42 +0,0 @@ -{{ - config( - indexes=[ - {'columns': ['master_vehicle_id']}, - {'columns': ['metric_timestamp']}, - {'columns': ['geom'], 'type': 'gist'}, - ] - ) -}} - --- All of silver.avl_pings, keyed by master_vehicle_id instead of the raw --- vehicle_id: resolving this ping's vehicle to the conformed identity in --- dim_vehicle_master unifies it with AFC's vehicle_number where a --- confirmed match exists. Always populated: every AVL vehicle_id has a --- master_vehicle_id, matched or not. --- --- The raw vehicle_id is deliberately not carried here: it is fully --- recoverable via dim_vehicle_master.gps_vehicle_id for any --- master_vehicle_id (a 1:1 relationship by construction), so keeping both --- would just be a redundant, source-specific id sitting next to the --- conformed one gold is for. Same reasoning fact_afc_boarding already --- follows: it never carried vehicle_number directly either, since that --- lives on dim_afc_trip. --- --- Indexes declared here (built after the table populates, not --- incrementally maintained during the bulk insert) rather than any --- drop-then-rebuild dance: dbt's postgres adapter already creates --- indexes only after a table materialization finishes. -select - dvm.master_vehicle_id, - a.device_id, - a.direction, - a.odometer, - a.route_code, - a.speed, - a.latitude, - a.longitude, - a.metric_timestamp, - a.geom -from {{ source('silver', 'avl_pings') }} as a -left join {{ ref('dim_vehicle_master') }} as dvm - on dvm.gps_vehicle_id = a.vehicle_id diff --git a/gold/models/vehicle/int_vehicle_dictionary.sql b/gold/models/vehicle/int_vehicle_dictionary.sql deleted file mode 100644 index 124e273..0000000 --- a/gold/models/vehicle/int_vehicle_dictionary.sql +++ /dev/null @@ -1,35 +0,0 @@ -{{ config(materialized='ephemeral') }} - --- Internal building block for dim_vehicle_master only, not meant for --- direct querying (hence "int_", not "dim_"): a plain crosswalk between --- AFC's vehicle identifier (cod_veiculo) and GPS's vehicle identifier --- (id_veiculo), from the latest ingested vehicle_dictionary snapshot --- only, not a union of every historical snapshot. It only covers --- vehicles present in the raw dictionary file; dim_vehicle_master is the --- complete picture (it also covers AFC-only and AVL-only vehicles that --- never appear here), so that's what anything downstream should use. --- Ephemeral rather than a table: nothing else references this, so there --- is no reason for it to exist as its own object in the database. --- --- cod_veiculo is not a reliable unique key: buses get reassigned over --- time, so some codes map to more than one id_veiculo within the same --- snapshot. Rather than silently picking one, is_cod_veiculo_ambiguous --- flags those rows so a consumer can decide how to handle them. --- --- id_veiculo is confirmed always numeric (verified against the real --- data), so it is safe to cast here to match avl_pings.vehicle_id's --- integer type. cod_veiculo is not: it includes non-numeric values (e.g. --- "DES 02002", "02008 - Desativado", "02018v"), so it stays text, same as --- fact_afc_boarding's vehicle_number. -with latest_snapshot as ( - select max(snapshot_date) as snapshot_date - from {{ source('silver', 'vehicle_dictionary') }} -) - -select - v.cod_veiculo, - v.id_veiculo::integer as id_veiculo, - v.snapshot_date, - count(*) over (partition by v.cod_veiculo) > 1 as is_cod_veiculo_ambiguous -from {{ source('silver', 'vehicle_dictionary') }} as v -inner join latest_snapshot as ls on v.snapshot_date = ls.snapshot_date diff --git a/gold/profiles.yml b/gold/profiles.yml deleted file mode 100644 index dab76b7..0000000 --- a/gold/profiles.yml +++ /dev/null @@ -1,12 +0,0 @@ -opa_gold: - target: dev - outputs: - dev: - type: postgres - host: "{{ env_var('SILVER_DB_HOST', 'localhost') }}" - port: 5432 - user: "{{ env_var('SILVER_DB_USER', 'opa') }}" - password: "{{ env_var('SILVER_DB_PASSWORD', 'opa') }}" - dbname: "{{ env_var('SILVER_DB_NAME', 'opa') }}" - schema: gold - threads: 4 diff --git a/gold/tests/dim_gtfs_agency_unique_per_feed.sql b/gold/tests/dim_gtfs_agency_unique_per_feed.sql deleted file mode 100644 index c5bca78..0000000 --- a/gold/tests/dim_gtfs_agency_unique_per_feed.sql +++ /dev/null @@ -1,5 +0,0 @@ --- dbt singular test: passes if this returns zero rows. -select feed_version_date, agency_id, count(*) -from {{ ref('dim_gtfs_agency') }} -group by feed_version_date, agency_id -having count(*) > 1 diff --git a/gold/tests/dim_gtfs_fare_rule_fare_exists.sql b/gold/tests/dim_gtfs_fare_rule_fare_exists.sql deleted file mode 100644 index 5ee3073..0000000 --- a/gold/tests/dim_gtfs_fare_rule_fare_exists.sql +++ /dev/null @@ -1,6 +0,0 @@ --- dbt singular test: passes if this returns zero rows. -select fr.feed_version_date, fr.fare_id, fr.route_id -from {{ ref('dim_gtfs_fare_rule') }} as fr -left join {{ ref('dim_gtfs_fare') }} as f - on f.feed_version_date = fr.feed_version_date and f.fare_id = fr.fare_id -where fr.fare_id is not null and f.fare_id is null diff --git a/gold/tests/dim_gtfs_fare_rule_route_exists.sql b/gold/tests/dim_gtfs_fare_rule_route_exists.sql deleted file mode 100644 index 7f3361f..0000000 --- a/gold/tests/dim_gtfs_fare_rule_route_exists.sql +++ /dev/null @@ -1,6 +0,0 @@ --- dbt singular test: passes if this returns zero rows. -select fr.feed_version_date, fr.fare_id, fr.route_id -from {{ ref('dim_gtfs_fare_rule') }} as fr -left join {{ ref('dim_gtfs_route') }} as r - on r.feed_version_date = fr.feed_version_date and r.route_id = fr.route_id -where fr.route_id is not null and r.route_id is null diff --git a/gold/tests/dim_gtfs_fare_unique_per_feed.sql b/gold/tests/dim_gtfs_fare_unique_per_feed.sql deleted file mode 100644 index f12a284..0000000 --- a/gold/tests/dim_gtfs_fare_unique_per_feed.sql +++ /dev/null @@ -1,5 +0,0 @@ --- dbt singular test: passes if this returns zero rows. -select feed_version_date, fare_id, count(*) -from {{ ref('dim_gtfs_fare') }} -group by feed_version_date, fare_id -having count(*) > 1 diff --git a/gold/tests/dim_gtfs_route_agency_exists.sql b/gold/tests/dim_gtfs_route_agency_exists.sql deleted file mode 100644 index d005b58..0000000 --- a/gold/tests/dim_gtfs_route_agency_exists.sql +++ /dev/null @@ -1,10 +0,0 @@ --- dbt singular test: passes if this returns zero rows. --- Every route's agency_id must exist in dim_gtfs_agency for the same --- feed_version_date (a plain dbt `relationships` test would only check --- agency_id in isolation, which is meaningless here since ids repeat --- across different feed_version_date snapshots). -select r.feed_version_date, r.route_id, r.agency_id -from {{ ref('dim_gtfs_route') }} as r -left join {{ ref('dim_gtfs_agency') }} as a - on a.feed_version_date = r.feed_version_date and a.agency_id = r.agency_id -where r.agency_id is not null and a.agency_id is null diff --git a/gold/tests/dim_gtfs_route_unique_per_feed.sql b/gold/tests/dim_gtfs_route_unique_per_feed.sql deleted file mode 100644 index 8035373..0000000 --- a/gold/tests/dim_gtfs_route_unique_per_feed.sql +++ /dev/null @@ -1,5 +0,0 @@ --- dbt singular test: passes if this returns zero rows. -select feed_version_date, route_id, count(*) -from {{ ref('dim_gtfs_route') }} -group by feed_version_date, route_id -having count(*) > 1 diff --git a/gold/tests/dim_gtfs_service_date_unique_per_day.sql b/gold/tests/dim_gtfs_service_date_unique_per_day.sql deleted file mode 100644 index 1df2cc5..0000000 --- a/gold/tests/dim_gtfs_service_date_unique_per_day.sql +++ /dev/null @@ -1,6 +0,0 @@ --- dbt singular test: passes if this returns zero rows. --- Avoids a dbt_utils dependency for one composite-uniqueness check. -select feed_version_date, service_id, calendar_date, count(*) -from {{ ref('dim_gtfs_service_date') }} -group by feed_version_date, service_id, calendar_date -having count(*) > 1 diff --git a/gold/tests/dim_gtfs_stop_parent_station_exists.sql b/gold/tests/dim_gtfs_stop_parent_station_exists.sql deleted file mode 100644 index 75737f0..0000000 --- a/gold/tests/dim_gtfs_stop_parent_station_exists.sql +++ /dev/null @@ -1,8 +0,0 @@ --- dbt singular test: passes if this returns zero rows. --- Self-referential: parent_station is empty in the current data, but --- this stays correct if a future feed ever populates it. -select child.feed_version_date, child.stop_id, child.parent_station -from {{ ref('dim_gtfs_stop') }} as child -left join {{ ref('dim_gtfs_stop') }} as parent - on parent.feed_version_date = child.feed_version_date and parent.stop_id = child.parent_station -where child.parent_station is not null and parent.stop_id is null diff --git a/gold/tests/dim_gtfs_stop_time_stop_exists.sql b/gold/tests/dim_gtfs_stop_time_stop_exists.sql deleted file mode 100644 index 47e5914..0000000 --- a/gold/tests/dim_gtfs_stop_time_stop_exists.sql +++ /dev/null @@ -1,6 +0,0 @@ --- dbt singular test: passes if this returns zero rows. -select st.feed_version_date, st.trip_id, st.stop_sequence, st.stop_id -from {{ ref('dim_gtfs_stop_time') }} as st -left join {{ ref('dim_gtfs_stop') }} as s - on s.feed_version_date = st.feed_version_date and s.stop_id = st.stop_id -where st.stop_id is not null and s.stop_id is null diff --git a/gold/tests/dim_gtfs_stop_time_trip_exists.sql b/gold/tests/dim_gtfs_stop_time_trip_exists.sql deleted file mode 100644 index a8a5b15..0000000 --- a/gold/tests/dim_gtfs_stop_time_trip_exists.sql +++ /dev/null @@ -1,6 +0,0 @@ --- dbt singular test: passes if this returns zero rows. -select st.feed_version_date, st.trip_id, st.stop_sequence -from {{ ref('dim_gtfs_stop_time') }} as st -left join {{ ref('dim_gtfs_trip') }} as t - on t.feed_version_date = st.feed_version_date and t.trip_id = st.trip_id -where st.trip_id is not null and t.trip_id is null diff --git a/gold/tests/dim_gtfs_stop_time_unique_per_feed.sql b/gold/tests/dim_gtfs_stop_time_unique_per_feed.sql deleted file mode 100644 index 25eeb3b..0000000 --- a/gold/tests/dim_gtfs_stop_time_unique_per_feed.sql +++ /dev/null @@ -1,7 +0,0 @@ --- dbt singular test: passes if this returns zero rows. --- Natural key is trip_id + stop_sequence, not trip_id + stop_id: a trip --- can visit the same stop more than once (e.g. a loop route). -select feed_version_date, trip_id, stop_sequence, count(*) -from {{ ref('dim_gtfs_stop_time') }} -group by feed_version_date, trip_id, stop_sequence -having count(*) > 1 diff --git a/gold/tests/dim_gtfs_stop_unique_per_feed.sql b/gold/tests/dim_gtfs_stop_unique_per_feed.sql deleted file mode 100644 index db4a187..0000000 --- a/gold/tests/dim_gtfs_stop_unique_per_feed.sql +++ /dev/null @@ -1,5 +0,0 @@ --- dbt singular test: passes if this returns zero rows. -select feed_version_date, stop_id, count(*) -from {{ ref('dim_gtfs_stop') }} -group by feed_version_date, stop_id -having count(*) > 1 diff --git a/gold/tests/dim_gtfs_trip_route_exists.sql b/gold/tests/dim_gtfs_trip_route_exists.sql deleted file mode 100644 index caa6f5c..0000000 --- a/gold/tests/dim_gtfs_trip_route_exists.sql +++ /dev/null @@ -1,6 +0,0 @@ --- dbt singular test: passes if this returns zero rows. -select t.feed_version_date, t.trip_id, t.route_id -from {{ ref('dim_gtfs_trip') }} as t -left join {{ ref('dim_gtfs_route') }} as r - on r.feed_version_date = t.feed_version_date and r.route_id = t.route_id -where t.route_id is not null and r.route_id is null diff --git a/gold/tests/dim_gtfs_trip_shape_exists.sql b/gold/tests/dim_gtfs_trip_shape_exists.sql deleted file mode 100644 index 8dd892f..0000000 --- a/gold/tests/dim_gtfs_trip_shape_exists.sql +++ /dev/null @@ -1,6 +0,0 @@ --- dbt singular test: passes if this returns zero rows. -select t.feed_version_date, t.trip_id, t.shape_id -from {{ ref('dim_gtfs_trip') }} as t -left join {{ ref('dim_shape') }} as s - on s.feed_version_date = t.feed_version_date and s.shape_id = t.shape_id -where t.shape_id is not null and s.shape_id is null diff --git a/gold/tests/dim_gtfs_trip_unique_per_feed.sql b/gold/tests/dim_gtfs_trip_unique_per_feed.sql deleted file mode 100644 index f6299bd..0000000 --- a/gold/tests/dim_gtfs_trip_unique_per_feed.sql +++ /dev/null @@ -1,5 +0,0 @@ --- dbt singular test: passes if this returns zero rows. -select feed_version_date, trip_id, count(*) -from {{ ref('dim_gtfs_trip') }} -group by feed_version_date, trip_id -having count(*) > 1 diff --git a/gold/tests/dim_shape_unique_per_feed.sql b/gold/tests/dim_shape_unique_per_feed.sql deleted file mode 100644 index 24ca141..0000000 --- a/gold/tests/dim_shape_unique_per_feed.sql +++ /dev/null @@ -1,6 +0,0 @@ --- dbt singular test: passes if this returns zero rows. --- Avoids a dbt_utils dependency for one composite-uniqueness check. -select feed_version_date, shape_id, count(*) -from {{ ref('dim_shape') }} -group by feed_version_date, shape_id -having count(*) > 1 diff --git a/ml/bus_matching_model/app/__init__.py b/ml/bus_matching_model/app/__init__.py new file mode 100644 index 0000000..eec81de --- /dev/null +++ b/ml/bus_matching_model/app/__init__.py @@ -0,0 +1 @@ +"""Bus Matching active learning labeling app.""" diff --git a/ml/bus_matching_model/app/assignment.py b/ml/bus_matching_model/app/assignment.py new file mode 100644 index 0000000..4b0c983 --- /dev/null +++ b/ml/bus_matching_model/app/assignment.py @@ -0,0 +1,168 @@ +"""Per-date global assignment (plan Section 9). + +`belief.compute_date_beliefs`'s cross-bus-date suppression is a single, +non-iterated pairwise-discount heuristic -- cheap enough for the live +labeling UI, but not an actual joint solve, so two buses can still end +up claiming the same device if neither individually clears the +suppression threshold. This module replaces that heuristic, per date, +with a real minimum-cost bipartite matching: every bus is matched to at +most one device (or explicitly "none"), and no device is matched to more +than one bus that day. + +Consumes `belief.compute_raw_beliefs`'s *pre-suppression* posterior +(never the suppressed one -- see that function's docstring) as the cost +input, since this is what the suppression heuristic itself approximates. + +Sized for real per-date graphs (confirmed live: ~1,600 buses x ~1,400 +devices, ~16,500 candidate edges on a weekday -- about 0.7% dense), so +`scipy.sparse.csgraph.min_weight_full_bipartite_matching` (sparse LAPJVsp) +is used rather than a dense `scipy.optimize.linear_sum_assignment`. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np +import pandas as pd +from belief import NONE_OPTION +from scipy.sparse import coo_array +from scipy.sparse.csgraph import min_weight_full_bipartite_matching + +# posterior=0 would make -log(posterior) infinite and posterior=1 would +# make the "none" alternative for that bus look impossibly bad (an +# LAPJVsp edge weight of exactly 0 is also invalid -- "non-zero weights" +# per scipy's own docs) -- clip away from both ends. +POSTERIOR_FLOOR = 1e-9 +POSTERIOR_CEIL = 1 - 1e-9 + + +@dataclass(frozen=True) +class DateAssignment: + """One date's global assignment result. + + Attributes: + bus_id: `(n_buses,)`. + device_id: `(n_buses,)`, `None` where the bus was assigned "none". + cost: `(n_buses,)` the assigned edge's cost (`-log(posterior)`). + margin: `(n_buses,)` cost increase if this bus's assigned edge + were forbidden and the date re-solved -- large means this + assignment was competitive across the whole date, not just + locally; small means a near-tie (plan Section 9.3). + + """ + + bus_id: np.ndarray + device_id: np.ndarray + cost: np.ndarray + margin: np.ndarray + + +def _none_option_cost(raw_beliefs: pd.DataFrame) -> pd.Series: + """Per-bus `-log(posterior)` for the `NONE_OPTION` row, indexed by `bus_id`.""" + none_rows = raw_beliefs[raw_beliefs["option"] == NONE_OPTION] + posterior = none_rows["posterior"].clip(POSTERIOR_FLOOR, POSTERIOR_CEIL) + return pd.Series( + (-np.log(posterior)).to_numpy(), index=none_rows["bus_id"].to_numpy() + ) + + +def build_cost_matrix( + raw_beliefs: pd.DataFrame, +) -> tuple[coo_array, np.ndarray, np.ndarray]: + """Build one date's sparse bus-x-(device+dummy) cost matrix. + + Args: + raw_beliefs: `belief.compute_raw_beliefs` output for a *single* + date (one date's `candidates`/`votes` only -- this function + doesn't group by date itself). + + Returns: + `(matrix, bus_ids, device_ids)`. `matrix` is `(n_buses, + n_devices + n_buses)`: columns `[0, n_devices)` are real devices + in `device_ids` order, columns `[n_devices, n_devices + + n_buses)` are each bus's own "assign to none" dummy (column + `n_devices + i` belongs to `bus_ids[i]`, no cross edges, so a + bus can never be forced onto another bus's dummy). + + """ + device_rows = raw_beliefs[raw_beliefs["option"] != NONE_OPTION] + bus_ids = np.sort(raw_beliefs["bus_id"].unique()) + device_ids = np.sort(device_rows["option"].unique()) + bus_idx = pd.Series(np.arange(len(bus_ids)), index=bus_ids) + device_idx = pd.Series(np.arange(len(device_ids)), index=device_ids) + + posterior = device_rows["posterior"].clip(POSTERIOR_FLOOR, POSTERIOR_CEIL) + cost = -np.log(posterior).to_numpy() + rows = bus_idx.loc[device_rows["bus_id"]].to_numpy() + cols = device_idx.loc[device_rows["option"]].to_numpy() + + none_cost = _none_option_cost(raw_beliefs).loc[bus_ids].to_numpy() + dummy_rows = np.arange(len(bus_ids)) + dummy_cols = len(device_ids) + np.arange(len(bus_ids)) + + all_rows = np.concatenate([rows, dummy_rows]) + all_cols = np.concatenate([cols, dummy_cols]) + all_data = np.concatenate([cost, none_cost]) + shape = (len(bus_ids), len(device_ids) + len(bus_ids)) + matrix = coo_array((all_data, (all_rows, all_cols)), shape=shape) + return matrix, bus_ids, device_ids + + +def _decode_assignment( + row_ind: np.ndarray, + col_ind: np.ndarray, + cost_lookup: dict[tuple[int, int], float], + device_ids: np.ndarray, +) -> tuple[np.ndarray, np.ndarray]: + n_devices = len(device_ids) + device_id = np.array( + [device_ids[c] if c < n_devices else None for c in col_ind], dtype=object + ) + cost = np.array([cost_lookup[r, c] for r, c in zip(row_ind, col_ind, strict=True)]) + return device_id, cost + + +def solve_date( + raw_beliefs: pd.DataFrame, *, compute_margins: bool = True +) -> DateAssignment: + """Solve one date's global assignment via minimum-weight full bipartite matching. + + Args: + raw_beliefs: `belief.compute_raw_beliefs` output for a single date. + compute_margins: If `True`, re-solve once per bus with that + bus's assigned edge forbidden, to get a real cross-date + competitiveness margin (plan Section 9.3) instead of a + locally-cheap proxy. `O(n_buses)` re-solves; confirmed + feasible at real per-date graph sizes -- see the module's + benchmark notebook cell. + + Returns: + `DateAssignment` for every bus with at least one candidate that + date (buses with zero candidates aren't in `raw_beliefs` at + all and must be handled by the caller as trivially unassigned). + + """ + matrix, bus_ids, device_ids = build_cost_matrix(raw_beliefs) + coo = matrix.tocoo() + cost_lookup = dict(zip(zip(coo.row, coo.col, strict=True), coo.data, strict=True)) + + row_ind, col_ind = min_weight_full_bipartite_matching(matrix.tocsr()) + device_id, cost = _decode_assignment(row_ind, col_ind, cost_lookup, device_ids) + orig_total = float(cost.sum()) + + margin = np.full(len(bus_ids), np.nan) + if compute_margins: + shape = matrix.shape + for i in range(len(bus_ids)): + keep = ~((coo.row == row_ind[i]) & (coo.col == col_ind[i])) + alt_matrix = coo_array( + (coo.data[keep], (coo.row[keep], coo.col[keep])), shape=shape + ).tocsr() + alt_row, alt_col = min_weight_full_bipartite_matching(alt_matrix) + alt_total = sum( + cost_lookup[r, c] for r, c in zip(alt_row, alt_col, strict=True) + ) + margin[i] = alt_total - orig_total + + return DateAssignment(bus_id=bus_ids, device_id=device_id, cost=cost, margin=margin) diff --git a/ml/bus_matching_model/app/belief.py b/ml/bus_matching_model/app/belief.py new file mode 100644 index 0000000..132a55d --- /dev/null +++ b/ml/bus_matching_model/app/belief.py @@ -0,0 +1,493 @@ +"""Probabilistic per-bus-date belief over candidates, updated from votes. + +Replaces the earlier hard "2 agreeing votes = resolved" rule with a +continuous belief: each trip-level vote is treated as noisy evidence +(never certain), starting from a prior seeded by a `score` column -- +the Tier 1 cold-start heuristic (`frac_good_trips`) until a model +exists, then the trained model's own calibrated probability once +`db.compute_candidate_scores` has one to use (see that function's +docstring). A bus-date resolves once its top option's +posterior clears a confidence bar *and* is clearly separated from the +runner-up -- one bad vote just gets outweighed by more evidence rather +than overridden by exactly one more matching vote, and a string of +votes that keep disagreeing simply never resolves. + +**Cross-bus-date suppression**: only one device can really be on one +bus at a time. If a device's posterior is high on one bus-date, its +posterior on every *other* bus-date that lists it as a candidate that +same day gets discounted before the final normalization -- this is +what lets confirming device D on bus B also help rule D out everywhere +else that date, without waiting for the full batch assignment (plan +Section 9, still a separate periodic job, not replicated here). + +This is a single-pass heuristic, not an iterated joint solve: cheap +enough to run live in the labeling UI, at the cost of not being exactly +optimal like a proper `linear_sum_assignment` over the whole date. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd + +NONE_OPTION = "__none_of_these__" + +# How strongly one "match"/"none_of_these" vote moves belief: picked +# option's log-odds go up by log(LR_FOR), every other option's log-odds +# go down by log(LR_AGAINST). Deliberately not overwhelming (LR_FOR=9 +# means one vote alone can take a 50/50 prior to ~90/10, not to +# certainty) -- a second, disagreeing vote can still pull it back. +LR_FOR = 9.0 +LR_AGAINST = 1.3 + +# Prior odds for "none of these" when no votes exist yet -- lower than +# an average fresh candidate's neutral 1:1, since blocking is designed +# to usually include the true device among the candidates. +PRIOR_NONE_ODDS = 0.3 + +# The Tier 1 cold-start score can be extremely decisive on its own +# (confirmed live: a clean candidate can score ~0.99 posterior from the +# prior alone, before any vote) -- which would let "resolved" happen +# without real human evidence, exactly what MIN_VOTES_TO_RESOLVE is +# supposed to prevent. Damping the prior's log-odds by this factor +# means even a maximal prior is worth less than a single vote +# (log(9) ~ 2.2 vs a damped max prior of ~0.6), so votes are what +# actually drive resolution; the undamped score still ranks selection +# (a bus-date the automation is already confident about is a good +# "quick confirm" candidate either way). +PRIOR_WEIGHT = 0.15 + +# A trained model's calibrated probability is not the same kind of +# thing as the static heuristic -- it's fit on real human-verified +# labels, not a fixed threshold rule, so it earns more trust. Damping +# it the same as the heuristic would mean confident model predictions +# could never count toward "confident_no_votes" coverage either, +# defeating the point of training a model at all (the coverage number +# would stay near zero forever, same as before a model existed). +# +# But a *fixed* weight is exactly what let a barely-trained model swing +# the whole system: confirmed live, with ~20-30 resolved bus-dates the +# calibration split has only 2-4 positives, and Platt scaling on that +# few examples is wildly unstable retrain to retrain (intercept swung +# from +4.98 to -0.87 between two consecutive retrains, pushing +# "confident coverage" from 26,000+ down to 30 and back). +# +# Ramping by raw label count alone is a blunt proxy, though -- it says +# nothing about whether the model is *actually* well-calibrated, just +# that time has passed. Tying the weight to `test_ece` (expected +# calibration error, already computed every retrain -- see `metrics.py`) +# instead means trust tracks a real, measured quality signal: a model +# with excellent raw discrimination but a still-shaky calibration layer +# earns less trust than one that's demonstrably well-calibrated, not +# just "has existed longer." `MODEL_TRUST_MIN_RESOLVED` is a floor +# underneath that -- ECE measured on a 2-3-row test set is itself not +# trustworthy, so below this many resolved bus-dates the weight is zero +# regardless of how good ECE happens to look. +MODEL_PRIOR_WEIGHT_CEILING = 0.6 +MODEL_TRUST_MIN_RESOLVED = 15 +ECE_FOR_ZERO_TRUST = 0.3 +ECE_FOR_FULL_TRUST = 0.05 + + +def model_prior_weight_for( + n_resolved_bus_dates: int | None, test_ece: float | None +) -> float: + """Scale `MODEL_PRIOR_WEIGHT_CEILING` by the model's measured calibration quality. + + Args: + n_resolved_bus_dates: Resolved-bus-date count recorded at the + model's own retrain time (`db.fetch_latest_model_run`'s + `n_resolved_bus_dates`), or `None`/0 if unknown. + test_ece: That same retrain's `test_ece` (lower is better -- + 0 is perfect calibration). `None`/`NaN` (empty test split) + is treated as untrustworthy. + + Returns: + `0.0` below `MODEL_TRUST_MIN_RESOLVED` or with no measured ECE. + Otherwise linearly interpolates from `0.0` at + `ECE_FOR_ZERO_TRUST` up to `MODEL_PRIOR_WEIGHT_CEILING` at + `ECE_FOR_FULL_TRUST` or better, clipped to that range. + + """ + if not n_resolved_bus_dates or n_resolved_bus_dates < MODEL_TRUST_MIN_RESOLVED: + return 0.0 + if test_ece is None or (isinstance(test_ece, float) and np.isnan(test_ece)): + return 0.0 + quality = (ECE_FOR_ZERO_TRUST - test_ece) / ( + ECE_FOR_ZERO_TRUST - ECE_FOR_FULL_TRUST + ) + return MODEL_PRIOR_WEIGHT_CEILING * float(np.clip(quality, 0.0, 1.0)) + + +# Plan Section 8's "temporal propagation": a pair confirmed as a match +# on one date raises that same pair's prior on *other* dates -- buses +# don't swap trackers daily. Deliberately not as strong as a real vote +# (LR_FOR=9) since it's indirect evidence, and deliberately never +# allowed to resolve a bus-date by itself (MIN_VOTES_TO_RESOLVE still +# requires a real vote) -- see `db.fetch_belief_summary` for how this +# stays acyclic: confirmed pairs are derived from a pass with this +# boost turned *off*, never from a boosted pass, so a pair can never +# boost itself into existence. +TEMPORAL_PRIOR_WEIGHT = 0.5 +TEMPORAL_BOOST_ODDS = 5.0 + +# On request, from a domain expert (not a guess): a dictionary-sourced +# pair is meaningfully more trustworthy than a blocking-only candidate, +# since the dictionary is "more or less guaranteed to have been on that +# bus at some point" -- but not certain (some dictionary pairs turned +# out wrong on inspection), and weaker specifically when a bus has +# *multiple* disagreeing dictionary-sourced devices (evidence of the +# rare real device-swap case, not a clean signal either way). Two +# independent dictionary tables agreeing on the exact same pair +# (`n_dictionary_sources == 2`) is stronger than either alone. Full +# (undamped) weight, unlike the static Tier 1 heuristic's `PRIOR_WEIGHT` +# -- this is real corroborating evidence, not an unvalidated score -- +# but `MIN_VOTES_TO_RESOLVE` still gates *training* label resolution +# regardless of how confident this prior gets, and Section 12's +# evaluation sample is what actually checks whether these weights are +# calibrated right rather than just plausible. +# +# Explicitly on request: the dictionary must not be able to resurrect a +# candidate the GPS evidence has already rejected -- "if it's clearly +# off in the metrics, it shouldn't win; only if the dictionary one is +# the highest fit or close to it." Confirmed live this was a real gap, +# not a hypothetical one: with a flat additive boost (no gate), 118 of +# ~32,000 solved bus-dates picked a dictionary-backed candidate scoring +# *worse* than a losing competitor -- one case as stark as 0.91 (GPS-only) +# losing to 0.15 (dictionary-backed) purely from the boost. The boost +# below is now zero unless the candidate's own `score` is within +# `DICTIONARY_RELATIVE_MARGIN` of the best `score` among that bus-date's +# candidates -- competitive with the evidence, not a way around it. +DICTIONARY_PRIOR_WEIGHT = 0.6 +DICTIONARY_BOOST_ODDS_SINGLE = 4.0 +DICTIONARY_BOOST_ODDS_BOTH = 12.0 +DICTIONARY_CONFLICT_DAMPING = 0.5 +DICTIONARY_RELATIVE_MARGIN = 0.2 +BOTH_DICTIONARIES = 2 + +CROSS_SUPPRESSION_STRENGTH = 3.0 +CROSS_SUPPRESSION_THRESHOLD = 0.6 + +CONFIDENCE_THRESHOLD = 0.85 +MARGIN_THRESHOLD = 0.3 +MIN_VOTES_TO_RESOLVE = 1 + +TOP_RANK = 1 +RUNNER_UP_RANK = 2 + + +def _softmax_within_groups( + df: pd.DataFrame, group_cols: list[str], score_col: str +) -> pd.Series: + """Softmax `score_col` within each `group_cols` group, fully vectorized. + + `groupby(...).transform(lambda s: ...)` with a custom callback hits + pandas' slow per-group Python path (confirmed live: ~30s for 40,007 + groups, rebuilding a MultiIndex per group). Built-in `.transform("max" + "/"sum")` aggregation names use pandas' Cython fast path instead -- + same math, orders of magnitude faster. + """ + group_max = df.groupby(group_cols)[score_col].transform("max") + exp_score = np.exp(df[score_col] - group_max) + group_sum = exp_score.groupby([df[c] for c in group_cols]).transform("sum") + return exp_score / group_sum + + +def _prior_odds(score: pd.Series) -> pd.Series: + """Map a 0-1 prior score to odds, neutral (1:1) if unscored.""" + clipped = score.clip(0.02, 0.98) + odds = clipped / (1 - clipped) + return odds.fillna(1.0) + + +def compute_raw_beliefs( + candidates: pd.DataFrame, + votes: pd.DataFrame, + model_prior_weight: float = MODEL_PRIOR_WEIGHT_CEILING, +) -> pd.DataFrame: + """Compute per-bus-date belief from prior + temporal + vote evidence only. + + This is `compute_date_beliefs` *without* cross-bus-date suppression -- + the raw, independent-per-bus-date posterior. Plan Section 9's global + assignment is the real joint solve that supersedes the suppression + heuristic, so it consumes *this* function's output (specifically + `log_odds`, as `cost = -log_odds`), not the suppressed one -- + layering a global solve on top of an already-suppressed posterior + would double-count the same "one device, one bus" constraint two + different ways. + + Args: + candidates: Columns `bus_id`, `date`, `device_id`, `score` (a 0-1 + prior -- `db.compute_candidate_scores`'s output: the trained + model's calibrated probability where available, else the + Tier 1 heuristic; may be `NaN` if unscored), `is_model_score` + (bool -- whether `score` came from the trained model, which + earns `model_prior_weight` trust, vs the static heuristic at + `PRIOR_WEIGHT`), optionally `has_temporal_support` (bool -- + whether this exact `(bus_id, device_id)` pair was confirmed a + match on a *different* date; adds `TEMPORAL_PRIOR_WEIGHT * + log(TEMPORAL_BOOST_ODDS)` to this row's log-odds, on top of + whichever score-based prior applies; missing/absent treated + as all `False`) -- every candidate for every bus-date on this + date. + votes: Columns `bus_id`, `date`, `device_id`, `decision` -- + every trip-level vote recorded so far for bus-dates on this + date (`decision` in `"match"`/`"none_of_these"`; `"unsure"` + rows should already be filtered out by the caller since they + carry no evidence). + model_prior_weight: How much to trust `is_model_score` rows -- + see `model_prior_weight_for`. Defaults to full trust for + direct/test callers that don't ramp it themselves. + + Returns: + One row per `(bus_id, date, option)` (`option` is a `device_id` + or the `NONE_OPTION` sentinel), columns `log_odds`, `posterior` + (sums to 1 within each bus-date, *not* suppressed), `n_votes` + (total votes for that bus-date, repeated per row). + + """ + if candidates.empty: + return pd.DataFrame( + columns=["bus_id", "date", "option", "log_odds", "posterior", "n_votes"] + ) + + is_model_score = candidates.get("is_model_score") + if is_model_score is None: + is_model_score = pd.Series(data=False, index=candidates.index) + prior_weight = np.where(is_model_score.to_numpy(), model_prior_weight, PRIOR_WEIGHT) + + rows = candidates[["bus_id", "date", "device_id"]].rename( + columns={"device_id": "option"} + ) + rows["log_odds"] = prior_weight * np.log( + _prior_odds(candidates["score"]).to_numpy() + ) + + has_temporal_support = candidates.get("has_temporal_support") + if has_temporal_support is not None: + rows["log_odds"] += np.where( + has_temporal_support.to_numpy(), + TEMPORAL_PRIOR_WEIGHT * np.log(TEMPORAL_BOOST_ODDS), + 0.0, + ) + + n_dictionary_sources = candidates.get("n_dictionary_sources") + if n_dictionary_sources is not None: + n_sources = n_dictionary_sources.to_numpy() + boost_odds = np.where( + n_sources >= BOTH_DICTIONARIES, + DICTIONARY_BOOST_ODDS_BOTH, + np.where(n_sources == 1, DICTIONARY_BOOST_ODDS_SINGLE, 1.0), + ) + has_conflict = candidates.get("bus_has_dictionary_conflict") + conflict_damping = ( + np.where(has_conflict.to_numpy(), DICTIONARY_CONFLICT_DAMPING, 1.0) + if has_conflict is not None + else 1.0 + ) + best_score_here = candidates.groupby(["bus_id", "date"])["score"].transform( + "max" + ) + is_competitive = ( + candidates["score"].fillna(0.0) + >= (best_score_here.fillna(0.0) - DICTIONARY_RELATIVE_MARGIN) + ).to_numpy() + rows["log_odds"] += np.where( + is_competitive, + DICTIONARY_PRIOR_WEIGHT * conflict_damping * np.log(boost_odds), + 0.0, + ) + + none_rows = ( + candidates[["bus_id", "date"]] + .drop_duplicates() + .assign(option=NONE_OPTION, log_odds=PRIOR_WEIGHT * np.log(PRIOR_NONE_ODDS)) + ) + all_rows = pd.concat([rows, none_rows], ignore_index=True) + + vote_counts = ( + votes.groupby(["bus_id", "date"]).size().rename("n_votes").reset_index() + if not votes.empty + else pd.DataFrame(columns=["bus_id", "date", "n_votes"]) + ) + + if not votes.empty: + picked = votes.assign( + option=votes.apply( + lambda r: r["device_id"] if r["decision"] == "match" else NONE_OPTION, + axis=1, + ) + ) + for_evidence = ( + picked.groupby(["bus_id", "date", "option"]) + .size() + .rename("n_for") + .reset_index() + ) + all_rows = all_rows.merge( + for_evidence, on=["bus_id", "date", "option"], how="left" + ) + all_rows["n_for"] = all_rows["n_for"].fillna(0) + all_rows = all_rows.merge(vote_counts, on=["bus_id", "date"], how="left") + all_rows["n_votes"] = all_rows["n_votes"].fillna(0) + n_against = all_rows["n_votes"] - all_rows["n_for"] + all_rows["log_odds"] += all_rows["n_for"] * np.log(LR_FOR) + all_rows["log_odds"] += n_against * np.log(1 / LR_AGAINST) + else: + all_rows["n_votes"] = 0 + + all_rows["posterior"] = _softmax_within_groups( + all_rows, ["bus_id", "date"], "log_odds" + ) + return all_rows[["bus_id", "date", "option", "log_odds", "posterior", "n_votes"]] + + +def compute_date_beliefs( + candidates: pd.DataFrame, + votes: pd.DataFrame, + model_prior_weight: float = MODEL_PRIOR_WEIGHT_CEILING, +) -> pd.DataFrame: + """Compute posterior belief over every bus-date's candidates, for one date's data. + + Args: + candidates: See `compute_raw_beliefs`. + votes: See `compute_raw_beliefs`. + model_prior_weight: See `compute_raw_beliefs`. + + Returns: + One row per `(bus_id, date, option)` (`option` is a `device_id` + or the `NONE_OPTION` sentinel), columns `posterior` (sums to 1 + within each bus-date), `n_votes` (total votes for that + bus-date, repeated per row), `rank` (1 = top option within its + bus-date). + + """ + if candidates.empty: + return pd.DataFrame( + columns=["bus_id", "date", "option", "posterior", "n_votes", "rank"] + ) + + all_rows = compute_raw_beliefs(candidates, votes, model_prior_weight) + + # Cross-bus-date suppression: for each device, its posterior on a + # given bus-date gets discounted by how confidently it's already + # claimed on a *different* bus-date the same day. + device_rows = all_rows[all_rows["option"] != NONE_OPTION].copy() + if not device_rows.empty: + # Vectorized "this device's best posterior on a *different* + # bus-date the same day": rank each device's posteriors within + # (date, option), take the top-2, then for each row use the + # runner-up if this row itself is the top one, else the top. + # (Row-wise .apply() here was the actual bottleneck -- confirmed + # live at ~30s for the full month; this vectorized version is + # the fix.) + ranked = device_rows.sort_values("posterior", ascending=False).copy() + ranked["_rk"] = ranked.groupby(["date", "option"]).cumcount() + 1 + rank_cols = ["date", "option", "posterior"] + top1 = ranked.loc[ranked["_rk"] == TOP_RANK, rank_cols].rename( + columns={"posterior": "_top1"} + ) + top2 = ranked.loc[ranked["_rk"] == RUNNER_UP_RANK, rank_cols].rename( + columns={"posterior": "_top2"} + ) + device_rows = device_rows.merge(top1, on=["date", "option"], how="left").merge( + top2, on=["date", "option"], how="left" + ) + device_rows["_top2"] = device_rows["_top2"].fillna(0.0) + is_top = device_rows["posterior"] >= device_rows["_top1"] - 1e-12 + device_rows["elsewhere_max"] = np.where( + is_top, device_rows["_top2"], device_rows["_top1"] + ) + device_rows = device_rows.drop(columns=["_top1", "_top2"]) + suppress = device_rows["elsewhere_max"] > CROSS_SUPPRESSION_THRESHOLD + device_rows.loc[suppress, "log_odds"] -= ( + device_rows.loc[suppress, "elsewhere_max"] * CROSS_SUPPRESSION_STRENGTH + ) + + none_rows_final = all_rows[all_rows["option"] == NONE_OPTION] + all_rows = pd.concat( + [device_rows.drop(columns="elsewhere_max"), none_rows_final], + ignore_index=True, + ) + all_rows["posterior"] = _softmax_within_groups( + all_rows, ["bus_id", "date"], "log_odds" + ) + + all_rows["rank"] = all_rows.groupby(["bus_id", "date"])["posterior"].rank( + ascending=False, method="first" + ) + return all_rows[["bus_id", "date", "option", "posterior", "n_votes", "rank"]] + + +def summarize_bus_dates(beliefs: pd.DataFrame) -> pd.DataFrame: + """Reduce per-option beliefs to one row per bus-date: top pick, margin, resolved. + + Args: + beliefs: `compute_date_beliefs` output. + + Returns: + Columns `bus_id`, `date`, `top_option`, `top_posterior`, `margin` + (top minus runner-up posterior), `n_votes`, `resolved` (bool -- + the training-data signal, requires at least + `MIN_VOTES_TO_RESOLVE` real human votes), `confident_no_votes` + (bool -- the *coverage* signal: would this bus-date already + clear the same confidence/margin bar from the prior alone, with + zero votes? This is what should approach full coverage as the + model improves, without a human voting on every one of + thousands of bus-dates -- `resolved` alone was being displayed + as if it were this number, which was misleading), `decision` + ("match"/"none_of_these", only meaningful if `resolved`), + `device_id` (only set if resolved as "match"). + + """ + if beliefs.empty: + return pd.DataFrame( + columns=[ + "bus_id", + "date", + "top_option", + "top_posterior", + "margin", + "n_votes", + "resolved", + "confident_no_votes", + "decision", + "device_id", + ] + ) + + top = beliefs[beliefs["rank"] == TOP_RANK].drop_duplicates(["bus_id", "date"]) + runner_up = beliefs[beliefs["rank"] == RUNNER_UP_RANK][ + ["bus_id", "date", "posterior"] + ].rename(columns={"posterior": "runner_up_posterior"}) + summary = top.merge(runner_up, on=["bus_id", "date"], how="left") + summary["runner_up_posterior"] = summary["runner_up_posterior"].fillna(0.0) + summary["margin"] = summary["posterior"] - summary["runner_up_posterior"] + summary["confident_no_votes"] = (summary["posterior"] >= CONFIDENCE_THRESHOLD) & ( + summary["margin"] >= MARGIN_THRESHOLD + ) + summary["resolved"] = summary["confident_no_votes"] & ( + summary["n_votes"] >= MIN_VOTES_TO_RESOLVE + ) + summary["decision"] = np.where( + summary["option"] == NONE_OPTION, "none_of_these", "match" + ) + summary["device_id"] = np.where( + summary["option"] == NONE_OPTION, None, summary["option"] + ) + return summary.rename( + columns={"posterior": "top_posterior", "option": "top_option"} + )[ + [ + "bus_id", + "date", + "top_option", + "top_posterior", + "margin", + "n_votes", + "resolved", + "confident_no_votes", + "decision", + "device_id", + ] + ] diff --git a/ml/bus_matching_model/app/calibration.py b/ml/bus_matching_model/app/calibration.py new file mode 100644 index 0000000..b3d5a26 --- /dev/null +++ b/ml/bus_matching_model/app/calibration.py @@ -0,0 +1,111 @@ +"""Platt scaling: a 1-D logistic regression recalibrating raw model probabilities.""" + +from __future__ import annotations + +import numpy as np +from sklearn.linear_model import LogisticRegression + +_EPS = 1e-6 + + +def _logit(probabilities: np.ndarray) -> np.ndarray: + clipped = np.clip(probabilities, _EPS, 1 - _EPS) + return np.log(clipped / (1 - clipped)) + + +class PlattCalibrator: + """Recalibrates raw model probabilities via logistic regression on their logit.""" + + def __init__(self) -> None: + """Initialize the underlying 1-D logistic regression, unfit.""" + self._model = LogisticRegression() + + def fit(self, raw_probabilities: np.ndarray, y_true: np.ndarray) -> PlattCalibrator: + """Fit the calibrator on a frozen calibration set. + + Args: + raw_probabilities: The base model's raw predicted probabilities. + y_true: The true labels for the same rows. + + Returns: + self, for chaining. + + """ + self._model.fit(_logit(raw_probabilities).reshape(-1, 1), y_true) + return self + + def predict(self, raw_probabilities: np.ndarray) -> np.ndarray: + """Recalibrate raw probabilities. + + Args: + raw_probabilities: The base model's raw predicted probabilities. + + Returns: + Calibrated probabilities of the positive class. + + """ + logits = _logit(raw_probabilities).reshape(-1, 1) + return self._model.predict_proba(logits)[:, 1] + + def to_params(self) -> dict[str, float]: + """Serialize the fitted coefficient/intercept for storage. + + Returns: + A dict with keys "coefficient" and "intercept". + + """ + return { + "coefficient": float(self._model.coef_[0, 0]), + "intercept": float(self._model.intercept_[0]), + } + + def to_sklearn_model(self) -> LogisticRegression: + """Return the underlying fitted sklearn model. + + For artifact storage: pickling this class directly ties the + artifact to *this exact* `PlattCalibrator` class object, which + breaks (`PicklingError`) if the `calibration` module gets + hot-reloaded (e.g. by Streamlit's file watcher during dev) + between when an instance was created and when it's pickled - + the reloaded module's class is a distinct object with the same + name. Storing/restoring the plain sklearn model instead + sidesteps that entirely. + + Returns: + The fitted `LogisticRegression`. + + """ + return self._model + + @classmethod + def from_params(cls, coefficient: float, intercept: float) -> PlattCalibrator: + """Reconstruct a calibrator from a coefficient/intercept saved via `to_params`. + + Args: + coefficient: The fitted logistic regression's coefficient. + intercept: The fitted logistic regression's intercept. + + Returns: + A `PlattCalibrator` ready to `predict`, without needing to re-`fit`. + + """ + model = LogisticRegression() + model.classes_ = np.array([False, True]) + model.coef_ = np.array([[coefficient]]) + model.intercept_ = np.array([intercept]) + return cls.from_sklearn_model(model) + + @classmethod + def from_sklearn_model(cls, model: LogisticRegression) -> PlattCalibrator: + """Reconstruct a calibrator from a model saved via `to_sklearn_model`. + + Args: + model: A previously fitted `LogisticRegression`. + + Returns: + A `PlattCalibrator` wrapping it. + + """ + calibrator = cls() + calibrator._model = model + return calibrator diff --git a/ml/bus_matching_model/app/db.py b/ml/bus_matching_model/app/db.py new file mode 100644 index 0000000..30db83a --- /dev/null +++ b/ml/bus_matching_model/app/db.py @@ -0,0 +1,1103 @@ +"""Database + feature-file access for the Bus Matching active learning app. + +Reads `ml.bus_matching_candidates`, `ml.bus_matching_contestedness`, +`ml.trip_validity_final`, `ml.trip_validity_fares_final`, +`ml.trip_validity_route_shapes`, and `ml.bus_matching_avl_positions` -- +all read-only, all built by earlier notebooks. Writes only to this app's +own `ml.bus_matching_trip_labels`. + +Tier 1 features are read from the Parquet checkpoints in +`artifacts/features/`, not a database table (per the plan: the active +learning loop must never recompute features, and a month of per-pair +rows is cheap to hold in memory). The full-month background build can +still be in progress when this app runs -- callers should treat a +bus-date with no feature row as "not yet scored", not an error. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal + +import belief +import pandas as pd +import psycopg +import training +from features import select_sample_trips as _select_sample_trips + +from opa_database.config import settings + +if TYPE_CHECKING: + import datetime + from collections.abc import Collection + +Decision = Literal["match", "none_of_these", "unsure"] +Mode = Literal["uncontested", "contested"] +SelectionMode = Literal["hardest", "random"] + +FEATURES_DIR = Path(__file__).resolve().parents[1] / "artifacts" / "features" + +# stopping_signal() thresholds -- see its docstring. +MIN_RUNS_FOR_RATE = 2 +MIN_RUNS_FOR_TREND = 3 +GROWTH_SLOWDOWN_FACTOR = 0.7 +BRIER_EPSILON = 1e-9 +BRIER_PLATEAU_TOLERANCE = 0.05 +CHECK_FEATURES_RESOLVED_THRESHOLD = 400 + + +def get_connection() -> psycopg.Connection: + """Open a new autocommit connection to the database. + + Returns: + An open connection, in autocommit mode so a single dropped query + can't leave the interactive Streamlit session's shared + connection stuck mid-transaction. + + """ + conn = psycopg.connect(settings.db_dsn) + conn.autocommit = True + return conn + + +def load_available_features() -> pd.DataFrame: + """Load every Tier 1 feature parquet checkpoint written so far. + + Returns: + Concatenated frame across all `date=*.parquet` files currently on + disk, columns `bus_id`, `device_id`, `date`, plus every Tier 1 + feature. Empty frame if the background build hasn't written + anything yet. + + """ + paths = sorted(FEATURES_DIR.glob("date=*.parquet")) + frames = [pd.read_parquet(p) for p in paths] + frames = [f for f in frames if not f.empty] + if not frames: + return pd.DataFrame() + return pd.concat(frames, ignore_index=True) + + +def trip_label_counts(conn: psycopg.Connection) -> dict[str, int]: + """Count individual trip-level labels by decision. + + Args: + conn: An open connection. + + Returns: + Counts keyed by "match", "none_of_these", "unsure", "total". + This is raw click volume, not resolved bus-dates -- see + `fetch_resolved_labels` for the count that actually feeds + training. + + """ + counts = {"match": 0, "none_of_these": 0, "unsure": 0} + with conn.cursor() as cur: + cur.execute( + "SELECT decision, count(*) FROM ml.bus_matching_trip_labels " + "GROUP BY decision;" + ) + counts.update(dict(cur.fetchall())) + counts["total"] = sum(counts.values()) + return counts + + +def labeled_trip_ids(conn: psycopg.Connection) -> set[int]: + """Every `trip_id` that already has a trip-level label. + + Args: + conn: An open connection. + + Returns: + Set of `trip_id`s -- used to avoid re-showing an already-decided + trip. + + """ + with conn.cursor() as cur: + cur.execute("SELECT trip_id FROM ml.bus_matching_trip_labels;") + return {row[0] for row in cur.fetchall()} + + +def fetch_all_candidates_with_scores( + conn: psycopg.Connection, features: pd.DataFrame +) -> pd.DataFrame: + """Every candidate for every bus-date, joined to its Tier 1 score. + + Args: + conn: An open connection. + features: `load_available_features()` output. + + Returns: + Columns `bus_id`, `date`, `device_id`, `from_dictionary`, + `n_dictionary_sources` (0/1/2 -- how many of the two dictionary + tables independently name this exact `(bus_id, device_id)` + pair; corroboration by both is stronger evidence than either + alone), `bus_has_dictionary_conflict` (bool -- this bus has + *multiple* distinct dictionary-sourced devices across all its + candidates, so any single dictionary pair for it is weaker + evidence, on request), `frac_good_trips` (`NaN` if not yet + featurized). + + """ + with conn.cursor() as cur: + cur.execute( + """ + WITH pair_sources AS ( + SELECT bus_id, device_id, count(DISTINCT origin) AS n_sources + FROM ml.bus_matching_candidate_pairs + GROUP BY bus_id, device_id + ), + bus_distinct_devices AS ( + SELECT bus_id, count(DISTINCT device_id) AS n_distinct_devices + FROM ml.bus_matching_candidate_pairs + GROUP BY bus_id + ) + SELECT + c.bus_id, c.date, c.device_id, c.from_dictionary, + coalesce(ps.n_sources, 0) AS n_dictionary_sources, + coalesce(bd.n_distinct_devices, 0) > 1 AS bus_has_dictionary_conflict + FROM ml.bus_matching_candidates c + LEFT JOIN pair_sources ps + ON ps.bus_id = c.bus_id AND ps.device_id = c.device_id + LEFT JOIN bus_distinct_devices bd ON bd.bus_id = c.bus_id; + """ + ) + cand = pd.DataFrame.from_records( + cur.fetchall(), + columns=[ + "bus_id", + "date", + "device_id", + "from_dictionary", + "n_dictionary_sources", + "bus_has_dictionary_conflict", + ], + ) + if features.empty: + cand["frac_good_trips"] = float("nan") + return cand + return cand.merge( + features[["bus_id", "device_id", "date", "frac_good_trips"]], + on=["bus_id", "device_id", "date"], + how="left", + ) + + +def compute_candidate_scores( + conn: psycopg.Connection, + features: pd.DataFrame, + model_state: dict[str, Any] | None, +) -> pd.DataFrame: + """Score every candidate: model's P(match) if available, else the Tier 1 heuristic. + + This is the "feed the model back into belief" step: once + `model_state` (from `registry.load_run`/a fresh `_run_training_cycle`) + exists, its calibrated probability is a genuinely better prior than + the static Tier 1 score, since it's fit on real labels instead of a + fixed threshold rule. Falls back to `frac_good_trips` per-row + whenever the model's own feature columns aren't available for that + row (not yet featurized) or no model exists yet at all. + + Args: + conn: An open connection. + features: `load_available_features()` output. + model_state: `{"model", "calibrator", "selected_features"}`, or + `None` before the first retrain. + + Returns: + Columns `bus_id`, `date`, `device_id`, `from_dictionary`, `score` + (0-1, `NaN` if neither source is available for that row), + `is_model_score` (bool -- whether `score` came from the model, so + `belief.py` knows to trust it more than the heuristic). + + """ + cand = fetch_all_candidates_with_scores(conn, features) + cand = cand.rename(columns={"frac_good_trips": "score"}) + cand["is_model_score"] = False + out_cols = [ + "bus_id", + "date", + "device_id", + "from_dictionary", + "n_dictionary_sources", + "bus_has_dictionary_conflict", + "score", + "is_model_score", + ] + if model_state is None or features.empty: + return cand[out_cols] + + selected = model_state["selected_features"] + merged = cand.merge( + features[["bus_id", "device_id", "date", *selected]], + on=["bus_id", "device_id", "date"], + how="left", + ) + has_features = merged[selected].notna().all(axis=1) + if has_features.any(): + raw = training.predict_positive_proba( + model_state["model"], merged.loc[has_features, selected] + ) + merged.loc[has_features, "score"] = model_state["calibrator"].predict(raw) + merged.loc[has_features, "is_model_score"] = True + return merged[out_cols] + + +def fetch_all_votes(conn: psycopg.Connection) -> pd.DataFrame: + """Every non-"unsure" trip-level vote recorded so far. + + Args: + conn: An open connection. + + Returns: + Columns `bus_id`, `date`, `device_id`, `decision`. + + """ + with conn.cursor() as cur: + cur.execute( + "SELECT bus_id, date, device_id, decision FROM ml.bus_matching_trip_labels " + "WHERE decision <> 'unsure';" + ) + return pd.DataFrame.from_records( + cur.fetchall(), columns=["bus_id", "date", "device_id", "decision"] + ) + + +def _add_temporal_support( + candidates: pd.DataFrame, confirmed_pairs: pd.DataFrame +) -> pd.DataFrame: + """Flag candidates whose `(bus_id, device_id)` pair was confirmed on another date. + + Fully vectorized (merge + groupby-any, no row-wise `.apply`) -- + confirmed at this scale (tens of thousands of candidate rows) the + row-wise version was slow enough to matter. + + Args: + candidates: Must have `bus_id`, `date`, `device_id`. + confirmed_pairs: `bus_id`, `date`, `device_id` of pairs already + confirmed a match (vote-only resolution, see + `fetch_belief_summary`). + + Returns: + `candidates` with an added `has_temporal_support` bool column. + + """ + candidates = candidates.copy() + if confirmed_pairs.empty: + candidates["has_temporal_support"] = False + return candidates + merged = candidates.merge( + confirmed_pairs.rename(columns={"date": "confirmed_date"}), + on=["bus_id", "device_id"], + how="left", + ) + merged["_supported"] = merged["confirmed_date"].notna() & ( + merged["confirmed_date"] != merged["date"] + ) + support = ( + merged.groupby(["bus_id", "date", "device_id"])["_supported"] + .any() + .reset_index(name="has_temporal_support") + ) + return candidates.merge(support, on=["bus_id", "date", "device_id"], how="left") + + +def fetch_belief_summary( + conn: psycopg.Connection, + features: pd.DataFrame, + model_state: dict[str, Any] | None = None, +) -> pd.DataFrame: + """Compute the current probabilistic belief for every bus-date -- see `belief.py`. + + Two passes to add the temporal-support prior (plan Section 8) + without circularity: pass 1 computes belief *without* any temporal + boost and reads off which pairs are already vote-confirmed matches; + pass 2 re-scores every candidate with that boost applied and + returns *that* result. A pair can never boost itself into + existence, since the confirmed-pairs list pass 1 produces never had + the boost applied in the first place. + + Args: + conn: An open connection. + features: `load_available_features()` output. + model_state: Passed through to `compute_candidate_scores` -- once + a model exists, its calibrated probability drives the prior + instead of the static Tier 1 heuristic. + + Returns: + `belief.summarize_bus_dates` output: one row per bus-date with + `top_option`, `top_posterior`, `margin`, `n_votes`, `resolved`, + `decision`, `device_id`, plus `has_dictionary` (bool -- any + candidate for this bus-date is dictionary-sourced) and + `has_score` (bool -- any candidate has a real, non-`NaN` score; + `False` means every candidate is missing GTFS/AVL data for + every sampled trip, so there is nothing to visually compare -- + see `fetch_next_trip`). + + """ + candidates = compute_candidate_scores(conn, features, model_state) + votes = fetch_all_votes(conn) + + run_row = (model_state or {}).get("run_row") or {} + model_prior_weight = belief.model_prior_weight_for( + run_row.get("n_resolved_bus_dates"), run_row.get("test_ece") + ) + + base_beliefs = belief.compute_date_beliefs(candidates, votes, model_prior_weight) + base_summary = belief.summarize_bus_dates(base_beliefs) + confirmed_pairs = base_summary[ + base_summary["resolved"] & (base_summary["decision"] == "match") + ][["bus_id", "date", "device_id"]] + + candidates = _add_temporal_support(candidates, confirmed_pairs) + beliefs = belief.compute_date_beliefs(candidates, votes, model_prior_weight) + summary = belief.summarize_bus_dates(beliefs) + + flags = candidates.groupby(["bus_id", "date"]).agg( + has_dictionary=("from_dictionary", "any"), + has_score=("score", lambda s: bool(s.notna().any())), + ) + return summary.merge(flags, on=["bus_id", "date"], how="left").fillna( + {"has_dictionary": False, "has_score": False} + ) + + +def fetch_resolved_labels( + conn: psycopg.Connection, + features: pd.DataFrame, + model_state: dict[str, Any] | None = None, +) -> pd.DataFrame: + """Bus-dates whose belief has resolved -- see `belief.summarize_bus_dates`. + + Args: + conn: An open connection. + features: `load_available_features()` output. + model_state: Passed through to `fetch_belief_summary`. + + Returns: + Columns `bus_id`, `date`, `decision`, `device_id` -- one row per + resolved bus-date only. + + """ + summary = fetch_belief_summary(conn, features, model_state) + resolved = summary[summary["resolved"]] + return resolved[["bus_id", "date", "decision", "device_id"]].reset_index(drop=True) + + +def resolved_bus_dates( + conn: psycopg.Connection, + features: pd.DataFrame, + model_state: dict[str, Any] | None = None, +) -> set[tuple[str, Any]]: + """Every `(bus_id, date)` that has already resolved -- see `fetch_resolved_labels`. + + Args: + conn: An open connection. + features: `load_available_features()` output. + model_state: Passed through to `fetch_resolved_labels`. + + Returns: + Set of `(bus_id, date)` tuples. + + """ + resolved = fetch_resolved_labels(conn, features, model_state) + if resolved.empty: + return set() + return set(zip(resolved["bus_id"], resolved["date"], strict=True)) + + +def total_bus_date_count(conn: psycopg.Connection) -> int: + """Total distinct bus-dates with at least one candidate -- the coverage denominator. + + Args: + conn: An open connection. + + Returns: + Count of distinct `(bus_id, date)` pairs in `ml.bus_matching_candidates`. + + """ + with conn.cursor() as cur: + cur.execute( + "SELECT count(DISTINCT (bus_id, date)) FROM ml.bus_matching_candidates;" + ) + row = cur.fetchone() + return row[0] if row else 0 + + +def model_coverage_count( + conn: psycopg.Connection, + features: pd.DataFrame, + model_state: dict[str, Any] | None, +) -> int: + """Count bus-dates the prior is *already* confident about, zero votes needed. + + This is the actual "how close to full coverage" number -- distinct + from `resolved_bus_dates`, which requires real human votes and is + meant to stay small (the plan's "few hundred" training-label + budget). This one is what should climb toward `total_bus_date_count` + as the model improves, without a human voting on every bus-date. + + Args: + conn: An open connection. + features: `load_available_features()` output. + model_state: Passed through to `fetch_belief_summary`. + + Returns: + Count of bus-dates with `confident_no_votes == True`. + + """ + summary = fetch_belief_summary(conn, features, model_state) + if summary.empty: + return 0 + return int(summary["confident_no_votes"].sum()) + + +def stopping_signal(conn: psycopg.Connection) -> dict[str, Any]: + """Auto-detect whether labeling looks like it's converging -- plan Section 11. + + Watches two things together across retrain history in + `ml.bus_matching_model_runs`: whether the resolved-bus-date growth + *rate* (resolved gained per trip-label spent) is slowing between the + last two retrain intervals, and whether `test_brier` has stopped + moving much across the last three retrains. Neither alone is + conclusive (see the plan: coverage, margin, switch-count, and + held-out agreement all need to plateau *together*) -- this covers + the two signals cheaply computable from what's already tracked, not + the full list. + + Args: + conn: An open connection. + + Returns: + Dict with `verdict` (one of `"too_early"`, `"keep_going"`, + `"slowing"`, `"stop_soon"`, `"check_features"`), a human-readable + `message`, and (when available) `n_resolved`/`recent_rate` for + display. + + """ + runs = fetch_model_runs(conn) + if "n_resolved_bus_dates" in runs.columns: + runs = runs.dropna(subset=["n_resolved_bus_dates", "n_trip_labels_total"]) + else: + runs = runs.iloc[0:0] + + if len(runs) < MIN_RUNS_FOR_RATE: + return { + "verdict": "too_early", + "message": "Not enough retrains yet to see a trend -- keep labeling.", + } + + latest, prev = runs.iloc[-1], runs.iloc[-2] + resolved_gain = latest["n_resolved_bus_dates"] - prev["n_resolved_bus_dates"] + label_cost = latest["n_trip_labels_total"] - prev["n_trip_labels_total"] + recent_rate = resolved_gain / label_cost if label_cost else 0.0 + + growth_slowing = False + if len(runs) >= MIN_RUNS_FOR_TREND: + prev2 = runs.iloc[-MIN_RUNS_FOR_TREND] + earlier_gain = prev["n_resolved_bus_dates"] - prev2["n_resolved_bus_dates"] + earlier_cost = prev["n_trip_labels_total"] - prev2["n_trip_labels_total"] + earlier_rate = earlier_gain / earlier_cost if earlier_cost else 0.0 + growth_slowing = ( + earlier_rate > 0 and recent_rate < earlier_rate * GROWTH_SLOWDOWN_FACTOR + ) + + metric_plateau = False + if len(runs) >= MIN_RUNS_FOR_TREND: + last3 = runs["test_brier"].tail(MIN_RUNS_FOR_TREND) + if last3.notna().all() and last3.iloc[0] > BRIER_EPSILON: + rel_change = abs(last3.iloc[-1] - last3.iloc[0]) / last3.iloc[0] + metric_plateau = rel_change < BRIER_PLATEAU_TOLERANCE + + n_resolved = int(latest["n_resolved_bus_dates"]) + if n_resolved > CHECK_FEATURES_RESOLVED_THRESHOLD and not ( + growth_slowing or metric_plateau + ): + verdict, message = ( + "check_features", + f"{n_resolved} resolved and still climbing steeply past the plan's ~400 " + "rough ceiling -- that usually points to a features/candidate-generation " + 'problem, not "label more."', + ) + elif growth_slowing and metric_plateau: + verdict, message = ( + "stop_soon", + "Both growth rate and model metrics have plateaued across the last few " + "retrains -- probably enough.", + ) + elif growth_slowing or metric_plateau: + verdict, message = ( + "slowing", + "One signal (growth rate or metrics) is slowing but not both yet -- " + "a bit more labeling may still help.", + ) + else: + verdict, message = "keep_going", "Still improving -- keep labeling." + + return { + "verdict": verdict, + "message": message, + "n_resolved": n_resolved, + "recent_rate": recent_rate, + } + + +def fetch_next_trip( + conn: psycopg.Connection, + features: pd.DataFrame, + *, + model_state: dict[str, Any] | None = None, + selection_mode: SelectionMode = "hardest", + exclude_trip_ids: Collection[int] = (), + exclude_bus_dates: Collection[tuple[str, Any]] = (), + max_attempts: int = 50, +) -> tuple[str, Any, int, bool] | None: + """Auto-select the single most informative next trip to label. + + Bus-dates that already have at least one vote and aren't resolved + yet come first, ranked by highest posterior first (closest to + crossing the confidence bar -- confirmed live this matters: with + `MIN_VOTES_TO_RESOLVE` effectively needing ~2 real votes to clear + `CONFIDENCE_THRESHOLD` from a single confirming vote alone, ranking + purely by margin let 10 single votes scatter across 10 different + bus-dates with zero resolutions, since a fresh zero-vote bus-date's + margin can look just as "small" as a partially-voted one's). This + part is unaffected by `selection_mode` -- finishing something + already started is worth doing either way. + + Only once nothing is in progress does selection fall back to fresh + zero-vote bus-dates. Both modes agree on one thing first: `has_score` + descending -- a bus-date where every candidate is missing GTFS/AVL + data for every sampled trip (`has_score=False`) has nothing to + visually compare and no real evidence driving its prior either + (`_prior_odds` treats a missing score as neutral 1:1 odds), so *all* + its candidates look equally "uncertain" by margin alone -- that's + absence of evidence masquerading as genuine ambiguity, not a hard + case, and not a representative "normal" case either. Pushed last in + both modes, on request: these may still get resolved indirectly + later, via cross-suppression or temporal support from other + confirmed dates, without ever needing a human look. + + From there the two modes diverge, on request (pure uncertainty + sampling concentrates training data right at the model's current + decision boundary and starves it of the "obviously easy" majority + case -- confirmed live: the model's own decision boundary visibly + jittered retrain to retrain, swinging live coverage by + thousands of bus-dates even with `test_ece` stable and trust + already at its ceiling): + + - `"hardest"` (default): among the analyzable remainder, tiebreak by + `has_dictionary` descending (dictionary-backed candidates are + safer/faster to label, on request), then `margin` ascending -- + closest call in the prior alone, per the plan's Section 7 + "contested pairs" priority. + - `"random"`: among the analyzable remainder, still tiebreaks by + `has_dictionary` descending first (dictionary-backed candidates + stay safer/faster to label even when the point is to sample the + ordinary case, on request) but shuffles uniformly at random + *within* each of those two tiers instead of sorting by margin -- + deliberately ignores margin, so labeling sweeps up the + ordinary/easy majority the hardest-first mode systematically + skips, anchoring the model against boundary jitter, without + giving up the dictionary safety net. + + No manual queue choice either way -- `selection_mode` toggles which + automatic policy runs, it's still never a hand-picked queue. + + Args: + conn: An open connection. + features: `load_available_features()` output. + model_state: Passed through to `fetch_belief_summary` -- once a + model exists it drives the ranking's prior, same as + resolution. + selection_mode: `"hardest"` (default) for uncertainty sampling, + `"random"` to sweep up ordinary/easy cases instead -- see + above. + exclude_trip_ids: Trip ids to treat as already decided (labeled + this session, or skipped). + exclude_bus_dates: `(bus_id, date)` pairs to skip entirely this + session (the "get a different bus/route" escape hatch). + max_attempts: How many top-ranked bus-dates to try before giving + up -- guards against a pathological run of exhausted + bus-dates (all sample trips already labeled/skipped, still + unresolved) burning unbounded time. + + Returns: + `(bus_id, date, trip_id, is_contested)`, or `None` if nothing is + left to label. + + """ + summary = fetch_belief_summary(conn, features, model_state) + unresolved = summary[~summary["resolved"]] + in_progress = unresolved[unresolved["n_votes"] > 0].sort_values( + "top_posterior", ascending=False + ) + zero_vote = unresolved[unresolved["n_votes"] == 0] + if selection_mode == "random": + analyzable = zero_vote[zero_vote["has_score"]] + unanalyzable = zero_vote[~zero_vote["has_score"]] + dict_backed = analyzable[analyzable["has_dictionary"]].sample(frac=1.0) + non_dict = analyzable[~analyzable["has_dictionary"]].sample(frac=1.0) + fresh = pd.concat([dict_backed, non_dict, unanalyzable], ignore_index=True) + else: + fresh = zero_vote.sort_values( + ["has_score", "has_dictionary", "margin"], ascending=[False, False, True] + ) + unresolved = pd.concat([in_progress, fresh], ignore_index=True) + if exclude_bus_dates: + excluded_df = pd.DataFrame(list(exclude_bus_dates), columns=["bus_id", "date"]) + unresolved = unresolved.merge( + excluded_df, on=["bus_id", "date"], how="left", indicator=True + ) + unresolved = unresolved[unresolved["_merge"] == "left_only"].drop( + columns="_merge" + ) + if unresolved.empty: + return None + + exclude = set(exclude_trip_ids) | labeled_trip_ids(conn) + for row in unresolved.head(max_attempts).itertuples(index=False): + trips = fetch_bus_date_trips(conn, row.bus_id, row.date) + if trips.empty: + continue + sampled = _select_sample_trips(trips) + remaining = sampled[~sampled["trip_id"].isin(exclude)] + if remaining.empty: + continue + with conn.cursor() as cur: + cur.execute( + "SELECT is_contested FROM ml.bus_matching_contestedness " + "WHERE bus_id = %(bus_id)s AND trip_date = %(date)s;", + {"bus_id": row.bus_id, "date": row.date}, + ) + contested_row = cur.fetchone() + is_contested = bool(contested_row[0]) if contested_row else True + return row.bus_id, row.date, int(remaining.iloc[0]["trip_id"]), is_contested + return None + + +def fetch_bus_date_candidates( + conn: psycopg.Connection, + features: pd.DataFrame, + bus_id: str, + date: datetime.date, + *, + model_state: dict[str, Any] | None = None, + max_shown: int = 6, +) -> pd.DataFrame: + """Every candidate device for one bus-date, joined to its best-available score. + + On request, shown directly to the labeler (score + dictionary-origin + badge on each item), which trades away the plan's anti-bias hiding + on purpose -- an informed, explicit choice, not an oversight. + + Args: + conn: An open connection. + features: `load_available_features()` output. + bus_id: The bus. + date: The trip date. + model_state: If given, candidates with available features get + the trained model's calibrated probability as `score` + instead of the Tier 1 heuristic (`compute_candidate_scores`' + per-bus-date-scoped equivalent). + max_shown: Contested mode caps the number of candidates actually + rendered (each adds a track + a chainage series) -- kept to + the top `max_shown` by score; every candidate is still a + selectable answer via "more candidates exist" being + surfaced separately, not silently dropped from the label + options. + + Returns: + Columns `device_id`, `overlap_count`, `from_dictionary`, + `from_blocking`, `score`, `is_model_score`, + `median_buffer_coverage_50`, sorted by `score` descending + (worst/unscored last), capped to `max_shown`. + + """ + with conn.cursor() as cur: + cur.execute( + """ + SELECT device_id, overlap_count, from_dictionary, from_blocking + FROM ml.bus_matching_candidates + WHERE bus_id = %(bus_id)s AND date = %(date)s; + """, + {"bus_id": bus_id, "date": date}, + ) + cand = pd.DataFrame.from_records( + cur.fetchall(), + columns=["device_id", "overlap_count", "from_dictionary", "from_blocking"], + ) + + if features.empty: + cand["score"] = float("nan") + cand["is_model_score"] = False + cand["median_buffer_coverage_50"] = float("nan") + return cand.sort_values("score", ascending=False, na_position="last").head( + max_shown + ) + + today = features[(features["bus_id"] == bus_id) & (features["date"] == date)] + cand = cand.merge( + today[["device_id", "frac_good_trips", "median_buffer_coverage_50"]], + on="device_id", + how="left", + ) + cand = cand.rename(columns={"frac_good_trips": "score"}) + cand["is_model_score"] = False + + if model_state is not None: + selected = model_state["selected_features"] + feat_cols = today[["device_id", *selected]] + feat_merged = cand[["device_id"]].merge(feat_cols, on="device_id", how="left") + has_features = feat_merged[selected].notna().all(axis=1) + if has_features.any(): + raw = training.predict_positive_proba( + model_state["model"], feat_merged.loc[has_features, selected] + ) + calibrated = model_state["calibrator"].predict(raw) + cand.loc[has_features.to_numpy(), "score"] = calibrated + cand.loc[has_features.to_numpy(), "is_model_score"] = True + + cand = cand.sort_values("score", ascending=False, na_position="last") + return cand.head(max_shown).reset_index(drop=True) + + +def fetch_bus_date_trips( + conn: psycopg.Connection, bus_id: str, date: datetime.date +) -> pd.DataFrame: + """Every valid trip for one bus-date, time-ordered. + + Args: + conn: An open connection. + bus_id: The bus. + date: The trip date. + + Returns: + Columns `trip_id`, `route_id`, `trip_start_timestamp`, + `trip_end_timestamp`, `gtfs_feed_version_date`, + `gtfs_shape_id_i`, `gtfs_shape_id_v`. + + """ + with conn.cursor() as cur: + cur.execute( + """ + SELECT trip_id, route_id, trip_start_timestamp, trip_end_timestamp, + gtfs_feed_version_date, gtfs_shape_id_i, gtfs_shape_id_v + FROM ml.trip_validity_final + WHERE is_valid AND bus_id = %(bus_id)s AND trip_date = %(date)s + ORDER BY trip_start_timestamp; + """, + {"bus_id": bus_id, "date": date}, + ) + return pd.DataFrame.from_records( + cur.fetchall(), + columns=[ + "trip_id", + "route_id", + "trip_start_timestamp", + "trip_end_timestamp", + "gtfs_feed_version_date", + "gtfs_shape_id_i", + "gtfs_shape_id_v", + ], + ) + + +def fetch_shape_geojson( + conn: psycopg.Connection, feed_version_date: datetime.date, shape_id: str | None +) -> list[list[float]] | None: + """GTFS shape coordinates for one direction, or `None` if unmatched. + + Args: + conn: An open connection. + feed_version_date: The trip's GTFS feed snapshot. + shape_id: `gtfs_shape_id_i` or `gtfs_shape_id_v`, possibly `None`. + + Returns: + `[lon, lat]` coordinate list, or `None`. + + """ + if shape_id is None or (isinstance(shape_id, float) and pd.isna(shape_id)): + return None + with conn.cursor() as cur: + cur.execute( + "SELECT ST_AsGeoJSON(shape_geom) FROM ml.trip_validity_route_shapes " + "WHERE feed_version_date = %(feed)s AND shape_id = %(shape_id)s;", + {"feed": feed_version_date, "shape_id": shape_id}, + ) + row = cur.fetchone() + if row is None or row[0] is None: + return None + return json.loads(row[0])["coordinates"] + + +def fetch_route_stops( + conn: psycopg.Connection, feed_version_date: datetime.date, shape_id: str | None +) -> pd.DataFrame: + """GTFS stops along one shape, unioned across stop-sequence variants. + + Args: + conn: An open connection. + feed_version_date: The trip's GTFS feed snapshot. + shape_id: The reference shape (see `db.fetch_shape_geojson`). + + Returns: + Columns `stop_id`, `stop_lat`, `stop_lon`, deduplicated -- empty + frame if `shape_id` is `None` or unmatched. + + """ + if shape_id is None or (isinstance(shape_id, float) and pd.isna(shape_id)): + return pd.DataFrame(columns=["stop_id", "stop_lat", "stop_lon"]) + with conn.cursor() as cur: + cur.execute( + """ + SELECT DISTINCT stop_id, stop_lat, stop_lon + FROM ml.trip_validity_route_stops + WHERE feed_version_date = %(feed)s AND shape_id = %(shape_id)s; + """, + {"feed": feed_version_date, "shape_id": shape_id}, + ) + return pd.DataFrame.from_records( + cur.fetchall(), columns=["stop_id", "stop_lat", "stop_lon"] + ) + + +def fetch_device_trip_positions( + conn: psycopg.Connection, + device_id: str, + start_ts: datetime.datetime, + end_ts: datetime.datetime, +) -> pd.DataFrame: + """One device's AVL positions inside one trip's time window. + + Args: + conn: An open connection. + device_id: The candidate device. + start_ts: Trip window start (timestamptz). + end_ts: Trip window end (timestamptz). + + Returns: + Columns `metric_timestamp`, `latitude`, `longitude`, `epoch` + (seconds since epoch, float), `x`/`y` (metric CRS SRID 31984, + matching the `gtfs_cache` shapes -- computed in SQL rather than + client-side reprojection, so no new dependency is needed), + ordered by time. + + """ + with conn.cursor() as cur: + cur.execute( + """ + SELECT metric_timestamp, latitude, longitude, + extract(epoch FROM metric_timestamp)::float8 AS epoch, + ST_X(ST_Transform(geom, 31984)) AS x, + ST_Y(ST_Transform(geom, 31984)) AS y + FROM ml.bus_matching_avl_positions + WHERE device_id = %(device_id)s + AND metric_timestamp >= %(start)s AND metric_timestamp <= %(end)s + ORDER BY metric_timestamp; + """, + {"device_id": device_id, "start": start_ts, "end": end_ts}, + ) + return pd.DataFrame.from_records( + cur.fetchall(), + columns=["metric_timestamp", "latitude", "longitude", "epoch", "x", "y"], + ) + + +def expand_labels_to_training_rows( + conn: psycopg.Connection, + features: pd.DataFrame, + model_state: dict[str, Any] | None = None, +) -> pd.DataFrame: + """Turn resolved bus-date verdicts into per-candidate binary training rows. + + Mirrors the plan's Section 8 immediate constraint: a resolved + "match" on device D makes D a positive row and every *other* + candidate shown for that bus-date a negative row; a resolved + "none_of_these" makes every shown candidate a negative row with no + positive. Only *resolved* bus-dates (see `fetch_resolved_labels`) + contribute rows -- a bus-date with disagreeing or too-few trip votes + isn't used for training yet. + + Args: + conn: An open connection. + features: `load_available_features()` output -- rows are dropped + if their `(bus_id, device_id, date)` isn't featurized yet + (the full-month background build may still be running). + model_state: Passed through to `fetch_resolved_labels` -- the + *previous* model (if any) is what determines which + bus-dates are already resolved going into this retrain. + + Returns: + Columns `bus_id`, `date`, `device_id`, `label` (bool), plus + every `features.DAY_FEATURE_NAMES` column. + + """ + labels = fetch_resolved_labels(conn, features, model_state) + if labels.empty or features.empty: + return pd.DataFrame() + + with conn.cursor() as cur: + cur.execute("SELECT bus_id, date, device_id FROM ml.bus_matching_candidates;") + candidates = pd.DataFrame.from_records( + cur.fetchall(), columns=["bus_id", "date", "device_id"] + ) + + rows = [] + for row in labels.itertuples(index=False): + bus_date_candidates = candidates[ + (candidates["bus_id"] == row.bus_id) & (candidates["date"] == row.date) + ]["device_id"] + for candidate_device in bus_date_candidates: + positive = row.decision == "match" and candidate_device == row.device_id + rows.append( + { + "bus_id": row.bus_id, + "date": row.date, + "device_id": candidate_device, + "label": positive, + } + ) + expanded = pd.DataFrame(rows) + return expanded.merge(features, on=["bus_id", "device_id", "date"], how="inner") + + +def insert_model_run(conn: psycopg.Connection, run: dict[str, Any]) -> int: + """Persist one training run's config, metrics, and artifact location. + + Args: + conn: An open connection. + run: Keys matching `ml.bus_matching_model_runs`'s columns. + + Returns: + The new row's `run_id`. + + """ + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO ml.bus_matching_model_runs + (run_type, n_train_labels, hyperparameters, selected_features, + calibration_params, cv_brier_score, test_auc, test_brier, + test_log_loss, test_ece, artifact_path, + n_resolved_bus_dates, n_trip_labels_total) + VALUES (%(run_type)s, %(n_train_labels)s, %(hyperparameters)s, + %(selected_features)s, %(calibration_params)s, %(cv_brier_score)s, + %(test_auc)s, %(test_brier)s, %(test_log_loss)s, %(test_ece)s, + %(artifact_path)s, %(n_resolved_bus_dates)s, + %(n_trip_labels_total)s) + RETURNING run_id; + """, + { + "run_type": run["run_type"], + "n_train_labels": run["n_train_labels"], + "hyperparameters": json.dumps(run["hyperparameters"]), + "selected_features": json.dumps(run["selected_features"]), + "calibration_params": json.dumps(run["calibration_params"]) + if run.get("calibration_params") is not None + else None, + "cv_brier_score": run.get("cv_brier_score"), + "test_auc": run.get("test_auc"), + "test_brier": run.get("test_brier"), + "test_log_loss": run.get("test_log_loss"), + "test_ece": run.get("test_ece"), + "artifact_path": run["artifact_path"], + "n_resolved_bus_dates": run.get("n_resolved_bus_dates"), + "n_trip_labels_total": run.get("n_trip_labels_total"), + }, + ) + result = cur.fetchone() + if result is None: + msg = "INSERT ... RETURNING run_id unexpectedly returned no row" + raise RuntimeError(msg) + return result[0] + + +def fetch_latest_model_run(conn: psycopg.Connection) -> dict[str, Any] | None: + """Fetch the most recent model run's full row. + + Args: + conn: An open connection. + + Returns: + A dict of column name to value, or `None` if no run exists yet. + + """ + with conn.cursor() as cur: + cur.execute( + "SELECT * FROM ml.bus_matching_model_runs ORDER BY created_at DESC LIMIT 1;" + ) + row = cur.fetchone() + if row is None: + return None + columns = [d.name for d in cur.description or []] + return dict(zip(columns, row, strict=True)) + + +def fetch_model_runs(conn: psycopg.Connection) -> pd.DataFrame: + """Fetch every model run, oldest first, for charting metrics over time. + + Args: + conn: An open connection. + + Returns: + A frame with one row per run, columns matching + `ml.bus_matching_model_runs`. + + """ + with conn.cursor() as cur: + cur.execute("SELECT * FROM ml.bus_matching_model_runs ORDER BY created_at;") + return pd.DataFrame.from_records( + cur.fetchall(), columns=[d.name for d in cur.description or []] + ) + + +def insert_trip_label( + conn: psycopg.Connection, + *, + bus_id: str, + date: datetime.date, + trip_id: int, + device_id: str | None, + decision: Decision, + mode: Mode, + n_candidates: int, +) -> None: + """Record one trip-level labeling decision. + + Args: + conn: An open connection. + bus_id: The bus. + date: The trip date. + trip_id: The specific trip this decision was made on. + device_id: The chosen device, or `None` for "none of these"/unsure. + decision: "match", "none_of_these", or "unsure". + mode: Which UI mode produced this decision. + n_candidates: How many candidates were shown. + + """ + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO ml.bus_matching_trip_labels + (bus_id, date, trip_id, device_id, decision, mode, n_candidates) + VALUES (%(bus_id)s, %(date)s, %(trip_id)s, %(device_id)s, %(decision)s, + %(mode)s, %(n_candidates)s) + ON CONFLICT (bus_id, date, trip_id) DO UPDATE SET + device_id = EXCLUDED.device_id, + decision = EXCLUDED.decision, + mode = EXCLUDED.mode, + n_candidates = EXCLUDED.n_candidates, + labeled_at = now(); + """, + { + "bus_id": bus_id, + "date": date, + "trip_id": trip_id, + "device_id": device_id, + "decision": decision, + "mode": mode, + "n_candidates": n_candidates, + }, + ) diff --git a/ml/bus_matching_model/app/device_coverage.py b/ml/bus_matching_model/app/device_coverage.py new file mode 100644 index 0000000..46dfc48 --- /dev/null +++ b/ml/bus_matching_model/app/device_coverage.py @@ -0,0 +1,156 @@ +"""The other half of Section 13: which real devices never got claimed. + +`ml.bus_matching_final_pairs` (`final_output.py`) is bus-centric -- for +every bus, which device, if any. That leaves an asymmetry unanswered: a +device can be genuinely active all month (tens of thousands of real +pings) and still never appear anywhere in that table, either because it +lost a competition for a bus another device won, or because blocking +never even considered it for anyone. Confirmed live: of 1,493 devices +that pinged at all in November 2023, 62 are claimed by no bus. + +This module answers "why" for each of those 62, at the same level of +honesty as `final_output.py`: every device gets a `reason`, none are +silently unexplained. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pandas as pd + +if TYPE_CHECKING: + import psycopg + +_ACTIVE_DEVICES_SQL = """ + SELECT + device_id, + count(*) AS n_pings, + count(DISTINCT metric_timestamp::date) AS n_days_active, + min(metric_timestamp::date) AS first_active_date, + max(metric_timestamp::date) AS last_active_date + FROM silver.avl_pings_y2023m11 + GROUP BY device_id; +""" + +_CANDIDATE_BUSES_SQL = """ + SELECT DISTINCT device_id, bus_id FROM ml.bus_matching_candidates; +""" + +_DICTIONARY_BUSES_SQL = """ + SELECT DISTINCT + device_id, + CASE WHEN length(regexp_replace(vehicle_number, '[^0-9]', '', 'g')) < 5 + THEN lpad(regexp_replace(vehicle_number, '[^0-9]', '', 'g'), 5, '0') + ELSE regexp_replace(vehicle_number, '[^0-9]', '', 'g') END AS bus_id + FROM silver.dictionary_device + WHERE device_id IS NOT NULL + AND regexp_replace(vehicle_number, '[^0-9]', '', 'g') <> ''; +""" + + +def build_unclaimed_devices( + conn: psycopg.Connection, ranked: pd.DataFrame, excluded: set[str] +) -> pd.DataFrame: + """List every active device that no bus claims, and why. + + Args: + conn: An open connection. + ranked: `pair_model.rank_pairs` output -- used to report a + device's best-scoring bus, when it had any in-scope + candidacy at all. + excluded: `exclusions.excluded_bus_ids` output. + + Returns: + One row per unclaimed device: `device_id`, `n_pings`, + `n_days_active`, `first_active_date`, `last_active_date`, + `in_dictionary`, `dictionary_bus_ids`, `best_candidate_bus_id`, + `best_candidate_score`, `reason`. `reason` is one of + `never_blocked` (blocking found no bus for it at all), + `blocked_only_to_excluded_bus` (its only candidacy was a + 67-prefix bus), or `lost_competition` (it competed for a + real bus and another device won). + + """ + active = pd.read_sql(_ACTIVE_DEVICES_SQL, conn) + claimed = { + r[0] + for r in conn.execute( + "SELECT DISTINCT device_id FROM ml.bus_matching_final_pairs " + "WHERE device_id IS NOT NULL;" + ).fetchall() + } + unclaimed = active[~active["device_id"].isin(claimed)].copy() + if unclaimed.empty: + return unclaimed + + candidates = pd.read_sql(_CANDIDATE_BUSES_SQL, conn) + candidates_by_device = candidates.groupby("device_id")["bus_id"].apply(list) + + dictionary = pd.read_sql(_DICTIONARY_BUSES_SQL, conn) + dict_by_device = dictionary.groupby("device_id")["bus_id"].apply(sorted) + + best = ( + ranked.sort_values("pair_score", ascending=False) + .drop_duplicates(subset="device_id", keep="first") + .set_index("device_id")[["bus_id", "pair_score"]] + if not ranked.empty + else pd.DataFrame(columns=["bus_id", "pair_score"]) + ) + + def _classify(device_id: str) -> tuple[str, str | None, float | None]: + buses = candidates_by_device.get(device_id, []) + in_scope_buses = [b for b in buses if b not in excluded] + if not buses: + return "never_blocked", None, None + if not in_scope_buses: + return "blocked_only_to_excluded_bus", None, None + if device_id in best.index: + row = best.loc[device_id] + return "lost_competition", row["bus_id"], float(row["pair_score"]) + return "lost_competition", None, None + + reasons = unclaimed["device_id"].apply(_classify) + unclaimed["reason"] = reasons.apply(lambda t: t[0]) + unclaimed["best_candidate_bus_id"] = reasons.apply(lambda t: t[1]) + unclaimed["best_candidate_score"] = reasons.apply(lambda t: t[2]) + + unclaimed["in_dictionary"] = unclaimed["device_id"].isin(dict_by_device.index) + unclaimed["dictionary_bus_ids"] = unclaimed["device_id"].apply( + lambda d: ",".join(dict_by_device.get(d, [])) or None + ) + + return unclaimed.sort_values("n_pings", ascending=False).reset_index(drop=True) + + +def save_unclaimed_devices(conn: psycopg.Connection, table: pd.DataFrame) -> None: + """Replace the persisted unclaimed-devices table wholesale. + + Args: + conn: An open connection. + table: `build_unclaimed_devices` output. + + """ + with conn.transaction(): + conn.execute("TRUNCATE ml.bus_matching_unclaimed_devices;") + with conn.cursor().copy( + "COPY ml.bus_matching_unclaimed_devices " + "(device_id, n_pings, n_days_active, first_active_date, " + "last_active_date, in_dictionary, dictionary_bus_ids, " + "best_candidate_bus_id, best_candidate_score, reason) FROM STDIN" + ) as copy: + for row in table.itertuples(index=False): + copy.write_row( + ( + row.device_id, + int(row.n_pings), + int(row.n_days_active), + row.first_active_date, + row.last_active_date, + bool(row.in_dictionary), + row.dictionary_bus_ids, + row.best_candidate_bus_id, + row.best_candidate_score, + row.reason, + ) + ) diff --git a/ml/bus_matching_model/app/exclusions.py b/ml/bus_matching_model/app/exclusions.py new file mode 100644 index 0000000..d9f7d20 --- /dev/null +++ b/ml/bus_matching_model/app/exclusions.py @@ -0,0 +1,105 @@ +"""Buses that no AVL-based matcher can ever resolve, excluded everywhere. + +Single source of truth, imported by the feature build, the pair layer, +and the assignment notebooks, so the three can never disagree about +which buses are in scope. + +Two independent rules, because neither alone is sufficient: + +1. **Company has no AVL at all.** Any `silver.dictionary_device` + company where 100% of its known devices never ping in November 2023. + Computed live rather than hardcoded, so it stays correct if this is + ever re-run on other data. Confirmed live: COOTRAPS (265 devices, + 0 pinging) and Fretcar (76 devices, 0 pinging) -- though Fretcar + turns out to have no valid trips in the period at all, so only + COOTRAPS actually removes anything. +2. **Bus-number prefix.** On request, every `67`-prefix bus is excluded + regardless of what the dictionary says. Rule 1 is dictionary-driven + and therefore blind to vehicles missing from the snapshot: it caught + 240 of the 249 COOTRAPS buses, and the 9 it missed went on to + dominate the labeling queue as apparent "hardest cases" (best of ~73 + candidates scoring 5.7e-7) before this rule existed. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import psycopg + +# On request: excluded outright, not merely deprioritized. These buses +# have real AFC trips but their devices never appear in the AVL feed, +# so every candidate blocking finds for them is spatial coincidence. +EXCLUDED_BUS_PREFIXES = ("67",) + +_NO_AVL_COMPANY_BUSES_SQL = """ + WITH company_ping_rates AS ( + SELECT + d.company, + count(DISTINCT d.device_id) AS n_devices, + count(DISTINCT d.device_id) FILTER ( + WHERE EXISTS ( + SELECT 1 FROM silver.avl_pings_y2023m11 p + WHERE p.device_id = d.device_id + ) + ) AS n_pinging + FROM silver.dictionary_device d + WHERE d.device_id IS NOT NULL + GROUP BY d.company + ), + no_avl AS ( + SELECT company FROM company_ping_rates + WHERE n_devices > 0 AND n_pinging = 0 + ), + normalized AS ( + SELECT regexp_replace(vehicle_number, '[^0-9]', '', 'g') AS digits + FROM silver.dictionary_device + WHERE company IN (SELECT company FROM no_avl) + ) + SELECT DISTINCT + CASE WHEN length(digits) < 5 THEN lpad(digits, 5, '0') ELSE digits END AS bus_id + FROM normalized + WHERE digits <> ''; +""" + + +def excluded_bus_ids(conn: psycopg.Connection) -> set[str]: + """Every bus id excluded from matching, by either rule. + + Args: + conn: An open connection. + + Returns: + Bus ids to drop. Callers should exclude these from candidates, + features, assignment, and the final table alike -- they belong + in an explicit `no_avl_data` bucket, not in any model's input. + + """ + real_buses = { + r[0] + for r in conn.execute( + "SELECT DISTINCT bus_id FROM ml.trip_validity_final WHERE is_valid;" + ).fetchall() + } + by_company = {r[0] for r in conn.execute(_NO_AVL_COMPANY_BUSES_SQL).fetchall()} + by_prefix = {b for b in real_buses if str(b).startswith(EXCLUDED_BUS_PREFIXES)} + # Intersected with buses that actually ran: the dictionary lists + # plenty of vehicles with no valid trips in the period, and counting + # those as "excluded" would overstate how much is being dropped. + return (by_company | by_prefix) & real_buses + + +def is_excluded(bus_id: str, excluded: set[str]) -> bool: + """Whether one bus is excluded, prefix rule included. + + Args: + bus_id: The bus to test. + excluded: `excluded_bus_ids` output. + + Returns: + `True` when the bus should be treated as structurally + unmatchable. + + """ + return bus_id in excluded or str(bus_id).startswith(EXCLUDED_BUS_PREFIXES) diff --git a/ml/bus_matching_model/app/features.py b/ml/bus_matching_model/app/features.py new file mode 100644 index 0000000..e6aab69 --- /dev/null +++ b/ml/bus_matching_model/app/features.py @@ -0,0 +1,576 @@ +"""Tier 1 feature computation for the Bus Matching model. + +Computes, per `(bus_id, device_id, date, trip)`, linear-referencing and +kinematic features from one date's AVL positions projected onto the +`gtfs_cache` shape arrays, then aggregates to one row per +`(bus_id, device_id, date)` -- the model's actual prediction unit. +Everything here is pure numpy/pandas against one bulk per-date pull; +there is no per-pair database query. + +Tier 2's cheap, high-value half is implemented here for *all* +candidates rather than a top-N subset: fare timing (the plan's named +discriminator for two buses on the same route minutes apart) and +day-level continuity (what makes a day unambiguous when individual +trips aren't). Both are per-pair-day rather than per-trip-per-segment, +so they add little to the run. + +Frechet/Hausdorff shape distance is deliberately **not** implemented: +it's a per-trip dynamic program, and across the month's ~3.3M +trip-candidate pairs it is the one Tier 2 item that would genuinely +push a full rebuild into many hours. `direction_correlation` already +captures the order-sensitivity Frechet was specified for. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import numpy as np +import pandas as pd +from gtfs_cache import min_distance_to_each, project_points_full + +if TYPE_CHECKING: + import datetime + from collections.abc import Mapping + + from gtfs_cache import RouteShape + +OFFSET_THRESHOLDS_M = (30.0, 50.0, 100.0) +CONTRADICTION_THRESHOLD_M = 500.0 +STOP_COINCIDENCE_M = 60.0 +STATIONARY_SPEED_KMH = 3.0 +CHAINAGE_BIN_M = 200.0 +MAX_TRIPS_PER_BUS_DATE = 8 +GOOD_TRIP_BUFFER_50_MIN = 0.5 +GOOD_TRIP_DIRECTION_MARGIN_MIN = 0.3 + +MIN_POINTS_FOR_CORRELATION = 3 +MIN_POINTS_FOR_MONOTONICITY = 2 +MIN_POINTS_FOR_DISTANCE = 2 +VISITED_BIN_OFFSET_M = 100.0 +N_GTFS_DIRECTIONS = 2 + +HEADING_TOLERANCE_DEG = 45.0 + +# Arbitrary, on request: AFC's `route_direction` is binary (0/1) and +# GTFS's is "I"/"V", with no documented correspondence between them. +# Rather than guess (or force) a mapping, pick one and let the model +# learn whichever sign is real -- a backwards mapping just yields a +# negative coefficient, which is equally informative. +GTFS_DIRECTION_AS_BINARY = {"I": 0, "V": 1} +BAD_TRIP_CONTRADICTION_MIN = 0.5 + +DAY_FEATURE_NAMES = [ + "n_trips_sampled", + "n_trips_with_data", + "n_trips_no_avl", + "n_trips_no_gtfs", + "frac_good_trips", + "n_clearly_bad_trips", + # Tier 2 / new signals -- see compute_trip_direction_features and + # _day_continuity_features. Everything from index 6 on is treated as + # NaN-able by the no-data early return, so new names belong here. + "median_heading_consistency", + "median_direction_agreement", + "median_fare_stationary_fraction", + "median_fare_near_stop_m", + "frac_device_points_in_windows", + "frac_device_moving_points_in_windows", + "first_activity_gap_seconds", + "last_activity_gap_seconds", + "median_buffer_coverage_50", + "median_shape_coverage", + "median_direction_margin", + "median_distance_ratio", + "median_mean_speed", + "median_stop_coincidence_fraction", + "median_route_id_agreement", + "median_offset_m", + "p90_offset_m", + "worst_contradiction_fraction", + "max_excursion_m", + "median_start_dist_route_endpoint_m", + "median_end_dist_route_endpoint_m", + "median_start_dist_stop_m", + "median_end_dist_stop_m", + "avl_points_per_minute", + "longest_gap_seconds", +] + + +@dataclass +class TripPositions: + """One device's AVL points for one day, ready for window slicing. + + Attributes: + epoch: `(n,)` seconds since epoch, sorted ascending. + xy: `(n, 2)` metric coordinates (SRID 31984). + speed_kmh: `(n,)` reported speed. + route_id: `(n,)` AVL-reported route id, as text. + heading_deg: `(n,)` AVL-reported compass heading in degrees + (0 = North, 90 = East), directly comparable to + `RouteShape.seg_bearing_deg`. + + """ + + epoch: np.ndarray + xy: np.ndarray + speed_kmh: np.ndarray + route_id: np.ndarray + heading_deg: np.ndarray + + def window(self, start_epoch: float, end_epoch: float) -> TripPositions: + """Slice to the points inside `[start_epoch, end_epoch]`.""" + lo = np.searchsorted(self.epoch, start_epoch, side="left") + hi = np.searchsorted(self.epoch, end_epoch, side="right") + return TripPositions( + epoch=self.epoch[lo:hi], + xy=self.xy[lo:hi], + speed_kmh=self.speed_kmh[lo:hi], + route_id=self.route_id[lo:hi], + heading_deg=self.heading_deg[lo:hi], + ) + + +def _nearest_dist(point: np.ndarray, candidates: np.ndarray) -> float | None: + if candidates.shape[0] == 0: + return None + return float(min_distance_to_each(point[None, :], candidates)[0]) + + +def _rank(a: np.ndarray) -> np.ndarray: + """Fast rank via a double argsort. + + Doesn't average-rank ties (unlike `scipy.stats.rankdata`) -- fine + here since `timestamp`/`chainage` are continuous float64s where + exact ties are effectively impossible, and this is the hot path. + """ + order = a.argsort() + ranks = np.empty(len(a), dtype=np.float64) + ranks[order] = np.arange(len(a)) + return ranks + + +def _fast_spearman(x: np.ndarray, y: np.ndarray) -> float: + """Spearman correlation without scipy's per-call validation overhead. + + Called once per candidate pair per trip per direction (hundreds of + thousands of times for a full month), where `scipy.stats.spearmanr`'s + generality overhead dominates its actual (tiny-array) work. + """ + rx = _rank(x) - (len(x) - 1) / 2.0 + ry = _rank(y) - (len(y) - 1) / 2.0 + denom = np.sqrt((rx * rx).sum() * (ry * ry).sum()) + if denom == 0.0: + return 0.0 + return float((rx * ry).sum() / denom) + + +def compute_trip_direction_features( + points: TripPositions, + shape: RouteShape, + trip_route_id: str, + afc_direction: int | None = None, + fare_epochs: np.ndarray | None = None, +) -> dict[str, float]: + """Compute one trip's features against one direction's shape. + + Args: + points: The device's AVL points inside this trip's time window. + shape: The candidate direction's `RouteShape` from the cache. + trip_route_id: The trip's own AFC `route_id`, for the + route-id-agreement feature. + afc_direction: The trip's own AFC `route_direction` (0/1), an + *independent* statement of which way the trip ran. Powers + `direction_agreement` -- see that feature's note below. + fare_epochs: Epoch seconds of this trip's fare taps, for the + fare-timing features (plan Section 3.3's discriminator for + two buses on the same route minutes apart). `None`/empty + leaves those features `NaN` rather than 0, so "no fares" is + never scored as "fares in the wrong place". + + Returns: + A flat dict of per-trip-direction metrics. Callers pick the + winning direction by `direction_correlation` before aggregating. + + """ + n = points.xy.shape[0] + chainage, offset, best_seg = project_points_full(shape, points.xy) + + corr = 0.0 + has_spread = np.ptp(points.epoch) > 0 and np.ptp(chainage) > 0 + if n >= MIN_POINTS_FOR_CORRELATION and has_spread: + corr = _fast_spearman(points.epoch, chainage) + + monotonic_frac = ( + float(np.mean(np.diff(chainage) >= 0)) + if n >= MIN_POINTS_FOR_MONOTONICITY + else 0.0 + ) + + buffer_coverage = { + f"buffer_coverage_{int(t)}": float(np.mean(offset <= t)) if n else 0.0 + for t in OFFSET_THRESHOLDS_M + } + + n_bins = max(int(np.ceil(shape.total_length / CHAINAGE_BIN_M)), 1) + visited_bins = ( + set((chainage[offset <= VISITED_BIN_OFFSET_M] // CHAINAGE_BIN_M).astype(int)) + if n + else set() + ) + shape_coverage = len(visited_bins) / n_bins + + point_to_point = ( + float(np.sqrt((np.diff(points.xy, axis=0) ** 2).sum(axis=1)).sum()) + if n >= MIN_POINTS_FOR_DISTANCE + else 0.0 + ) + distance_ratio = ( + point_to_point / shape.total_length if shape.total_length > 0 else 0.0 + ) + + stationary = points.speed_kmh <= STATIONARY_SPEED_KMH + if stationary.any() and shape.stop_points.shape[0] > 0: + stop_dists = min_distance_to_each(points.xy[stationary], shape.stop_points) + stop_coincidence = float(np.mean(stop_dists <= STOP_COINCIDENCE_M)) + else: + stop_coincidence = 0.0 + + route_agreement = float(np.mean(points.route_id == trip_route_id)) if n else 0.0 + + start_route_dist = ( + _nearest_dist(points.xy[0], shape.start_point[None, :]) if n else None + ) + end_route_dist = ( + _nearest_dist(points.xy[-1], shape.end_point[None, :]) if n else None + ) + start_stop_dist = ( + _nearest_dist(points.xy[0], shape.first_stop_points) if n else None + ) + end_stop_dist = _nearest_dist(points.xy[-1], shape.last_stop_points) if n else None + + # Heading consistency: does the device's own reported compass heading + # agree with the bearing of the shape segment it matched? An + # independent cross-check on direction that doesn't rely on the + # chainage-vs-time correlation, and one that stays meaningful on + # trips too short or too sparse for a stable correlation. + if n: + heading_delta = np.abs(points.heading_deg - shape.seg_bearing_deg[best_seg]) + heading_delta = np.minimum(heading_delta, 360.0 - heading_delta) + heading_consistency = float(np.mean(heading_delta <= HEADING_TOLERANCE_DEG)) + else: + heading_consistency = np.nan + + # Direction agreement: AFC's own binary `route_direction` against this + # shape's GTFS direction, under an ARBITRARY fixed mapping + # (`GTFS_DIRECTION_AS_BINARY`). On request, deliberately not a + # researched/forced correspondence -- if the mapping is backwards, + # the model simply learns a negative coefficient and the feature is + # just as useful. That only works because this feeds a *trained* + # model rather than a hand-set weight. + if afc_direction is None: + direction_agreement = np.nan + else: + shape_binary = GTFS_DIRECTION_AS_BINARY.get(shape.direction) + direction_agreement = ( + np.nan if shape_binary is None else float(afc_direction == shape_binary) + ) + + # Fare timing (plan Section 3.3): fares are collected while stopped, + # so in a true pairing the bus's fare taps land where this device was + # stationary and near a stop on this route. This is the plan's named + # discriminator for the hardest case -- two buses on the same route + # minutes apart, where every adherence feature looks identical. + fare_stationary_fraction = np.nan + fare_near_stop_m = np.nan + if fare_epochs is not None and fare_epochs.size and n: + idx = np.clip(np.searchsorted(points.epoch, fare_epochs), 0, n - 1) + fare_stationary_fraction = float( + np.mean(points.speed_kmh[idx] <= STATIONARY_SPEED_KMH) + ) + if shape.stop_points.shape[0]: + fare_stop_dists = min_distance_to_each(points.xy[idx], shape.stop_points) + fare_near_stop_m = float(np.median(fare_stop_dists)) + + return { + "n_points": n, + "direction_correlation": corr, + "monotonicity_fraction": monotonic_frac, + "heading_consistency": heading_consistency, + "direction_agreement": direction_agreement, + "fare_stationary_fraction": fare_stationary_fraction, + "fare_near_stop_m": fare_near_stop_m, + **buffer_coverage, + "shape_coverage": shape_coverage, + "median_offset_m": float(np.median(offset)) if n else np.nan, + "p90_offset_m": float(np.percentile(offset, 90)) if n else np.nan, + "distance_ratio": distance_ratio, + "mean_speed_kmh": float(points.speed_kmh.mean()) if n else np.nan, + "max_speed_kmh": float(points.speed_kmh.max()) if n else np.nan, + "stationary_fraction": float(stationary.mean()) if n else 0.0, + "stop_coincidence_fraction": stop_coincidence, + "contradiction_fraction": ( + float(np.mean(offset > CONTRADICTION_THRESHOLD_M)) if n else 1.0 + ), + "max_excursion_m": float(offset.max()) if n else np.nan, + "route_id_agreement": route_agreement, + "start_dist_route_endpoint_m": start_route_dist, + "end_dist_route_endpoint_m": end_route_dist, + "start_dist_stop_m": start_stop_dist, + "end_dist_stop_m": end_stop_dist, + } + + +def select_sample_trips(bus_trips: pd.DataFrame) -> pd.DataFrame: + """Pick up to `MAX_TRIPS_PER_BUS_DATE` trips spread across the day. + + Args: + bus_trips: One bus's valid trips for one date, any order. + + Returns: + A time-sorted subset, evenly spaced by index when there are more + than `MAX_TRIPS_PER_BUS_DATE` trips, so the sample spans the + whole operating day rather than clustering at its start. + + """ + ordered = bus_trips.sort_values("trip_start_timestamp").reset_index(drop=True) + if len(ordered) <= MAX_TRIPS_PER_BUS_DATE: + return ordered + idx = np.linspace(0, len(ordered) - 1, MAX_TRIPS_PER_BUS_DATE).round().astype(int) + return ordered.iloc[np.unique(idx)].reset_index(drop=True) + + +def _matched_shapes( + feed_version_date: datetime.date, + shape_id_i: str | None, + shape_id_v: str | None, + shape_cache: dict[tuple, RouteShape], +) -> list[RouteShape]: + """GTFS shapes a trip's I/V directions resolve to in `shape_cache`.""" + candidates = [] + for shape_id in (shape_id_i, shape_id_v): + if shape_id is None or pd.isna(shape_id): + continue + key = (feed_version_date, shape_id) + if key in shape_cache: + candidates.append(shape_cache[key]) + return candidates + + +_DAY_CONTINUITY_NAMES = ( + "frac_device_points_in_windows", + "frac_device_moving_points_in_windows", + "first_activity_gap_seconds", + "last_activity_gap_seconds", +) + + +def _day_continuity_features( + all_trips: pd.DataFrame, device_positions: TripPositions | None +) -> dict[str, float]: + """Day-level "does this device's whole day look like this bus's day" features. + + Plan Section 3.3's day-level continuity block. These are the + features that make a *day* unambiguous even when individual trips + aren't: a device that genuinely runs this bus should spend its + moving time inside this bus's trip windows, and start and stop + around when the bus does. Computed once per pair-day (not per trip), + so they cost almost nothing on top of the per-trip loop. + + Args: + all_trips: Every one of this bus's trips for the date -- *not* + just the sampled ones, since "what fraction of the device's + day does this bus explain" is only meaningful against the + bus's full schedule. + device_positions: The device's full day of AVL points, or `None`. + + Returns: + A dict with every name in `_DAY_CONTINUITY_NAMES`, all `NaN` + when there's no AVL to measure against (never 0 -- missing data + is not negative evidence). + + """ + if device_positions is None or device_positions.epoch.size == 0 or all_trips.empty: + return dict.fromkeys(_DAY_CONTINUITY_NAMES, np.nan) + + epoch = device_positions.epoch + starts = all_trips["trip_start_timestamp"].to_numpy(dtype=np.float64) + ends = all_trips["trip_end_timestamp"].to_numpy(dtype=np.float64) + order = np.argsort(starts) + starts, ends = starts[order], ends[order] + + # A point is "in a window" if the nearest window starting at or + # before it hasn't ended yet -- one vectorized searchsorted rather + # than an interval loop. + idx = np.clip(np.searchsorted(starts, epoch, side="right") - 1, 0, len(starts) - 1) + in_window = (epoch >= starts[idx]) & (epoch <= ends[idx]) + + moving = device_positions.speed_kmh > STATIONARY_SPEED_KMH + frac_moving_in_windows = ( + float(np.mean(in_window[moving])) if moving.any() else np.nan + ) + moving_epochs = epoch[moving] + + return { + "frac_device_points_in_windows": float(np.mean(in_window)), + "frac_device_moving_points_in_windows": frac_moving_in_windows, + "first_activity_gap_seconds": ( + abs(float(moving_epochs[0] - starts[0])) if moving_epochs.size else np.nan + ), + "last_activity_gap_seconds": ( + abs(float(moving_epochs[-1] - ends[-1])) if moving_epochs.size else np.nan + ), + } + + +def compute_pair_day_features( + sampled_trips: pd.DataFrame, + device_positions: TripPositions | None, + shape_cache: dict[tuple, RouteShape], + fares_by_trip: Mapping[int, np.ndarray] | None = None, + all_trips: pd.DataFrame | None = None, +) -> dict[str, float]: + """Aggregate one candidate `(bus_id, device_id, date)` pair to one row. + + Args: + sampled_trips: This bus's sampled trips for the date (from + `select_sample_trips`), columns include + `trip_start_timestamp`/`trip_end_timestamp` (epoch seconds), + `route_id`, `route_direction`, `trip_id`, + `gtfs_feed_version_date`, `gtfs_shape_id_i`, + `gtfs_shape_id_v`. + all_trips: This bus's *full* set of trips for the date, for the + day-continuity features only -- "what fraction of the + device's day does this bus explain" is meaningless against + a sample (8 of ~17 trips would undercount by half). + Defaults to `sampled_trips` when not supplied. + device_positions: The candidate device's full day of AVL points, + or `None` if the device has zero pings that day (dead-AVL + day -- every trip is skipped, never scored negative). + shape_cache: The `gtfs_cache.build_shape_cache` result. + fares_by_trip: `trip_id -> epoch seconds of that trip's fare + taps`, for the fare-timing features. `None` leaves them + `NaN`. + + Returns: + A flat dict with every name in `DAY_FEATURE_NAMES`. + + """ + per_trip: list[dict[str, float]] = [] + n_no_avl = 0 + n_no_gtfs = 0 + + for trip in sampled_trips.itertuples(index=False): + candidates = _matched_shapes( + trip.gtfs_feed_version_date, + trip.gtfs_shape_id_i, + trip.gtfs_shape_id_v, + shape_cache, + ) + if not candidates: + n_no_gtfs += 1 + continue + if device_positions is None: + n_no_avl += 1 + continue + window = device_positions.window( + trip.trip_start_timestamp, trip.trip_end_timestamp + ) + if window.xy.shape[0] == 0: + n_no_avl += 1 + continue + + afc_direction = getattr(trip, "route_direction", None) + fare_epochs = ( + fares_by_trip.get(trip.trip_id) if fares_by_trip is not None else None + ) + direction_results = [ + compute_trip_direction_features( + window, shape, trip.route_id, afc_direction, fare_epochs + ) + for shape in candidates + ] + best = max(direction_results, key=lambda r: r["direction_correlation"]) + if len(direction_results) == N_GTFS_DIRECTIONS: + best["direction_margin"] = abs( + direction_results[0]["direction_correlation"] + - direction_results[1]["direction_correlation"] + ) + else: + best["direction_margin"] = abs(best["direction_correlation"]) + gaps = np.diff(window.epoch) + best["longest_gap_seconds"] = float(gaps.max()) if gaps.size else 0.0 + duration_min = (trip.trip_end_timestamp - trip.trip_start_timestamp) / 60.0 + best["points_per_minute"] = ( + window.xy.shape[0] / duration_min if duration_min > 0 else 0.0 + ) + per_trip.append(best) + + n_sampled = len(sampled_trips) + n_with_data = len(per_trip) + + if n_with_data == 0: + return { + "n_trips_sampled": n_sampled, + "n_trips_with_data": 0, + "n_trips_no_avl": n_no_avl, + "n_trips_no_gtfs": n_no_gtfs, + "frac_good_trips": np.nan, + "n_clearly_bad_trips": 0, + **dict.fromkeys(DAY_FEATURE_NAMES[6:], np.nan), + } + + def col(name: str) -> np.ndarray: + return np.array([row[name] for row in per_trip], dtype=np.float64) + + buffer_coverage_50 = col("buffer_coverage_50") + direction_margin = col("direction_margin") + contradiction_fraction = col("contradiction_fraction") + good = (buffer_coverage_50 >= GOOD_TRIP_BUFFER_50_MIN) & ( + direction_margin >= GOOD_TRIP_DIRECTION_MARGIN_MIN + ) + bad = contradiction_fraction >= BAD_TRIP_CONTRADICTION_MIN + day_level = _day_continuity_features( + sampled_trips if all_trips is None else all_trips, device_positions + ) + + return { + "n_trips_sampled": n_sampled, + "n_trips_with_data": n_with_data, + "n_trips_no_avl": n_no_avl, + "n_trips_no_gtfs": n_no_gtfs, + "frac_good_trips": float(good.mean()), + "n_clearly_bad_trips": int(bad.sum()), + "median_heading_consistency": float(np.nanmedian(col("heading_consistency"))), + "median_direction_agreement": float(np.nanmedian(col("direction_agreement"))), + "median_fare_stationary_fraction": float( + np.nanmedian(col("fare_stationary_fraction")) + ), + "median_fare_near_stop_m": float(np.nanmedian(col("fare_near_stop_m"))), + **day_level, + "median_buffer_coverage_50": float(np.median(buffer_coverage_50)), + "median_shape_coverage": float(np.median(col("shape_coverage"))), + "median_direction_margin": float(np.median(direction_margin)), + "median_distance_ratio": float(np.median(col("distance_ratio"))), + "median_mean_speed": float(np.nanmedian(col("mean_speed_kmh"))), + "median_stop_coincidence_fraction": float( + np.median(col("stop_coincidence_fraction")) + ), + "median_route_id_agreement": float(np.median(col("route_id_agreement"))), + "median_offset_m": float(np.nanmedian(col("median_offset_m"))), + "p90_offset_m": float(np.nanmedian(col("p90_offset_m"))), + "worst_contradiction_fraction": float(contradiction_fraction.max()), + "max_excursion_m": float(np.nanmax(col("max_excursion_m"))), + "median_start_dist_route_endpoint_m": float( + np.nanmedian(col("start_dist_route_endpoint_m")) + ), + "median_end_dist_route_endpoint_m": float( + np.nanmedian(col("end_dist_route_endpoint_m")) + ), + "median_start_dist_stop_m": float(np.nanmedian(col("start_dist_stop_m"))), + "median_end_dist_stop_m": float(np.nanmedian(col("end_dist_stop_m"))), + "avl_points_per_minute": float(np.median(col("points_per_minute"))), + "longest_gap_seconds": float(col("longest_gap_seconds").max()), + } diff --git a/ml/bus_matching_model/app/final_output.py b/ml/bus_matching_model/app/final_output.py new file mode 100644 index 0000000..fce5119 --- /dev/null +++ b/ml/bus_matching_model/app/final_output.py @@ -0,0 +1,357 @@ +"""Section 13's final output: one settled answer per bus for the month. + +Every bus that ran gets exactly one row (a device-swap bus gets one row +per interval instead) with an explicit `method` saying how it was +settled -- following the same convention `ml.bus_matching_global_assignment` +already uses (`method IN ('global_assignment', 'no_candidates', +'no_avl_data')`), just at the month grain instead of the bus-date grain. +Nothing is silently dropped: a bus that cannot be resolved still gets a +row, with a method that says why. + +**Split detection.** Not every `verdict='unsure'` label means the same +thing -- some are a real device swap mid-month (two devices, each +confidently correct on its own disjoint stretch of dates), some are the +user genuinely not knowing. `detect_split` tells them apart from the +day-score time series itself: a swap shows up as one confident device, +then a single clean changeover, then a different confident device, +never as several changeovers or two devices confident on the same day +without an obvious handover. Confirmed live on two buses caught by +hand (12225, 35403): both detect as clean single-changeover splits at +exactly the dates the day-score table showed. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pandas as pd +from pair_model import MIN_EVIDENCE_SCORE + +if TYPE_CHECKING: + from collections.abc import Callable + from datetime import date + + import psycopg + +# A day counts as "confident" for split detection at the same bar the +# rest of the pipeline calls a match trustworthy -- see +# pair_model.MIN_EVIDENCE_SCORE for the opposite (no-signal) bar. +SPLIT_CONFIDENCE = 0.9 + +# Below this many confident days, a device's "block" is too thin to +# trust as a real occupancy stretch rather than a one-off lucky score. +MIN_BLOCK_DAYS = 2 + +# More than this many distinct confident devices for one bus is not a +# swap, it is genuine multi-way ambiguity -- flagged for human review +# rather than guessed at. +MAX_SPLIT_DEVICES = 2 + + +def detect_split(day_scores: pd.DataFrame) -> dict[str, Any]: + """Classify one bus's day-score time series across all its candidates. + + Args: + day_scores: Columns `date`, `device_id`, `day_score` -- every + candidate device for one bus, every date the day-level + feature build produced a row (present even on days with no + AVL data, scored near zero). + + Returns: + `{"kind": "single", "device_id", "n_confident_days"}` when only + one device is ever confident (the "unsure" verdict undersold a + bus that is not actually ambiguous); + `{"kind": "split", "intervals": [...]}` when exactly two devices + each hold a clean, contiguous confident block with one + changeover between them -- each interval has `device_id`, + `start_date`, `end_date`, `n_confident_days`, and `note` for the + overlap/gap case; + `{"kind": "unclear", "reason": str}` otherwise -- more than two + confident devices, more than one changeover, or too few + confident days to say anything -- meant for human review, not + an auto-resolution. + + """ + confident = day_scores[day_scores["day_score"] >= SPLIT_CONFIDENCE].copy() + if confident.empty: + return {"kind": "unclear", "reason": "no confident days for any candidate"} + + # One winner per date: the rare overlap day (both devices confident, + # the true handover point) is resolved to whichever scored higher. + winners = ( + confident.sort_values("day_score", ascending=False) + .drop_duplicates(subset="date", keep="first") + .sort_values("date") + ) + overlap_dates = confident["date"][confident["date"].duplicated(keep=False)].unique() + + devices = winners["device_id"].unique() + if len(devices) == 1: + return { + "kind": "single", + "device_id": devices[0], + "n_confident_days": len(winners), + } + if len(devices) > MAX_SPLIT_DEVICES: + return { + "kind": "unclear", + "reason": f"{len(devices)} distinct devices confident on different days", + } + + # Exactly two devices: count changeovers in chronological order. + # A real swap has exactly one; anything else is genuine flip-flop + # ambiguity, not a clean handover. + sequence = winners["device_id"].to_numpy() + n_switches = int((sequence[1:] != sequence[:-1]).sum()) + if n_switches != 1: + return { + "kind": "unclear", + "reason": f"{n_switches} changeovers between {len(devices)} devices " + "(a clean swap has exactly one)", + } + + intervals = [] + for device_id, block in winners.groupby("device_id", sort=False): + if len(block) < MIN_BLOCK_DAYS: + return { + "kind": "unclear", + "reason": f"device {device_id} only confident on {len(block)} " + f"day(s), too thin to trust as a real block", + } + note = ( + "overlap day resolved to higher score" + if any(d in block["date"].to_numpy() for d in overlap_dates) + else "" + ) + intervals.append( + { + "device_id": device_id, + "start_date": block["date"].min(), + "end_date": block["date"].max(), + "n_confident_days": len(block), + "note": note, + } + ) + intervals.sort(key=lambda i: i["start_date"]) + return {"kind": "split", "intervals": intervals} + + +def build_final_pairs( + conn: psycopg.Connection, + day: pd.DataFrame, + ranked: pd.DataFrame, + labels: pd.DataFrame, + excluded: set[str], + threshold: float, +) -> pd.DataFrame: + """Build one settled row per bus (more for a detected split). + + Priority per bus, each bus landing in exactly one bucket: + + 1. `excluded_no_avl` -- structurally unmatchable (`exclusions.py`). + 2. `no_candidates` -- blocking found nothing at all. + 3. `hand_confirmed` -- a `verdict='correct'` label exists. + 4. An `unsure` label exists -> `detect_split` decides between + `split_detected`, `resolved_after_review` (turned out to have + only one real confident device after all), or `needs_review`. + 5. `no_evidence` -- has candidates, but the best score never clears + `pair_model.MIN_EVIDENCE_SCORE`. + 6. `pair_model` -- best candidate clears `threshold`. + 7. `below_threshold` -- has real evidence, just not enough of it yet. + + Args: + conn: An open connection. + day: Concatenated day features with a `day_score` column + (`training.predict_positive_proba` output already attached). + ranked: `pair_model.rank_pairs` output. + labels: `pair_model.fetch_pair_labels` output. + excluded: `exclusions.excluded_bus_ids` output. + threshold: The ship threshold. + + Returns: + One row per settled bus (or interval), columns `bus_id`, + `device_id`, `start_date`, `end_date`, `confidence`, `method`, + `n_days_with_data`, `notes`. + + """ + all_buses = { + r[0] + for r in conn.execute( + "SELECT DISTINCT bus_id FROM ml.trip_validity_final WHERE is_valid;" + ).fetchall() + } + month_start = conn.execute( + "SELECT min(trip_date) FROM ml.trip_validity_final WHERE is_valid;" + ).fetchone()[0] + month_end = conn.execute( + "SELECT max(trip_date) FROM ml.trip_validity_final WHERE is_valid;" + ).fetchone()[0] + + buses_with_candidates = set(ranked["bus_id"].unique()) + tops = ranked[ranked["pair_rank"] == 1].set_index("bus_id") + correct_labels = ( + labels[labels["verdict"] == "correct"] + .drop_duplicates(subset="bus_id", keep="last") + .set_index("bus_id") + ) + unsure_buses = set(labels[labels["verdict"] == "unsure"]["bus_id"]) + day_by_bus = dict(list(day.groupby("bus_id"))) + + rows: list[dict[str, Any]] = [] + for bus_id in sorted(all_buses): + rows.extend( + _classify_bus( + bus_id, + excluded=excluded, + buses_with_candidates=buses_with_candidates, + tops=tops, + correct_labels=correct_labels, + unsure_buses=unsure_buses, + day_by_bus=day_by_bus, + threshold=threshold, + month_start=month_start, + month_end=month_end, + ) + ) + + return pd.DataFrame(rows) + + +def _base_row( + bus_id: str, month_start: date, month_end: date, **kw: object +) -> dict[str, Any]: + return { + "bus_id": bus_id, + "device_id": None, + "start_date": month_start, + "end_date": month_end, + "confidence": None, + "n_days_with_data": None, + "notes": "", + **kw, + } + + +def _rows_from_split_result( + result: dict[str, Any], base: Callable[..., dict[str, Any]] +) -> list[dict[str, Any]]: + """Turn one `detect_split` verdict into final-table rows for that bus.""" + if result["kind"] == "split": + return [ + base( + device_id=interval["device_id"], + start_date=interval["start_date"], + end_date=interval["end_date"], + confidence=SPLIT_CONFIDENCE, + n_days_with_data=interval["n_confident_days"], + method="split_detected", + notes=interval["note"], + ) + for interval in result["intervals"] + ] + if result["kind"] == "single": + return [ + base( + device_id=result["device_id"], + confidence=SPLIT_CONFIDENCE, + n_days_with_data=result["n_confident_days"], + method="resolved_after_review", + notes="unsure label, but only one device was ever " + "confident -- not actually ambiguous", + ) + ] + return [base(method="needs_review", notes=result["reason"])] + + +def _classify_bus( + bus_id: str, + *, + excluded: set[str], + buses_with_candidates: set[str], + tops: pd.DataFrame, + correct_labels: pd.DataFrame, + unsure_buses: set[str], + day_by_bus: dict[str, pd.DataFrame], + threshold: float, + month_start: date, + month_end: date, +) -> list[dict[str, Any]]: + """Settle one bus into its bucket. + + See `build_final_pairs` for the priority order this follows. + """ + + def base(**kw: object) -> dict[str, Any]: + return _base_row(bus_id, month_start, month_end, **kw) + + if bus_id in excluded: + return [base(method="excluded_no_avl")] + if bus_id not in buses_with_candidates: + return [base(method="no_candidates")] + + if bus_id in correct_labels.index: + lab = correct_labels.loc[bus_id] + top = tops.loc[bus_id] if bus_id in tops.index else None + return [ + base( + device_id=lab["device_id"], + confidence=float(lab["model_confidence"] or 1.0), + n_days_with_data=( + int(top["n_days_with_data"]) if top is not None else None + ), + method="hand_confirmed", + ) + ] + + if bus_id in unsure_buses: + cols = ["date", "device_id", "day_score"] + bus_scores = day_by_bus.get(bus_id) + bus_day_scores = ( + bus_scores[cols] if bus_scores is not None else pd.DataFrame(columns=cols) + ) + return _rows_from_split_result(detect_split(bus_day_scores), base) + + top = tops.loc[bus_id] + score = float(top["pair_score"]) + if score < MIN_EVIDENCE_SCORE: + return [base(method="no_evidence")] + method = "pair_model" if score >= threshold else "below_threshold" + return [ + base( + device_id=top["device_id"], + confidence=score, + n_days_with_data=int(top["n_days_with_data"]), + method=method, + ) + ] + + +def save_final_pairs(conn: psycopg.Connection, table: pd.DataFrame) -> None: + """Replace the persisted final table wholesale. + + Args: + conn: An open connection. + table: `build_final_pairs` output. + + """ + with conn.transaction(): + conn.execute("TRUNCATE ml.bus_matching_final_pairs;") + with conn.cursor().copy( + "COPY ml.bus_matching_final_pairs " + "(bus_id, device_id, start_date, end_date, confidence, " + "n_days_with_data, method, notes) FROM STDIN" + ) as copy: + for row in table.itertuples(index=False): + n_days = row.n_days_with_data + copy.write_row( + ( + row.bus_id, + row.device_id, + row.start_date, + row.end_date, + row.confidence, + None if n_days is None or pd.isna(n_days) else int(n_days), + row.method, + row.notes, + ) + ) diff --git a/ml/bus_matching_model/app/gtfs_cache.py b/ml/bus_matching_model/app/gtfs_cache.py new file mode 100644 index 0000000..faffdaf --- /dev/null +++ b/ml/bus_matching_model/app/gtfs_cache.py @@ -0,0 +1,354 @@ +"""In-memory GTFS shape cache for the Bus Matching model. + +Builds, once per process, a dict keyed by `(feed_version_date, shape_id)` +holding densified numpy arrays for fast linear referencing. Every +point-to-route projection downstream (chainage, offset, endpoint/stop +distances) is pure numpy against this cache, never a per-trip PostGIS +round trip -- there are only a few hundred `(feed_version_date, +shape_id)` combinations, so the whole cache fits comfortably in memory +and rebuilding it takes seconds. + +All coordinates are in the same metric CRS as +`ml.trip_validity_route_shapes.shape_geom_metric` (SRID 31984), so +distances are already in meters with no reprojection needed downstream. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import numpy as np + +if TYPE_CHECKING: + import datetime + + import psycopg + +ShapeKey = tuple["datetime.date", str] + + +@dataclass(frozen=True) +class RouteShape: + """Precomputed linear-referencing arrays for one GTFS shape. + + Attributes: + feed_version_date: The GTFS feed snapshot this shape belongs to. + shape_id: GTFS shape id, e.g. `"shape0051-I"`. + route_short_name: The route's short name (matches + `ml.trip_validity_final.route_id` once zero-padded). + direction: `"I"` or `"V"`. + points: `(n, 2)` ordered shape vertices, metric xy. + seg_start: `(n-1, 2)` segment start points. + seg_end: `(n-1, 2)` segment end points. + seg_vec: `(n-1, 2)` segment vectors (`seg_end - seg_start`). + seg_len: `(n-1,)` segment lengths in meters. + seg_cum_start: `(n-1,)` cumulative length along the route at each + segment's start, i.e. the chainage of `seg_start[i]`. + seg_bearing_deg: `(n-1,)` each segment's compass bearing in + degrees (0 = North, 90 = East), directly comparable to the + AVL feed's own `heading_degrees` column. + total_length: Total route length in meters. + start_point: `(2,)` the shape's first vertex. + end_point: `(2,)` the shape's last vertex. + bbox: `(minx, miny, maxx, maxy)`. + stop_points: `(m, 2)` every distinct stop along this shape, union + of all `route_stops` variants -- for the stop-coincidence + feature. Empty (`shape (0, 2)`) if no stops matched. + stop_ids: `stop_id` for each row of `stop_points`, same order. + first_stop_points: `(k, 2)` union, across all variants, of the + stop at that variant's minimum `stop_sequence`. Variants can + genuinely start at different physical stops. + last_stop_points: `(k, 2)` same, for each variant's maximum + `stop_sequence`. + + """ + + feed_version_date: datetime.date + shape_id: str + route_short_name: str + direction: str + points: np.ndarray + seg_start: np.ndarray + seg_end: np.ndarray + seg_vec: np.ndarray + seg_len: np.ndarray + seg_cum_start: np.ndarray + seg_bearing_deg: np.ndarray + total_length: float + start_point: np.ndarray + end_point: np.ndarray + bbox: tuple[float, float, float, float] + stop_points: np.ndarray + stop_ids: list[str] + first_stop_points: np.ndarray + last_stop_points: np.ndarray + + +_SHAPES_QUERY = """ + SELECT + s.feed_version_date, + s.shape_id, + s.route_short_name, + s.direction, + (dp).path[1] AS pt_order, + ST_X((dp).geom) AS x, + ST_Y((dp).geom) AS y + FROM ml.trip_validity_route_shapes s, + LATERAL ST_DumpPoints(s.shape_geom_metric) AS dp + ORDER BY s.feed_version_date, s.shape_id, pt_order; +""" + +_STOPS_QUERY = """ + WITH shape_stops AS ( + SELECT + feed_version_date, + shape_id, + variant_id, + stop_sequence, + stop_id, + ST_X(ST_Transform(geom, 31984)) AS x, + ST_Y(ST_Transform(geom, 31984)) AS y + FROM ml.trip_validity_route_stops + ), + ranked AS ( + SELECT + *, + row_number() OVER ( + PARTITION BY feed_version_date, shape_id, variant_id + ORDER BY stop_sequence + ) AS rn_first, + row_number() OVER ( + PARTITION BY feed_version_date, shape_id, variant_id + ORDER BY stop_sequence DESC + ) AS rn_last + FROM shape_stops + ) + SELECT + feed_version_date, + shape_id, + stop_id, + x, + y, + bool_or(rn_first = 1) AS is_first, + bool_or(rn_last = 1) AS is_last + FROM ranked + GROUP BY feed_version_date, shape_id, stop_id, x, y; +""" + + +def build_shape_cache(conn: psycopg.Connection) -> dict[ShapeKey, RouteShape]: + """Build the full in-memory GTFS shape cache. + + Args: + conn: An open connection. + + Returns: + Dict keyed by `(feed_version_date, shape_id)`. A trip whose + `(gtfs_feed_version_date, gtfs_shape_id_i/v)` isn't a key here has + no usable GTFS geometry for that direction and must be skipped, + not scored -- confirmed live that ~4.2% of valid trips + (30,132 / 720,080) reference a `shape_id` absent from their own + feed's `route_shapes` rows, plus 6,833 more with no GTFS feed + match at all. + + """ + shapes_by_key: dict[ShapeKey, dict[str, object]] = {} + with conn.cursor() as cur: + cur.execute(_SHAPES_QUERY) + for ( + feed_version_date, + shape_id, + route_short_name, + direction, + _pt_order, + x, + y, + ) in cur.fetchall(): + key = (feed_version_date, shape_id) + entry = shapes_by_key.setdefault( + key, + { + "route_short_name": route_short_name, + "direction": direction, + "xs": [], + "ys": [], + }, + ) + entry["xs"].append(x) + entry["ys"].append(y) + + stops_by_key: dict[ShapeKey, dict[str, list]] = {} + with conn.cursor() as cur: + cur.execute(_STOPS_QUERY) + for ( + feed_version_date, + shape_id, + stop_id, + x, + y, + is_first, + is_last, + ) in cur.fetchall(): + key = (feed_version_date, shape_id) + entry = stops_by_key.setdefault( + key, + { + "stop_ids": [], + "stop_xy": [], + "first_xy": [], + "last_xy": [], + }, + ) + entry["stop_ids"].append(stop_id) + entry["stop_xy"].append((x, y)) + if is_first: + entry["first_xy"].append((x, y)) + if is_last: + entry["last_xy"].append((x, y)) + + cache: dict[ShapeKey, RouteShape] = {} + for key, entry in shapes_by_key.items(): + points = np.column_stack( + [ + np.asarray(entry["xs"], dtype=np.float64), + np.asarray(entry["ys"], dtype=np.float64), + ] + ) + seg_start = points[:-1] + seg_end = points[1:] + seg_vec = seg_end - seg_start + seg_len = np.linalg.norm(seg_vec, axis=1) + seg_cum_start = np.concatenate([[0.0], np.cumsum(seg_len)[:-1]]) + # Compass bearing (0 = North, 90 = East) to match the AVL feed's + # own heading convention: atan2 takes easting first, northing + # second (the transpose of the usual math convention), so the + # result is already clockwise-from-north. + seg_bearing_deg = np.degrees(np.arctan2(seg_vec[:, 0], seg_vec[:, 1])) % 360.0 + total_length = float(seg_len.sum()) + + stops = stops_by_key.get( + key, {"stop_ids": [], "stop_xy": [], "first_xy": [], "last_xy": []} + ) + stop_points = ( + np.asarray(stops["stop_xy"], dtype=np.float64) + if stops["stop_xy"] + else np.empty((0, 2), dtype=np.float64) + ) + first_stop_points = ( + np.asarray(stops["first_xy"], dtype=np.float64) + if stops["first_xy"] + else np.empty((0, 2), dtype=np.float64) + ) + last_stop_points = ( + np.asarray(stops["last_xy"], dtype=np.float64) + if stops["last_xy"] + else np.empty((0, 2), dtype=np.float64) + ) + + cache[key] = RouteShape( + feed_version_date=key[0], + shape_id=key[1], + route_short_name=str(entry["route_short_name"]), + direction=str(entry["direction"]), + points=points, + seg_start=seg_start, + seg_end=seg_end, + seg_vec=seg_vec, + seg_len=seg_len, + seg_cum_start=seg_cum_start, + seg_bearing_deg=seg_bearing_deg, + total_length=total_length, + start_point=points[0], + end_point=points[-1], + bbox=( + float(points[:, 0].min()), + float(points[:, 1].min()), + float(points[:, 0].max()), + float(points[:, 1].max()), + ), + stop_points=stop_points, + stop_ids=list(stops["stop_ids"]), + first_stop_points=first_stop_points, + last_stop_points=last_stop_points, + ) + return cache + + +def project_points_full( + shape: RouteShape, xy: np.ndarray +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Project points onto a shape, also returning the matched segment index. + + Args: + shape: The route shape to project onto. + xy: `(k, 2)` points to project, same metric CRS as `shape`. + + Returns: + `(chainage, offset, best_seg)`, each `(k,)`. `best_seg` indexes + `shape.seg_*` arrays -- needed by the heading-consistency + feature, which compares each point's own compass heading to + `shape.seg_bearing_deg` at the segment it actually matched. + + """ + # (k, n-1, 2): each point against every segment's start->vector. + delta = xy[:, None, :] - shape.seg_start[None, :, :] + seg_len_sq = np.clip(shape.seg_len**2, a_min=1e-12, a_max=None) + t = np.clip( + (delta * shape.seg_vec[None, :, :]).sum(axis=2) / seg_len_sq[None, :], 0.0, 1.0 + ) + # Squared distance from each point to its projection on each segment, + # without materializing the (k, n-1, 2) "closest point" array and + # without the dispatch overhead of np.linalg.norm -- this is the hot + # path (called once per candidate pair per trip per direction), so + # sqrt is only taken once per point below, not once per point-segment + # pair. + diff = delta - t[:, :, None] * shape.seg_vec[None, :, :] + dist_sq = np.einsum("ijk,ijk->ij", diff, diff) + best_seg = dist_sq.argmin(axis=1) + rows = np.arange(xy.shape[0]) + offset = np.sqrt(dist_sq[rows, best_seg]) + chainage = ( + shape.seg_cum_start[best_seg] + t[rows, best_seg] * shape.seg_len[best_seg] + ) + return chainage, offset, best_seg + + +def project_points(shape: RouteShape, xy: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Project points onto a shape via vectorized nearest-segment search. + + For each input point, finds the closest point on any of the shape's + segments and returns that point's chainage (distance along the + route) and offset (perpendicular distance from the shape). + + Args: + shape: The route shape to project onto. + xy: `(k, 2)` points to project, same metric CRS as `shape`. + + Returns: + `(chainage, offset)`, each `(k,)`. `chainage` is meters along the + route from `shape.start_point`; `offset` is the perpendicular + distance in meters from the nearest point on the shape. + + """ + chainage, offset, _ = project_points_full(shape, xy) + return chainage, offset + + +def min_distance_to_each(points: np.ndarray, candidates: np.ndarray) -> np.ndarray: + """Vectorized min distance from each of `points` to any of `candidates`. + + Args: + points: `(k, 2)` query points. + candidates: `(m, 2)` reference points (e.g. stops). + + Returns: + `(k,)` distances in meters; `inf` for every point when `candidates` + is empty. + + """ + if candidates.shape[0] == 0 or points.shape[0] == 0: + return np.full(points.shape[0], np.inf) + diff = points[:, None, :] - candidates[None, :, :] + dist_sq = np.einsum("ijk,ijk->ij", diff, diff) + return np.sqrt(dist_sq.min(axis=1)) diff --git a/ml/bus_matching_model/app/maps.py b/ml/bus_matching_model/app/maps.py new file mode 100644 index 0000000..ff55040 --- /dev/null +++ b/ml/bus_matching_model/app/maps.py @@ -0,0 +1,167 @@ +"""Folium map builders for the Bus Matching labeler. + +One small map per candidate device -- each map shows both GTFS +directions (I in blue, V in orange, when both exist) plus that one +candidate's own AVL trail, gradient-shaded white (trip start) to black +(trip end) so direction and dwell time read at a glance, matching +`trip_validity_model/app/maps.py`'s convention. Candidates are +distinguished by their device id label and score/origin badge in the +app, not by color -- no per-candidate color coding here. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import folium + +if TYPE_CHECKING: + from collections.abc import Mapping + + import pandas as pd + +_FORTALEZA_LATLON = (-3.7319, -38.5267) + +# CartoDB Positron: a light, near-monochrome basemap. The default OSM +# tiles are busy/saturated enough that they visually compete with the +# route line and AVL trail on top of them -- confirmed as the actual +# "fighting the lines" complaint, not a rendering bug. +_BASEMAP = "CartoDB positron" + +_DIRECTION_COLORS = {"I": "#1e64c8", "V": "#e8820c"} +_ROUTE_WEIGHT_PX = 7 +_ROUTE_OPACITY = 0.9 +_STOP_COLOR = "#666666" +_STOP_RADIUS_PX = 3 +_START_COLOR = "#00aa00" +_END_COLOR = "#dc0000" +_ENDPOINT_RADIUS_PX = 9 +_TRAIL_RADIUS_PX = 6 +_TRAIL_OUTLINE_COLOR = "#000000" +_TRAIL_OUTLINE_WEIGHT_PX = 1.5 +_MIN_POINTS_TO_FIT_BOUNDS = 2 + + +def _grayscale_trail(track: pd.DataFrame) -> list[tuple[float, float, str, str]]: + """Turn a time-ordered track into `(lat, lon, hex_color, iso_ts)` points.""" + if track.empty: + return [] + timestamps = list(track["metric_timestamp"]) + t_min, t_max = min(timestamps), max(timestamps) + span = (t_max - t_min).total_seconds() or 1.0 + points = [] + for lat, lon, ts in zip( + track["latitude"], track["longitude"], timestamps, strict=True + ): + fraction = (ts - t_min).total_seconds() / span + shade = round(255 * (1 - fraction)) + points.append((lat, lon, f"#{shade:02x}{shade:02x}{shade:02x}", ts.isoformat())) + return points + + +def _add_route_and_stops( + fmap: folium.Map, + direction: str, + shape_latlon: list[tuple[float, float]], + stops: pd.DataFrame, +) -> None: + route_color = _DIRECTION_COLORS.get(direction, "#444444") + if shape_latlon: + folium.PolyLine( + locations=shape_latlon, + color=route_color, + weight=_ROUTE_WEIGHT_PX, + opacity=_ROUTE_OPACITY, + tooltip=f"direction {direction}", + ).add_to(fmap) + folium.CircleMarker( + location=shape_latlon[0], + radius=_ENDPOINT_RADIUS_PX, + color=_TRAIL_OUTLINE_COLOR, + weight=1, + fill=True, + fill_color=_START_COLOR, + fill_opacity=1, + tooltip=folium.Tooltip( + f"{direction} START", permanent=True, direction="top" + ), + ).add_to(fmap) + folium.CircleMarker( + location=shape_latlon[-1], + radius=_ENDPOINT_RADIUS_PX, + color=_TRAIL_OUTLINE_COLOR, + weight=1, + fill=True, + fill_color=_END_COLOR, + fill_opacity=1, + tooltip=folium.Tooltip(f"{direction} END", permanent=True, direction="top"), + ).add_to(fmap) + + for stop in stops.itertuples(index=False): + folium.CircleMarker( + location=(stop.stop_lat, stop.stop_lon), + radius=_STOP_RADIUS_PX, + color=_STOP_COLOR, + weight=1, + fill=True, + fill_color=_STOP_COLOR, + fill_opacity=0.7, + tooltip=f"stop {stop.stop_id} (dir {direction})", + ).add_to(fmap) + + +def build_candidate_map( + shapes: Mapping[str, tuple[list[list[float]] | None, pd.DataFrame]], + track: pd.DataFrame, +) -> folium.Map: + """Build one candidate's own small map: both directions + its AVL trail. + + Args: + shapes: `{"I": (geojson_coords, stops), "V": (...)}`, only the + directions that actually exist in GTFS for this trip's + route -- direction I drawn in blue, V in orange, so a + genuinely divergent I/V pair (not just the same corridor + reversed) is visible on one map instead of assumed away. + track: This candidate's AVL positions in the trip window -- + columns `metric_timestamp`, `latitude`, `longitude`. + + Returns: + A Folium map fit to the union of the shape and the trail. + + """ + trail = _grayscale_trail(track) + trail_latlon = [(lat, lon) for lat, lon, _, _ in trail] + all_points = list(trail_latlon) + + center = list(_FORTALEZA_LATLON) + for coords, _stops in shapes.values(): + if coords: + center = [coords[0][1], coords[0][0]] + break + if trail_latlon: + center = list(trail_latlon[0]) + fmap = folium.Map(location=center, zoom_start=13, tiles=_BASEMAP) + + for direction, (coords, stops) in shapes.items(): + shape_latlon = [(lat, lon) for lon, lat in coords or []] + all_points.extend(shape_latlon) + _add_route_and_stops(fmap, direction, shape_latlon, stops) + + for lat, lon, color, timestamp in trail: + folium.CircleMarker( + location=(lat, lon), + radius=_TRAIL_RADIUS_PX, + color=_TRAIL_OUTLINE_COLOR, + weight=_TRAIL_OUTLINE_WEIGHT_PX, + fill=True, + fill_color=color, + fill_opacity=0.9, + tooltip=timestamp, + ).add_to(fmap) + + if len(all_points) >= _MIN_POINTS_TO_FIT_BOUNDS: + lats = [p[0] for p in all_points] + lons = [p[1] for p in all_points] + fmap.fit_bounds([[min(lats), min(lons)], [max(lats), max(lons)]]) + + return fmap diff --git a/ml/bus_matching_model/app/metrics.py b/ml/bus_matching_model/app/metrics.py new file mode 100644 index 0000000..7afb581 --- /dev/null +++ b/ml/bus_matching_model/app/metrics.py @@ -0,0 +1,61 @@ +"""Evaluation metrics for the Bus Matching model: AUC, Brier, log-loss, ECE.""" + +from __future__ import annotations + +import numpy as np +from sklearn.metrics import brier_score_loss, log_loss, roc_auc_score + + +def expected_calibration_error( + y_true: np.ndarray, y_prob: np.ndarray, *, n_bins: int = 10 +) -> float: + """Compute the expected calibration error via equal-width probability bins. + + Args: + y_true: True binary labels. + y_prob: Predicted probabilities of the positive class. + n_bins: Number of equal-width bins over `[0, 1]`. + + Returns: + The sample-weighted mean absolute gap between each bin's average + predicted probability and its actual positive rate. + + """ + bin_edges = np.linspace(0.0, 1.0, n_bins + 1) + bin_indices = np.clip(np.digitize(y_prob, bin_edges[1:-1]), 0, n_bins - 1) + total = len(y_true) + ece = 0.0 + for b in range(n_bins): + mask = bin_indices == b + if not mask.any(): + continue + bin_confidence = y_prob[mask].mean() + bin_accuracy = y_true[mask].mean() + ece += (mask.sum() / total) * abs(bin_confidence - bin_accuracy) + return float(ece) + + +def evaluate(y_true: np.ndarray, y_prob: np.ndarray) -> dict[str, float]: + """Compute every reported test-set metric at once. + + Args: + y_true: True binary labels. + y_prob: Calibrated predicted probabilities of the positive class. + + Returns: + Dict with keys "auc", "brier", "log_loss", "ece". "auc" is + `NaN` if `y_true` has only one class present (undefined + otherwise). + + """ + auc = ( + float(roc_auc_score(y_true, y_prob)) + if len(set(y_true.tolist())) > 1 + else float("nan") + ) + return { + "auc": auc, + "brier": float(brier_score_loss(y_true, y_prob)), + "log_loss": float(log_loss(y_true, y_prob, labels=[False, True])), + "ece": expected_calibration_error(y_true, y_prob), + } diff --git a/ml/bus_matching_model/app/pair_features.py b/ml/bus_matching_model/app/pair_features.py new file mode 100644 index 0000000..58745e8 --- /dev/null +++ b/ml/bus_matching_model/app/pair_features.py @@ -0,0 +1,226 @@ +"""Month-level `(bus_id, device_id)` features -- the pair model's input. + +The day-level model answers "did this device drive this bus *on this +date*". That was the right unit while the pipeline was per-date, but a +device essentially never changes bus mid-month, so the question that +actually needs answering is "is this device this bus's device", full +stop. This module aggregates every day's evidence into one row per +candidate pair, which is both the real prediction unit and a much +stronger signal: a single ambiguous day is noise, but twenty days of +consistent mild evidence is close to conclusive. + +**Deliberately excludes Section 9/10 output** (`ml.bus_matching_global_assignment`, +`ml.bus_matching_intervals`). Those are downstream of the day model, +and the pair model is meant to *replace* the hand-weighted belief layer +that produced them -- feeding their output back in as features would +make the final confidence partly a function of the very heuristics it +exists to retire. Everything here is derived from raw day features, +the day model's own scores, the dictionaries, and candidate +competition. + +Every signal that used to be a hand-set constant in `belief.py` +(dictionary trust, temporal continuity, cross-bus suppression) appears +here as a plain feature instead, so the pair model learns its weight +from labels rather than inheriting a number someone guessed. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np +import pandas as pd + +if TYPE_CHECKING: + import psycopg + +DAY_SCORE_HIT_THRESHOLD = 0.5 + +# Day features worth carrying up to the pair level. Not all 31 -- the +# per-day counts (n_trips_sampled etc.) are summarized by the coverage +# block below instead, and carrying every one at four aggregations each +# would quadruple width for little signal. +_AGGREGATED_DAY_FEATURES = ( + "frac_good_trips", + "median_buffer_coverage_50", + "median_shape_coverage", + "median_direction_margin", + "median_route_id_agreement", + "median_offset_m", + "worst_contradiction_fraction", + "max_excursion_m", + "median_start_dist_stop_m", + "median_end_dist_stop_m", + "median_heading_consistency", + "median_direction_agreement", + "median_fare_stationary_fraction", + "median_fare_near_stop_m", + "frac_device_points_in_windows", + "frac_device_moving_points_in_windows", + "n_clearly_bad_trips", +) + +_DICTIONARY_SQL = """ + WITH pair_sources AS ( + SELECT bus_id, device_id, count(DISTINCT origin) AS n_dictionary_sources + FROM ml.bus_matching_candidate_pairs + GROUP BY bus_id, device_id + ), + bus_devices AS ( + SELECT bus_id, count(DISTINCT device_id) AS n_dict_devices_for_bus + FROM ml.bus_matching_candidate_pairs GROUP BY bus_id + ), + device_buses AS ( + SELECT device_id, count(DISTINCT bus_id) AS n_dict_buses_for_device + FROM ml.bus_matching_candidate_pairs GROUP BY device_id + ) + SELECT + p.bus_id, p.device_id, p.n_dictionary_sources, + coalesce(bd.n_dict_devices_for_bus, 0) AS n_dict_devices_for_bus, + coalesce(db_.n_dict_buses_for_device, 0) AS n_dict_buses_for_device + FROM pair_sources p + LEFT JOIN bus_devices bd ON bd.bus_id = p.bus_id + LEFT JOIN device_buses db_ ON db_.device_id = p.device_id; +""" + +_BUS_RUNNING_DAYS_SQL = """ + SELECT bus_id, count(DISTINCT trip_date) AS n_days_bus_ran + FROM ml.trip_validity_final WHERE is_valid GROUP BY bus_id; +""" + + +def _aggregate_day_features(day_features: pd.DataFrame) -> pd.DataFrame: + """Collapse per-day rows to one row per pair, with robust aggregations.""" + usable = [c for c in _AGGREGATED_DAY_FEATURES if c in day_features.columns] + grouped = day_features.groupby(["bus_id", "device_id"]) + + agg = grouped[usable].agg(["median", "mean", "std"]) + agg.columns = [f"{col}__{stat}" for col, stat in agg.columns] + + # Consistency matters as much as level: a pair that looks good on + # average but wildly variable day to day is weaker evidence than a + # steady one, and std is what carries that. + agg["n_days_with_features"] = grouped.size() + agg["n_days_with_data"] = grouped["n_trips_with_data"].apply( + lambda s: (s > 0).sum() + ) + return agg.reset_index() + + +def _day_model_score_aggregates( + day_features: pd.DataFrame, day_scores: pd.Series +) -> pd.DataFrame: + """Aggregate the day model's own P(match) across each pair's days. + + The single most informative input: it is the whole day-level model + (31 features, trained on real trip labels) reduced to one number per + day, then summarized over the month. + """ + scored = day_features[["bus_id", "device_id"]].copy() + scored["day_score"] = np.asarray(day_scores, dtype=np.float64) + grouped = scored.groupby(["bus_id", "device_id"])["day_score"] + out = grouped.agg( + day_score_median="median", + day_score_mean="mean", + day_score_max="max", + day_score_min="min", + day_score_std="std", + ).reset_index() + out["day_score_frac_above_half"] = ( + scored.assign(hit=scored["day_score"] >= DAY_SCORE_HIT_THRESHOLD) + .groupby(["bus_id", "device_id"])["hit"] + .mean() + .to_numpy() + ) + return out + + +def _competition_features(pair_scores: pd.DataFrame) -> pd.DataFrame: + """How this pair stacks up against its rivals, on both sides. + + Replaces `belief.py`'s hand-weighted cross-bus suppression with + plain features: a device that is some *other* bus's clear favourite + is weaker evidence here, and the model learns how much weaker + rather than being told via `CROSS_SUPPRESSION_STRENGTH`. + """ + out = pair_scores.copy() + by_bus = out.groupby("bus_id")["day_score_mean"] + out["bus_best_score"] = by_bus.transform("max") + out["bus_rank"] = by_bus.rank(ascending=False, method="min") + out["score_gap_to_bus_best"] = out["bus_best_score"] - out["day_score_mean"] + out["n_candidates_for_bus"] = by_bus.transform("size") + + by_device = out.groupby("device_id")["day_score_mean"] + out["device_best_score"] = by_device.transform("max") + out["device_rank"] = by_device.rank(ascending=False, method="min") + out["score_gap_to_device_best"] = out["device_best_score"] - out["day_score_mean"] + out["n_buses_claiming_device"] = by_device.transform("size") + + # Mutual-best is the single cleanest structural signal available + # without running an assignment: this bus's favourite device also + # has this bus as its own favourite. + out["is_mutual_best"] = ((out["bus_rank"] == 1) & (out["device_rank"] == 1)).astype( + int + ) + return out.drop(columns=["bus_best_score", "device_best_score"]) + + +def build_pair_features( + conn: psycopg.Connection, + day_features: pd.DataFrame, + day_scores: pd.Series, +) -> pd.DataFrame: + """Build the full month-level feature table, one row per candidate pair. + + Args: + conn: An open connection (for the dictionary and bus-schedule + lookups). + day_features: Concatenated `features_v2` rows for every date. + day_scores: The day model's calibrated P(match) for each row of + `day_features`, same order and length. + + Returns: + One row per `(bus_id, device_id)` with every pair-level feature. + Columns `bus_id`/`device_id` identify the pair; everything else + is a model input. + + """ + agg = _aggregate_day_features(day_features) + scores = _day_model_score_aggregates(day_features, day_scores) + pairs = agg.merge(scores, on=["bus_id", "device_id"], how="left") + pairs = _competition_features(pairs) + + dictionary = pd.read_sql(_DICTIONARY_SQL, conn) + pairs = pairs.merge(dictionary, on=["bus_id", "device_id"], how="left") + for col in ( + "n_dictionary_sources", + "n_dict_devices_for_bus", + "n_dict_buses_for_device", + ): + pairs[col] = pairs[col].fillna(0).astype(int) + # A bus whose dictionaries name several different devices is exactly + # the rare real device-swap case; a device named for several buses + # likewise. Both make any single dictionary hit weaker evidence -- + # expressed as features so the model prices them, rather than via + # the old hand-set DICTIONARY_CONFLICT_DAMPING. + pairs["bus_has_dictionary_conflict"] = (pairs["n_dict_devices_for_bus"] > 1).astype( + int + ) + pairs["device_has_dictionary_conflict"] = ( + pairs["n_dict_buses_for_device"] > 1 + ).astype(int) + + running = pd.read_sql(_BUS_RUNNING_DAYS_SQL, conn) + pairs = pairs.merge(running, on="bus_id", how="left") + pairs["n_days_bus_ran"] = pairs["n_days_bus_ran"].fillna(0).astype(int) + pairs["frac_bus_days_with_data"] = np.where( + pairs["n_days_bus_ran"] > 0, + pairs["n_days_with_data"] / pairs["n_days_bus_ran"], + np.nan, + ) + return pairs + + +def pair_feature_names(pairs: pd.DataFrame) -> list[str]: + """Every model-input column in a `build_pair_features` frame.""" + return [c for c in pairs.columns if c not in {"bus_id", "device_id"}] diff --git a/ml/bus_matching_model/app/pair_labeler.py b/ml/bus_matching_model/app/pair_labeler.py new file mode 100644 index 0000000..62d29da --- /dev/null +++ b/ml/bus_matching_model/app/pair_labeler.py @@ -0,0 +1,531 @@ +"""Pair-validation UI: confirm or reject a whole `(bus, device)` pairing. + +Run with:: + + uv run streamlit run ml/bus_matching_model/app/pair_labeler.py --server.port 8502 + +A different question from `streamlit_app.py`'s trip labeler, and a +different unit. That one asks "which candidate drove this *trip*" and +feeds the day model. This one asks "is this device this bus's device +for the month", which is the question the deliverable is actually about +-- and because a device essentially never changes bus mid-month, one +decision here settles ~20 days of evidence at once and implies a +negative for every rival candidate of that bus. + +**Layout, on request**: candidates across as columns, sampled trips +down as rows, one map per cell -- so genuinely confusing candidates are +compared side by side on the same trips rather than judged one at a +time from memory. + +**Queue**: the buses whose top two candidates are closest, excluding +buses with no real evidence for anything (see +`pair_model.select_hard_buses` -- without that filter the queue fills +with zero-evidence buses whose near-zero scores produce a near-zero +margin). +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import TYPE_CHECKING, Any + +_APP_DIR = Path(__file__).resolve().parent +if str(_APP_DIR) not in sys.path: + sys.path.insert(0, str(_APP_DIR)) + +import db # noqa: E402 +import exclusions # noqa: E402 +import maps # noqa: E402 +import pair_features # noqa: E402 +import pair_model # noqa: E402 +import pandas as pd # noqa: E402 +import streamlit as st # noqa: E402 +import streamlit_folium # noqa: E402 +import training # noqa: E402 +from features import DAY_FEATURE_NAMES # noqa: E402 +from gtfs_cache import build_shape_cache # noqa: E402 +from schema import ensure_schema # noqa: E402 + +if TYPE_CHECKING: + import psycopg + +FEATURES_V2_DIR = Path(__file__).resolve().parents[1] / "artifacts" / "features_v2" + +MAX_CANDIDATE_COLUMNS = 4 +MAX_TRIP_ROWS = 3 +MAP_HEIGHT_PX = 240 +RETRAIN_EVERY = 10 + +# Default ship threshold. Not a guess dressed up as a constant: it is +# the *starting* point for `pair_model.precision_at_threshold`, which +# reports measured precision at whatever cut is chosen, so this number +# is meant to be moved once there are labels to measure against. +DEFAULT_SHIP_THRESHOLD = 0.90 + +st.set_page_config(page_title="Bus-Device Pair Validation", layout="wide") + + +@st.cache_resource +def get_connection() -> psycopg.Connection: + """Open (and cache) this app's database connection.""" + conn = db.get_connection() + ensure_schema(conn) + return conn + + +@st.cache_resource +def get_shape_cache(_conn: psycopg.Connection) -> dict[tuple, Any]: + """Build (and cache) the GTFS shape cache.""" + return build_shape_cache(_conn) + + +@st.cache_data(show_spinner="Loading month features...") +def load_day_features() -> pd.DataFrame: + """Every date's v2 feature parquet, concatenated.""" + paths = sorted(FEATURES_V2_DIR.glob("date=*.parquet")) + if not paths: + return pd.DataFrame() + return pd.concat([pd.read_parquet(p) for p in paths], ignore_index=True) + + +@st.cache_data(show_spinner="Building pair features...") +def build_pairs(_conn: psycopg.Connection, n_labels: int) -> pd.DataFrame: # noqa: ARG001 + """Day features -> day model -> month-level pair features. + + Args: + _conn: An open connection (underscore: not a cache key). + n_labels: Trip-label count, included purely so the cache + invalidates when the day model's training data changes. + + Returns: + `pair_features.build_pair_features` output, minus excluded buses. + + """ + day = load_day_features() + if day.empty: + return pd.DataFrame() + + excluded = exclusions.excluded_bus_ids(_conn) + day = day[~day["bus_id"].isin(excluded)] + + expanded = db.expand_labels_to_training_rows(_conn, day, None) + if expanded.empty or expanded["label"].nunique() < pair_model.BOTH_CLASSES: + return pd.DataFrame() + + day_model = training.train_model( + expanded[DAY_FEATURE_NAMES], expanded["label"].to_numpy() + ) + day_scores = training.predict_positive_proba(day_model, day[DAY_FEATURE_NAMES]) + return pair_features.build_pair_features(_conn, day, pd.Series(day_scores)) + + +def _fit_pair_model( + conn: psycopg.Connection, pairs: pd.DataFrame, labels: pd.DataFrame +) -> dict[str, Any] | None: + """Fit the pair model and persist it, so a restart doesn't lose it.""" + rows = pair_model.build_training_rows(pairs, labels) + state = pair_model.train_pair_model(rows) + if state is not None: + state["run_id"] = pair_model.save_pair_run( + conn, state, n_labeled_pairs=int(labels["bus_id"].nunique()) + ) + conn.commit() + return state + + +def _sample_trips_for_bus(conn: psycopg.Connection, bus_id: str) -> pd.DataFrame: + """Pick a few of this bus's trips, spread across different dates. + + Spread matters: two trips from the same morning would show the same + corridor twice, while trips from different dates test whether a + candidate tracks the bus consistently over the month -- which is the + actual claim being judged. + """ + trips = pd.read_sql( + """ + SELECT trip_id, bus_id, route_id, trip_date, + trip_start_timestamp, trip_end_timestamp, + gtfs_feed_version_date, gtfs_shape_id_i, gtfs_shape_id_v + FROM ml.trip_validity_final + WHERE is_valid AND bus_id = %(bus_id)s + ORDER BY trip_start_timestamp; + """, + conn, + params={"bus_id": bus_id}, + ) + if trips.empty: + return trips + per_date = trips.groupby("trip_date", group_keys=False).head(1) + step = max(len(per_date) // MAX_TRIP_ROWS, 1) + return per_date.iloc[::step].head(MAX_TRIP_ROWS).reset_index(drop=True) + + +def _shapes_for_trip(conn: psycopg.Connection, trip: pd.Series) -> dict[str, tuple]: + shapes: dict[str, tuple] = {} + for direction, col in (("I", "gtfs_shape_id_i"), ("V", "gtfs_shape_id_v")): + shape_id = trip[col] + if shape_id is None or pd.isna(shape_id): + continue + geojson = db.fetch_shape_geojson(conn, trip["gtfs_feed_version_date"], shape_id) + stops = db.fetch_route_stops(conn, trip["gtfs_feed_version_date"], shape_id) + if geojson: + shapes[direction] = (geojson, stops) + return shapes + + +def _on_mode_change() -> None: + """Drop the current bus so the new mode picks its own next one.""" + st.session_state.current_bus = None + + +_STOPPING_RENDER = { + "too_early": st.info, + "keep_going": st.info, + "precision_low": st.warning, + "queue_empty": st.warning, + "done": st.success, +} + + +def _render_progress( + ranked: pd.DataFrame, labels: pd.DataFrame, threshold: float +) -> None: + prog = pair_model.progress_summary(ranked, labels, threshold) + cols = st.columns(5) + cols[0].metric("Buses in scope", prog["total_buses"]) + cols[1].metric("Done (confirmed or confident)", prog["done"]) + cols[2].metric("Confirmed by hand", prog["confirmed_by_hand"]) + cols[3].metric("Model-confident", prog["model_confident"]) + cols[4].metric("Remaining", prog["remaining"]) + st.progress( + prog["done"] / prog["total_buses"] if prog["total_buses"] else 0.0, + text=f"{prog['done']} / {prog['total_buses']} buses settled", + ) + + audit = pair_model.audit_precision(labels) + if audit["n"]: + st.caption( + f"**Unbiased precision (audit sample): {audit['precision']:.1%}** " + f"({audit['n_correct']}/{audit['n']} randomly-sampled confident picks " + "correct). This is the number to trust." + ) + bands = audit.get("by_band") or [] + if bands: + st.caption( + "By confidence band — an overall figure hides which band the " + "labels came from, and the bands are what differ: " + + " · ".join( + f"**{b['band']}**: {b['precision']:.0%} ({b['n_correct']}/{b['n']})" + for b in bands + ) + ) + else: + st.caption( + "**No audit labels yet, so there is no unbiased precision figure.** " + "Queue labels alone cannot reveal a confidently-wrong prediction, " + "because the queue only ever shows ambiguous buses. Switch to " + "'Audit confident picks' in the sidebar to start measuring." + ) + + prec = pair_model.precision_at_threshold(ranked, labels, threshold) + if prec["n"]: + st.caption( + f"Precision over *all* labels at {threshold:.2f}: " + f"{prec['precision']:.1%} ({prec['n_correct']}/{prec['n']}) -- " + "includes queue labels, which are drawn from the hardest cases, so " + "read it as a floor rather than an estimate." + ) + + signal = pair_model.stopping_signal(ranked, labels, threshold) + render = _STOPPING_RENDER.get(signal["verdict"], st.info) + render(f"**When to stop:** {signal['message']}") + + +def _record( + conn: psycopg.Connection, + *, + bus_id: str, + device_id: str, + verdict: str, + is_top: bool, + confidence: float | None, + n_candidates: int, +) -> None: + pair_model.insert_pair_label( + conn, + bus_id=bus_id, + device_id=device_id, + verdict=verdict, + was_top_candidate=is_top, + model_confidence=confidence, + n_candidates=n_candidates, + label_source=st.session_state.get("label_mode", "queue"), + ) + conn.commit() + st.session_state.labels_since_fit += 1 + st.session_state.current_bus = None + st.rerun() + + +def _render_sidebar(ranked: pd.DataFrame, state: dict[str, Any] | None) -> float: + """Draw the sidebar and return the chosen ship threshold.""" + with st.sidebar: + st.subheader("What to label") + st.radio( + "Sampling mode", + options=["queue", "audit"], + format_func=lambda m: ( + "Hard cases — teach the model" + if m == "queue" + else "Audit confident picks — measure it" + ), + key="label_mode", + on_change=_on_mode_change, + help=( + "Hard cases are the most ambiguous buses: best for teaching, " + "but their precision understates the system because they are " + "deliberately the difficult ones. Audit randomly samples buses " + "the model is ALREADY confident about -- the only way to catch " + "a confidently-wrong prediction, and the only unbiased " + "precision estimate." + ), + ) + st.divider() + st.subheader("Ship threshold") + threshold = st.slider( + "Confidence to count a bus as settled", + min_value=0.50, + max_value=0.99, + value=DEFAULT_SHIP_THRESHOLD, + step=0.01, + help=( + "Not a hardcoded constant -- pick it by watching measured " + "precision in the main panel." + ), + ) + st.divider() + if state is None: + st.info( + f"Pair model not fitted yet (needs ~{pair_model.MIN_PAIRS_TO_TRAIN} " + "labeled pairs). Ordering falls back to the day model's own score, " + "so labeling works from the first click." + ) + else: + m = state["metrics"] + st.success("Pair model fitted") + st.caption( + f"train {state['n_train']} / test {state['n_test']} rows · " + f"{len(state['selected_features'])} features" + ) + st.caption( + f"holdout AUC {m['auc']:.3f} · Brier {m['brier']:.3f} · " + f"ECE {m['ece']:.3f}" + ) + cv = state.get("cv", {}) + if cv.get("n_folds"): + st.caption( + f"{cv['n_folds']}-fold CV AUC " + f"{cv['auc_mean']:.3f} ± {cv['auc_std']:.3f} · " + f"ECE {cv['ece_mean']:.3f} — the steadier read" + ) + if state.get("run_id"): + st.caption(f"saved as pair run #{state['run_id']}") + st.divider() + no_ev = pair_model.no_evidence_buses(ranked) + st.caption( + f"{len(no_ev)} buses have no real evidence for any candidate " + "(excluded from the queue -- they need candidate generation, not a " + "human decision)." + ) + return threshold + + +def _render_candidate_headers(bus_pairs: pd.DataFrame) -> None: + """One column header per candidate: confidence and supporting counts.""" + header_cols = st.columns(len(bus_pairs)) + for col, (_, cand) in zip(header_cols, bus_pairs.iterrows(), strict=False): + with col: + badge = " · dictionary" if cand["n_dictionary_sources"] > 0 else "" + st.markdown(f"**{cand['device_id']}**{badge}") + st.metric("Confidence", f"{cand['pair_score']:.3f}") + st.caption( + f"{int(cand['n_days_with_data'])} days with data · " + f"day-score mean {cand['day_score_mean']:.3f}" + ) + + +def _render_trip_grid( + conn: psycopg.Connection, + bus_id: str, + trips: pd.DataFrame, + bus_pairs: pd.DataFrame, +) -> None: + """Draw the comparison grid: one row per trip, one map per candidate.""" + for _, trip in trips.iterrows(): + st.markdown( + f"**{trip['trip_date']}** · route {trip['route_id']} · " + f"{trip['trip_start_timestamp']:%H:%M}-{trip['trip_end_timestamp']:%H:%M}" + ) + shapes = _shapes_for_trip(conn, trip) + row_cols = st.columns(len(bus_pairs)) + for col, (_, cand) in zip(row_cols, bus_pairs.iterrows(), strict=False): + with col: + track = db.fetch_device_trip_positions( + conn, + cand["device_id"], + trip["trip_start_timestamp"], + trip["trip_end_timestamp"], + ) + if not shapes: + st.caption("no GTFS shape") + elif track.empty: + st.caption("no AVL in this window") + else: + streamlit_folium.st_folium( + maps.build_candidate_map(shapes, track), + height=MAP_HEIGHT_PX, + use_container_width=True, + returned_objects=[], + key=f"m_{bus_id}_{trip['trip_id']}_{cand['device_id']}", + ) + st.divider() + + +def _ensure_pair_state( + conn: psycopg.Connection, pairs: pd.DataFrame, labels: pd.DataFrame +) -> dict | None: + """Fit the pair model on first load and every `RETRAIN_EVERY` labels.""" + needs_fit = ( + "pair_state" not in st.session_state + or st.session_state.labels_since_fit >= RETRAIN_EVERY + ) + if needs_fit: + with st.spinner("Fitting pair model..."): + st.session_state.pair_state = _fit_pair_model(conn, pairs, labels) + st.session_state.labels_since_fit = 0 + return st.session_state.pair_state + + +def main() -> None: + """Render the pair-validation UI.""" + conn = get_connection() + if "labels_since_fit" not in st.session_state: + st.session_state.labels_since_fit = 0 + if "current_bus" not in st.session_state: + st.session_state.current_bus = None + + trip_label_count = db.trip_label_counts(conn)["total"] + pairs = build_pairs(conn, trip_label_count) + if pairs.empty: + st.error( + "No pair features yet -- run scripts/build_features.py and make sure " + "some trip labels exist." + ) + return + + labels = pair_model.fetch_pair_labels(conn) + state = _ensure_pair_state(conn, pairs, labels) + ranked = pair_model.rank_pairs(pairs, state) + threshold = _render_sidebar(ranked, state) + + st.title("Bus-Device Pair Validation") + _render_progress(ranked, labels, threshold) + st.divider() + + mode = st.session_state.get("label_mode", "queue") + if mode == "audit": + queue = pair_model.select_audit_buses(ranked, labels, threshold, limit=50) + empty_msg = f"No unlabeled buses above {threshold:.2f} left to audit." + else: + queue = pair_model.select_hard_buses(ranked, labels, limit=50) + empty_msg = "Nothing ambiguous left in the queue." + if queue.empty: + st.success(empty_msg) + return + + if st.session_state.current_bus is None: + st.session_state.current_bus = queue.iloc[0]["bus_id"] + bus_id = st.session_state.current_bus + + bus_pairs = ranked[ranked["bus_id"] == bus_id].nlargest( + MAX_CANDIDATE_COLUMNS, "pair_score" + ) + n_candidates = int(bus_pairs["n_candidates_for_bus"].iloc[0]) + margin = float(bus_pairs["pair_margin"].iloc[0]) + + st.subheader(f"Bus {bus_id}") + st.caption( + f"{n_candidates} candidates · top-two margin {margin:.3f} " + f"· showing the {len(bus_pairs)} best" + ) + + trips = _sample_trips_for_bus(conn, bus_id) + if trips.empty: + st.warning("No valid trips for this bus.") + return + + _render_candidate_headers(bus_pairs) + _render_trip_grid(conn, bus_id, trips, bus_pairs) + _render_verdicts(conn, bus_id, bus_pairs, queue, n_candidates) + + +def _render_verdicts( + conn: psycopg.Connection, + bus_id: str, + bus_pairs: pd.DataFrame, + queue: pd.DataFrame, + n_candidates: int, +) -> None: + """Draw the decision row: pick a candidate, reject the top pick, or defer.""" + st.subheader("Verdict") + verdict_cols = st.columns(len(bus_pairs)) + for col, (_, cand) in zip(verdict_cols, bus_pairs.iterrows(), strict=False): + with col: + if st.button( + f"✓ {cand['device_id']} is correct", + key=f"ok_{cand['device_id']}", + width="stretch", + type="primary" if cand["pair_rank"] == 1 else "secondary", + ): + _record( + conn, + bus_id=bus_id, + device_id=cand["device_id"], + verdict="correct", + is_top=bool(cand["pair_rank"] == 1), + confidence=float(cand["pair_score"]), + n_candidates=n_candidates, + ) + + top = bus_pairs.iloc[0] + action_cols = st.columns(3) + if action_cols[0].button("✗ Top pick is wrong", width="stretch"): + _record( + conn, + bus_id=bus_id, + device_id=top["device_id"], + verdict="wrong", + is_top=True, + confidence=float(top["pair_score"]), + n_candidates=n_candidates, + ) + if action_cols[1].button("? Unsure", width="stretch"): + _record( + conn, + bus_id=bus_id, + device_id=top["device_id"], + verdict="unsure", + is_top=True, + confidence=float(top["pair_score"]), + n_candidates=n_candidates, + ) + if action_cols[2].button("Skip to another bus", width="stretch"): + remaining = queue[queue["bus_id"] != bus_id] + st.session_state.current_bus = ( + remaining.iloc[0]["bus_id"] if not remaining.empty else None + ) + st.rerun() + + +main() diff --git a/ml/bus_matching_model/app/pair_model.py b/ml/bus_matching_model/app/pair_model.py new file mode 100644 index 0000000..9e8d300 --- /dev/null +++ b/ml/bus_matching_model/app/pair_model.py @@ -0,0 +1,803 @@ +"""Train, apply, and drive labeling for the month-level pair model. + +This is the layer that retires `belief.py`'s hand-set constants. Every +signal that used to arrive as a guessed weight -- how much a dictionary +hit is worth, how much cross-bus competition should suppress a +candidate, how much day-to-day continuity counts -- is a plain feature +in `pair_features.py`, and this module learns their weights from +`ml.bus_matching_pair_labels` instead. + +The three things the pipeline still decides structurally, stated +plainly because they are *not* learned: + +1. Which features exist (`pair_features.py`). +2. One device per bus (enforced by assignment, a physical constraint). +3. The ship threshold, which is chosen from *measured* precision on + held-out labels (`precision_at_threshold`) rather than picked. +""" + +from __future__ import annotations + +import json +import time +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import joblib +import numpy as np +import pandas as pd +import training +from metrics import evaluate as evaluate_metrics +from pair_features import pair_feature_names + +if TYPE_CHECKING: + import psycopg + +# Below this many labeled pairs a fitted model is not worth trusting +# over the raw day-score ordering -- with a handful of labels the +# test-split metrics are themselves too noisy to tell a good model from +# a lucky one. Not a trust *weight* (there are none any more), just a +# floor on when to start using the model at all. +MIN_PAIRS_TO_TRAIN = 20 +MIN_CLASS_COUNT = 5 +BOTH_CLASSES = 2 +TEST_FRAC = 0.3 +RANDOM_SEED = 42 + +# A bus whose *best* candidate scores below this has no real evidence +# for anything, as opposed to competing evidence. Kept deliberately low +# -- it asks whether there is a signal at all, not whether the signal is +# good enough to ship -- so it only ever catches the genuinely empty +# cases. +MIN_EVIDENCE_SCORE = 0.01 + +# Precision measured on a handful of labels is not a measurement. Below +# this many labeled pairs above the threshold, `stopping_signal` reports +# "too early" instead of quoting a number that would swing wildly with +# the next click. +MIN_MEASURED_FOR_PRECISION = 20 + +# Confidence bands the audit samples across. Uniform random sampling of +# "confident" buses is not enough: confirmed live that 84.6% of buses +# above 0.90 sit at >=0.999, so a uniform sample lands almost entirely +# on near-certain ones and reports 100% precision while never probing +# the band where errors would actually live. Sampling evenly across +# these bands puts eyes on the risky ones in proportion to their risk, +# not their frequency. +AUDIT_BANDS = ((0.90, 0.99), (0.99, 0.999), (0.999, 1.01)) + +# Folds for the cross-validated metrics. Grouped by bus, so a bus's +# positive and its implied negatives never straddle a fold. +CV_FOLDS = 5 + +ARTIFACTS_DIR = Path(__file__).resolve().parent.parent / "artifacts" / "pair_models" + + +def fetch_pair_labels(conn: psycopg.Connection) -> pd.DataFrame: + """Fetch every recorded pair verdict. + + Args: + conn: An open connection. + + Returns: + Columns `bus_id`, `device_id`, `verdict`, `was_top_candidate`, + `model_confidence`, `n_candidates`, `label_source`. + + """ + return pd.read_sql( + "SELECT bus_id, device_id, verdict, was_top_candidate, " + "model_confidence, n_candidates, label_source " + "FROM ml.bus_matching_pair_labels;", + conn, + ) + + +def insert_pair_label( + conn: psycopg.Connection, + *, + bus_id: str, + device_id: str, + verdict: str, + was_top_candidate: bool, + model_confidence: float | None, + n_candidates: int, + label_source: str = "queue", +) -> None: + """Record (or overwrite) one pair verdict. + + Args: + conn: An open connection. + bus_id: The bus. + device_id: The device being judged for that bus. + verdict: `"correct"`, `"wrong"`, or `"unsure"`. + was_top_candidate: Whether this was the model's own top pick at + labeling time -- lets evaluation separate "confirmed the + model" from "overrode the model" after the fact. + model_confidence: The model's P(correct) at labeling time, or + `None` before any model exists. + n_candidates: How many candidates were on screen. + label_source: `"queue"` (ambiguity-ranked, biased toward hard + cases) or `"audit"` (random confident sample, the only + unbiased precision source). Never pool the two. + + """ + conn.execute( + """ + INSERT INTO ml.bus_matching_pair_labels + (bus_id, device_id, verdict, was_top_candidate, + model_confidence, n_candidates, label_source) + VALUES (%(bus_id)s, %(device_id)s, %(verdict)s, %(was_top)s, + %(conf)s, %(n_candidates)s, %(source)s) + ON CONFLICT (bus_id, device_id) DO UPDATE SET + verdict = EXCLUDED.verdict, + was_top_candidate = EXCLUDED.was_top_candidate, + model_confidence = EXCLUDED.model_confidence, + n_candidates = EXCLUDED.n_candidates, + label_source = EXCLUDED.label_source, + labeled_at = now(); + """, + { + "bus_id": bus_id, + "device_id": device_id, + "verdict": verdict, + "was_top": was_top_candidate, + "conf": model_confidence, + "n_candidates": n_candidates, + "source": label_source, + }, + ) + + +def build_training_rows(pairs: pd.DataFrame, labels: pd.DataFrame) -> pd.DataFrame: + """Turn pair verdicts into binary training rows. + + A `"correct"` verdict is a positive for that pair *and* a negative + for every other candidate of the same bus -- one device per bus, so + confirming one rules out the rest. A `"wrong"` verdict is a single + negative and says nothing about the others. `"unsure"` contributes + nothing at all, which is the point of having it. + + Args: + pairs: `pair_features.build_pair_features` output. + labels: `fetch_pair_labels` output. + + Returns: + `pairs` columns plus a boolean `label`, only for rows that a + verdict actually implies. + + """ + if labels.empty: + return pairs.iloc[0:0].assign(label=pd.Series(dtype=bool)) + + decided = labels[labels["verdict"].isin(["correct", "wrong"])] + if decided.empty: + return pairs.iloc[0:0].assign(label=pd.Series(dtype=bool)) + + positives = decided[decided["verdict"] == "correct"][["bus_id", "device_id"]] + negatives = decided[decided["verdict"] == "wrong"][["bus_id", "device_id"]] + + # Every other candidate of a bus with a confirmed device is a + # negative, which is what makes a single click worth many rows. + implied = pairs.merge(positives[["bus_id"]], on="bus_id", how="inner") + implied = implied.merge( + positives.assign(_is_pos=True), on=["bus_id", "device_id"], how="left" + ) + implied["label"] = implied["_is_pos"].fillna(value=False).astype(bool) + implied = implied.drop(columns="_is_pos") + + explicit_neg = pairs.merge(negatives, on=["bus_id", "device_id"], how="inner") + explicit_neg = explicit_neg.assign(label=False) + + rows = pd.concat([implied, explicit_neg], ignore_index=True) + return rows.drop_duplicates(subset=["bus_id", "device_id"], keep="first") + + +def train_pair_model(rows: pd.DataFrame) -> dict[str, Any] | None: + """Fit the pair model, held out by bus so no bus spans the split. + + Args: + rows: `build_training_rows` output. + + Returns: + `{"model", "selected_features", "metrics", "n_train", "n_test"}`, + or `None` when there isn't yet enough labeled data to fit + something worth trusting. + + """ + if rows.empty or len(rows) < MIN_PAIRS_TO_TRAIN: + return None + if rows["label"].sum() < MIN_CLASS_COUNT: + return None + if (~rows["label"]).sum() < MIN_CLASS_COUNT: + return None + + names = [ + c for c in pair_feature_names(rows) if c != "label" and rows[c].dtype != object + ] + + # Split by bus, not by row: a bus's positive and its negatives share + # almost all their context, so letting them straddle the split would + # leak and inflate every metric below. + buses = rows["bus_id"].unique() + rng = np.random.default_rng(RANDOM_SEED) + shuffled = rng.permutation(buses) + n_test = max(1, int(len(shuffled) * TEST_FRAC)) + test_buses = set(shuffled[:n_test]) + + is_test = rows["bus_id"].isin(test_buses) + train_df, test_df = rows[~is_test], rows[is_test] + if train_df["label"].nunique() < BOTH_CLASSES: + return None + + model = training.train_model(train_df[names], train_df["label"].to_numpy()) + + metrics = {"auc": float("nan"), "brier": float("nan"), "ece": float("nan")} + if not test_df.empty and test_df["label"].nunique() >= BOTH_CLASSES: + preds = training.predict_positive_proba(model, test_df[names]) + metrics = evaluate_metrics(test_df["label"].to_numpy(), preds) + + cv = cross_validated_metrics(rows, names) + + # The shipped model is refit on *everything*, since a model trained + # on 70% of an already-small label set is strictly worse at the job + # it actually has to do. `metrics`/`cv` describe held-out + # performance; `model` is the one that scores production. + final_model = training.train_model(rows[names], rows["label"].to_numpy()) + + return { + "model": final_model, + "selected_features": names, + "metrics": metrics, + "cv": cv, + "n_train": len(train_df), + "n_test": len(test_df), + "n_rows": len(rows), + } + + +def cross_validated_metrics(rows: pd.DataFrame, names: list[str]) -> dict[str, Any]: + """Compute grouped k-fold metrics -- steadier than one 70/30 split. + + With only a few dozen labeled buses, a single held-out split lands + on a handful of buses and its AUC swings wildly with which ones. + K-fold uses every bus as test exactly once and reports the spread, + so "AUC 1.000" can be distinguished from "AUC 1.000 +/- 0.000" + (genuinely separable) versus "0.87 +/- 0.19" (noise). + + Grouped by bus for the same reason as the single split: a bus's + positive and its implied negatives share nearly all their features. + + Args: + rows: `build_training_rows` output. + names: Feature columns to fit on. + + Returns: + `{"auc_mean", "auc_std", "brier_mean", "ece_mean", "n_folds"}`, + all `NaN` when there aren't enough buses of both classes to + make folds. + + """ + buses = rows["bus_id"].unique() + empty = { + "auc_mean": float("nan"), + "auc_std": float("nan"), + "brier_mean": float("nan"), + "ece_mean": float("nan"), + "n_folds": 0, + } + if len(buses) < CV_FOLDS: + return empty + + rng = np.random.default_rng(RANDOM_SEED) + folds = np.array_split(rng.permutation(buses), CV_FOLDS) + + aucs, briers, eces = [], [], [] + for fold in folds: + is_test = rows["bus_id"].isin(set(fold)) + train_df, test_df = rows[~is_test], rows[is_test] + if ( + train_df["label"].nunique() < BOTH_CLASSES + or test_df["label"].nunique() < BOTH_CLASSES + ): + continue + fold_model = training.train_model(train_df[names], train_df["label"].to_numpy()) + preds = training.predict_positive_proba(fold_model, test_df[names]) + m = evaluate_metrics(test_df["label"].to_numpy(), preds) + aucs.append(m["auc"]) + briers.append(m["brier"]) + eces.append(m["ece"]) + + if not aucs: + return empty + return { + "auc_mean": float(np.mean(aucs)), + "auc_std": float(np.std(aucs)), + "brier_mean": float(np.mean(briers)), + "ece_mean": float(np.mean(eces)), + "n_folds": len(aucs), + } + + +def score_pairs(pairs: pd.DataFrame, state: dict[str, Any] | None) -> pd.Series: + """Score P(correct) for every pair. + + Args: + pairs: `pair_features.build_pair_features` output. + state: `train_pair_model` output, or `None`. + + Returns: + A float Series aligned to `pairs`. Before a model exists this + falls back to the day model's own aggregated score, so the UI + and the ordering work from the very first label rather than + needing a bootstrap phase. + + """ + if state is None: + return pairs["day_score_mean"].astype(float) + preds = training.predict_positive_proba( + state["model"], pairs[state["selected_features"]] + ) + return pd.Series(preds, index=pairs.index) + + +def rank_pairs(pairs: pd.DataFrame, state: dict[str, Any] | None) -> pd.DataFrame: + """Attach `pair_score` and per-bus ranking/margin to every pair. + + Args: + pairs: `pair_features.build_pair_features` output. + state: `train_pair_model` output, or `None`. + + Returns: + `pairs` plus `pair_score`, `pair_rank` (1 = this bus's best), + and `pair_margin` (top score minus runner-up, repeated on every + row of the bus). `pair_margin` is the ambiguity measure the + labeling queue sorts on. + + """ + out = pairs.copy() + out["pair_score"] = score_pairs(out, state) + by_bus = out.groupby("bus_id")["pair_score"] + out["pair_rank"] = by_bus.rank(ascending=False, method="first") + + ordered = out.sort_values(["bus_id", "pair_score"], ascending=[True, False]) + top2 = ordered.groupby("bus_id")["pair_score"].nth(1).rename("runner_up") + best = ordered.groupby("bus_id")["pair_score"].max().rename("best") + margins = pd.concat([best, top2], axis=1) + margins["pair_margin"] = margins["best"] - margins["runner_up"].fillna(0.0) + + return out.merge( + margins[["pair_margin"]], left_on="bus_id", right_index=True, how="left" + ) + + +def select_hard_buses( + ranked: pd.DataFrame, labels: pd.DataFrame, limit: int = 50 +) -> pd.DataFrame: + """Pick the buses whose answer is least settled -- the labeling queue. + + Ambiguity, not low confidence, is what makes a bus worth a human + look: a bus whose top two candidates are nearly tied is one where a + single decision resolves real uncertainty *and* generates several + implied negatives. Buses already labeled (any verdict, including + "unsure") drop out. + + **Buses with no real evidence for any candidate are excluded**, not + ranked first. A bus whose best candidate scores ~1e-6 has a tiny + top-to-runner-up margin purely because every option is near zero -- + that is absence of evidence, not ambiguity, and sorting on raw + margin puts exactly those buses at the front of the queue. Confirmed + live: without this filter the 10 "hardest" buses were all + zero-evidence 67-prefix ones (best of 73 candidates scoring 5.7e-7), + which would have wasted the entire first labeling session on buses + whose true device has no AVL at all. They surface through + `no_evidence_buses` instead, where they belong. + + Args: + ranked: `rank_pairs` output. + labels: `fetch_pair_labels` output. + limit: How many buses to return. + + Returns: + One row per unlabeled, evidence-bearing bus -- its top + candidate, that candidate's score, and the bus's `pair_margin` + -- ascending by margin, so the most genuinely confusing bus + comes first. + + """ + tops = ranked[ranked["pair_rank"] == 1].copy() + if not labels.empty: + tops = tops[~tops["bus_id"].isin(set(labels["bus_id"]))] + tops = tops[tops["pair_score"] >= MIN_EVIDENCE_SCORE] + cols = ["bus_id", "device_id", "pair_score", "pair_margin", "n_candidates_for_bus"] + return tops.sort_values("pair_margin")[cols].head(limit).reset_index(drop=True) + + +def no_evidence_buses(ranked: pd.DataFrame) -> pd.DataFrame: + """Buses where not even the best candidate clears `MIN_EVIDENCE_SCORE`. + + Kept as an explicit, countable bucket rather than being silently + mixed into the labeling queue -- these need candidate generation or + a data source to change, not a human decision. + + Args: + ranked: `rank_pairs` output. + + Returns: + One row per such bus, descending by its (still tiny) best score. + + """ + tops = ranked[ranked["pair_rank"] == 1] + out = tops[tops["pair_score"] < MIN_EVIDENCE_SCORE] + cols = ["bus_id", "device_id", "pair_score", "n_candidates_for_bus"] + return out.sort_values("pair_score", ascending=False)[cols].reset_index(drop=True) + + +def select_audit_buses( + ranked: pd.DataFrame, + labels: pd.DataFrame, + threshold: float, + limit: int = 50, + seed: int = RANDOM_SEED, +) -> pd.DataFrame: + """Sample confident buses for audit, stratified across confidence bands. + + Plan Section 7's "random confident pairs -- small but + non-negotiable": the ambiguity-ranked queue only ever shows hard + cases, so labeling it can never reveal a *silent* error, a bus the + model is confidently wrong about. + + **Stratified, not uniform.** Confirmed live that 84.6% of buses + above 0.90 sit at >=0.999, so uniform sampling spends nearly every + label on near-certain buses and yields a 100% precision figure that + never touched the 0.90-0.99 band where errors would live. Sampling + evenly across `AUDIT_BANDS` fixes that; within each band the draw is + still uniform, so each band's precision stays an unbiased estimate + *for that band*. + + Args: + ranked: `rank_pairs` output. + labels: `fetch_pair_labels` output. + threshold: Confidence cut defining "already confident". Bands + below it are skipped. + limit: Total buses to sample across all bands. + seed: Sampling seed, so the audit set is reproducible. + + Returns: + Same columns as `select_hard_buses`, plus `audit_band`, shuffled + so bands are interleaved rather than labeled in blocks. + + """ + tops = ranked[(ranked["pair_rank"] == 1) & (ranked["pair_score"] >= threshold)] + if not labels.empty: + tops = tops[~tops["bus_id"].isin(set(labels["bus_id"]))] + cols = ["bus_id", "device_id", "pair_score", "pair_margin", "n_candidates_for_bus"] + if tops.empty: + return tops.reindex(columns=[*cols, "audit_band"]).head(0) + + bands = [(lo, hi) for lo, hi in AUDIT_BANDS if hi > threshold] + per_band = max(limit // max(len(bands), 1), 1) + picked = [] + for lo, hi in bands: + in_band = tops[(tops["pair_score"] >= lo) & (tops["pair_score"] < hi)] + if in_band.empty: + continue + n = min(per_band, len(in_band)) + chunk = in_band.sample(n=n, random_state=seed)[cols].copy() + chunk["audit_band"] = f"{lo:g}-{hi:g}" + picked.append(chunk) + if not picked: + return tops.reindex(columns=[*cols, "audit_band"]).head(0) + out = pd.concat(picked, ignore_index=True) + return out.sample(frac=1.0, random_state=seed).reset_index(drop=True) + + +def audit_precision(labels: pd.DataFrame) -> dict[str, Any]: + """Unbiased precision, measured on audit-sampled labels only. + + Queue-sourced labels are deliberately drawn from the hardest cases, + so pooling them with audit labels produces a number that means + neither one thing nor the other. This function uses audit rows + alone. + + Args: + labels: `fetch_pair_labels` output (needs `label_source`). + + Returns: + `{"n", "n_correct", "precision"}` over audit rows with a + decisive verdict. `precision` is `NaN` until any exist. + + """ + if labels.empty or "label_source" not in labels.columns: + return {"n": 0, "n_correct": 0, "precision": float("nan")} + audit = labels[ + (labels["label_source"] == "audit") + & (labels["verdict"].isin(["correct", "wrong"])) + ] + n = len(audit) + n_correct = int((audit["verdict"] == "correct").sum()) if n else 0 + + # Per-band, because an overall figure is dominated by whichever band + # happens to have the most labels -- and the bands differ in exactly + # the way that matters. `model_confidence` is the score at labeling + # time, which is the score the verdict actually judged. + by_band = [] + for lo, hi in AUDIT_BANDS: + rows = audit[ + (audit["model_confidence"] >= lo) & (audit["model_confidence"] < hi) + ] + if rows.empty: + continue + band_n = len(rows) + band_correct = int((rows["verdict"] == "correct").sum()) + by_band.append( + { + "band": f"{lo:g}-{hi:g}", + "n": band_n, + "n_correct": band_correct, + "precision": band_correct / band_n, + } + ) + + return { + "n": n, + "n_correct": n_correct, + "precision": (n_correct / n) if n else float("nan"), + "by_band": by_band, + } + + +def precision_at_threshold( + ranked: pd.DataFrame, labels: pd.DataFrame, threshold: float +) -> dict[str, Any]: + """Measure precision of "ship the top candidate above `threshold`". + + This is what turns the ship threshold from a guess into a choice: + it reports, over the labeled pairs only, how often the model's own + top pick above a given confidence was actually judged correct. + + Args: + ranked: `rank_pairs` output. + labels: `fetch_pair_labels` output. + threshold: The confidence cut to evaluate. + + Returns: + `{"n", "n_correct", "precision", "coverage_buses"}`. `precision` + is `NaN` when no labeled pair clears the threshold yet. + + """ + tops = ranked[ranked["pair_rank"] == 1] + judged = tops.merge( + labels[labels["verdict"].isin(["correct", "wrong"])], + on=["bus_id", "device_id"], + how="inner", + ) + above = judged[judged["pair_score"] >= threshold] + n = len(above) + n_correct = int((above["verdict"] == "correct").sum()) if n else 0 + return { + "n": n, + "n_correct": n_correct, + "precision": (n_correct / n) if n else float("nan"), + "coverage_buses": int((tops["pair_score"] >= threshold).sum()), + } + + +def stopping_signal( + ranked: pd.DataFrame, + labels: pd.DataFrame, + threshold: float, + target_precision: float = 0.95, +) -> dict[str, Any]: + """Report whether labeling is done, and if not, what is still missing. + + Deliberately descriptive rather than prescriptive: it reports the + two numbers that actually decide the question -- how many buses are + still unsettled, and what precision has been *measured* at the + current threshold -- and only calls "done" for the unambiguous case. + There is no invented convergence heuristic here; `target_precision` + is a choice the caller makes with the measured number in front of + them, not a fact about the data. + + Args: + ranked: `rank_pairs` output. + labels: `fetch_pair_labels` output. + threshold: Current ship threshold. + target_precision: The precision the caller wants before + trusting the unlabeled remainder. + + Returns: + `verdict` (`"too_early"`, `"keep_going"`, `"precision_low"`, + `"queue_empty"`, `"done"`), a human-readable `message`, plus the + underlying `remaining`, `precision`, and `n_measured` so the + caller can show the evidence rather than just the conclusion. + + """ + prog = progress_summary(ranked, labels, threshold) + # Audit labels only: queue labels are drawn from the hardest cases + # *and* (in practice) tend to be confirmations of the top pick, so a + # precision computed over them says nothing about whether confident + # predictions are silently wrong -- which is the actual question + # "can I stop?" depends on. + prec = audit_precision(labels) + queue = select_hard_buses(ranked, labels, limit=1) + + remaining = prog["remaining"] + measured = prec["precision"] + n_measured = prec["n"] + base = { + "remaining": remaining, + "precision": measured, + "n_measured": n_measured, + } + + if n_measured < MIN_MEASURED_FOR_PRECISION: + return { + **base, + "verdict": "too_early", + "message": ( + f"Only {n_measured} audit-sampled labels so far -- not enough to " + "measure precision. Switch to 'Audit confident picks' and label " + f"~{MIN_MEASURED_FOR_PRECISION} of them: queue labels alone can " + "never reveal a confidently-wrong prediction." + ), + } + if measured < target_precision: + return { + **base, + "verdict": "precision_low", + "message": ( + f"Measured precision {measured:.1%} is below the " + f"{target_precision:.0%} you asked for. Either keep labeling, " + "or raise the threshold " + "(fewer buses auto-accepted, each one safer)." + ), + } + if queue.empty: + return { + **base, + "verdict": "queue_empty", + "message": ( + f"Nothing ambiguous left to label, and precision is {measured:.1%}. " + f"{remaining} buses remain below the threshold -- they need " + "candidate generation or new data, not more labeling." + ), + } + if remaining == 0: + return { + **base, + "verdict": "done", + "message": ( + f"Every bus is settled and measured precision is {measured:.1%}. " + "Safe to stop." + ), + } + return { + **base, + "verdict": "keep_going", + "message": ( + f"Precision {measured:.1%} at {threshold:.2f}, {remaining} buses still " + "unsettled. Each label also implies negatives for that bus's rivals, " + "so this number falls faster than one-per-click." + ), + } + + +def progress_summary( + ranked: pd.DataFrame, labels: pd.DataFrame, threshold: float +) -> dict[str, Any]: + """Summarize how much is settled, and how -- the UI's headline counters. + + Args: + ranked: `rank_pairs` output. + labels: `fetch_pair_labels` output. + threshold: Current ship threshold. + + Returns: + Counts of total buses, buses confirmed by hand, buses the model + is confident about on its own, the union of those two (the real + "done" number), and how many remain. + + """ + total_buses = int(ranked["bus_id"].nunique()) + confirmed = ( + set(labels[labels["verdict"] == "correct"]["bus_id"]) + if not labels.empty + else set() + ) + tops = ranked[ranked["pair_rank"] == 1] + model_sure = set(tops[tops["pair_score"] >= threshold]["bus_id"]) + done = confirmed | model_sure + return { + "total_buses": total_buses, + "confirmed_by_hand": len(confirmed), + "model_confident": len(model_sure), + "done": len(done), + "remaining": total_buses - len(done), + "labeled_any": int(labels["bus_id"].nunique()) if not labels.empty else 0, + } + + +def save_pair_run( + conn: psycopg.Connection, state: dict[str, Any], n_labeled_pairs: int +) -> int: + """Persist a fitted pair model to disk and its metadata to Postgres. + + Without this the pair model lived only in Streamlit session state, + so it vanished on restart and nothing downstream (the final output + table, any later audit) could reproduce the scores that produced a + given result. + + Args: + conn: An open connection. + state: `train_pair_model` output. + n_labeled_pairs: Distinct labeled pairs behind this fit. + + Returns: + The new `ml.bus_matching_pair_model_runs` row's `run_id`. + + """ + ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True) + path = ARTIFACTS_DIR / f"pair_run_{n_labeled_pairs:05d}_{int(time.time())}.joblib" + joblib.dump( + {"model": state["model"], "selected_features": state["selected_features"]}, + path, + ) + + metrics = state["metrics"] + cv = state.get("cv", {}) + row = conn.execute( + """ + INSERT INTO ml.bus_matching_pair_model_runs + (n_train_labels, n_labeled_pairs, hyperparameters, selected_features, + test_auc, test_brier, test_log_loss, test_ece, artifact_path) + VALUES (%(n_train)s, %(n_pairs)s, %(hyper)s, %(feats)s, + %(auc)s, %(brier)s, %(logloss)s, %(ece)s, %(path)s) + RETURNING run_id; + """, + { + "n_train": int(state.get("n_rows", state["n_train"])), + "n_pairs": n_labeled_pairs, + # CV metrics ride along in hyperparameters rather than new + # columns: they describe this fit, and the run table is + # shared shape with the day model's. + "hyper": json.dumps({**training.FIXED_HYPERPARAMETERS, "cv": cv}), + "feats": json.dumps(state["selected_features"]), + "auc": _none_if_nan(metrics.get("auc")), + "brier": _none_if_nan(metrics.get("brier")), + "logloss": _none_if_nan(metrics.get("log_loss")), + "ece": _none_if_nan(metrics.get("ece")), + "path": str(path), + }, + ).fetchone() + return int(row[0]) + + +def _none_if_nan(value: float | None) -> float | None: + """NaN -> None, so Postgres stores a real NULL rather than 'NaN'.""" + if value is None: + return None + return None if np.isnan(value) else float(value) + + +def load_latest_pair_run(conn: psycopg.Connection) -> dict[str, Any] | None: + """Reload the most recent persisted pair model, if any. + + Args: + conn: An open connection. + + Returns: + `{"model", "selected_features", "run_id"}`, or `None` when no + run has been saved or its artifact is missing from disk. + + """ + row = conn.execute( + "SELECT run_id, artifact_path FROM ml.bus_matching_pair_model_runs " + "ORDER BY run_id DESC LIMIT 1;" + ).fetchone() + if row is None: + return None + path = Path(row[1]) + if not path.exists(): + return None + loaded = joblib.load(path) + return {**loaded, "run_id": int(row[0])} diff --git a/ml/bus_matching_model/app/registry.py b/ml/bus_matching_model/app/registry.py new file mode 100644 index 0000000..3e64e57 --- /dev/null +++ b/ml/bus_matching_model/app/registry.py @@ -0,0 +1,107 @@ +"""Persists trained models/calibrators to disk and their metadata to Postgres.""" + +from __future__ import annotations + +import time +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import db +import joblib +from calibration import PlattCalibrator + +if TYPE_CHECKING: + import lightgbm as lgb + import psycopg + +ARTIFACTS_DIR = Path(__file__).resolve().parent.parent / "artifacts" / "models" + + +def save_run( + conn: psycopg.Connection, + *, + n_train_labels: int, + model: lgb.LGBMClassifier, + calibrator: PlattCalibrator, + selected_features: list[str], + hyperparameters: dict[str, Any], + test_metrics: dict[str, float], + n_resolved_bus_dates: int | None = None, + n_trip_labels_total: int | None = None, +) -> int: + """Serialize a trained model + calibrator to disk and record its metadata. + + Args: + conn: An open connection. + n_train_labels: Training pool size (row count, post-expansion) + at this run. + model: The fitted LightGBM classifier. + calibrator: The fitted Platt calibrator. + selected_features: Feature columns the model was fit on (always + every Tier 1 feature -- see plan Section 5, no selection). + hyperparameters: The fixed hyperparameters used. + test_metrics: Output of `metrics.evaluate` on the frozen test set. + n_resolved_bus_dates: Resolved bus-date count *at this retrain* + -- feeds `db.stopping_signal`'s growth-rate check. + n_trip_labels_total: Total trip-level decisions *at this + retrain* -- same purpose. + + Returns: + The new `ml.bus_matching_model_runs` row's `run_id`. + + """ + ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True) + artifact_path = ( + ARTIFACTS_DIR / f"run_{n_train_labels:04d}_{int(time.time())}.joblib" + ) + # Stored as plain data plus the calibrator's underlying sklearn model, + # never a PlattCalibrator instance directly -- pickling that ties the + # artifact to *this exact* class object, which breaks if this app's + # modules get hot-reloaded (Streamlit's file watcher) between when + # the instance was created and when it's pickled here. + joblib.dump( + { + "model": model, + "calibrator_model": calibrator.to_sklearn_model(), + "selected_features": selected_features, + "hyperparameters": hyperparameters, + }, + artifact_path, + ) + + return db.insert_model_run( + conn, + { + "run_type": "cycle", + "n_train_labels": n_train_labels, + "hyperparameters": hyperparameters, + "selected_features": selected_features, + "calibration_params": calibrator.to_params(), + "cv_brier_score": None, + "test_auc": test_metrics["auc"], + "test_brier": test_metrics["brier"], + "test_log_loss": test_metrics["log_loss"], + "test_ece": test_metrics["ece"], + "artifact_path": str(artifact_path), + "n_resolved_bus_dates": n_resolved_bus_dates, + "n_trip_labels_total": n_trip_labels_total, + }, + ) + + +def load_run(run_row: dict[str, Any]) -> dict[str, Any]: + """Load a run's serialized model + calibrator + feature list from disk. + + Args: + run_row: A row dict as returned by `db.fetch_latest_model_run`. + + Returns: + Dict with keys "model", "calibrator", "selected_features". + + """ + artifact = joblib.load(run_row["artifact_path"]) + return { + "model": artifact["model"], + "calibrator": PlattCalibrator.from_sklearn_model(artifact["calibrator_model"]), + "selected_features": artifact["selected_features"], + } diff --git a/ml/bus_matching_model/app/schema.py b/ml/bus_matching_model/app/schema.py new file mode 100644 index 0000000..9d36985 --- /dev/null +++ b/ml/bus_matching_model/app/schema.py @@ -0,0 +1,222 @@ +"""DDL bootstrap for the Bus Matching active-learning app's own tables. + +Only ever creates `ml.bus_matching_trip_labels`, +`ml.bus_matching_model_runs`, `ml.bus_matching_pair_labels`, +`ml.bus_matching_pair_model_runs`, `ml.bus_matching_final_pairs`, and +`ml.bus_matching_unclaimed_devices`. +Everything else this app reads +(`ml.bus_matching_candidates`, `ml.bus_matching_contestedness`, +`ml.trip_validity_final`, `ml.trip_validity_fares_final`, +`ml.trip_validity_route_shapes`, `ml.bus_matching_avl_positions`) is +read-only and belongs to earlier notebooks. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import psycopg + +# One row per *trip*, not per bus-date: each decision is scoped to the +# single trip on screen ("which candidate looks right for THIS trip"), +# so a bus-date can accumulate several independent trip-level votes. +# `db.fetch_resolved_labels` turns these into a day-level verdict only +# once enough votes agree -- a single wrong click can never resolve a +# bus-date by itself, it needs a second, matching wrong click. +_TRIP_LABELS_DDL = """ +CREATE TABLE IF NOT EXISTS ml.bus_matching_trip_labels ( + bus_id TEXT NOT NULL, + date DATE NOT NULL, + trip_id BIGINT NOT NULL, + device_id TEXT, + decision TEXT NOT NULL CHECK ( + decision IN ('match', 'none_of_these', 'unsure') + ), + mode TEXT NOT NULL CHECK (mode IN ('uncontested', 'contested')), + n_candidates INTEGER NOT NULL, + labeled_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (bus_id, date, trip_id), + CHECK (decision <> 'match' OR device_id IS NOT NULL) +); +""" + +_MODEL_RUNS_DDL = """ +CREATE TABLE IF NOT EXISTS ml.bus_matching_model_runs ( + run_id SERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + run_type TEXT NOT NULL CHECK (run_type IN ('cycle', 'milestone')), + n_train_labels INTEGER NOT NULL, + hyperparameters JSONB NOT NULL, + selected_features JSONB NOT NULL, + calibration_params JSONB, + cv_brier_score DOUBLE PRECISION, + test_auc DOUBLE PRECISION, + test_brier DOUBLE PRECISION, + test_log_loss DOUBLE PRECISION, + test_ece DOUBLE PRECISION, + artifact_path TEXT NOT NULL +); +""" + +# Added after the table already existed in some environments (never had +# real rows before this, so a plain ADD COLUMN is safe) -- tracks the +# resolved-bus-date and total-trip-label counts *at each retrain*, which +# is what the stopping-criteria signal (`db.stopping_signal`) needs to +# see growth rate over time, not just the latest snapshot. +_MODEL_RUNS_MIGRATION_DDL = """ +ALTER TABLE ml.bus_matching_model_runs + ADD COLUMN IF NOT EXISTS n_resolved_bus_dates INTEGER, + ADD COLUMN IF NOT EXISTS n_trip_labels_total INTEGER; +""" + +# One row per *pair* for the whole month -- the unit the final +# deliverable is actually about ("is this device this bus's device"), +# and a different question from the trip-level table above. A device +# essentially never changes bus mid-month, so a month-level verdict is +# both answerable and far more informative per click than a per-date +# one: twenty days of evidence collapse into a single decision. +# +# `verdict` is deliberately three-way. "unsure" is a real, useful +# answer here -- it keeps a genuinely ambiguous pair out of the +# training set instead of forcing a coin-flip into it, and the +# selection logic can stop re-showing it. +_PAIR_LABELS_DDL = """ +CREATE TABLE IF NOT EXISTS ml.bus_matching_pair_labels ( + bus_id TEXT NOT NULL, + device_id TEXT NOT NULL, + verdict TEXT NOT NULL CHECK ( + verdict IN ('correct', 'wrong', 'unsure') + ), + was_top_candidate BOOLEAN NOT NULL, + model_confidence DOUBLE PRECISION, + n_candidates INTEGER NOT NULL, + labeled_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (bus_id, device_id) +); +""" + +# `label_source` separates the two sampling regimes, which measure +# genuinely different things and must never be pooled into one precision +# figure: +# +# - 'queue' -- the ambiguity-ranked labeling queue. Deliberately biased +# toward hard cases, so precision over these understates +# the system. +# - 'audit' -- a *random* sample of pairs the model is already confident +# about (plan Section 7's "random confident pairs, small +# but non-negotiable"). This is the only unbiased estimate +# of whether confident predictions are actually right, and +# the only one that should ever be quoted as "precision". +# +# Added after the table existed; existing rows are queue-sourced. +_PAIR_LABELS_MIGRATION_DDL = """ +ALTER TABLE ml.bus_matching_pair_labels + ADD COLUMN IF NOT EXISTS label_source TEXT NOT NULL DEFAULT 'queue'; +""" + +# Same shape as the day model's run table, kept separate so the two +# models' histories (and their trust ramps) never mix. +_PAIR_MODEL_RUNS_DDL = """ +CREATE TABLE IF NOT EXISTS ml.bus_matching_pair_model_runs ( + run_id SERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + n_train_labels INTEGER NOT NULL, + n_labeled_pairs INTEGER NOT NULL, + hyperparameters JSONB NOT NULL, + selected_features JSONB NOT NULL, + test_auc DOUBLE PRECISION, + test_brier DOUBLE PRECISION, + test_log_loss DOUBLE PRECISION, + test_ece DOUBLE PRECISION, + artifact_path TEXT NOT NULL +); +""" + +# One row per settled bus for the whole month, or per interval for a +# detected mid-month device swap -- Section 13's deliverable. `method` +# follows the same convention as `ml.bus_matching_global_assignment` +# (an explicit unresolved bucket, never a silent drop), just at the +# month grain: 'excluded_no_avl', 'no_candidates', 'no_evidence', +# 'hand_confirmed', 'pair_model', 'below_threshold', 'split_detected', +# 'resolved_after_review', 'needs_review'. +_FINAL_PAIRS_DDL = """ +CREATE TABLE IF NOT EXISTS ml.bus_matching_final_pairs ( + bus_id TEXT NOT NULL, + device_id TEXT, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + confidence DOUBLE PRECISION, + n_days_with_data INTEGER, + method TEXT NOT NULL, + notes TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (bus_id, start_date) +); +""" + +# The device-side mirror of `bus_matching_final_pairs`: every active +# device (>=1 real AVL ping in the period) that no bus claims, with an +# explicit `reason` -- 'never_blocked' (blocking found no bus for it at +# all), 'blocked_only_to_excluded_bus' (its only candidacy was a +# 67-prefix bus), or 'lost_competition' (it competed for a real bus and +# another device won). Rebuilt wholesale alongside `bus_matching_final_pairs`, +# not incrementally maintained. +_UNCLAIMED_DEVICES_DDL = """ +CREATE TABLE IF NOT EXISTS ml.bus_matching_unclaimed_devices ( + device_id TEXT PRIMARY KEY, + n_pings INTEGER NOT NULL, + n_days_active INTEGER NOT NULL, + first_active_date DATE NOT NULL, + last_active_date DATE NOT NULL, + in_dictionary BOOLEAN NOT NULL, + dictionary_bus_ids TEXT, + best_candidate_bus_id TEXT, + best_candidate_score DOUBLE PRECISION, + reason TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +""" + +_INDEXES_DDL = """ +CREATE INDEX IF NOT EXISTS bus_matching_trip_labels_bus_date_idx + ON ml.bus_matching_trip_labels (bus_id, date); +CREATE INDEX IF NOT EXISTS bus_matching_model_runs_created_at_idx + ON ml.bus_matching_model_runs (created_at); +CREATE INDEX IF NOT EXISTS bus_matching_pair_labels_bus_idx + ON ml.bus_matching_pair_labels (bus_id); +CREATE INDEX IF NOT EXISTS bus_matching_pair_model_runs_created_at_idx + ON ml.bus_matching_pair_model_runs (created_at); +CREATE INDEX IF NOT EXISTS bus_matching_final_pairs_method_idx + ON ml.bus_matching_final_pairs (method); +CREATE INDEX IF NOT EXISTS bus_matching_final_pairs_device_idx + ON ml.bus_matching_final_pairs (device_id); +CREATE INDEX IF NOT EXISTS bus_matching_unclaimed_devices_reason_idx + ON ml.bus_matching_unclaimed_devices (reason); +""" + +_DROP_OLD_LABELS_TABLE_DDL = """ +DROP TABLE IF EXISTS ml.bus_matching_labels; +""" + + +def ensure_schema(conn: psycopg.Connection) -> None: + """Create the active learning app's tables if they don't already exist. + + Args: + conn: An open connection to the database. + + """ + with conn.transaction(): + # Superseded by bus_matching_trip_labels (trip-level, not + # bus-date-level) -- never had real rows, safe to drop outright. + conn.execute(_DROP_OLD_LABELS_TABLE_DDL) + conn.execute(_TRIP_LABELS_DDL) + conn.execute(_MODEL_RUNS_DDL) + conn.execute(_MODEL_RUNS_MIGRATION_DDL) + conn.execute(_PAIR_LABELS_DDL) + conn.execute(_PAIR_LABELS_MIGRATION_DDL) + conn.execute(_PAIR_MODEL_RUNS_DDL) + conn.execute(_FINAL_PAIRS_DDL) + conn.execute(_UNCLAIMED_DEVICES_DDL) + conn.execute(_INDEXES_DDL) diff --git a/ml/bus_matching_model/app/smoothing.py b/ml/bus_matching_model/app/smoothing.py new file mode 100644 index 0000000..2d796eb --- /dev/null +++ b/ml/bus_matching_model/app/smoothing.py @@ -0,0 +1,472 @@ +"""Per-device temporal smoothing (plan Section 10). + +Collapses each device's daily sequence of Section 9 (`assignment.py`) +assignments into `(bus_id, device_id, from_date, to_date)` intervals. + +**Scope, confirmed live against the real assignment table**: of 1,437 +devices with at least one assigned day, 1,407 (97.9%) stick to exactly +one bus for the entire month -- nothing to smooth, one trivial +interval. Only 30 devices (60 device-bus pairs) ever show more than one +bus, and 25 of those 60 pairs are a *single* day -- inspecting several +by hand showed the textbook pattern the plan describes: a solid run of +bus A, one day of bus B, the same solid run of A resuming. At this +scale a transparent, inspectable rule-based smoother is a better fit +than the plan's suggested HMM/changepoint-detection machinery, not an +under-implementation of it -- 30 devices can be read by eye if needed, +a fitted model would be opaque for no benefit here. + +**Two rules, both gated by a cross-device conflict check**: + +1. **Gap bridging** (plan Section 10.2): if a device shows the same bus + on both sides of a run of unassigned calendar days, those days are + folded into one interval -- *unless* any of those gap days has that + bus **directly confirmed to a different device** by Section 9. A + real, resolved conflict is not "missing data" (the plan's own rule: + missing data is never negative evidence -- but this isn't missing, + it's positive evidence for someone else), so those days break the + run instead of being bridged. Confirmed live this matters: an early + version without this check bridged a device with only 2 solved days + 14 days apart, straight through 6+ days where the same bus was + solidly confirmed to a *different* device -- a real double-claim, + not a smoothing nicety. `ml.bus_matching_intervals`'s own + overlap-freeness (checked in the notebook) is the regression test + for this. +2. **Isolated one-day deviation** (plan Section 10.3): a single day + whose bus differs from both its immediate preceding *and* following + assigned-day, when those two neighbors agree with each other, is + corrected to that surrounding bus and logged -- gated by the same + conflict check (the surrounding bus must not already be confirmed + to some other device that exact day). A 2+-day run is never touched + -- only exact single-day blips. A single day at the very start or + end of a device's whole sequence (no both-sides neighbor to compare) + is never corrected either. + +Deliberately **not** implemented here (out of scope for what the real +data needed): a rolling-window mode filter or HMM for longer/noisier +sequences, since none exist in this month's data to justify it. +""" + +from __future__ import annotations + +import datetime +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import pandas as pd + +if TYPE_CHECKING: + from collections.abc import Mapping + +ONE_DAY = datetime.timedelta(days=1) + +# A `bus_owner` mapping is `(bus_id, date) -> device_id` for the device +# Section 9 directly confirmed that day, for every day with a non-"none" +# assignment. A day absent from the map (bus didn't run, had no +# candidates, or resolved to "none") is never treated as a conflict -- +# only a *different*, positively-confirmed device is. + + +@dataclass(frozen=True) +class DeviceRun: + """One contiguous (post-smoothing) run of a device on one bus. + + Attributes: + bus_id: The bus this run is assigned to. + from_date: First assigned day in the run. + to_date: Last assigned day in the run. + n_days: Count of actually-assigned days within the run (may be + less than the calendar span `to_date - from_date + 1` -- + the difference is bridged dead-AVL/no-candidate days). + n_corrected: How many of `n_days` were isolated-deviation + corrections rather than the solver's own direct output. + + """ + + bus_id: str + from_date: datetime.date + to_date: datetime.date + n_days: int + n_corrected: int + + +def _gap_conflicts( + bus_id: str, + start: datetime.date, + end: datetime.date, + device_id: str, + bus_owner: Mapping[tuple[str, datetime.date], str], +) -> bool: + """Check for `bus_id` confirmed to another device on any day in `(start, end)`.""" + day = start + ONE_DAY + while day < end: + owner = bus_owner.get((bus_id, day)) + if owner is not None and owner != device_id: + return True + day += ONE_DAY + return False + + +def _device_gap_conflicts( + device_id: str, + start: datetime.date, + end: datetime.date, + bus_id: str, + device_owner: Mapping[tuple[str, datetime.date], str], +) -> bool: + """Check for `device_id` confirmed to another bus on any day in `[start, end]`. + + Inclusive on both ends (unlike `_gap_conflicts`'s exclusive range) -- + harmless redundancy, since `start`/`end` are already-verified + running days by the time a caller reaches this check, and it avoids + an off-by-one trap on single-day gaps. + """ + day = start + while day <= end: + owner = device_owner.get((device_id, day)) + if owner is not None and owner != bus_id: + return True + day += ONE_DAY + return False + + +def _raw_runs( + dates: list[datetime.date], buses: list[str] +) -> list[tuple[str, list[datetime.date]]]: + """Group into runs of calendar-*and*-bus-consecutive days only -- no bridging.""" + runs: list[tuple[str, list[datetime.date]]] = [] + for date, bus_id in zip(dates, buses, strict=True): + same_bus_adjacent = ( + runs and runs[-1][0] == bus_id and date - runs[-1][1][-1] == ONE_DAY + ) + if same_bus_adjacent: + runs[-1][1].append(date) + else: + runs.append((bus_id, [date])) + return runs + + +def smooth_device_sequence( + device_id: str, + dates: list[datetime.date], + buses: list[str], + bus_owner: Mapping[tuple[str, datetime.date], str], +) -> tuple[list[DeviceRun], list[dict]]: + """Smooth and collapse one device's assigned-day sequence into runs. + + Args: + device_id: The device these observations belong to (needed to + tell "this bus is confirmed to *me*" apart from "to someone + else" when consulting `bus_owner`). + dates: Assigned days for this device, ascending, no duplicates. + buses: Same length as `dates` -- the bus assigned each day. + bus_owner: See module docstring -- `(bus_id, date) -> device_id`. + + Returns: + `(runs, corrections)`. `runs` are the final `DeviceRun`s. + `corrections` is one dict per corrected day (`date`, `from_bus`, + `to_bus`) for logging -- "a cluster of them on one device + indicates a data problem worth investigating" (plan Section + 10.3). + + """ + raw_runs = _raw_runs(dates, buses) + + # Deviation detection happens on the *raw* (pre-bridge) runs, on + # request-shaped intuition: "a single day sandwiched in an + # otherwise-stable stretch" is about the observed sequence, not + # about whichever gaps happen to bridge successfully. Computed in + # one pass over the original data (not iteratively re-checked after + # each correction) -- correcting one length-1 run can only ever + # grow its neighbors, never create a *new* length-1 run elsewhere, + # so a fixed point is reached in this single pass. + corrections: list[dict] = [] + corrected_buses = list(buses) + date_pos = {d: i for i, d in enumerate(dates)} + for i, (bus_id, run_dates) in enumerate(raw_runs): + is_interior = 0 < i < len(raw_runs) - 1 + if not is_interior or len(run_dates) != 1: + continue + surrounding_bus = raw_runs[i - 1][0] + if surrounding_bus != raw_runs[i + 1][0] or surrounding_bus == bus_id: + continue + deviation_date = run_dates[0] + owner = bus_owner.get((surrounding_bus, deviation_date)) + if owner not in (None, device_id): + continue + corrections.append( + {"date": deviation_date, "from_bus": bus_id, "to_bus": surrounding_bus} + ) + corrected_buses[date_pos[deviation_date]] = surrounding_bus + + # Re-run from scratch on the corrected labels, then bridge gaps -- + # reusing the same validated merge logic for both the "already + # calendar-adjacent" and "bridged across a gap" cases, rather than + # a second hand-rolled merge path that could (and did, before this + # rewrite) skip the conflict check. + corrected_runs = _raw_runs(dates, corrected_buses) + merged: list[tuple[str, list[datetime.date]]] = [] + for bus_id, run_dates in corrected_runs: + prev_bus, prev_dates = merged[-1] if merged else (None, None) + can_merge = ( + merged + and prev_bus == bus_id + and not _gap_conflicts( + bus_id, prev_dates[-1], run_dates[0], device_id, bus_owner + ) + ) + if can_merge: + merged[-1][1].extend(run_dates) + else: + merged.append((bus_id, list(run_dates))) + + corrected_dates = {c["date"] for c in corrections} + device_runs = [ + DeviceRun( + bus_id=bus_id, + from_date=min(run_dates), + to_date=max(run_dates), + n_days=len(run_dates), + n_corrected=sum(1 for d in run_dates if d in corrected_dates), + ) + for bus_id, run_dates in merged + ] + return device_runs, corrections + + +def smooth_all_devices(assignments: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame]: + """Run `smooth_device_sequence` over every device in `assignments`. + + Args: + assignments: Columns `bus_id`, `date`, `device_id` -- rows where + a device was actually assigned to a bus (e.g. + `ml.bus_matching_global_assignment` filtered to + `device_id IS NOT NULL`). One row per `(bus_id, date)`, but + grouped here by `device_id`. + + Returns: + `(intervals, corrections)`. `intervals` has columns `device_id`, + `bus_id`, `from_date`, `to_date`, `n_days`, `n_corrected`. + `corrections` has columns `device_id`, `date`, `from_bus`, + `to_bus`. + + """ + owner_cols = zip( + assignments["bus_id"], + assignments["date"], + assignments["device_id"], + strict=True, + ) + bus_owner: dict[tuple[str, datetime.date], str] = { + (bus_id, date): device_id for bus_id, date, device_id in owner_cols + } + + interval_rows = [] + correction_rows = [] + for device_id, group in assignments.sort_values("date").groupby("device_id"): + runs, corrections = smooth_device_sequence( + device_id, group["date"].tolist(), group["bus_id"].tolist(), bus_owner + ) + interval_rows.extend( + { + "device_id": device_id, + "bus_id": run.bus_id, + "from_date": run.from_date, + "to_date": run.to_date, + "n_days": run.n_days, + "n_corrected": run.n_corrected, + } + for run in runs + ) + correction_rows.extend({"device_id": device_id, **c} for c in corrections) + + intervals = pd.DataFrame( + interval_rows, + columns=[ + "device_id", + "bus_id", + "from_date", + "to_date", + "n_days", + "n_corrected", + ], + ) + corrections_df = pd.DataFrame( + correction_rows, columns=["device_id", "date", "from_bus", "to_bus"] + ) + return intervals, corrections_df + + +def build_device_owner( + assignments: pd.DataFrame, corrections: pd.DataFrame +) -> dict[tuple[str, datetime.date], str]: + """Build `extend_to_bus_bounds`'s `device_owner` from smoothed, not raw, data. + + Confirmed live this distinction matters: building it straight from + `assignments` (Section 9's raw per-day output) and using that to + gate `extend_to_bus_bounds` split intervals on phantom conflicts -- + days `smooth_all_devices` had *already* corrected away as isolated + one-day noise (recorded in `corrections`) still showed their old, + overridden bus in the raw table, so extension treated Section 10's + own resolved noise as if it were a live, unresolved conflict. + Applying `corrections` on top before use fixes it. + + Args: + assignments: Same frame passed to `smooth_all_devices`. + corrections: That call's second return value. + + Returns: + `(device_id, date) -> bus_id`, corrections applied. + + """ + device_owner = { + (device_id, date): bus_id + for bus_id, date, device_id in zip( + assignments["bus_id"], + assignments["date"], + assignments["device_id"], + strict=True, + ) + } + for device_id, date, to_bus in zip( + corrections["device_id"], + corrections["date"], + corrections["to_bus"], + strict=True, + ): + device_owner[device_id, date] = to_bus + return device_owner + + +def _extend_one_bus( + bus_id: str, + device_id: str, + group: pd.DataFrame, + running_dates: list[datetime.date], + device_owner: Mapping[tuple[str, datetime.date], str], +) -> list[dict]: + """`extend_to_bus_bounds` for a single already-single-device bus.""" + # Calendar days already inside one of this bus's own Section 10 + # interval spans -- includes that section's *own* bridged days, not + # just its directly-solved ones (those stay distinguishable via each + # original row's own n_days/n_corrected, preserved below; this set + # is only for "already accounted for" vs "new"). + already_covered: set[datetime.date] = set() + for row in group.itertuples(index=False): + day = row.from_date + while day <= row.to_date: + already_covered.add(day) + day += ONE_DAY + + # Walk the bus's own *running* days in order (never a calendar day it + # didn't operate -- that's not a gap to bridge, just a day off). + # Skip a day this bus's device is directly confirmed elsewhere on; + # for every other day, merge it into the current run only if the + # *entire calendar range* back to the run's last day (not just + # running days) is conflict-free -- checking only individual running + # days would silently bridge straight through a conflict that falls + # entirely on days this bus simply has no trips on. Confirmed live + # this matters: a device legitimately confirmed to a different bus + # for a few days this bus never runs on got bridged through when + # this check only looked at running days one at a time instead of + # the full range between them. + runs: list[list[datetime.date]] = [] + for day in running_dates: + is_available = day in already_covered or device_owner.get((device_id, day)) in ( + None, + bus_id, + ) + if not is_available: + continue + if runs and not _device_gap_conflicts( + device_id, runs[-1][-1], day, bus_id, device_owner + ): + runs[-1].append(day) + else: + runs.append([day]) + + rows = [] + for run_dates in runs: + from_date, to_date = min(run_dates), max(run_dates) + subsumed = group[ + (group["from_date"] >= from_date) & (group["to_date"] <= to_date) + ] + n_already_covered = sum(1 for d in run_dates if d in already_covered) + rows.append( + { + "device_id": device_id, + "bus_id": bus_id, + "from_date": from_date, + "to_date": to_date, + "n_days": int(subsumed["n_days"].sum()), + "n_corrected": int(subsumed["n_corrected"].sum()), + "n_extended": len(run_dates) - n_already_covered, + } + ) + return rows + + +def extend_to_bus_bounds( + intervals: pd.DataFrame, + bus_running_dates: Mapping[str, list[datetime.date]], + device_owner: Mapping[tuple[str, datetime.date], str], +) -> pd.DataFrame: + """Extend single-device buses' intervals to their full running range. + + On request, from a domain expert: a device essentially never changes + bus mid-month ("hardly ever, perhaps once every blue moon"), so once + a bus has exactly one distinct confirmed device across *all* its + Section 10 intervals, that device very likely covers every day the + bus ran that month -- not just the days Section 9 happened to find + enough direct evidence for. Extends before the earliest interval, + after the latest, and through any gap between same-device intervals. + + Still gated by the same cross-device conflict check as gap-bridging + (never extend into a day where the target device is already + confirmed to a *different* bus) -- "hardly ever" is not "never", and + a bus's own running-date range can include a day genuinely covered + by the rare real swap. + + Buses with more than one distinct device across their intervals are + left completely untouched -- that's exactly the rare swap case this + rule doesn't try to resolve, on request (ambiguous which device to + extrapolate into the gaps for those). + + Args: + intervals: `smooth_all_devices` output. + bus_running_dates: `bus_id -> every date that bus had a valid + trip` (not just days it has an interval for). + device_owner: `(device_id, date) -> bus_id` for every day + Section 9 directly confirmed that device to some bus -- + built from the same source as `smooth_all_devices`'s + internal `bus_owner`, just keyed the other way around. + + Returns: + A new intervals-shaped `DataFrame` (same columns as + `smooth_all_devices`, plus `n_extended`: days added beyond what + was directly solved). Untouched (multi-device) buses' original + rows pass through with `n_extended=0`. + + """ + rows = [] + for bus_id, group in intervals.groupby("bus_id"): + device_ids = group["device_id"].unique() + running_dates = sorted(bus_running_dates.get(bus_id, [])) + if len(device_ids) != 1 or not running_dates: + rows.extend({**r, "n_extended": 0} for r in group.to_dict("records")) + continue + rows.extend( + _extend_one_bus(bus_id, device_ids[0], group, running_dates, device_owner) + ) + + return pd.DataFrame( + rows, + columns=[ + "device_id", + "bus_id", + "from_date", + "to_date", + "n_days", + "n_corrected", + "n_extended", + ], + ) diff --git a/ml/bus_matching_model/app/streamlit_app.py b/ml/bus_matching_model/app/streamlit_app.py new file mode 100644 index 0000000..bd40578 --- /dev/null +++ b/ml/bus_matching_model/app/streamlit_app.py @@ -0,0 +1,669 @@ +"""Streamlit active-learning labeling UI for the Bus Matching model. + +Run with `uv run streamlit run ml/bus_matching_model/app/streamlit_app.py` +from the repo root. + +**Labeling unit is one trip, not one bus-date.** Each decision judges +only the trip on screen ("which candidate looks right for THIS trip"), +recorded in `ml.bus_matching_trip_labels`. A bus-date's day-level +verdict is a continuous probabilistic belief (`belief.py`), not a hard +vote count: each vote is noisy evidence that shifts belief, starting +from a prior seeded by the Tier 1 cold-start score. A bus-date only +counts as "resolved" (and feeds training) once its top option clears a +confidence bar *and* is clearly separated from the runner-up -- so a +single mistaken click can never resolve anything by itself, and +disagreeing votes just leave it open rather than forcing a verdict. +Confirming a device on one bus-date also discounts that same device's +belief on every *other* bus-date that lists it as a candidate the same +day (`belief.py`'s cross-suppression), which is what lets labeling one +bus confidently help disambiguate others without a separate vote. + +**No manual queue.** `db.fetch_next_trip` always picks the single most +informative next trip on its own -- the unresolved bus-date with the +smallest belief margin (closest call) that still has an unlabeled +sample trip. Decision controls live in the sidebar, which Streamlit +keeps fixed in place while the main panel (maps) scrolls, so they stay +clickable without hunting for them. + +**On request**: score + dictionary-origin badge shown directly on every +candidate (both sidebar and main panel), candidates ordered best to +worst score -- the plan's anti-bias hiding, deliberately overridden. +The chainage-time plot was tried and dropped (not useful in practice); +maps are the only evidence shown now, two per candidate (one per GTFS +direction). + +**Scope of this first pass**, relative to the full plan (Section 4): +implemented -- contested/uncontested routing off +`ml.bus_matching_contestedness`, two maps per candidate device (route + +stops + that candidate's own white-to-black AVL trail), forced-choice- +in-contested / yes-no-unsure-in-uncontested decisions, model-informed +belief once a model exists (Section 5). **Not yet implemented**: the +full batch `linear_sum_assignment` per date (Section 9) and temporal +smoothing (Section 10) -- those stay periodic background jobs, not part +of this live UI -- the day-summary strip, and real keyboard shortcuts +(buttons only, matching how `trip_validity_model`'s own labeler is +actually built despite the plan mentioning keys). +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import TYPE_CHECKING, Any + +_APP_DIR = Path(__file__).resolve().parent +if str(_APP_DIR) not in sys.path: + sys.path.insert(0, str(_APP_DIR)) + +import db # noqa: E402 +import maps # noqa: E402 +import numpy as np # noqa: E402 +import pandas as pd # noqa: E402 +import registry # noqa: E402 +import streamlit as st # noqa: E402 +import streamlit_folium # noqa: E402 +import training # noqa: E402 +from calibration import PlattCalibrator # noqa: E402 +from features import DAY_FEATURE_NAMES, select_sample_trips # noqa: E402 +from gtfs_cache import build_shape_cache # noqa: E402 +from metrics import evaluate as evaluate_metrics # noqa: E402 +from schema import ensure_schema # noqa: E402 + +if TYPE_CHECKING: + import datetime + from collections.abc import Callable + + import psycopg + +MAP_HEIGHT_PX = 280 + +# The plan expects a total label budget in the low hundreds (Section +# 11), nowhere near trip_validity_model's 500-row calibration/test +# budget -- so the first retrain happens much sooner, and every retrain +# afterwards is a full refit (no incremental "cycle" vs "milestone" +# split; that distinction is deferred to the one proper tuning pass +# after labeling converges, per Section 5). Thresholds count *resolved +# bus-dates* (db.resolved_bus_dates), not raw trip clicks, since that's +# what actually produces training rows. +SEED_LABEL_THRESHOLD = 15 +RETRAIN_INTERVAL = 5 +MIN_CLASS_COUNT = 3 +MIN_BUS_DATES_FOR_SPLIT = 5 +MIN_LABEL_CLASSES = 2 + +st.set_page_config(page_title="Bus Matching Labeler", layout="wide") + + +@st.cache_resource +def get_connection() -> psycopg.Connection: + """Open (and cache across reruns) the app's single database connection.""" + conn = db.get_connection() + ensure_schema(conn) + return conn + + +@st.cache_resource +def get_shape_cache(_conn: psycopg.Connection) -> dict[tuple, Any]: + """Build (and cache across reruns) the GTFS shape cache.""" + return build_shape_cache(_conn) + + +@st.cache_data(ttl=30) +def get_features() -> pd.DataFrame: + """Load whatever Tier 1 feature parquet checkpoints exist so far. + + Cached for 30s at a time (not forever) since the full-month + background build can still be writing new `date=*.parquet` files + while this app is in use. + """ + return db.load_available_features() + + +@st.cache_data(ttl=300) +def get_total_bus_dates(_conn: psycopg.Connection) -> int: + """Total bus-dates in scope -- the resolution-progress denominator.""" + return db.total_bus_date_count(_conn) + + +def _available_shapes(row: pd.Series, shape_cache: dict[tuple, Any]) -> dict[str, Any]: + """Every GTFS direction that actually has a cached shape for this trip.""" + shapes = {} + for direction, shape_id_col in (("I", "gtfs_shape_id_i"), ("V", "gtfs_shape_id_v")): + key = (row["gtfs_feed_version_date"], row[shape_id_col]) + if key in shape_cache: + shapes[direction] = shape_cache[key] + return shapes + + +def _ensure_current_trip(conn: psycopg.Connection, features: pd.DataFrame) -> None: + if st.session_state.get("current") is not None: + return + next_trip = db.fetch_next_trip( + conn, + features, + model_state=st.session_state.model_state, + selection_mode=st.session_state.selection_mode, + exclude_trip_ids=st.session_state.skipped_trips, + exclude_bus_dates=st.session_state.skipped_bus_dates, + ) + st.session_state.current = next_trip + + +def _on_selection_mode_change() -> None: + """Force a fresh pick under the new mode instead of finishing out the old one.""" + st.session_state.current = None + + +def _load_latest_model(conn: psycopg.Connection) -> dict[str, Any] | None: + run_row = db.fetch_latest_model_run(conn) + if run_row is None: + return None + loaded = registry.load_run(run_row) + loaded["run_row"] = run_row + return loaded + + +def _group_split( + bus_dates: list[tuple[str, Any]], + *, + test_frac: float = 0.3, +) -> tuple[set, set]: + """Split `(bus_id, date)` groups into train/test. + + Splitting by bus-date (not by expanded row) keeps a bus-date's + positive and negative rows -- which share almost all their context + and are highly correlated -- on the same side of the split, rather + than leaking related rows across train and test. + + No separate calibration split (on request: calibration is disabled, + see `_run_training_cycle`) -- that slice is folded into `test` + instead, so `test_ece` (the sole number `belief.model_prior_weight_for` + now trusts) gets measured on more data than the old 15%-calib + + 15%-test split gave it. + """ + rng = np.random.default_rng(42) + shuffled = list(bus_dates) + rng.shuffle(shuffled) + n = len(shuffled) + n_test = max(1, int(n * test_frac)) + test = set(shuffled[:n_test]) + train = set(shuffled[n_test:]) + return train, test + + +def _run_training_cycle(conn: psycopg.Connection) -> None: + features = db.load_available_features() + expanded = db.expand_labels_to_training_rows( + conn, features, st.session_state.model_state + ) + if expanded.empty: + return + class_counts = expanded["label"].value_counts() + if ( + class_counts.get(True, 0) < MIN_CLASS_COUNT + or class_counts.get(False, 0) < MIN_CLASS_COUNT + ): + return + + bus_dates = list( + expanded[["bus_id", "date"]] + .drop_duplicates() + .itertuples(index=False, name=None) + ) + if len(bus_dates) < MIN_BUS_DATES_FOR_SPLIT: + return + train_keys, test_keys = _group_split(bus_dates) + + def _subset(keys: set) -> pd.DataFrame: + mask = expanded.apply(lambda r: (r["bus_id"], r["date"]) in keys, axis=1) + return expanded[mask] + + train_df, test_df = _subset(train_keys), _subset(test_keys) + if train_df["label"].nunique() < MIN_LABEL_CLASSES: + return + + model = training.train_model( + train_df[DAY_FEATURE_NAMES], train_df["label"].to_numpy() + ) + + # Calibration disabled, on request: a Platt fit on a bus-date-count + # this small kept swinging wildly retrain to retrain (intercept + # ranged -1.8 to +0.66 across 18 real retrains) even after the old + # `MIN_CALIB_POSITIVES` guard, and that swing alone moved live + # coverage by ~9x with no change in the underlying model -- confirmed + # live by rescoring the same model through two different fitted + # calibrators. Identity mapping means `score` downstream is the raw + # model probability, so `test_ece` below measures the *model's own* + # calibration quality directly, and `belief.model_prior_weight_for` + # already refuses to trust it until that's genuinely good -- nothing + # else needs to change to revisit real calibration later, once there + # are enough resolved bus-dates for a calibration split to be stable + # in its own right. + calibrator = PlattCalibrator.from_params(coefficient=1.0, intercept=0.0) + + test_metrics = { + "auc": float("nan"), + "brier": float("nan"), + "log_loss": float("nan"), + "ece": float("nan"), + } + if not test_df.empty: + raw_test = training.predict_positive_proba(model, test_df[DAY_FEATURE_NAMES]) + calibrated_test = calibrator.predict(raw_test) + test_metrics = evaluate_metrics(test_df["label"].to_numpy(), calibrated_test) + + registry.save_run( + conn, + n_train_labels=len(train_df), + model=model, + calibrator=calibrator, + selected_features=DAY_FEATURE_NAMES, + hyperparameters=training.FIXED_HYPERPARAMETERS, + test_metrics=test_metrics, + n_resolved_bus_dates=len(bus_dates), + n_trip_labels_total=db.trip_label_counts(conn)["total"], + ) + # Re-fetch rather than construct the row by hand, so `run_row` (used + # by `belief.model_prior_weight_for` to ramp trust) is populated + # immediately -- without this, a freshly retrained model would fall + # back to zero prior weight until the next full page reload. + st.session_state.model_state = { + "model": model, + "calibrator": calibrator, + "selected_features": DAY_FEATURE_NAMES, + "run_row": db.fetch_latest_model_run(conn), + } + + +def _maybe_retrain(conn: psycopg.Connection, features: pd.DataFrame) -> None: + usable = len(db.resolved_bus_dates(conn, features, st.session_state.model_state)) + crossed_seed = usable == SEED_LABEL_THRESHOLD + crossed_interval = ( + usable > SEED_LABEL_THRESHOLD + and (usable - SEED_LABEL_THRESHOLD) % RETRAIN_INTERVAL == 0 + ) + if crossed_seed or crossed_interval: + with st.spinner(f"Retraining on {usable} resolved bus-dates..."): + _run_training_cycle(conn) + + +def _handle_decision( + conn: psycopg.Connection, + features: pd.DataFrame, + *, + bus_id: str, + date: datetime.date, + trip_id: int, + device_id: str | None, + decision: db.Decision, + mode: db.Mode, + n_candidates: int, +) -> None: + db.insert_trip_label( + conn, + bus_id=bus_id, + date=date, + trip_id=trip_id, + device_id=device_id, + decision=decision, + mode=mode, + n_candidates=n_candidates, + ) + _maybe_retrain(conn, features) + st.session_state.current = None + st.rerun() + + +def _render_contested_decisions( + candidates: pd.DataFrame, decide: Callable[[str | None, db.Decision], None] +) -> None: + for row in candidates.itertuples(index=False): + score_str = f"{row.score:.2f}" if pd.notna(row.score) else "?" + dict_badge = " · dictionary pick" if row.from_dictionary else "" + st.caption(f"score {score_str}{dict_badge}") + if st.button(row.device_id, key=f"pick_{row.device_id}", width="stretch"): + decide(row.device_id, "match") + if st.button("None of these", width="stretch"): + decide(None, "none_of_these") + if st.button("Unsure", width="stretch"): + decide(None, "unsure") + + +def _render_uncontested_decision( + top_device: str | None, decide: Callable[[str | None, db.Decision], None] +) -> None: + if st.button( + "Yes, matches this trip", + type="primary", + disabled=top_device is None, + width="stretch", + ): + decide(top_device, "match") + if st.button("No", width="stretch"): + decide(None, "none_of_these") + if st.button("Unsure", width="stretch"): + decide(None, "unsure") + + +def _render_sidebar_decisions( + conn: psycopg.Connection, + features: pd.DataFrame, + *, + bus_id: str, + date: datetime.date, + trip_id: int, + candidates: pd.DataFrame, + is_contested: bool, +) -> None: + device_ids = candidates["device_id"].tolist() + mode: db.Mode = "contested" if is_contested else "uncontested" + + def decide(device_id: str | None, decision: db.Decision) -> None: + _handle_decision( + conn, + features, + bus_id=bus_id, + date=date, + trip_id=trip_id, + device_id=device_id, + decision=decision, + mode=mode, + n_candidates=len(device_ids), + ) + + st.caption( + "Which candidate looks right for THIS trip?" + if is_contested + else "Does this device match THIS trip?" + ) + if is_contested: + _render_contested_decisions(candidates, decide) + else: + top_device = device_ids[0] if device_ids else None + _render_uncontested_decision(top_device, decide) + + if st.button("Skip this trip", width="stretch"): + st.session_state.skipped_trips.add(trip_id) + st.session_state.current = None + st.rerun() + + if st.button("Get a different bus/route", width="stretch"): + st.session_state.skipped_bus_dates.add((bus_id, date)) + st.session_state.current = None + st.rerun() + + +_STOPPING_VERDICT_RENDER = { + "too_early": st.info, + "keep_going": st.info, + "slowing": st.warning, + "stop_soon": st.success, + "check_features": st.error, +} + + +def _render_status_panel(conn: psycopg.Connection, features: pd.DataFrame) -> None: + """Progress, model status, and the auto stopping-criteria signal. + + Deliberately in the main panel, not the sidebar -- the sidebar is + reserved for the clickable decision controls only. + """ + with st.expander("Status: progress, model, stopping signal", expanded=True): + trip_counts = db.trip_label_counts(conn) + model_state = st.session_state.model_state + n_resolved = len(db.resolved_bus_dates(conn, features, model_state)) + n_coverage = db.model_coverage_count(conn, features, model_state) + n_total = get_total_bus_dates(conn) + + st.caption( + f"**{trip_counts['total']} trip decisions** · " + f"match {trip_counts['match']} · " + f"none-of-these {trip_counts['none_of_these']} · " + f"unsure {trip_counts['unsure']}" + ) + st.caption( + f"**{n_resolved} bus-dates resolved by labeling** " + "(training data -- meant to stay small, a few hundred per the plan)" + ) + st.caption( + f"**{n_coverage} / {n_total} bus-dates** the prior is confident about " + "with zero votes (the real coverage number -- climbs as the model " + "improves, without you voting on all of them)" + ) + st.progress(min(n_coverage / n_total, 1.0) if n_total else 0.0) + + n_features_dates = features["date"].nunique() if not features.empty else 0 + st.caption(f"Tier 1 features available for {n_features_dates} dates so far") + st.caption( + "Selection: model-informed uncertainty sampling" + if model_state is not None + else "Selection: Tier 1 heuristic-informed uncertainty sampling " + "(no model yet)" + ) + + if model_state is None: + to_go = max(SEED_LABEL_THRESHOLD - n_resolved, 0) + st.caption( + f"No model trained yet ({to_go} more resolved bus-dates " + "until the first retrain)." + if to_go + else "First retrain due any label now." + ) + else: + run_row = model_state.get("run_row") + if run_row: + st.caption( + f"Model: {run_row['n_train_labels']} train rows · " + f"test AUC {run_row['test_auc']:.2f} · " + f"test Brier {run_row['test_brier']:.3f}" + ) + model_runs = db.fetch_model_runs(conn) + if len(model_runs) > 1: + st.line_chart( + model_runs.set_index("n_train_labels")[["test_brier", "test_auc"]] + ) + + signal = db.stopping_signal(conn) + render_fn = _STOPPING_VERDICT_RENDER.get(signal["verdict"], st.info) + render_fn(f"**Stopping signal**: {signal['message']}") + + +def _init_session_state(conn: psycopg.Connection) -> None: + if "current" not in st.session_state: + st.session_state.current = None + if "skipped_trips" not in st.session_state: + st.session_state.skipped_trips = set() + if "skipped_bus_dates" not in st.session_state: + st.session_state.skipped_bus_dates = set() + if "model_state" not in st.session_state: + st.session_state.model_state = _load_latest_model(conn) + if "selection_mode" not in st.session_state: + st.session_state.selection_mode = "hardest" + + +def _render_sidebar( + conn: psycopg.Connection, features: pd.DataFrame, current: tuple | None +) -> None: + """Sidebar holds only the clickable decision controls, on request. + + Streamlit keeps it fixed in place while the main panel scrolls, so + this is what actually stays reachable without hunting for it. + Everything informational (counts, model status, stopping signal) + lives in the main panel's status section instead. + """ + with st.sidebar: + st.radio( + "Next-pick mode", + options=["hardest", "random"], + format_func=lambda m: ( + "Hardest case (default)" if m == "hardest" else "Random bus-date" + ), + key="selection_mode", + on_change=_on_selection_mode_change, + help=( + "Hardest: uncertainty sampling, most informative for the model. " + "Random: sweeps up ordinary/easy cases too, to counter decision-" + "boundary jitter from labeling only hard cases." + ), + ) + st.divider() + if current is None: + st.success("Nothing left to label right now.") + return + bus_id, date, trip_id, is_contested = current + candidates = db.fetch_bus_date_candidates( + conn, features, bus_id, date, model_state=st.session_state.model_state + ) + _render_sidebar_decisions( + conn, + features, + bus_id=bus_id, + date=date, + trip_id=trip_id, + candidates=candidates, + is_contested=is_contested, + ) + + +def _render_shape_caption(shapes: dict[str, Any]) -> None: + if not shapes: + st.caption( + "No matched GTFS shape for this trip -- no route to compare against." + ) + elif len(shapes) == 1: + st.caption( + f"Only direction {next(iter(shapes))} exists in GTFS for this route." + ) + else: + st.caption("Both directions I (blue) and V (orange) shown below.") + + +def _build_map_shapes( + conn: psycopg.Connection, trip: pd.Series, shapes: dict[str, Any] +) -> dict[str, tuple[list[list[float]] | None, pd.DataFrame]]: + map_shapes: dict[str, tuple[list[list[float]] | None, pd.DataFrame]] = {} + for direction, shape_id_col in (("I", "gtfs_shape_id_i"), ("V", "gtfs_shape_id_v")): + if direction not in shapes: + continue + shape_id = trip[shape_id_col] + geojson = db.fetch_shape_geojson(conn, trip["gtfs_feed_version_date"], shape_id) + stops = db.fetch_route_stops(conn, trip["gtfs_feed_version_date"], shape_id) + map_shapes[direction] = (geojson, stops) + return map_shapes + + +def _render_candidate_maps( + *, + bus_id: str, + date: datetime.date, + trip_id: int, + candidates: pd.DataFrame, + is_contested: bool, + map_shapes: dict[str, tuple[list[list[float]] | None, pd.DataFrame]], + tracks: dict[str, pd.DataFrame], +) -> None: + map_candidates = candidates if is_contested else candidates.head(1) + st.caption( + "One map per candidate per GTFS direction -- ordered best to worst score, " + "gray-to-black trail is trip start to trip end" + if is_contested + else "Maps for the top-scored candidate -- gray-to-black trail is trip " + "start to trip end" + ) + for row in map_candidates.itertuples(index=False): + device_id = row.device_id + score_str = f"{row.score:.2f}" if pd.notna(row.score) else "not yet scored" + dict_badge = " · dictionary pick" if row.from_dictionary else "" + st.markdown(f"**{device_id}** — score {score_str}{dict_badge}") + if not map_shapes: + st.caption("No matched GTFS shape for this trip.") + continue + + map_cols = st.columns(len(map_shapes)) + for col, direction in zip(map_cols, map_shapes, strict=False): + with col: + st.caption(f"Map {direction}") + streamlit_folium.st_folium( + maps.build_candidate_map( + {direction: map_shapes[direction]}, tracks[device_id] + ), + height=MAP_HEIGHT_PX, + use_container_width=True, + returned_objects=[], + key=f"map_{bus_id}_{date}_{trip_id}_{device_id}_{direction}", + ) + st.divider() + + +def main() -> None: + """Render the labeling UI.""" + conn = get_connection() + shape_cache = get_shape_cache(conn) + features = get_features() + + _init_session_state(conn) + _ensure_current_trip(conn, features) + current = st.session_state.current + + _render_sidebar(conn, features, current) + _render_status_panel(conn, features) + + if current is None: + st.info( + "Nothing left to label right now -- check back once more dates " + "are featurized." + ) + return + bus_id, date, trip_id, is_contested = current + + trips = db.fetch_bus_date_trips(conn, bus_id, date) + trip_rows = trips[trips["trip_id"] == trip_id] + if trip_rows.empty: + st.session_state.current = None + st.rerun() + return + trip = trip_rows.iloc[0] + + candidates = db.fetch_bus_date_candidates( + conn, features, bus_id, date, model_state=st.session_state.model_state + ) + device_ids = candidates["device_id"].tolist() + + sampled = select_sample_trips(trips) + labeled_here = db.labeled_trip_ids(conn) & set(sampled["trip_id"]) + + st.subheader( + f"Bus {bus_id} · {date} · {'CONTESTED' if is_contested else 'uncontested'}" + ) + start_str = f"{trip['trip_start_timestamp']:%H:%M:%S}" + end_str = f"{trip['trip_end_timestamp']:%H:%M:%S}" + st.caption( + f"This trip: route {trip['route_id']} · {start_str} - {end_str}" + f" · {len(labeled_here)}/{len(sampled)} sampled trips labeled " + "for this bus-date so far" + " · decide in the sidebar" + ) + + shapes = _available_shapes(trip, shape_cache) + _render_shape_caption(shapes) + + tracks: dict[str, pd.DataFrame] = { + device_id: db.fetch_device_trip_positions( + conn, device_id, trip["trip_start_timestamp"], trip["trip_end_timestamp"] + ) + for device_id in device_ids + } + map_shapes = _build_map_shapes(conn, trip, shapes) + + _render_candidate_maps( + bus_id=bus_id, + date=date, + trip_id=trip_id, + candidates=candidates, + is_contested=is_contested, + map_shapes=map_shapes, + tracks=tracks, + ) + + +main() diff --git a/ml/bus_matching_model/app/training.py b/ml/bus_matching_model/app/training.py new file mode 100644 index 0000000..05be210 --- /dev/null +++ b/ml/bus_matching_model/app/training.py @@ -0,0 +1,63 @@ +"""LightGBM training for the Bus Matching model. + +Per the plan's Section 5: fixed hyperparameters for the entire active +learning loop (tuning every round costs minutes and buys nothing while +the labeled set is still growing), and no feature selection (the +constraint-generated negatives already give plenty of training rows, +and the features are largely non-redundant by construction). One +proper hyperparameter tuning pass happens only after labeling +converges -- not implemented yet, since labeling hasn't produced enough +rows for that to matter. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import lightgbm as lgb +import numpy as np + +if TYPE_CHECKING: + import pandas as pd + +FIXED_HYPERPARAMETERS: dict[str, object] = { + "num_leaves": 31, + "learning_rate": 0.05, + "n_estimators": 300, + "min_child_samples": 20, +} + + +def train_model(features: pd.DataFrame, y: np.ndarray) -> lgb.LGBMClassifier: + """Fit a LightGBM binary classifier with the loop's fixed hyperparameters. + + Args: + features: Training pool feature columns (every Tier 1 feature). + y: Training pool labels. + + Returns: + The fitted model. + + """ + model = lgb.LGBMClassifier( + objective="binary", verbosity=-1, **FIXED_HYPERPARAMETERS + ) + model.fit(features, y) + return model + + +def predict_positive_proba( + model: lgb.LGBMClassifier, features: pd.DataFrame +) -> np.ndarray: + """Predict P(this candidate is the real match) as a plain ndarray. + + Args: + model: A fitted LightGBM classifier. + features: Rows to score, columns matching what `model` was fit on. + + Returns: + The model's raw (uncalibrated) predicted probability of the + positive class. + + """ + return np.asarray(model.predict_proba(features))[:, 1] diff --git a/ml/bus_matching_model/artifacts/models/run_0112_1787432905.joblib b/ml/bus_matching_model/artifacts/models/run_0112_1787432905.joblib new file mode 100644 index 0000000..ddddf16 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0112_1787432905.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0144_1787435204.joblib b/ml/bus_matching_model/artifacts/models/run_0144_1787435204.joblib new file mode 100644 index 0000000..470e19f Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0144_1787435204.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0193_1787433756.joblib b/ml/bus_matching_model/artifacts/models/run_0193_1787433756.joblib new file mode 100644 index 0000000..54fd637 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0193_1787433756.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0195_1787435308.joblib b/ml/bus_matching_model/artifacts/models/run_0195_1787435308.joblib new file mode 100644 index 0000000..511e865 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0195_1787435308.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0226_1787434431.joblib b/ml/bus_matching_model/artifacts/models/run_0226_1787434431.joblib new file mode 100644 index 0000000..223f556 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0226_1787434431.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0226_1787434545.joblib b/ml/bus_matching_model/artifacts/models/run_0226_1787434545.joblib new file mode 100644 index 0000000..223f556 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0226_1787434545.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0226_1787434626.joblib b/ml/bus_matching_model/artifacts/models/run_0226_1787434626.joblib new file mode 100644 index 0000000..223f556 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0226_1787434626.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0228_1787435443.joblib b/ml/bus_matching_model/artifacts/models/run_0228_1787435443.joblib new file mode 100644 index 0000000..8d8f618 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0228_1787435443.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0249_1787435719.joblib b/ml/bus_matching_model/artifacts/models/run_0249_1787435719.joblib new file mode 100644 index 0000000..c057a97 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0249_1787435719.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0249_1787435811.joblib b/ml/bus_matching_model/artifacts/models/run_0249_1787435811.joblib new file mode 100644 index 0000000..c057a97 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0249_1787435811.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0287_1787436011.joblib b/ml/bus_matching_model/artifacts/models/run_0287_1787436011.joblib new file mode 100644 index 0000000..59257d0 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0287_1787436011.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0287_1787436271.joblib b/ml/bus_matching_model/artifacts/models/run_0287_1787436271.joblib new file mode 100644 index 0000000..a927a9a Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0287_1787436271.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0287_1787445109.joblib b/ml/bus_matching_model/artifacts/models/run_0287_1787445109.joblib new file mode 100644 index 0000000..a927a9a Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0287_1787445109.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0340_1787445681.joblib b/ml/bus_matching_model/artifacts/models/run_0340_1787445681.joblib new file mode 100644 index 0000000..8db875e Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0340_1787445681.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0368_1787445862.joblib b/ml/bus_matching_model/artifacts/models/run_0368_1787445862.joblib new file mode 100644 index 0000000..a0a9e76 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0368_1787445862.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0388_1787446110.joblib b/ml/bus_matching_model/artifacts/models/run_0388_1787446110.joblib new file mode 100644 index 0000000..7575a1d Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0388_1787446110.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0419_1787446310.joblib b/ml/bus_matching_model/artifacts/models/run_0419_1787446310.joblib new file mode 100644 index 0000000..22c5a09 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0419_1787446310.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0419_1787446337.joblib b/ml/bus_matching_model/artifacts/models/run_0419_1787446337.joblib new file mode 100644 index 0000000..22c5a09 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0419_1787446337.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0463_1787447029.joblib b/ml/bus_matching_model/artifacts/models/run_0463_1787447029.joblib new file mode 100644 index 0000000..4f71372 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0463_1787447029.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0498_1787447184.joblib b/ml/bus_matching_model/artifacts/models/run_0498_1787447184.joblib new file mode 100644 index 0000000..513b8c4 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0498_1787447184.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0508_1787447209.joblib b/ml/bus_matching_model/artifacts/models/run_0508_1787447209.joblib new file mode 100644 index 0000000..e3925a8 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0508_1787447209.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0538_1787447467.joblib b/ml/bus_matching_model/artifacts/models/run_0538_1787447467.joblib new file mode 100644 index 0000000..d207b17 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0538_1787447467.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0559_1787448112.joblib b/ml/bus_matching_model/artifacts/models/run_0559_1787448112.joblib new file mode 100644 index 0000000..67caa02 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0559_1787448112.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0559_1787448144.joblib b/ml/bus_matching_model/artifacts/models/run_0559_1787448144.joblib new file mode 100644 index 0000000..67caa02 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0559_1787448144.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0609_1787448280.joblib b/ml/bus_matching_model/artifacts/models/run_0609_1787448280.joblib new file mode 100644 index 0000000..53e0ce9 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0609_1787448280.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0609_1787448333.joblib b/ml/bus_matching_model/artifacts/models/run_0609_1787448333.joblib new file mode 100644 index 0000000..53e0ce9 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0609_1787448333.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0643_1787448539.joblib b/ml/bus_matching_model/artifacts/models/run_0643_1787448539.joblib new file mode 100644 index 0000000..1c2d214 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0643_1787448539.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0674_1787448728.joblib b/ml/bus_matching_model/artifacts/models/run_0674_1787448728.joblib new file mode 100644 index 0000000..4c99d9f Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0674_1787448728.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0674_1787448756.joblib b/ml/bus_matching_model/artifacts/models/run_0674_1787448756.joblib new file mode 100644 index 0000000..4c99d9f Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0674_1787448756.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0674_1787448778.joblib b/ml/bus_matching_model/artifacts/models/run_0674_1787448778.joblib new file mode 100644 index 0000000..4c99d9f Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0674_1787448778.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0675_1787448670.joblib b/ml/bus_matching_model/artifacts/models/run_0675_1787448670.joblib new file mode 100644 index 0000000..a5f03fb Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0675_1787448670.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0699_1787448989.joblib b/ml/bus_matching_model/artifacts/models/run_0699_1787448989.joblib new file mode 100644 index 0000000..d5bb662 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0699_1787448989.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0699_1787449016.joblib b/ml/bus_matching_model/artifacts/models/run_0699_1787449016.joblib new file mode 100644 index 0000000..d5bb662 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0699_1787449016.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0702_1787448898.joblib b/ml/bus_matching_model/artifacts/models/run_0702_1787448898.joblib new file mode 100644 index 0000000..474c765 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0702_1787448898.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0736_1787450387.joblib b/ml/bus_matching_model/artifacts/models/run_0736_1787450387.joblib new file mode 100644 index 0000000..7caeb3b Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0736_1787450387.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0779_1787450465.joblib b/ml/bus_matching_model/artifacts/models/run_0779_1787450465.joblib new file mode 100644 index 0000000..054cad5 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0779_1787450465.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0779_1787450491.joblib b/ml/bus_matching_model/artifacts/models/run_0779_1787450491.joblib new file mode 100644 index 0000000..054cad5 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0779_1787450491.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0815_1787450604.joblib b/ml/bus_matching_model/artifacts/models/run_0815_1787450604.joblib new file mode 100644 index 0000000..be1d327 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0815_1787450604.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0815_1787450626.joblib b/ml/bus_matching_model/artifacts/models/run_0815_1787450626.joblib new file mode 100644 index 0000000..be1d327 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0815_1787450626.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0852_1787450733.joblib b/ml/bus_matching_model/artifacts/models/run_0852_1787450733.joblib new file mode 100644 index 0000000..305456c Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0852_1787450733.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0852_1787450771.joblib b/ml/bus_matching_model/artifacts/models/run_0852_1787450771.joblib new file mode 100644 index 0000000..305456c Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0852_1787450771.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0896_1787450897.joblib b/ml/bus_matching_model/artifacts/models/run_0896_1787450897.joblib new file mode 100644 index 0000000..0e63ad7 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0896_1787450897.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0896_1787451545.joblib b/ml/bus_matching_model/artifacts/models/run_0896_1787451545.joblib new file mode 100644 index 0000000..0e63ad7 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0896_1787451545.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0896_1787451571.joblib b/ml/bus_matching_model/artifacts/models/run_0896_1787451571.joblib new file mode 100644 index 0000000..0e63ad7 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0896_1787451571.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0929_1787451789.joblib b/ml/bus_matching_model/artifacts/models/run_0929_1787451789.joblib new file mode 100644 index 0000000..6e04dba Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0929_1787451789.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0929_1787451829.joblib b/ml/bus_matching_model/artifacts/models/run_0929_1787451829.joblib new file mode 100644 index 0000000..6e04dba Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0929_1787451829.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0957_1787451986.joblib b/ml/bus_matching_model/artifacts/models/run_0957_1787451986.joblib new file mode 100644 index 0000000..d780e6c Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0957_1787451986.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0965_1787452044.joblib b/ml/bus_matching_model/artifacts/models/run_0965_1787452044.joblib new file mode 100644 index 0000000..7cdcbe6 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0965_1787452044.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0984_1787452170.joblib b/ml/bus_matching_model/artifacts/models/run_0984_1787452170.joblib new file mode 100644 index 0000000..f670e8e Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0984_1787452170.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_0985_1787452236.joblib b/ml/bus_matching_model/artifacts/models/run_0985_1787452236.joblib new file mode 100644 index 0000000..30ac2a3 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_0985_1787452236.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_1034_1787452359.joblib b/ml/bus_matching_model/artifacts/models/run_1034_1787452359.joblib new file mode 100644 index 0000000..813630c Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_1034_1787452359.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_1072_1787452487.joblib b/ml/bus_matching_model/artifacts/models/run_1072_1787452487.joblib new file mode 100644 index 0000000..06c4711 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_1072_1787452487.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_1072_1787452532.joblib b/ml/bus_matching_model/artifacts/models/run_1072_1787452532.joblib new file mode 100644 index 0000000..06c4711 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_1072_1787452532.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_1110_1787452665.joblib b/ml/bus_matching_model/artifacts/models/run_1110_1787452665.joblib new file mode 100644 index 0000000..a917419 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_1110_1787452665.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_1110_1787452706.joblib b/ml/bus_matching_model/artifacts/models/run_1110_1787452706.joblib new file mode 100644 index 0000000..a917419 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_1110_1787452706.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_1130_1787452833.joblib b/ml/bus_matching_model/artifacts/models/run_1130_1787452833.joblib new file mode 100644 index 0000000..a189edb Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_1130_1787452833.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_1168_1787452988.joblib b/ml/bus_matching_model/artifacts/models/run_1168_1787452988.joblib new file mode 100644 index 0000000..e327e21 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_1168_1787452988.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_1195_1787453121.joblib b/ml/bus_matching_model/artifacts/models/run_1195_1787453121.joblib new file mode 100644 index 0000000..dbfd2cc Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_1195_1787453121.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_1238_1787453233.joblib b/ml/bus_matching_model/artifacts/models/run_1238_1787453233.joblib new file mode 100644 index 0000000..228ed71 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_1238_1787453233.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_1238_1787453291.joblib b/ml/bus_matching_model/artifacts/models/run_1238_1787453291.joblib new file mode 100644 index 0000000..228ed71 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_1238_1787453291.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_1270_1787453453.joblib b/ml/bus_matching_model/artifacts/models/run_1270_1787453453.joblib new file mode 100644 index 0000000..6fdb6cf Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_1270_1787453453.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_1323_1787453607.joblib b/ml/bus_matching_model/artifacts/models/run_1323_1787453607.joblib new file mode 100644 index 0000000..2391b0d Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_1323_1787453607.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_1344_1787453716.joblib b/ml/bus_matching_model/artifacts/models/run_1344_1787453716.joblib new file mode 100644 index 0000000..06729b2 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_1344_1787453716.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_1380_1787453961.joblib b/ml/bus_matching_model/artifacts/models/run_1380_1787453961.joblib new file mode 100644 index 0000000..f682448 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_1380_1787453961.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_1380_1787453991.joblib b/ml/bus_matching_model/artifacts/models/run_1380_1787453991.joblib new file mode 100644 index 0000000..f682448 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_1380_1787453991.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_1391_1787453850.joblib b/ml/bus_matching_model/artifacts/models/run_1391_1787453850.joblib new file mode 100644 index 0000000..46d8e93 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_1391_1787453850.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_1412_1787454123.joblib b/ml/bus_matching_model/artifacts/models/run_1412_1787454123.joblib new file mode 100644 index 0000000..c83689a Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_1412_1787454123.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_1467_1787457606.joblib b/ml/bus_matching_model/artifacts/models/run_1467_1787457606.joblib new file mode 100644 index 0000000..17366e9 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_1467_1787457606.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_1485_1787457833.joblib b/ml/bus_matching_model/artifacts/models/run_1485_1787457833.joblib new file mode 100644 index 0000000..6167666 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_1485_1787457833.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_1485_1787457899.joblib b/ml/bus_matching_model/artifacts/models/run_1485_1787457899.joblib new file mode 100644 index 0000000..6167666 Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_1485_1787457899.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_1535_1787458214.joblib b/ml/bus_matching_model/artifacts/models/run_1535_1787458214.joblib new file mode 100644 index 0000000..9810bec Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_1535_1787458214.joblib differ diff --git a/ml/bus_matching_model/artifacts/models/run_1538_1787458097.joblib b/ml/bus_matching_model/artifacts/models/run_1538_1787458097.joblib new file mode 100644 index 0000000..88c69bb Binary files /dev/null and b/ml/bus_matching_model/artifacts/models/run_1538_1787458097.joblib differ diff --git a/ml/bus_matching_model/artifacts/pair_models/pair_run_00042_1787510520.joblib b/ml/bus_matching_model/artifacts/pair_models/pair_run_00042_1787510520.joblib new file mode 100644 index 0000000..8aace5c Binary files /dev/null and b/ml/bus_matching_model/artifacts/pair_models/pair_run_00042_1787510520.joblib differ diff --git a/ml/bus_matching_model/artifacts/pair_models/pair_run_00042_1787510717.joblib b/ml/bus_matching_model/artifacts/pair_models/pair_run_00042_1787510717.joblib new file mode 100644 index 0000000..8aace5c Binary files /dev/null and b/ml/bus_matching_model/artifacts/pair_models/pair_run_00042_1787510717.joblib differ diff --git a/ml/bus_matching_model/artifacts/pair_models/pair_run_00047_1787512641.joblib b/ml/bus_matching_model/artifacts/pair_models/pair_run_00047_1787512641.joblib new file mode 100644 index 0000000..26977ce Binary files /dev/null and b/ml/bus_matching_model/artifacts/pair_models/pair_run_00047_1787512641.joblib differ diff --git a/ml/bus_matching_model/artifacts/pair_models/pair_run_00057_1787512869.joblib b/ml/bus_matching_model/artifacts/pair_models/pair_run_00057_1787512869.joblib new file mode 100644 index 0000000..bf52e8e Binary files /dev/null and b/ml/bus_matching_model/artifacts/pair_models/pair_run_00057_1787512869.joblib differ diff --git a/ml/bus_matching_model/artifacts/pair_models/pair_run_00067_1787513357.joblib b/ml/bus_matching_model/artifacts/pair_models/pair_run_00067_1787513357.joblib new file mode 100644 index 0000000..1338e29 Binary files /dev/null and b/ml/bus_matching_model/artifacts/pair_models/pair_run_00067_1787513357.joblib differ diff --git a/ml/bus_matching_model/artifacts/pair_models/pair_run_00074_1787513854.joblib b/ml/bus_matching_model/artifacts/pair_models/pair_run_00074_1787513854.joblib new file mode 100644 index 0000000..c8b2f9e Binary files /dev/null and b/ml/bus_matching_model/artifacts/pair_models/pair_run_00074_1787513854.joblib differ diff --git a/ml/bus_matching_model/artifacts/pair_models/pair_run_00080_1787515435.joblib b/ml/bus_matching_model/artifacts/pair_models/pair_run_00080_1787515435.joblib new file mode 100644 index 0000000..dc3c381 Binary files /dev/null and b/ml/bus_matching_model/artifacts/pair_models/pair_run_00080_1787515435.joblib differ diff --git a/ml/bus_matching_model/artifacts/pair_models/pair_run_00081_1787515493.joblib b/ml/bus_matching_model/artifacts/pair_models/pair_run_00081_1787515493.joblib new file mode 100644 index 0000000..05b5d45 Binary files /dev/null and b/ml/bus_matching_model/artifacts/pair_models/pair_run_00081_1787515493.joblib differ diff --git a/ml/bus_matching_model/artifacts/pair_models/pair_run_00081_1787604587.joblib b/ml/bus_matching_model/artifacts/pair_models/pair_run_00081_1787604587.joblib new file mode 100644 index 0000000..05b5d45 Binary files /dev/null and b/ml/bus_matching_model/artifacts/pair_models/pair_run_00081_1787604587.joblib differ diff --git a/ml/bus_matching_model/artifacts/pair_models/pair_run_00084_1787605446.joblib b/ml/bus_matching_model/artifacts/pair_models/pair_run_00084_1787605446.joblib new file mode 100644 index 0000000..cc01aef Binary files /dev/null and b/ml/bus_matching_model/artifacts/pair_models/pair_run_00084_1787605446.joblib differ diff --git a/ml/bus_matching_model/artifacts/pair_models/pair_run_00084_1787605524.joblib b/ml/bus_matching_model/artifacts/pair_models/pair_run_00084_1787605524.joblib new file mode 100644 index 0000000..cc01aef Binary files /dev/null and b/ml/bus_matching_model/artifacts/pair_models/pair_run_00084_1787605524.joblib differ diff --git a/ml/bus_matching_model/artifacts/pair_models/pair_run_00084_1787606663.joblib b/ml/bus_matching_model/artifacts/pair_models/pair_run_00084_1787606663.joblib new file mode 100644 index 0000000..cc01aef Binary files /dev/null and b/ml/bus_matching_model/artifacts/pair_models/pair_run_00084_1787606663.joblib differ diff --git a/ml/bus_matching_model/artifacts/pair_models/pair_run_00084_1787607268.joblib b/ml/bus_matching_model/artifacts/pair_models/pair_run_00084_1787607268.joblib new file mode 100644 index 0000000..cc01aef Binary files /dev/null and b/ml/bus_matching_model/artifacts/pair_models/pair_run_00084_1787607268.joblib differ diff --git a/ml/bus_matching_model/artifacts/pair_models/pair_run_00087_1787607908.joblib b/ml/bus_matching_model/artifacts/pair_models/pair_run_00087_1787607908.joblib new file mode 100644 index 0000000..df5eefb Binary files /dev/null and b/ml/bus_matching_model/artifacts/pair_models/pair_run_00087_1787607908.joblib differ diff --git a/ml/bus_matching_model/artifacts/pair_models/pair_run_00087_1787607967.joblib b/ml/bus_matching_model/artifacts/pair_models/pair_run_00087_1787607967.joblib new file mode 100644 index 0000000..df5eefb Binary files /dev/null and b/ml/bus_matching_model/artifacts/pair_models/pair_run_00087_1787607967.joblib differ diff --git a/ml/bus_matching_model/artifacts/pair_models/pair_run_00087_1789160130.joblib b/ml/bus_matching_model/artifacts/pair_models/pair_run_00087_1789160130.joblib new file mode 100644 index 0000000..5aaf663 Binary files /dev/null and b/ml/bus_matching_model/artifacts/pair_models/pair_run_00087_1789160130.joblib differ diff --git a/ml/bus_matching_model/notebooks/01_build_candidate_pairs.ipynb b/ml/bus_matching_model/notebooks/01_build_candidate_pairs.ipynb new file mode 100644 index 0000000..9b48c99 --- /dev/null +++ b/ml/bus_matching_model/notebooks/01_build_candidate_pairs.ipynb @@ -0,0 +1,486 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "c0bdf31d", + "metadata": {}, + "source": [ + "# 01 - Build `ml.bus_matching_candidate_pairs`\n", + "\n", + "First notebook for the Bus Matching model: builds the full universe of\n", + "*plausible* `bus_id` <-> `device_id` pairs from the two vehicle\n", + "dictionaries, for a later ML model to pick the real match out of. Unlike\n", + "`ml.trip_validity_bus_avl_match` (the Trip Validity model's crosswalk,\n", + "which picks one arbitrary-but-deterministic match per `bus_id`), this\n", + "notebook keeps **every** plausible pair, many-to-many, and tags each with\n", + "where it came from -- collapsing to a single best match is the next\n", + "model's job, not this notebook's.\n", + "\n", + "`bus_id` uses the exact same column name and normalization as\n", + "`ml.trip_validity_final.bus_id`: strip everything but digits, then\n", + "left-pad with zeros to 5 characters (only when shorter -- never\n", + "truncate, so a noisier/longer code is kept as-is rather than corrupted).\n", + "\n", + "A pair is only kept when **both** sides are independently corroborated:\n", + "`bus_id` must be one of the `ml.trip_validity_final.bus_id` values\n", + "**and** `device_id` must actually appear in `silver.avl_pings` during the\n", + "window below. A pair where either side has no independent evidence isn't\n", + "useful for the matching model -- there'd be nothing on that side to\n", + "derive features from or compare against.\n", + "\n", + "## The two sources\n", + "\n", + "- `silver.dictionary_device` (2,219 rows, 1 snapshot): `codigo` normalizes\n", + " to `bus_id` as above (6 rows strip to empty and are dropped, 401 have\n", + " no `device_id` at all and are dropped too, since there's nothing to\n", + " pair). Kept only when `bus_id` is one of the 1,730 distinct `bus_id`s\n", + " in `ml.trip_validity_final` **and** `device_id` actually pinged in\n", + " `silver.avl_pings` during the window.\n", + "- `silver.dictionary_vehicle` (4,530 rows, 1 snapshot): `cod_veiculo`\n", + " normalizes to `bus_id` the same way (72 rows strip to empty and are\n", + " dropped). `id_veiculo` is always plain-integer text and equals\n", + " `avl_pings.vehicle_id` once cast (confirmed in\n", + " `ml/trip_validity_model/notebooks/04_avl_positions.ipynb`), so every\n", + " `device_id` that vehicle actually pinged from during the window becomes\n", + " a candidate -- kept only for the vehicles whose normalized `bus_id` is\n", + " also one of `ml.trip_validity_final`'s.\n", + "\n", + "## Why \"November 2023 +/- one day\" is materialized once, up front\n", + "\n", + "`silver.avl_pings` is indexed on `metric_timestamp` for every monthly\n", + "partition, but the covering `(vehicle_id, metric_timestamp)` /\n", + "`(device_id, metric_timestamp)` indexes only exist on the November 2023\n", + "partition (backfilled in notebook 04) -- October and December have\n", + "neither. Probing `avl_pings` once per dictionary row (thousands of\n", + "probes) against those two unindexed partitions would mean a sequential\n", + "scan of each on every probe. Instead, Stage 0 below does one single\n", + "`SELECT DISTINCT (vehicle_id, device_id)` scan across the whole window\n", + "into a temp table (confirmed live: ~95s for November alone via the\n", + "timestamp index), then everything downstream joins against that small\n", + "temp table instead of `avl_pings` directly.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "1e3f9206", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T16:56:08.047261Z", + "iopub.status.busy": "2026-08-22T16:56:08.047184Z", + "iopub.status.idle": "2026-08-22T16:56:08.075691Z", + "shell.execute_reply": "2026-08-22T16:56:08.075326Z" + } + }, + "outputs": [], + "source": [ + "import os\n", + "from pathlib import Path\n", + "\n", + "import psycopg" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "9e8d282d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T16:56:08.077133Z", + "iopub.status.busy": "2026-08-22T16:56:08.077055Z", + "iopub.status.idle": "2026-08-22T16:56:08.079814Z", + "shell.execute_reply": "2026-08-22T16:56:08.079603Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "'/home/victor/repos/opa-database'" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "_root = Path.cwd()\n", + "while not (_root / \"pyproject.toml\").exists():\n", + " _root = _root.parent\n", + "os.chdir(_root)\n", + "os.environ.setdefault(\"RAW_DATA_ROOT\", str(_root))" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "c5cbf562", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T16:56:08.080718Z", + "iopub.status.busy": "2026-08-22T16:56:08.080653Z", + "iopub.status.idle": "2026-08-22T16:56:08.130555Z", + "shell.execute_reply": "2026-08-22T16:56:08.130223Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ml schema ready\n" + ] + } + ], + "source": [ + "from opa_database.config import settings\n", + "\n", + "WINDOW_START = \"2023-10-31\"\n", + "WINDOW_END = \"2023-12-02\"\n", + "\n", + "conn = psycopg.connect(settings.db_dsn)\n", + "conn.execute(\"CREATE SCHEMA IF NOT EXISTS ml;\")\n", + "conn.commit()\n", + "print(\"ml schema ready\")" + ] + }, + { + "cell_type": "markdown", + "id": "4652c08f", + "metadata": {}, + "source": [ + "## Stage 0 - materialize the window's `(vehicle_id, device_id)` roster\n", + "\n", + "One row per distinct vehicle/device pairing actually seen in\n", + "`silver.avl_pings` during `[WINDOW_START, WINDOW_END)`. Indexed on both\n", + "columns so the two joins below (device_id membership check, vehicle_id\n", + "lookup) are cheap against this small table instead of `avl_pings`\n", + "itself." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "22fe3a77", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T16:56:08.131376Z", + "iopub.status.busy": "2026-08-22T16:56:08.131308Z", + "iopub.status.idle": "2026-08-22T16:56:21.089496Z", + "shell.execute_reply": "2026-08-22T16:56:21.089043Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "distinct (vehicle_id, device_id) pairs in window: 1498\n" + ] + } + ], + "source": [ + "conn.execute(\"DROP TABLE IF EXISTS avl_window_vehicle_device;\")\n", + "conn.execute(\n", + " \"\"\"\n", + " CREATE TEMP TABLE avl_window_vehicle_device AS\n", + " SELECT DISTINCT vehicle_id, device_id\n", + " FROM silver.avl_pings\n", + " WHERE metric_timestamp >= %(start)s AND metric_timestamp < %(end)s;\n", + " \"\"\",\n", + " {\"start\": WINDOW_START, \"end\": WINDOW_END},\n", + ")\n", + "conn.execute(\"CREATE INDEX ON avl_window_vehicle_device (vehicle_id);\")\n", + "conn.execute(\"CREATE INDEX ON avl_window_vehicle_device (device_id);\")\n", + "conn.execute(\"ANALYZE avl_window_vehicle_device;\")\n", + "conn.commit()\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\"SELECT count(*) FROM avl_window_vehicle_device;\")\n", + " print(\"distinct (vehicle_id, device_id) pairs in window:\", cur.fetchone()[0])" + ] + }, + { + "cell_type": "markdown", + "id": "ce96bd19", + "metadata": {}, + "source": [ + "## Stage 1 - `ml.bus_matching_candidate_pairs`\n", + "\n", + "`bus_id` matches `ml.trip_validity_final.bus_id`'s own column name.\n", + "`PRIMARY KEY (bus_id, device_id, origin)`, deliberately not just\n", + "`(bus_id, device_id)`: a pair corroborated by both dictionaries should\n", + "show up as two rows, not collapse into one -- being backed by both\n", + "sources is itself a signal worth keeping for the model that eventually\n", + "picks the real match." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "000f7dfb", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T16:56:21.090612Z", + "iopub.status.busy": "2026-08-22T16:56:21.090524Z", + "iopub.status.idle": "2026-08-22T16:56:21.096426Z", + "shell.execute_reply": "2026-08-22T16:56:21.096132Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ml.bus_matching_candidate_pairs created\n" + ] + } + ], + "source": [ + "conn.execute(\"\"\"\n", + " DROP TABLE IF EXISTS ml.bus_matching_candidate_pairs CASCADE;\n", + "\n", + " CREATE TABLE ml.bus_matching_candidate_pairs (\n", + " bus_id text NOT NULL,\n", + " device_id text NOT NULL,\n", + " origin text NOT NULL\n", + " CHECK (origin IN ('dictionary_device', 'dictionary_vehicle')),\n", + " PRIMARY KEY (bus_id, device_id, origin)\n", + " );\n", + "\"\"\")\n", + "conn.commit()\n", + "print(\"ml.bus_matching_candidate_pairs created\")" + ] + }, + { + "cell_type": "markdown", + "id": "83cd3919", + "metadata": {}, + "source": [ + "## Stage 2 - pairs from `silver.dictionary_device`\n", + "\n", + "`codigo` -> digits-only -> zero-padded to 5 -> `bus_id`. Rows with no\n", + "`device_id` or that strip to an empty string are dropped (nothing to\n", + "pair). Kept only when `bus_id` is a known `ml.trip_validity_final.bus_id`\n", + "**and** `device_id` is in the window's AVL roster." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "47fa1498", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T16:56:21.097324Z", + "iopub.status.busy": "2026-08-22T16:56:21.097248Z", + "iopub.status.idle": "2026-08-22T16:56:21.203147Z", + "shell.execute_reply": "2026-08-22T16:56:21.202780Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "dictionary_device pairs kept: 1160\n" + ] + } + ], + "source": [ + "conn.execute(\"\"\"\n", + " WITH normalized AS (\n", + " SELECT\n", + " regexp_replace(codigo, '[^0-9]', '', 'g') AS digits,\n", + " device_id\n", + " FROM silver.dictionary_device\n", + " WHERE device_id IS NOT NULL\n", + " ),\n", + " padded AS (\n", + " SELECT\n", + " CASE WHEN length(digits) < 5 THEN lpad(digits, 5, '0') ELSE digits END\n", + " AS bus_id,\n", + " device_id\n", + " FROM normalized\n", + " WHERE digits <> ''\n", + " )\n", + " INSERT INTO ml.bus_matching_candidate_pairs (bus_id, device_id, origin)\n", + " SELECT DISTINCT p.bus_id, p.device_id, 'dictionary_device'\n", + " FROM padded p\n", + " WHERE p.bus_id IN (SELECT DISTINCT bus_id FROM ml.trip_validity_final)\n", + " AND p.device_id IN (SELECT device_id FROM avl_window_vehicle_device);\n", + "\"\"\")\n", + "conn.commit()\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\n", + " \"\"\"\n", + " SELECT count(*) FROM ml.bus_matching_candidate_pairs\n", + " WHERE origin = 'dictionary_device';\n", + " \"\"\"\n", + " )\n", + " print(\"dictionary_device pairs kept:\", cur.fetchone()[0])" + ] + }, + { + "cell_type": "markdown", + "id": "9923955b", + "metadata": {}, + "source": [ + "## Stage 3 - pairs from `silver.dictionary_vehicle`\n", + "\n", + "`cod_veiculo` normalizes to `bus_id` the same way as Stage 2.\n", + "`id_veiculo::integer` joins the Stage 0 roster on `vehicle_id` to pull\n", + "every `device_id` that vehicle actually pinged from in-window -- the\n", + "join itself guarantees the `device_id` side, so the only extra filter\n", + "needed here is the same `bus_id` membership check as Stage 2." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "356cbd0c", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T16:56:21.204176Z", + "iopub.status.busy": "2026-08-22T16:56:21.204101Z", + "iopub.status.idle": "2026-08-22T16:56:21.313100Z", + "shell.execute_reply": "2026-08-22T16:56:21.312673Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "dictionary_vehicle pairs kept: 1122\n" + ] + } + ], + "source": [ + "conn.execute(\"\"\"\n", + " WITH normalized AS (\n", + " SELECT\n", + " regexp_replace(cod_veiculo, '[^0-9]', '', 'g') AS digits,\n", + " id_veiculo::integer AS vehicle_id\n", + " FROM silver.dictionary_vehicle\n", + " ),\n", + " padded AS (\n", + " SELECT\n", + " CASE WHEN length(digits) < 5 THEN lpad(digits, 5, '0') ELSE digits END\n", + " AS bus_id,\n", + " vehicle_id\n", + " FROM normalized\n", + " WHERE digits <> ''\n", + " )\n", + " INSERT INTO ml.bus_matching_candidate_pairs (bus_id, device_id, origin)\n", + " SELECT DISTINCT p.bus_id, w.device_id, 'dictionary_vehicle'\n", + " FROM padded p\n", + " JOIN avl_window_vehicle_device w ON w.vehicle_id = p.vehicle_id\n", + " WHERE p.bus_id IN (SELECT DISTINCT bus_id FROM ml.trip_validity_final);\n", + "\"\"\")\n", + "conn.commit()\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\n", + " \"\"\"\n", + " SELECT count(*) FROM ml.bus_matching_candidate_pairs\n", + " WHERE origin = 'dictionary_vehicle';\n", + " \"\"\"\n", + " )\n", + " print(\"dictionary_vehicle pairs kept:\", cur.fetchone()[0])" + ] + }, + { + "cell_type": "markdown", + "id": "fd0ba85a", + "metadata": {}, + "source": [ + "## Stage 4 - index and summarize" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "c673b09d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T16:56:21.313949Z", + "iopub.status.busy": "2026-08-22T16:56:21.313874Z", + "iopub.status.idle": "2026-08-22T16:56:21.331953Z", + "shell.execute_reply": "2026-08-22T16:56:21.331609Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "('dictionary_device', 1160, 1160, 1160)\n", + "('dictionary_vehicle', 1122, 1113, 1122)\n", + "total rows: 2282\n", + "pairs corroborated by both dictionaries: 610\n" + ] + } + ], + "source": [ + "conn.execute(\"\"\"\n", + " CREATE INDEX bus_matching_candidate_pairs_bus_id_idx\n", + " ON ml.bus_matching_candidate_pairs (bus_id);\n", + " CREATE INDEX bus_matching_candidate_pairs_device_id_idx\n", + " ON ml.bus_matching_candidate_pairs (device_id);\n", + " ANALYZE ml.bus_matching_candidate_pairs;\n", + "\"\"\")\n", + "conn.commit()\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\"\"\"\n", + " SELECT\n", + " origin,\n", + " count(*) AS pairs,\n", + " count(DISTINCT bus_id) AS distinct_bus_ids,\n", + " count(DISTINCT device_id) AS distinct_device_ids\n", + " FROM ml.bus_matching_candidate_pairs\n", + " GROUP BY origin\n", + " ORDER BY origin;\n", + " \"\"\")\n", + " for row in cur.fetchall():\n", + " print(row)\n", + "\n", + " cur.execute(\"SELECT count(*) FROM ml.bus_matching_candidate_pairs;\")\n", + " print(\"total rows:\", cur.fetchone()[0])\n", + "\n", + " cur.execute(\"\"\"\n", + " SELECT count(*) FROM (\n", + " SELECT bus_id, device_id\n", + " FROM ml.bus_matching_candidate_pairs\n", + " GROUP BY bus_id, device_id\n", + " HAVING count(DISTINCT origin) = 2\n", + " ) both_sources;\n", + " \"\"\")\n", + " print(\"pairs corroborated by both dictionaries:\", cur.fetchone()[0])" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (opa-database)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/ml/bus_matching_model/notebooks/02_build_avl_positions.ipynb b/ml/bus_matching_model/notebooks/02_build_avl_positions.ipynb new file mode 100644 index 0000000..ff28bee --- /dev/null +++ b/ml/bus_matching_model/notebooks/02_build_avl_positions.ipynb @@ -0,0 +1,284 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "d3a20b24", + "metadata": {}, + "source": [ + "# 02 - Build `ml.bus_matching_avl_positions`\n", + "\n", + "Second notebook for the Bus Matching model: a view over\n", + "`silver.avl_pings`, scoped to the same `[2023-10-31, 2023-12-02)` window\n", + "as `01_build_candidate_pairs.ipynb` (\"November 2023 +/- one day\"),\n", + "renaming/reformatting a few columns for this model's use.\n", + "\n", + "## Why a view, not a materialized table\n", + "\n", + "Originally built as a full copy: confirmed live that the window is\n", + "~140M rows (the November 2023 partition alone is 130,957,352 rows /\n", + "39 GB), and that filtering to only the devices in\n", + "`ml.bus_matching_candidate_pairs` wouldn't meaningfully shrink that\n", + "(1,402 of 1,493 distinct devices pinging in November are already in the\n", + "candidate table -- row count is driven by ping frequency, not device\n", + "count). Stepping back: the only actual value-add over querying\n", + "`silver.avl_pings` directly is a few renamed/reformatted columns (below)\n", + "-- nothing that requires duplicating the data. A view gets the same\n", + "renaming/formatting for free: it's a query rewrite, not a copy, so it\n", + "costs no storage and reads through it use exactly the indexes\n", + "`silver.avl_pings` already has (`ts_idx` everywhere, plus\n", + "`device_ts_idx`/`vehicle_ts_idx` on the November partition). The one\n", + "thing a view can't offer is a real surrogate primary key -- not needed\n", + "here, since the natural use of this data is \"positions for a given\n", + "device in a given window,\" not \"look up one specific position by id.\"\n", + "\n", + "## Column choices, and why they differ from `silver.avl_pings`\n", + "\n", + "- `device_id`, `metric_timestamp`, `latitude`, `longitude`, `speed`,\n", + " `geom`: passed through unchanged -- every existing `ml.trip_validity_*`\n", + " table that carries AVL columns forward keeps these names identical\n", + " rather than inventing new ones.\n", + "- `heading_degrees` (was `direction`): renamed. It's the GPS unit's\n", + " compass heading, not a route direction -- colliding in name (though\n", + " not meaning) with `route_direction`/`direction` elsewhere in\n", + " `ml.trip_validity_*`, which are AFC's binary out/back flag. A small\n", + " sample suggested a clean 0-359 range, but the full window tells a\n", + " different story: confirmed live that it actually runs 0-9360 in this\n", + " window, on exactly 10 of the 140,655,482 rows (9 at 360, 1 at 9360 --\n", + " both, as it happens, exact multiples of 360). Rather than pass those\n", + " 10 through as nonsensical headings, they're mapped to 0 in the view\n", + " itself (`direction >= 360` -> `0`).\n", + "- `route_id` (was `route_code`, integer): cast to text and zero-padded\n", + " to 3 characters (only when shorter -- never truncating), exactly\n", + " matching how `ml.trip_validity_trips.route_id` formats AFC's own\n", + " `line_number`. Same column name too, for consistency with that table.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "d89caa1c", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T17:24:20.463895Z", + "iopub.status.busy": "2026-08-22T17:24:20.463825Z", + "iopub.status.idle": "2026-08-22T17:24:20.493393Z", + "shell.execute_reply": "2026-08-22T17:24:20.493012Z" + } + }, + "outputs": [], + "source": [ + "import os\n", + "from pathlib import Path\n", + "\n", + "import psycopg\n", + "from psycopg import sql" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "d228d706", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T17:24:20.494686Z", + "iopub.status.busy": "2026-08-22T17:24:20.494585Z", + "iopub.status.idle": "2026-08-22T17:24:20.497477Z", + "shell.execute_reply": "2026-08-22T17:24:20.497227Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "'/home/victor/repos/opa-database'" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "_root = Path.cwd()\n", + "while not (_root / \"pyproject.toml\").exists():\n", + " _root = _root.parent\n", + "os.chdir(_root)\n", + "os.environ.setdefault(\"RAW_DATA_ROOT\", str(_root))" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "a02d0616", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T17:24:20.498360Z", + "iopub.status.busy": "2026-08-22T17:24:20.498293Z", + "iopub.status.idle": "2026-08-22T17:24:20.547868Z", + "shell.execute_reply": "2026-08-22T17:24:20.547398Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ml schema ready\n" + ] + } + ], + "source": [ + "from opa_database.config import settings\n", + "\n", + "WINDOW_START = \"2023-10-31\"\n", + "WINDOW_END = \"2023-12-02\"\n", + "\n", + "conn = psycopg.connect(settings.db_dsn)\n", + "conn.execute(\"CREATE SCHEMA IF NOT EXISTS ml;\")\n", + "conn.commit()\n", + "print(\"ml schema ready\")" + ] + }, + { + "cell_type": "markdown", + "id": "8cd89e9e", + "metadata": {}, + "source": [ + "## `ml.bus_matching_avl_positions`\n", + "\n", + "The window bounds are baked into the view definition (a view takes no\n", + "runtime parameters), built from the same `WINDOW_START`/`WINDOW_END`\n", + "constants as notebook 01 rather than hand-typed a second time." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "437f83ed", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T17:24:20.548623Z", + "iopub.status.busy": "2026-08-22T17:24:20.548552Z", + "iopub.status.idle": "2026-08-22T17:24:20.553175Z", + "shell.execute_reply": "2026-08-22T17:24:20.552850Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ml.bus_matching_avl_positions view created\n" + ] + } + ], + "source": [ + "conn.execute(\n", + " sql.SQL(\n", + " \"\"\"\n", + " CREATE OR REPLACE VIEW ml.bus_matching_avl_positions AS\n", + " SELECT\n", + " device_id,\n", + " metric_timestamp,\n", + " latitude,\n", + " longitude,\n", + " CASE WHEN direction >= 360 THEN 0 ELSE direction END\n", + " AS heading_degrees,\n", + " speed,\n", + " CASE WHEN length(route_code::text) < 3\n", + " THEN lpad(route_code::text, 3, '0')\n", + " ELSE route_code::text\n", + " END AS route_id,\n", + " geom\n", + " FROM silver.avl_pings\n", + " WHERE metric_timestamp >= {start} AND metric_timestamp < {end};\n", + " \"\"\"\n", + " ).format(start=sql.Literal(WINDOW_START), end=sql.Literal(WINDOW_END))\n", + ")\n", + "conn.commit()\n", + "print(\"ml.bus_matching_avl_positions view created\")" + ] + }, + { + "cell_type": "markdown", + "id": "66af4e2f", + "metadata": {}, + "source": [ + "## Sanity check" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "51acae4e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T17:24:20.554040Z", + "iopub.status.busy": "2026-08-22T17:24:20.553974Z", + "iopub.status.idle": "2026-08-22T17:26:14.104136Z", + "shell.execute_reply": "2026-08-22T17:26:14.103709Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "(140655482, 1495, 348, datetime.datetime(2023, 10, 31, 0, 0, tzinfo=zoneinfo.ZoneInfo(key='Etc/UTC')), datetime.datetime(2023, 12, 1, 23, 59, 59, tzinfo=zoneinfo.ZoneInfo(key='Etc/UTC')))\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "heading_degrees min/max, speed min/max: (0, 359, 0, 199)\n", + "sample route_id: ['000', '000', '395', '000', '000']\n" + ] + } + ], + "source": [ + "with conn.cursor() as cur:\n", + " cur.execute(\"\"\"\n", + " SELECT\n", + " count(*) AS rows,\n", + " count(DISTINCT device_id) AS distinct_devices,\n", + " count(DISTINCT route_id) AS distinct_routes,\n", + " min(metric_timestamp) AS earliest,\n", + " max(metric_timestamp) AS latest\n", + " FROM ml.bus_matching_avl_positions;\n", + " \"\"\")\n", + " print(cur.fetchone())\n", + "\n", + " cur.execute(\"\"\"\n", + " SELECT min(heading_degrees), max(heading_degrees), min(speed), max(speed)\n", + " FROM ml.bus_matching_avl_positions;\n", + " \"\"\")\n", + " print(\"heading_degrees min/max, speed min/max:\", cur.fetchone())\n", + "\n", + " cur.execute(\"SELECT route_id FROM ml.bus_matching_avl_positions LIMIT 5;\")\n", + " print(\"sample route_id:\", [r[0] for r in cur.fetchall()])" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (opa-database)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/ml/bus_matching_model/notebooks/03_indexes_and_contestedness.ipynb b/ml/bus_matching_model/notebooks/03_indexes_and_contestedness.ipynb new file mode 100644 index 0000000..74d2019 --- /dev/null +++ b/ml/bus_matching_model/notebooks/03_indexes_and_contestedness.ipynb @@ -0,0 +1,324 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "c3abed9d", + "metadata": {}, + "source": [ + "# 03 - Indexes and `ml.bus_matching_contestedness`\n", + "\n", + "Third notebook for the Bus Matching model: closes out the Section 1\n", + "precomputation work not already covered by `01_build_candidate_pairs.ipynb`\n", + "and `02_build_avl_positions.ipynb` -- a `bus_id`-first index on\n", + "`ml.trip_validity_final` (the trips table this model actually reads;\n", + "`silver.avl_pings` and the fares table already have everything the plan\n", + "asks for, confirmed against `pg_indexes` before writing this) and the\n", + "contestedness table that routes the labeling UI between uncontested\n", + "(map-only) and contested (side-by-side) mode.\n", + "\n", + "**Unit here is `(bus_id, trip_date)`, valid trips only** (`is_valid`),\n", + "matching the plan's stated input population.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "bc3d6f03", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T17:49:34.941325Z", + "iopub.status.busy": "2026-08-22T17:49:34.941208Z", + "iopub.status.idle": "2026-08-22T17:49:34.970971Z", + "shell.execute_reply": "2026-08-22T17:49:34.970650Z" + } + }, + "outputs": [], + "source": [ + "import os\n", + "from pathlib import Path\n", + "\n", + "import psycopg" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "5e730ad2", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T17:49:34.972072Z", + "iopub.status.busy": "2026-08-22T17:49:34.972000Z", + "iopub.status.idle": "2026-08-22T17:49:34.974500Z", + "shell.execute_reply": "2026-08-22T17:49:34.974295Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "'/home/victor/repos/opa-database'" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "_root = Path.cwd()\n", + "while not (_root / \"pyproject.toml\").exists():\n", + " _root = _root.parent\n", + "os.chdir(_root)\n", + "os.environ.setdefault(\"RAW_DATA_ROOT\", str(_root))" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "ca1b833b", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T17:49:34.975301Z", + "iopub.status.busy": "2026-08-22T17:49:34.975236Z", + "iopub.status.idle": "2026-08-22T17:49:35.032737Z", + "shell.execute_reply": "2026-08-22T17:49:35.032353Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ml schema ready\n" + ] + } + ], + "source": [ + "from opa_database.config import settings\n", + "\n", + "conn = psycopg.connect(settings.db_dsn)\n", + "conn.execute(\"CREATE SCHEMA IF NOT EXISTS ml;\")\n", + "conn.commit()\n", + "print(\"ml schema ready\")" + ] + }, + { + "cell_type": "markdown", + "id": "62e75fc8", + "metadata": {}, + "source": [ + "## Stage 1 - `bus_id`-first index on `ml.trip_validity_final`\n", + "\n", + "`trip_validity_final` only had `route_date_idx (route_id, trip_date)`\n", + "and the two shape indexes -- nothing keyed by `bus_id` first. Every\n", + "downstream step in this model (feature computation walks one date's\n", + "candidate buses; the contestedness build and later per-bus lookups) is a\n", + "`bus_id`-first access pattern, so add the composite the plan's\n", + "Section 1.1 calls for.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "7f47177b", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T17:49:35.033818Z", + "iopub.status.busy": "2026-08-22T17:49:35.033737Z", + "iopub.status.idle": "2026-08-22T17:49:35.720341Z", + "shell.execute_reply": "2026-08-22T17:49:35.719872Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "trip_validity_final_bus_date_idx ready\n" + ] + } + ], + "source": [ + "conn.execute(\n", + " \"CREATE INDEX IF NOT EXISTS trip_validity_final_bus_date_idx \"\n", + " \"ON ml.trip_validity_final (bus_id, trip_date);\"\n", + ")\n", + "conn.execute(\"ANALYZE ml.trip_validity_final;\")\n", + "conn.commit()\n", + "print(\"trip_validity_final_bus_date_idx ready\")" + ] + }, + { + "cell_type": "markdown", + "id": "115f410e", + "metadata": {}, + "source": [ + "## Stage 2 - `ml.bus_matching_contestedness`\n", + "\n", + "Pure AFC query over `ml.trip_validity_final`, no AVL involved. For each\n", + "valid trip, count the distinct *other* `bus_id`s running the *same*\n", + "`route_id` on the *same* `trip_date` with an overlapping\n", + "`[trip_start_timestamp, trip_end_timestamp)` window (strict interval\n", + "overlap: `t2.start < t1.end AND t2.end > t1.start`). Aggregate to\n", + "`(bus_id, trip_date)`: trip count, contested trip count, contested\n", + "fraction, the max number of simultaneously competing buses on any one\n", + "trip, and a boolean `is_contested` flag for routing the labeling UI.\n", + "\n", + "Timed the self-join first via `EXPLAIN (ANALYZE, BUFFERS)` on the full\n", + "720,080-row valid-trip population before committing to the full build:\n", + "confirmed live at **9.8s** for the join+aggregate (Postgres picks a\n", + "sort-merge join on `(route_id, trip_date)`, spilling ~140MB to disk\n", + "temp files -- fine for a one-time build, not worth forcing a different\n", + "plan). No new index needed for it: `route_date_idx` already covers the\n", + "join key.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "2d247b27", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T17:49:35.721208Z", + "iopub.status.busy": "2026-08-22T17:49:35.721132Z", + "iopub.status.idle": "2026-08-22T17:49:43.012966Z", + "shell.execute_reply": "2026-08-22T17:49:43.012500Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ml.bus_matching_contestedness built\n" + ] + } + ], + "source": [ + "conn.execute(\"DROP TABLE IF EXISTS ml.bus_matching_contestedness;\")\n", + "conn.execute(\n", + " \"\"\"\n", + " CREATE TABLE ml.bus_matching_contestedness AS\n", + " WITH trips AS (\n", + " SELECT trip_id, bus_id, route_id, trip_date,\n", + " trip_start_timestamp, trip_end_timestamp\n", + " FROM ml.trip_validity_final\n", + " WHERE is_valid\n", + " ),\n", + " overlap_counts AS (\n", + " SELECT\n", + " t1.trip_id,\n", + " count(DISTINCT t2.bus_id) AS competing_buses\n", + " FROM trips t1\n", + " JOIN trips t2\n", + " ON t2.route_id = t1.route_id\n", + " AND t2.trip_date = t1.trip_date\n", + " AND t2.bus_id <> t1.bus_id\n", + " AND t2.trip_start_timestamp < t1.trip_end_timestamp\n", + " AND t2.trip_end_timestamp > t1.trip_start_timestamp\n", + " GROUP BY t1.trip_id\n", + " )\n", + " SELECT\n", + " t.bus_id,\n", + " t.trip_date,\n", + " count(*)::integer AS n_trips,\n", + " count(oc.trip_id)::integer AS n_contested_trips,\n", + " (count(oc.trip_id)::float8 / count(*)) AS contested_fraction,\n", + " coalesce(max(oc.competing_buses), 0)::integer AS max_competing_buses,\n", + " (coalesce(max(oc.competing_buses), 0) > 0) AS is_contested\n", + " FROM trips t\n", + " LEFT JOIN overlap_counts oc ON oc.trip_id = t.trip_id\n", + " GROUP BY t.bus_id, t.trip_date;\n", + " \"\"\"\n", + ")\n", + "conn.execute(\n", + " \"ALTER TABLE ml.bus_matching_contestedness ADD PRIMARY KEY (bus_id, trip_date);\"\n", + ")\n", + "conn.execute(\"CREATE INDEX ON ml.bus_matching_contestedness (trip_date, is_contested);\")\n", + "conn.execute(\"ANALYZE ml.bus_matching_contestedness;\")\n", + "conn.commit()\n", + "print(\"ml.bus_matching_contestedness built\")" + ] + }, + { + "cell_type": "markdown", + "id": "278f02d5", + "metadata": {}, + "source": [ + "## Sanity check" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "da5a4d6a", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T17:49:43.013914Z", + "iopub.status.busy": "2026-08-22T17:49:43.013834Z", + "iopub.status.idle": "2026-08-22T17:49:43.027101Z", + "shell.execute_reply": "2026-08-22T17:49:43.026791Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "bus-date rows: (41332,)\n", + "total bus-dates / contested bus-dates: (41332, 39998)\n", + "avg / max contested_fraction: (Decimal('0.9347'), Decimal('1.0000'))\n", + "distribution of max_competing_buses: [(0, 1334), (1, 3037), (2, 4267), (3, 4046), (4, 2891), (5, 3506), (6, 2047), (7, 1470), (8, 1460), (9, 1128), (10, 1095), (11, 890), (12, 1224), (13, 2210), (14, 1775), (15, 1598), (16, 1392), (17, 1004), (18, 636), (19, 1342), (20, 816), (21, 709), (22, 295), (23, 31), (24, 73), (25, 759), (26, 287), (27, 10)]\n", + "total trips / total contested trips: (720080, 636635)\n" + ] + } + ], + "source": [ + "with conn.cursor() as cur:\n", + " cur.execute(\"SELECT count(*) FROM ml.bus_matching_contestedness;\")\n", + " print(\"bus-date rows:\", cur.fetchone())\n", + "\n", + " cur.execute(\n", + " \"SELECT count(*), count(*) FILTER (WHERE is_contested) \"\n", + " \"FROM ml.bus_matching_contestedness;\"\n", + " )\n", + " print(\"total bus-dates / contested bus-dates:\", cur.fetchone())\n", + "\n", + " cur.execute(\n", + " \"SELECT round(avg(contested_fraction)::numeric, 4), \"\n", + " \"round(max(contested_fraction)::numeric, 4) \"\n", + " \"FROM ml.bus_matching_contestedness;\"\n", + " )\n", + " print(\"avg / max contested_fraction:\", cur.fetchone())\n", + "\n", + " cur.execute(\n", + " \"SELECT max_competing_buses, count(*) FROM ml.bus_matching_contestedness \"\n", + " \"GROUP BY max_competing_buses ORDER BY max_competing_buses;\"\n", + " )\n", + " print(\"distribution of max_competing_buses:\", cur.fetchall())\n", + "\n", + " cur.execute(\n", + " \"SELECT sum(n_trips), sum(n_contested_trips) \"\n", + " \"FROM ml.bus_matching_contestedness;\"\n", + " )\n", + " print(\"total trips / total contested trips:\", cur.fetchone())" + ] + } + ], + "metadata": { + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/ml/bus_matching_model/notebooks/04_signature_tables.ipynb b/ml/bus_matching_model/notebooks/04_signature_tables.ipynb new file mode 100644 index 0000000..db363fc --- /dev/null +++ b/ml/bus_matching_model/notebooks/04_signature_tables.ipynb @@ -0,0 +1,440 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "05e0c4eb", + "metadata": {}, + "source": [ + "# 04 - Spatiotemporal Signature Tables\n", + "\n", + "Fourth notebook for the Bus Matching model: Section 1.2/1.3 of the plan\n", + "-- the in-memory GTFS shape cache (`app/gtfs_cache.py`) plus the two\n", + "coarse signature tables that make blocking (next notebook) a cheap\n", + "integer hash join instead of a per-pair spatial query.\n", + "\n", + "**Grid choice**: a 300m rounded grid directly on the metric CRS\n", + "(SRID 31984) already used by `shape_geom_metric`, not `h3`/`geohash` --\n", + "no new dependency, and it's the same projection the shape cache already\n", + "uses, so no extra reprojection at query time.\n", + "\n", + "**Time bucket**: 15 minutes, as `floor(epoch_seconds / 900)`. This is a\n", + "*global* bucket index (not reset per day), which is fine because `date`\n", + "is carried as its own join column throughout -- it just means bucket\n", + "ids aren't meaningful on their own, only in combination with `date`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "def38b0b", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T18:03:16.554274Z", + "iopub.status.busy": "2026-08-22T18:03:16.554192Z", + "iopub.status.idle": "2026-08-22T18:03:16.727911Z", + "shell.execute_reply": "2026-08-22T18:03:16.727549Z" + } + }, + "outputs": [], + "source": [ + "import os\n", + "import sys\n", + "import time\n", + "from pathlib import Path\n", + "\n", + "import numpy as np\n", + "import pandas as pd\n", + "import psycopg" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "791b52d1", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T18:03:16.728967Z", + "iopub.status.busy": "2026-08-22T18:03:16.728862Z", + "iopub.status.idle": "2026-08-22T18:03:16.730700Z", + "shell.execute_reply": "2026-08-22T18:03:16.730431Z" + } + }, + "outputs": [], + "source": [ + "_root = Path.cwd()\n", + "while not (_root / \"pyproject.toml\").exists():\n", + " _root = _root.parent\n", + "os.chdir(_root)\n", + "os.environ.setdefault(\"RAW_DATA_ROOT\", str(_root))\n", + "sys.path.insert(0, str(_root / \"ml\" / \"bus_matching_model\" / \"app\"))" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "fc262a13", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T18:03:16.731349Z", + "iopub.status.busy": "2026-08-22T18:03:16.731270Z", + "iopub.status.idle": "2026-08-22T18:03:16.782794Z", + "shell.execute_reply": "2026-08-22T18:03:16.782379Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ml schema ready\n" + ] + } + ], + "source": [ + "from gtfs_cache import build_shape_cache\n", + "\n", + "from opa_database.config import settings\n", + "\n", + "CELL_METERS = 300\n", + "TIME_BUCKET_SECONDS = 900\n", + "SAMPLE_STEP_METERS = 250\n", + "\n", + "conn = psycopg.connect(settings.db_dsn)\n", + "conn.execute(\"CREATE SCHEMA IF NOT EXISTS ml;\")\n", + "conn.commit()\n", + "print(\"ml schema ready\")" + ] + }, + { + "cell_type": "markdown", + "id": "e5f721b4", + "metadata": {}, + "source": [ + "## Stage 1 - `ml.bus_matching_device_signatures`\n", + "\n", + "`SELECT DISTINCT device_id, date, cell_x, cell_y, time_bucket` over\n", + "`ml.bus_matching_avl_positions`. Can't `TABLESAMPLE` a view, so timing\n", + "was confirmed live against the underlying partition instead: a 0.5%\n", + "`TABLESAMPLE` of `silver.avl_pings_y2023m11` (~131M estimated rows)\n", + "returned 654K rows with the same `ST_Transform`/grid expressions in\n", + "under 1s, so the full ~140M-row window was expected to land in the\n", + "low-minutes range for a one-time build -- confirmed live below.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "ae76e370", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T18:03:16.783695Z", + "iopub.status.busy": "2026-08-22T18:03:16.783618Z", + "iopub.status.idle": "2026-08-22T18:05:26.884850Z", + "shell.execute_reply": "2026-08-22T18:05:26.884344Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "device signatures built in 129.6s\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "rows: (28895366,)\n" + ] + } + ], + "source": [ + "start = time.monotonic()\n", + "conn.execute(\"DROP TABLE IF EXISTS ml.bus_matching_device_signatures;\")\n", + "conn.execute(\n", + " \"\"\"\n", + " CREATE TABLE ml.bus_matching_device_signatures AS\n", + " SELECT DISTINCT\n", + " device_id,\n", + " metric_timestamp::date AS date,\n", + " floor(ST_X(ST_Transform(geom, 31984)) / %(cell)s)::int AS cell_x,\n", + " floor(ST_Y(ST_Transform(geom, 31984)) / %(cell)s)::int AS cell_y,\n", + " floor(extract(epoch FROM metric_timestamp) / %(bucket)s)::int AS time_bucket\n", + " FROM ml.bus_matching_avl_positions;\n", + " \"\"\",\n", + " {\"cell\": CELL_METERS, \"bucket\": TIME_BUCKET_SECONDS},\n", + ")\n", + "conn.commit()\n", + "print(f\"device signatures built in {time.monotonic() - start:.1f}s\")\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\"SELECT count(*) FROM ml.bus_matching_device_signatures;\")\n", + " print(\"rows:\", cur.fetchone())" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "de851667", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T18:05:26.885729Z", + "iopub.status.busy": "2026-08-22T18:05:26.885653Z", + "iopub.status.idle": "2026-08-22T18:05:40.770832Z", + "shell.execute_reply": "2026-08-22T18:05:40.770396Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "device signature indexes built in 13.9s\n" + ] + } + ], + "source": [ + "start = time.monotonic()\n", + "conn.execute(\n", + " \"CREATE INDEX bus_matching_device_signatures_join_idx \"\n", + " \"ON ml.bus_matching_device_signatures (date, cell_x, cell_y, time_bucket);\"\n", + ")\n", + "conn.execute(\"CREATE INDEX ON ml.bus_matching_device_signatures (device_id);\")\n", + "conn.execute(\"ANALYZE ml.bus_matching_device_signatures;\")\n", + "conn.commit()\n", + "print(f\"device signature indexes built in {time.monotonic() - start:.1f}s\")" + ] + }, + { + "cell_type": "markdown", + "id": "a388ddeb", + "metadata": {}, + "source": [ + "## Stage 2 - `ml.bus_matching_bus_signatures`\n", + "\n", + "Not a SQL query: per the plan, walk each valid trip's GTFS shape(s) --\n", + "both directions, since direction is unknown at this stage -- and emit\n", + "the cells it passes through, timestamps assigned proportionally to\n", + "distance along the route. This has to happen in Python against the\n", + "`gtfs_cache` numpy arrays, not PostGIS, per the plan's core speed\n", + "argument.\n", + "\n", + "**Sampling**: a fixed spatial step (250m, slightly under the 300m cell\n", + "size so no cell along the path gets skipped) per shape, computed *once\n", + "per shape* (1,864 of them) rather than per trip -- the sample\n", + "`(fraction_along_route, cell_x, cell_y)` triple only depends on the\n", + "shape, not on any individual trip's timings. Per trip, only the\n", + "timestamp mapping (`trip_start + fraction * duration`) differs, so\n", + "trips are grouped by `(feed_version_date, shape_id)` and that mapping\n", + "is applied as one vectorized numpy broadcast per group instead of a\n", + "Python loop per trip.\n", + "\n", + "**Why no cross-date dedup pass is needed**: `date` is part of the\n", + "signature tuple, so two different dates can never collide -- deduping\n", + "within each date (via `pandas.drop_duplicates`) is already the full\n", + "global dedup, confirmed live below (2023-11-01 alone: 27,569 trips ->\n", + "2.68M raw samples -> 1.89M deduped rows, in 0.6s total). That lets each\n", + "date be processed and appended independently, keeping memory bounded\n", + "instead of holding the whole month's raw samples at once.\n", + "\n", + "Trips whose `(gtfs_feed_version_date, gtfs_shape_id_i/v)` isn't a\n", + "`gtfs_cache` key are silently skipped for that direction -- this is the\n", + "plan's \"missing data is never negative evidence\" rule: a trip missing\n", + "its GTFS shape just doesn't contribute a bus signature, it isn't scored\n", + "against anything.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "41de1af1", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T18:05:40.771643Z", + "iopub.status.busy": "2026-08-22T18:05:40.771567Z", + "iopub.status.idle": "2026-08-22T18:05:41.703715Z", + "shell.execute_reply": "2026-08-22T18:05:41.703243Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "sample grids built for 1864 shapes\n" + ] + } + ], + "source": [ + "cache = build_shape_cache(conn)\n", + "\n", + "sample_grid: dict[tuple, tuple[np.ndarray, np.ndarray, np.ndarray]] = {}\n", + "for key, shape in cache.items():\n", + " n = max(int(np.ceil(shape.total_length / SAMPLE_STEP_METERS)) + 1, 5)\n", + " fractions = np.linspace(0.0, 1.0, n)\n", + " chainage = fractions * shape.total_length\n", + " idx = np.searchsorted(shape.seg_cum_start, chainage, side=\"right\") - 1\n", + " idx = np.clip(idx, 0, len(shape.seg_len) - 1)\n", + " seg_frac = np.clip(\n", + " (chainage - shape.seg_cum_start[idx]) / np.maximum(shape.seg_len[idx], 1e-9),\n", + " 0,\n", + " 1,\n", + " )\n", + " xy = shape.seg_start[idx] + seg_frac[:, None] * shape.seg_vec[idx]\n", + " cell_x = np.floor(xy[:, 0] / CELL_METERS).astype(np.int64)\n", + " cell_y = np.floor(xy[:, 1] / CELL_METERS).astype(np.int64)\n", + " sample_grid[key] = (fractions, cell_x, cell_y)\n", + "\n", + "print(f\"sample grids built for {len(sample_grid)} shapes\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6022e373", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T18:05:41.704509Z", + "iopub.status.busy": "2026-08-22T18:05:41.704426Z", + "iopub.status.idle": "2026-08-22T18:05:41.707397Z", + "shell.execute_reply": "2026-08-22T18:05:41.707129Z" + } + }, + "outputs": [], + "source": "def bus_signature_rows_for_date(\n conn: psycopg.Connection, trip_date: str\n) -> pd.DataFrame:\n \"\"\"Compute deduped bus-signature rows for one trip_date.\"\"\"\n with conn.cursor() as cur:\n cur.execute(\n \"\"\"\n SELECT bus_id, trip_date,\n extract(epoch FROM trip_start_timestamp)::float8 AS start_epoch,\n extract(epoch FROM trip_end_timestamp)::float8 AS end_epoch,\n gtfs_feed_version_date, gtfs_shape_id_i, gtfs_shape_id_v\n FROM ml.trip_validity_final\n WHERE is_valid AND trip_date = %(trip_date)s;\n \"\"\",\n {\"trip_date\": trip_date},\n )\n cols = [d.name for d in cur.description]\n trips = pd.DataFrame.from_records(cur.fetchall(), columns=cols)\n\n parts = []\n for direction_col in (\"gtfs_shape_id_i\", \"gtfs_shape_id_v\"):\n sub = trips.dropna(subset=[\"gtfs_feed_version_date\", direction_col])\n for (feed, shape_id), g in sub.groupby(\n [\"gtfs_feed_version_date\", direction_col]\n ):\n key = (feed, shape_id)\n if key not in sample_grid:\n continue\n fractions, cell_x, cell_y = sample_grid[key]\n n = len(fractions)\n k = len(g)\n start_epoch = g[\"start_epoch\"].to_numpy()[:, None]\n end_epoch = g[\"end_epoch\"].to_numpy()[:, None]\n ts = start_epoch + fractions[None, :] * (end_epoch - start_epoch)\n time_bucket = np.floor(ts / TIME_BUCKET_SECONDS).astype(np.int64)\n parts.append(\n pd.DataFrame(\n {\n \"bus_id\": np.repeat(g[\"bus_id\"].to_numpy(), n),\n \"date\": np.repeat(g[\"trip_date\"].to_numpy(), n),\n \"cell_x\": np.tile(cell_x, k),\n \"cell_y\": np.tile(cell_y, k),\n \"time_bucket\": time_bucket.ravel(),\n }\n )\n )\n if not parts:\n return pd.DataFrame(\n columns=[\"bus_id\", \"date\", \"cell_x\", \"cell_y\", \"time_bucket\"]\n )\n return pd.concat(parts, ignore_index=True).drop_duplicates()" + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "470263d6", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T18:05:41.708396Z", + "iopub.status.busy": "2026-08-22T18:05:41.708322Z", + "iopub.status.idle": "2026-08-22T18:05:42.057599Z", + "shell.execute_reply": "2026-08-22T18:05:42.057180Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "30 trip dates to process: 2023-11-01 .. 2023-11-30\n" + ] + } + ], + "source": [ + "conn.execute(\"DROP TABLE IF EXISTS ml.bus_matching_bus_signatures;\")\n", + "conn.execute(\n", + " \"\"\"\n", + " CREATE TABLE ml.bus_matching_bus_signatures (\n", + " bus_id text NOT NULL,\n", + " date date NOT NULL,\n", + " cell_x integer NOT NULL,\n", + " cell_y integer NOT NULL,\n", + " time_bucket bigint NOT NULL\n", + " );\n", + " \"\"\"\n", + ")\n", + "conn.commit()\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\n", + " \"SELECT DISTINCT trip_date FROM ml.trip_validity_final \"\n", + " \"WHERE is_valid ORDER BY trip_date;\"\n", + " )\n", + " trip_dates = [row[0] for row in cur.fetchall()]\n", + "print(f\"{len(trip_dates)} trip dates to process:\", trip_dates[0], \"..\", trip_dates[-1])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ac28efe7", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T18:05:42.058488Z", + "iopub.status.busy": "2026-08-22T18:05:42.058400Z", + "iopub.status.idle": "2026-08-22T18:07:05.126289Z", + "shell.execute_reply": "2026-08-22T18:07:05.125917Z" + } + }, + "outputs": [], + "source": "start = time.monotonic()\ntotal_rows = 0\nfor trip_date in trip_dates:\n day_df = bus_signature_rows_for_date(conn, trip_date)\n if day_df.empty:\n continue\n with (\n conn.cursor() as cur,\n cur.copy(\n \"COPY ml.bus_matching_bus_signatures \"\n \"(bus_id, date, cell_x, cell_y, time_bucket) FROM STDIN\"\n ) as copy,\n ):\n for row in day_df.itertuples(index=False):\n copy.write_row(row)\n conn.commit()\n total_rows += len(day_df)\n\nelapsed = time.monotonic() - start\nprint(\n f\"loaded {total_rows} bus-signature rows across {len(trip_dates)} dates \"\n f\"in {elapsed:.1f}s\"\n)" + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "bb641762", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T18:07:05.127288Z", + "iopub.status.busy": "2026-08-22T18:07:05.127213Z", + "iopub.status.idle": "2026-08-22T18:07:35.411503Z", + "shell.execute_reply": "2026-08-22T18:07:35.410994Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "bus signature indexes built in 30.3s\n" + ] + } + ], + "source": [ + "start = time.monotonic()\n", + "conn.execute(\n", + " \"CREATE INDEX bus_matching_bus_signatures_join_idx \"\n", + " \"ON ml.bus_matching_bus_signatures (date, cell_x, cell_y, time_bucket);\"\n", + ")\n", + "conn.execute(\"CREATE INDEX ON ml.bus_matching_bus_signatures (bus_id);\")\n", + "conn.execute(\"ANALYZE ml.bus_matching_bus_signatures;\")\n", + "conn.commit()\n", + "print(f\"bus signature indexes built in {time.monotonic() - start:.1f}s\")" + ] + }, + { + "cell_type": "markdown", + "id": "d00381ca", + "metadata": {}, + "source": [ + "## Sanity checks" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "884c859a", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T18:07:35.412458Z", + "iopub.status.busy": "2026-08-22T18:07:35.412374Z", + "iopub.status.idle": "2026-08-22T18:07:44.970060Z", + "shell.execute_reply": "2026-08-22T18:07:44.969540Z" + } + }, + "outputs": [], + "source": "with conn.cursor() as cur:\n cur.execute(\n \"SELECT count(*), count(DISTINCT bus_id) FROM ml.bus_matching_bus_signatures;\"\n )\n print(\"bus_signatures rows / distinct bus_id:\", cur.fetchone())\n\n cur.execute(\n \"SELECT count(*), count(DISTINCT device_id) \"\n \"FROM ml.bus_matching_device_signatures;\"\n )\n print(\"device_signatures rows / distinct device_id:\", cur.fetchone())\n\n cur.execute(\n \"\"\"\n SELECT count(*) FROM ml.trip_validity_final f\n WHERE f.is_valid AND NOT EXISTS (\n SELECT 1 FROM ml.bus_matching_bus_signatures s\n WHERE s.bus_id = f.bus_id AND s.date = f.trip_date\n );\n \"\"\"\n )\n print(\"valid trips whose bus-date got zero signature rows:\", cur.fetchone())\n\n cur.execute(\n \"\"\"\n SELECT count(DISTINCT bus_id || trip_date::text) FROM ml.trip_validity_final f\n WHERE f.is_valid AND NOT EXISTS (\n SELECT 1 FROM ml.bus_matching_bus_signatures s\n WHERE s.bus_id = f.bus_id AND s.date = f.trip_date\n );\n \"\"\"\n )\n print(\"distinct bus-dates with zero signature rows:\", cur.fetchone())" + } + ], + "metadata": { + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/ml/bus_matching_model/notebooks/05_blocking_candidates.ipynb b/ml/bus_matching_model/notebooks/05_blocking_candidates.ipynb new file mode 100644 index 0000000..698e920 --- /dev/null +++ b/ml/bus_matching_model/notebooks/05_blocking_candidates.ipynb @@ -0,0 +1,348 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "52decc53", + "metadata": {}, + "source": [ + "# 05 - Blocking: `ml.bus_matching_candidates`\n", + "\n", + "Fifth notebook: Section 2 of the plan. Joins the two signature tables\n", + "on `(date, cell_x, cell_y, time_bucket)` -- a hash/merge join over\n", + "integers, no geometry -- aggregates to `(date, bus_id, device_id)`\n", + "overlap counts, keeps the top 10 devices per bus-date by overlap, and\n", + "unions in every dictionary-sourced pair for that bus regardless of\n", + "rank. Everything else never gets scored.\n", + "\n", + "**Confirmed live before committing to the full build**: one date alone\n", + "(2023-11-01, 1,682 buses x 1,495 devices) produces **1.17M** raw\n", + "`(bus_id, device_id)` pairs with *any* nonzero cell/bucket overlap --\n", + "the grid+bucket signature alone is not very selective over a full day\n", + "(routes physically overlap all over the network, and a 15-minute bucket\n", + "is coarse across 18+ operating hours), confirmed at 15.5s for that one\n", + "date via `EXPLAIN ANALYZE`. This is expected and is exactly why the\n", + "plan doesn't stop at \"nonzero overlap\" -- the top-10-per-bus-date cut\n", + "below is what actually does the blocking; raw overlap counts are never\n", + "persisted on their own.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "8d8cf674", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T18:19:19.371181Z", + "iopub.status.busy": "2026-08-22T18:19:19.371109Z", + "iopub.status.idle": "2026-08-22T18:19:19.402518Z", + "shell.execute_reply": "2026-08-22T18:19:19.401930Z" + } + }, + "outputs": [], + "source": [ + "import os\n", + "import time\n", + "from pathlib import Path\n", + "\n", + "import psycopg" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "68cf5bba", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T18:19:19.403449Z", + "iopub.status.busy": "2026-08-22T18:19:19.403367Z", + "iopub.status.idle": "2026-08-22T18:19:19.406146Z", + "shell.execute_reply": "2026-08-22T18:19:19.405892Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "'/home/victor/repos/opa-database'" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "_root = Path.cwd()\n", + "while not (_root / \"pyproject.toml\").exists():\n", + " _root = _root.parent\n", + "os.chdir(_root)\n", + "os.environ.setdefault(\"RAW_DATA_ROOT\", str(_root))" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "480b6f2d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T18:19:19.406764Z", + "iopub.status.busy": "2026-08-22T18:19:19.406686Z", + "iopub.status.idle": "2026-08-22T18:19:19.455588Z", + "shell.execute_reply": "2026-08-22T18:19:19.455360Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ml schema ready\n" + ] + } + ], + "source": [ + "from opa_database.config import settings\n", + "\n", + "TOP_N_PER_BUS_DATE = 10\n", + "\n", + "conn = psycopg.connect(settings.db_dsn)\n", + "conn.execute(\"CREATE SCHEMA IF NOT EXISTS ml;\")\n", + "conn.commit()\n", + "print(\"ml schema ready\")" + ] + }, + { + "cell_type": "markdown", + "id": "af93962c", + "metadata": {}, + "source": [ + "## Build\n", + "\n", + "`bus_dates` is every `(bus_id, trip_date)` with at least one valid trip\n", + "-- the actual unit of prediction, not just whatever happens to have a\n", + "GTFS-matched signature. `top_blocking` keeps the best\n", + "`TOP_N_PER_BUS_DATE` devices per bus-date by overlap count.\n", + "`dictionary_candidates` cross-joins every `bus_matching_candidate_pairs`\n", + "row onto every date that bus actually ran, since the dictionary itself\n", + "carries no date. A `FULL OUTER JOIN` merges the two so a pair that's\n", + "both dictionary-sourced *and* blocking-ranked keeps its overlap count\n", + "instead of appearing twice.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "f413346d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T18:19:19.456463Z", + "iopub.status.busy": "2026-08-22T18:19:19.456392Z", + "iopub.status.idle": "2026-08-22T18:29:07.433198Z", + "shell.execute_reply": "2026-08-22T18:29:07.432835Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ml.bus_matching_candidates built in 588.0s\n" + ] + } + ], + "source": [ + "start = time.monotonic()\n", + "conn.execute(\"DROP TABLE IF EXISTS ml.bus_matching_candidates;\")\n", + "conn.execute(\n", + " \"\"\"\n", + " CREATE TABLE ml.bus_matching_candidates AS\n", + " WITH overlap AS (\n", + " SELECT bs.date, bs.bus_id, ds.device_id, count(*) AS overlap_count\n", + " FROM ml.bus_matching_bus_signatures bs\n", + " JOIN ml.bus_matching_device_signatures ds\n", + " ON ds.date = bs.date AND ds.cell_x = bs.cell_x\n", + " AND ds.cell_y = bs.cell_y AND ds.time_bucket = bs.time_bucket\n", + " GROUP BY bs.date, bs.bus_id, ds.device_id\n", + " ),\n", + " ranked AS (\n", + " SELECT *,\n", + " row_number() OVER (\n", + " PARTITION BY date, bus_id ORDER BY overlap_count DESC\n", + " ) AS rnk\n", + " FROM overlap\n", + " ),\n", + " top_blocking AS (\n", + " SELECT date, bus_id, device_id, overlap_count\n", + " FROM ranked\n", + " WHERE rnk <= %(top_n)s\n", + " ),\n", + " bus_dates AS (\n", + " SELECT DISTINCT bus_id, trip_date AS date\n", + " FROM ml.trip_validity_final\n", + " WHERE is_valid\n", + " ),\n", + " dictionary_pairs_distinct AS (\n", + " SELECT DISTINCT bus_id, device_id FROM ml.bus_matching_candidate_pairs\n", + " ),\n", + " dictionary_candidates AS (\n", + " SELECT bd.date, bd.bus_id, cp.device_id\n", + " FROM bus_dates bd\n", + " JOIN dictionary_pairs_distinct cp ON cp.bus_id = bd.bus_id\n", + " )\n", + " SELECT\n", + " coalesce(tb.date, dc.date) AS date,\n", + " coalesce(tb.bus_id, dc.bus_id) AS bus_id,\n", + " coalesce(tb.device_id, dc.device_id) AS device_id,\n", + " tb.overlap_count,\n", + " (dc.device_id IS NOT NULL) AS from_dictionary,\n", + " (tb.device_id IS NOT NULL) AS from_blocking\n", + " FROM top_blocking tb\n", + " FULL OUTER JOIN dictionary_candidates dc\n", + " ON dc.date = tb.date AND dc.bus_id = tb.bus_id AND dc.device_id = tb.device_id;\n", + " \"\"\",\n", + " {\"top_n\": TOP_N_PER_BUS_DATE},\n", + ")\n", + "conn.commit()\n", + "print(f\"ml.bus_matching_candidates built in {time.monotonic() - start:.1f}s\")" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "116a8952", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T18:29:07.434074Z", + "iopub.status.busy": "2026-08-22T18:29:07.434003Z", + "iopub.status.idle": "2026-08-22T18:29:07.916735Z", + "shell.execute_reply": "2026-08-22T18:29:07.916390Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "indexes built in 0.5s\n" + ] + } + ], + "source": [ + "start = time.monotonic()\n", + "conn.execute(\"CREATE INDEX ON ml.bus_matching_candidates (date, bus_id);\")\n", + "conn.execute(\"CREATE INDEX ON ml.bus_matching_candidates (date, device_id);\")\n", + "conn.execute(\"ANALYZE ml.bus_matching_candidates;\")\n", + "conn.commit()\n", + "print(f\"indexes built in {time.monotonic() - start:.1f}s\")" + ] + }, + { + "cell_type": "markdown", + "id": "38cd0a2c", + "metadata": {}, + "source": [ + "## Sanity checks" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "4d21176f", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T18:29:07.917541Z", + "iopub.status.busy": "2026-08-22T18:29:07.917466Z", + "iopub.status.idle": "2026-08-22T18:29:08.216863Z", + "shell.execute_reply": "2026-08-22T18:29:08.216510Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "total candidate rows: (407517,)\n", + "total / from_dictionary / from_blocking / both: (407517, 40265, 399212, 31960)\n", + "bus-dates covered / avg / min / max candidates per bus-date: (40007, Decimal('10.19'), 1, 13)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "valid bus-dates with zero candidates at all: (1325,)\n", + "dictionary pairs missing from candidates entirely (should be 0): (0,)\n" + ] + } + ], + "source": [ + "with conn.cursor() as cur:\n", + " cur.execute(\"SELECT count(*) FROM ml.bus_matching_candidates;\")\n", + " print(\"total candidate rows:\", cur.fetchone())\n", + "\n", + " cur.execute(\n", + " \"SELECT count(*), count(*) FILTER (WHERE from_dictionary), \"\n", + " \"count(*) FILTER (WHERE from_blocking), \"\n", + " \"count(*) FILTER (WHERE from_dictionary AND from_blocking) \"\n", + " \"FROM ml.bus_matching_candidates;\"\n", + " )\n", + " print(\"total / from_dictionary / from_blocking / both:\", cur.fetchone())\n", + "\n", + " cur.execute(\n", + " \"\"\"\n", + " SELECT count(*), avg(n)::numeric(10,2), min(n), max(n) FROM (\n", + " SELECT date, bus_id, count(*) AS n\n", + " FROM ml.bus_matching_candidates GROUP BY date, bus_id\n", + " ) x;\n", + " \"\"\"\n", + " )\n", + " print(\n", + " \"bus-dates covered / avg / min / max candidates per bus-date:\", cur.fetchone()\n", + " )\n", + "\n", + " cur.execute(\n", + " \"\"\"\n", + " SELECT count(*) FROM (\n", + " SELECT DISTINCT bus_id, trip_date FROM ml.trip_validity_final WHERE is_valid\n", + " ) bd\n", + " WHERE NOT EXISTS (\n", + " SELECT 1 FROM ml.bus_matching_candidates c\n", + " WHERE c.bus_id = bd.bus_id AND c.date = bd.trip_date\n", + " );\n", + " \"\"\"\n", + " )\n", + " print(\"valid bus-dates with zero candidates at all:\", cur.fetchone())\n", + "\n", + " cur.execute(\n", + " \"\"\"\n", + " SELECT count(*) FROM ml.bus_matching_candidate_pairs cp\n", + " WHERE NOT EXISTS (\n", + " SELECT 1 FROM ml.bus_matching_candidates c\n", + " WHERE c.bus_id = cp.bus_id AND c.device_id = cp.device_id\n", + " );\n", + " \"\"\"\n", + " )\n", + " print(\n", + " \"dictionary pairs missing from candidates entirely (should be 0):\",\n", + " cur.fetchone(),\n", + " )" + ] + } + ], + "metadata": { + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/ml/bus_matching_model/notebooks/06_global_assignment.ipynb b/ml/bus_matching_model/notebooks/06_global_assignment.ipynb new file mode 100644 index 0000000..2a5991c --- /dev/null +++ b/ml/bus_matching_model/notebooks/06_global_assignment.ipynb @@ -0,0 +1,970 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "2078b627", + "metadata": {}, + "source": [ + "# 06 - Global Assignment (Plan Section 9)\n", + "\n", + "`belief.py`'s cross-bus-date suppression (used live in the labeling UI)\n", + "is a single, non-iterated pairwise-discount heuristic: it can leave two\n", + "buses both claiming the same device if neither individually clears its\n", + "suppression threshold. This notebook replaces that heuristic, **per\n", + "date**, with a real joint solve: minimum-weight full bipartite matching\n", + "between buses and devices, so a device is matched to at most one bus\n", + "that day.\n", + "\n", + "**Cost input**: `belief.compute_raw_beliefs`'s *pre-suppression*\n", + "posterior (`cost = -log(posterior)`), for the same reason the module\n", + "docstring gives -- this is what the suppression heuristic itself\n", + "approximates, so the global solve supersedes it rather than layering on\n", + "top of it.\n", + "\n", + "**Sparse, not dense**: confirmed live (see `app/assignment.py`'s\n", + "docstring) a weekday's bipartite graph is ~1,600 buses x ~1,400 devices\n", + "at ~0.7% density, so `scipy.sparse.csgraph.min_weight_full_bipartite_matching`\n", + "(sparse LAPJVsp) is the right tool, not a dense\n", + "`scipy.optimize.linear_sum_assignment`.\n", + "\n", + "**\"None of these\"**: every bus gets its own dummy \"assign to none\"\n", + "column (cost = that bus's own `NONE_OPTION` posterior from the same\n", + "belief computation), so a bus with no good candidate isn't forced onto\n", + "a bad one -- confirmed live, sample date assigned 397/1,609 buses to\n", + "\"none\".\n", + "\n", + "**Zero-candidate bus-dates**: 1,325 of 41,332 valid bus-dates (3.2%,\n", + "concentrated in just 51 buses) have no candidate at all -- these never\n", + "enter the solver, and are written with `method='no_candidates'`\n", + "instead.\n", + "\n", + "**Margin** (plan Section 9.3): for each assigned bus, re-solve the\n", + "*whole date* with that bus's edge forbidden and take the increase in\n", + "total cost. Confirmed live: ~2.3ms/bus, ~3.6s for a full weekday\n", + "including all ~1,600 margin re-solves -- cheap enough to do exactly as\n", + "specified, not an approximation.\n", + "\n", + "**Batch job, not live**: per the plan and the labeling app's own\n", + "docstring, this runs as a periodic background job -- `belief.py`'s\n", + "cross-suppression stays as-is for the interactive labeling UI.\n", + "\n", + "**On request, two more changes this run:**\n", + "\n", + "- **No-AVL company exclusion**: `silver.dictionary_device` companies\n", + " where 100% of their known devices never ping in November 2023 --\n", + " confirmed live to be COOTRAPS and Fretcar, no others -- are excluded\n", + " from the solver entirely and written `method='no_avl_data'`, computed\n", + " live from the data each run rather than a hardcoded list (so it stays\n", + " correct if this is ever re-run on different data). These buses have\n", + " real AFC trips but their assigned devices structurally cannot appear\n", + " in any AVL-based match; the ~249 COOTRAPS buses this affects were\n", + " previously showing up as ordinary (wrong-looking) `global_assignment`\n", + " \"none\" results.\n", + "- **Dictionary corroboration now feeds the cost, not just the UI**: per\n", + " a domain expert, a dictionary-sourced pair is real corroborating\n", + " evidence (not certain, weaker when a bus has conflicting dictionary\n", + " devices) -- `belief.compute_raw_beliefs` now adds a log-odds term for\n", + " this (`DICTIONARY_PRIOR_WEIGHT`/`DICTIONARY_BOOST_ODDS_*`), so it\n", + " flows into this notebook's cost matrix automatically.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "84fb133d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T15:02:12.635384Z", + "iopub.status.busy": "2026-08-23T15:02:12.635321Z", + "iopub.status.idle": "2026-08-23T15:02:12.637581Z", + "shell.execute_reply": "2026-08-23T15:02:12.637342Z" + } + }, + "outputs": [], + "source": [ + "import os\n", + "import sys\n", + "from pathlib import Path\n", + "\n", + "_root = Path.cwd()\n", + "while not (_root / \"pyproject.toml\").exists():\n", + " _root = _root.parent\n", + "os.chdir(_root)\n", + "os.environ.setdefault(\"RAW_DATA_ROOT\", str(_root))\n", + "sys.path.insert(0, str(_root / \"ml\" / \"bus_matching_model\" / \"app\"))" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "ef188787", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T15:02:12.638589Z", + "iopub.status.busy": "2026-08-23T15:02:12.638521Z", + "iopub.status.idle": "2026-08-23T15:02:13.260160Z", + "shell.execute_reply": "2026-08-23T15:02:13.259825Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ml schema ready\n" + ] + } + ], + "source": [ + "import datetime\n", + "import time\n", + "\n", + "import assignment\n", + "import belief\n", + "import db\n", + "import numpy as np\n", + "import pandas as pd\n", + "import psycopg\n", + "import registry\n", + "\n", + "from opa_database.config import settings\n", + "\n", + "conn = psycopg.connect(settings.db_dsn)\n", + "conn.execute(\"CREATE SCHEMA IF NOT EXISTS ml;\")\n", + "conn.commit()\n", + "print(\"ml schema ready\")" + ] + }, + { + "cell_type": "markdown", + "id": "6e8fc8c6", + "metadata": {}, + "source": [ + "## Table\n", + "\n", + "`(bus_id, date)` primary key, one row per valid bus-date (all 41,332,\n", + "not just the 40,007 with candidates) -- Section 13's \"include an\n", + "explicit unresolved bucket\" starts here: `method='no_candidates'` rows\n", + "are exactly that bucket for this layer.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "2c0fe49d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T15:02:13.261022Z", + "iopub.status.busy": "2026-08-23T15:02:13.260876Z", + "iopub.status.idle": "2026-08-23T15:02:13.269831Z", + "shell.execute_reply": "2026-08-23T15:02:13.269433Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ml.bus_matching_global_assignment created\n" + ] + } + ], + "source": [ + "conn.execute(\"DROP TABLE IF EXISTS ml.bus_matching_global_assignment;\")\n", + "ddl = (\n", + " \"CREATE TABLE ml.bus_matching_global_assignment (\\n\"\n", + " \" bus_id text NOT NULL,\\n\"\n", + " \" date date NOT NULL,\\n\"\n", + " \" device_id text,\\n\"\n", + " \" cost double precision,\\n\"\n", + " \" margin double precision,\\n\"\n", + " \" method text NOT NULL,\\n\"\n", + " \" PRIMARY KEY (bus_id, date)\\n\"\n", + " \");\"\n", + ")\n", + "conn.execute(ddl)\n", + "conn.commit()\n", + "print(\"ml.bus_matching_global_assignment created\")" + ] + }, + { + "cell_type": "markdown", + "id": "2ad9a0d9", + "metadata": {}, + "source": [ + "## Solve every date\n", + "\n", + "Load candidates/votes/model once (same pattern as the labeling app),\n", + "compute the ramped `model_prior_weight` from the latest trained run,\n", + "then solve each date independently and `COPY` the results in.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "48b53af7", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T15:02:13.270567Z", + "iopub.status.busy": "2026-08-23T15:02:13.270494Z", + "iopub.status.idle": "2026-08-23T15:02:14.394135Z", + "shell.execute_reply": "2026-08-23T15:02:14.393611Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "model_prior_weight=0.600, run_id=73\n", + "30 dates, 41332 valid bus-date cells total\n" + ] + } + ], + "source": [ + "run_row = db.fetch_latest_model_run(conn)\n", + "model_state = registry.load_run(run_row) if run_row else None\n", + "if model_state is not None:\n", + " model_state[\"run_row\"] = run_row\n", + "features = db.load_available_features()\n", + "\n", + "candidates_all = db.compute_candidate_scores(conn, features, model_state)\n", + "votes_all = db.fetch_all_votes(conn)\n", + "model_prior_weight = belief.model_prior_weight_for(\n", + " (run_row or {}).get(\"n_resolved_bus_dates\"), (run_row or {}).get(\"test_ece\")\n", + ")\n", + "run_id = (run_row or {}).get(\"run_id\")\n", + "print(f\"model_prior_weight={model_prior_weight:.3f}, run_id={run_id}\")\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\n", + " \"SELECT DISTINCT trip_date FROM ml.trip_validity_final \"\n", + " \"WHERE is_valid ORDER BY trip_date;\"\n", + " )\n", + " all_dates = [row[0] for row in cur.fetchall()]\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\n", + " \"SELECT DISTINCT bus_id, trip_date FROM ml.trip_validity_final WHERE is_valid;\"\n", + " )\n", + " all_bus_dates = pd.DataFrame(cur.fetchall(), columns=[\"bus_id\", \"date\"])\n", + "\n", + "print(f\"{len(all_dates)} dates, {len(all_bus_dates)} valid bus-date cells total\")" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "99953307", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T15:02:14.394984Z", + "iopub.status.busy": "2026-08-23T15:02:14.394900Z", + "iopub.status.idle": "2026-08-23T15:02:14.473554Z", + "shell.execute_reply": "2026-08-23T15:02:14.473188Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "no-AVL companies: ['COOTRAPS', 'Fretcar']\n", + "240 buses excluded as no_avl_data (of 366 named)\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_1156184/3321833271.py:34: UserWarning: pandas only supports SQLAlchemy connectable (engine/connection) or database string URI or sqlite3 DBAPI2 connection. Other DBAPI2 objects are not tested. Please consider using SQLAlchemy.\n", + " no_avl_df = pd.read_sql(no_avl_sql, conn)\n" + ] + } + ], + "source": [ + "no_avl_sql = (\n", + " \"WITH company_ping_rates AS (\\n\"\n", + " \" SELECT\\n\"\n", + " \" d.company,\\n\"\n", + " \" count(DISTINCT d.device_id) AS n_devices,\\n\"\n", + " \" count(DISTINCT d.device_id) FILTER (\\n\"\n", + " \" WHERE EXISTS (\\n\"\n", + " \" SELECT 1 FROM silver.avl_pings_y2023m11 p\\n\"\n", + " \" WHERE p.device_id = d.device_id\\n\"\n", + " \" )\\n\"\n", + " \" ) AS n_pinging\\n\"\n", + " \" FROM silver.dictionary_device d\\n\"\n", + " \" WHERE d.device_id IS NOT NULL\\n\"\n", + " \" GROUP BY d.company\\n\"\n", + " \"),\\n\"\n", + " \"no_avl_companies AS (\\n\"\n", + " \" SELECT company FROM company_ping_rates\\n\"\n", + " \" WHERE n_devices > 0 AND n_pinging = 0\\n\"\n", + " \"),\\n\"\n", + " \"normalized AS (\\n\"\n", + " \" SELECT\\n\"\n", + " \" regexp_replace(vehicle_number, '[^0-9]', '', 'g') AS digits,\\n\"\n", + " \" company\\n\"\n", + " \" FROM silver.dictionary_device\\n\"\n", + " \" WHERE company IN (SELECT company FROM no_avl_companies)\\n\"\n", + " \")\\n\"\n", + " \"SELECT DISTINCT\\n\"\n", + " \" CASE WHEN length(digits) < 5 THEN lpad(digits, 5, '0') ELSE digits END\\n\"\n", + " \" AS bus_id,\\n\"\n", + " \" company\\n\"\n", + " \"FROM normalized\\n\"\n", + " \"WHERE digits <> '';\"\n", + ")\n", + "no_avl_df = pd.read_sql(no_avl_sql, conn)\n", + "no_avl_bus_ids = set(no_avl_df[\"bus_id\"]) & set(all_bus_dates[\"bus_id\"])\n", + "n_named = no_avl_df[\"bus_id\"].nunique()\n", + "print(f\"no-AVL companies: {sorted(no_avl_df['company'].unique())}\")\n", + "print(f\"{len(no_avl_bus_ids)} buses excluded as no_avl_data (of {n_named} named)\")" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "65aee673", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T15:02:14.474515Z", + "iopub.status.busy": "2026-08-23T15:02:14.474440Z", + "iopub.status.idle": "2026-08-23T15:02:14.477043Z", + "shell.execute_reply": "2026-08-23T15:02:14.476730Z" + } + }, + "outputs": [], + "source": [ + "def rows_for_date(trip_date: datetime.date) -> list[tuple]:\n", + " \"\"\"Solve one date, returning COPY-ready rows for every valid bus-date that day.\"\"\"\n", + " day_bus_ids = set(all_bus_dates.loc[all_bus_dates[\"date\"] == trip_date, \"bus_id\"])\n", + "\n", + " rows: list[tuple] = []\n", + " no_avl_today = day_bus_ids & no_avl_bus_ids\n", + " rows.extend((b, trip_date, None, None, None, \"no_avl_data\") for b in no_avl_today)\n", + " day_bus_ids = day_bus_ids - no_avl_today\n", + "\n", + " cand_today = candidates_all[candidates_all[\"date\"] == trip_date]\n", + " cand_today = cand_today[cand_today[\"bus_id\"].isin(day_bus_ids)]\n", + "\n", + " no_candidate_buses = day_bus_ids - set(cand_today[\"bus_id\"].unique())\n", + " rows.extend(\n", + " (b, trip_date, None, None, None, \"no_candidates\") for b in no_candidate_buses\n", + " )\n", + "\n", + " if cand_today.empty:\n", + " return rows\n", + "\n", + " votes_today = (\n", + " votes_all[votes_all[\"date\"] == trip_date] if not votes_all.empty else votes_all\n", + " )\n", + " raw = belief.compute_raw_beliefs(cand_today, votes_today, model_prior_weight)\n", + " result = assignment.solve_date(raw, compute_margins=True)\n", + " for bus_id, device_id, cost, margin in zip(\n", + " result.bus_id, result.device_id, result.cost, result.margin, strict=True\n", + " ):\n", + " margin_val = None if np.isnan(margin) else float(margin)\n", + " rows.append(\n", + " (bus_id, trip_date, device_id, float(cost), margin_val, \"global_assignment\")\n", + " )\n", + " return rows" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "23a4889d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T15:02:14.477852Z", + "iopub.status.busy": "2026-08-23T15:02:14.477786Z", + "iopub.status.idle": "2026-08-23T15:03:11.741270Z", + "shell.execute_reply": "2026-08-23T15:03:11.740859Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2023-11-01: 1652 rows (2.6s elapsed)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2023-11-02: 660 rows (2.9s elapsed)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2023-11-03: 1587 rows (5.3s elapsed)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2023-11-04: 1140 rows (6.2s elapsed)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2023-11-05: 612 rows (6.4s elapsed)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2023-11-06: 1661 rows (9.2s elapsed)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2023-11-07: 1665 rows (11.8s elapsed)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2023-11-08: 1657 rows (14.4s elapsed)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2023-11-09: 1663 rows (17.0s elapsed)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2023-11-10: 1668 rows (19.6s elapsed)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2023-11-11: 1137 rows (20.5s elapsed)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2023-11-12: 594 rows (20.8s elapsed)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2023-11-13: 1664 rows (23.3s elapsed)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2023-11-14: 1670 rows (26.0s elapsed)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2023-11-15: 603 rows (26.2s elapsed)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2023-11-16: 1665 rows (28.8s elapsed)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2023-11-17: 1665 rows (31.5s elapsed)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2023-11-18: 1129 rows (32.3s elapsed)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2023-11-19: 575 rows (32.6s elapsed)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2023-11-20: 1669 rows (35.2s elapsed)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2023-11-21: 1671 rows (37.8s elapsed)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2023-11-22: 1660 rows (40.4s elapsed)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2023-11-23: 1665 rows (43.0s elapsed)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2023-11-24: 1665 rows (45.7s elapsed)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2023-11-25: 1116 rows (46.5s elapsed)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2023-11-26: 583 rows (46.8s elapsed)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2023-11-27: 1665 rows (49.4s elapsed)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2023-11-28: 1660 rows (52.0s elapsed)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2023-11-29: 1657 rows (54.6s elapsed)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2023-11-30: 1654 rows (57.3s elapsed)\n", + "loaded 41332 rows in 57.3s\n" + ] + } + ], + "source": [ + "start = time.monotonic()\n", + "total_rows = 0\n", + "with (\n", + " conn.cursor() as cur,\n", + " cur.copy(\n", + " \"COPY ml.bus_matching_global_assignment \"\n", + " \"(bus_id, date, device_id, cost, margin, method) FROM STDIN\"\n", + " ) as copy,\n", + "):\n", + " for trip_date in all_dates:\n", + " day_rows = rows_for_date(trip_date)\n", + " for row in day_rows:\n", + " copy.write_row(row)\n", + " total_rows += len(day_rows)\n", + " elapsed = time.monotonic() - start\n", + " print(f\"{trip_date}: {len(day_rows)} rows ({elapsed:.1f}s elapsed)\")\n", + "conn.commit()\n", + "print(f\"loaded {total_rows} rows in {time.monotonic() - start:.1f}s\")" + ] + }, + { + "cell_type": "markdown", + "id": "c96d3f74", + "metadata": {}, + "source": [ + "## Sanity checks" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "b41236dd", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T15:03:11.742360Z", + "iopub.status.busy": "2026-08-23T15:03:11.742281Z", + "iopub.status.idle": "2026-08-23T15:03:11.845345Z", + "shell.execute_reply": "2026-08-23T15:03:11.844860Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "total rows / distinct bus-dates: (41332, 41332)\n", + "method / total / with device:\n", + " ('global_assignment', 34581, 31963)\n", + " ('no_candidates', 1322, 0)\n", + " ('no_avl_data', 5429, 0)\n", + "device assigned to >1 bus on the same date (must be 0): (0,)\n", + "solved buses assigned 'none' (real candidates existed, none good enough): (2618,)\n", + "mean / median margin: (2.937160924582337, 3.347464716185357)\n" + ] + } + ], + "source": [ + "with conn.cursor() as cur:\n", + " cur.execute(\n", + " \"SELECT count(*), count(DISTINCT bus_id || date::text) \"\n", + " \"FROM ml.bus_matching_global_assignment;\"\n", + " )\n", + " print(\"total rows / distinct bus-dates:\", cur.fetchone())\n", + "\n", + " cur.execute(\n", + " \"SELECT method, count(*), count(*) FILTER (WHERE device_id IS NOT NULL) \"\n", + " \"FROM ml.bus_matching_global_assignment GROUP BY method;\"\n", + " )\n", + " print(\"method / total / with device:\")\n", + " for row in cur.fetchall():\n", + " print(\" \", row)\n", + "\n", + " dupe_sql = (\n", + " \"SELECT count(*) FROM (\\n\"\n", + " \" SELECT date, device_id, count(DISTINCT bus_id) AS n_buses\\n\"\n", + " \" FROM ml.bus_matching_global_assignment\\n\"\n", + " \" WHERE device_id IS NOT NULL\\n\"\n", + " \" GROUP BY date, device_id\\n\"\n", + " \" HAVING count(DISTINCT bus_id) > 1\\n\"\n", + " \") dupes;\"\n", + " )\n", + " cur.execute(dupe_sql)\n", + " print(\"device assigned to >1 bus on the same date (must be 0):\", cur.fetchone())\n", + "\n", + " cur.execute(\n", + " \"SELECT count(*) FROM ml.bus_matching_global_assignment \"\n", + " \"WHERE method = %(method)s AND device_id IS NULL;\",\n", + " {\"method\": \"global_assignment\"},\n", + " )\n", + " print(\n", + " \"solved buses assigned 'none' (real candidates existed, none good enough):\",\n", + " cur.fetchone(),\n", + " )\n", + "\n", + " cur.execute(\n", + " \"SELECT avg(margin), percentile_cont(0.5) WITHIN GROUP (ORDER BY margin) \"\n", + " \"FROM ml.bus_matching_global_assignment WHERE margin IS NOT NULL;\"\n", + " )\n", + " print(\"mean / median margin:\", cur.fetchone())" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "48e9f070", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T15:03:11.846202Z", + "iopub.status.busy": "2026-08-23T15:03:11.846115Z", + "iopub.status.idle": "2026-08-23T15:03:11.855676Z", + "shell.execute_reply": "2026-08-23T15:03:11.855411Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "15 lowest-margin (most contested) assignments:\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_1156184/1215165295.py:8: UserWarning: pandas only supports SQLAlchemy connectable (engine/connection) or database string URI or sqlite3 DBAPI2 connection. Other DBAPI2 objects are not tested. Please consider using SQLAlchemy.\n", + " sample = pd.read_sql(sample_sql, conn)\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
bus_iddatedevice_idcostmarginmethod
0307082023-11-11ep1-4281037150.936385-1.080025e-12global_assignment
1307082023-11-02ep1-4281037150.936385-2.842171e-14global_assignment
2351322023-11-02ep1-4281035840.936385-2.842171e-14global_assignment
3351272023-11-29ep1-4281095411.1293593.669597e-05global_assignment
4351492023-11-30ep1-4281168081.1124329.338116e-04global_assignment
5123132023-11-09ep1-4281139001.1115062.314063e-03global_assignment
6120082023-11-05ep1-4281157471.1103823.988990e-03global_assignment
7265012023-11-08ep1-4281124891.1103034.107773e-03global_assignment
8303152023-11-03ep1-4281146621.1100924.421276e-03global_assignment
9267052023-11-08ep1-4281087391.1098204.827135e-03global_assignment
10309022023-11-29ep1-4281051361.1091225.869348e-03global_assignment
11305152023-11-05ep1-4281145571.3056415.904766e-03global_assignment
12304092023-11-09ep1-4281110001.1086746.537239e-03global_assignment
13352672023-11-13ep1-4281045981.1085116.780347e-03global_assignment
14147012023-11-16ep1-4281107911.1080737.434371e-03global_assignment
\n", + "
" + ], + "text/plain": [ + " bus_id date device_id cost margin \\\n", + "0 30708 2023-11-11 ep1-428103715 0.936385 -1.080025e-12 \n", + "1 30708 2023-11-02 ep1-428103715 0.936385 -2.842171e-14 \n", + "2 35132 2023-11-02 ep1-428103584 0.936385 -2.842171e-14 \n", + "3 35127 2023-11-29 ep1-428109541 1.129359 3.669597e-05 \n", + "4 35149 2023-11-30 ep1-428116808 1.112432 9.338116e-04 \n", + "5 12313 2023-11-09 ep1-428113900 1.111506 2.314063e-03 \n", + "6 12008 2023-11-05 ep1-428115747 1.110382 3.988990e-03 \n", + "7 26501 2023-11-08 ep1-428112489 1.110303 4.107773e-03 \n", + "8 30315 2023-11-03 ep1-428114662 1.110092 4.421276e-03 \n", + "9 26705 2023-11-08 ep1-428108739 1.109820 4.827135e-03 \n", + "10 30902 2023-11-29 ep1-428105136 1.109122 5.869348e-03 \n", + "11 30515 2023-11-05 ep1-428114557 1.305641 5.904766e-03 \n", + "12 30409 2023-11-09 ep1-428111000 1.108674 6.537239e-03 \n", + "13 35267 2023-11-13 ep1-428104598 1.108511 6.780347e-03 \n", + "14 14701 2023-11-16 ep1-428110791 1.108073 7.434371e-03 \n", + "\n", + " method \n", + "0 global_assignment \n", + "1 global_assignment \n", + "2 global_assignment \n", + "3 global_assignment \n", + "4 global_assignment \n", + "5 global_assignment \n", + "6 global_assignment \n", + "7 global_assignment \n", + "8 global_assignment \n", + "9 global_assignment \n", + "10 global_assignment \n", + "11 global_assignment \n", + "12 global_assignment \n", + "13 global_assignment \n", + "14 global_assignment " + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "sample_sql = (\n", + " \"SELECT bus_id, date, device_id, cost, margin, method\\n\"\n", + " \"FROM ml.bus_matching_global_assignment\\n\"\n", + " \"WHERE device_id IS NOT NULL\\n\"\n", + " \"ORDER BY margin ASC NULLS LAST\\n\"\n", + " \"LIMIT 15;\"\n", + ")\n", + "sample = pd.read_sql(sample_sql, conn)\n", + "print(\"15 lowest-margin (most contested) assignments:\")\n", + "sample" + ] + } + ], + "metadata": { + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/ml/bus_matching_model/notebooks/07_temporal_smoothing.ipynb b/ml/bus_matching_model/notebooks/07_temporal_smoothing.ipynb new file mode 100644 index 0000000..73580c4 --- /dev/null +++ b/ml/bus_matching_model/notebooks/07_temporal_smoothing.ipynb @@ -0,0 +1,730 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "7bfb64f4", + "metadata": {}, + "source": [ + "# 07 - Temporal Smoothing (Plan Section 10)\n", + "\n", + "Collapses each device's daily sequence of Section 9\n", + "(`ml.bus_matching_global_assignment`) assignments into\n", + "`(bus_id, device_id, from_date, to_date)` intervals.\n", + "\n", + "**Scope, confirmed live**: of 1,437 devices with at least one assigned\n", + "day, 1,407 (97.9%) stick to exactly one bus all month -- nothing to\n", + "smooth. Only 30 devices (60 device-bus pairs) ever show more than one\n", + "bus, and 25 of those 60 pairs are a single day. Hand-inspecting several\n", + "showed the exact pattern the plan describes: a solid run of bus A, one\n", + "day of bus B, the same run of A resuming. At this scale a transparent,\n", + "inspectable rule-based smoother (`app/smoothing.py`) is a better fit\n", + "than the plan's suggested HMM/changepoint-detection machinery -- see\n", + "that module's docstring for the full reasoning and the two rules it\n", + "implements (isolated one-day-deviation correction, and gap-bridging\n", + "that falls out of interval construction for free).\n", + "\n", + "Every rule was verified against the real hand-inspected sequences\n", + "before being trusted here -- see the correctness checks below.\n", + "\n", + "**On request, one more step this run**: `smoothing.extend_to_bus_bounds`.\n", + "Per a domain expert, a device essentially never changes bus mid-month\n", + "(\"hardly ever, perhaps once every blue moon\"), so once a bus has\n", + "exactly one distinct confirmed device across all its intervals, that\n", + "device very likely covers the bus's *entire* running range that month,\n", + "not just the days directly solved or internally bridged. Still gated\n", + "by the same cross-device conflict check -- confirmed live this matters:\n", + "an early version checked only the extending bus's own running days for\n", + "conflicts and missed one that fell entirely on days that bus doesn't\n", + "run, silently double-claiming a device across two buses; fixed by\n", + "scanning the full calendar range between candidate days, not just the\n", + "running-day positions. A second bug found the same way: the conflict\n", + "lookup was built from Section 9's *raw* per-day output, so it still saw\n", + "the old value on days Section 10 had already corrected as noise --\n", + "`smoothing.build_device_owner` applies those corrections first.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "3690d078", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T15:04:43.761613Z", + "iopub.status.busy": "2026-08-23T15:04:43.761547Z", + "iopub.status.idle": "2026-08-23T15:04:43.763891Z", + "shell.execute_reply": "2026-08-23T15:04:43.763625Z" + } + }, + "outputs": [], + "source": [ + "import os\n", + "import sys\n", + "from pathlib import Path\n", + "\n", + "_root = Path.cwd()\n", + "while not (_root / \"pyproject.toml\").exists():\n", + " _root = _root.parent\n", + "os.chdir(_root)\n", + "os.environ.setdefault(\"RAW_DATA_ROOT\", str(_root))\n", + "sys.path.insert(0, str(_root / \"ml\" / \"bus_matching_model\" / \"app\"))" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "56dfb351", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T15:04:43.764643Z", + "iopub.status.busy": "2026-08-23T15:04:43.764579Z", + "iopub.status.idle": "2026-08-23T15:04:43.977411Z", + "shell.execute_reply": "2026-08-23T15:04:43.977110Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ml schema ready\n" + ] + } + ], + "source": [ + "import time\n", + "\n", + "import pandas as pd\n", + "import psycopg\n", + "import smoothing\n", + "\n", + "from opa_database.config import settings\n", + "\n", + "conn = psycopg.connect(settings.db_dsn)\n", + "conn.execute(\"CREATE SCHEMA IF NOT EXISTS ml;\")\n", + "conn.commit()\n", + "print(\"ml schema ready\")" + ] + }, + { + "cell_type": "markdown", + "id": "689cc77c", + "metadata": {}, + "source": [ + "## Tables\n", + "\n", + "`ml.bus_matching_intervals`: the Section 10 deliverable -- one row per\n", + "contiguous `(device_id, bus_id)` run. `ml.bus_matching_smoothing_corrections`:\n", + "an audit log of every isolated-deviation correction, so a cluster on\n", + "one device (a real data problem, not noise) stays visible rather than\n", + "silently smoothed away.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "bca75a97", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T15:04:43.978440Z", + "iopub.status.busy": "2026-08-23T15:04:43.978340Z", + "iopub.status.idle": "2026-08-23T15:04:44.005473Z", + "shell.execute_reply": "2026-08-23T15:04:44.005125Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ml.bus_matching_intervals and ml.bus_matching_smoothing_corrections created\n" + ] + } + ], + "source": [ + "conn.execute(\"DROP TABLE IF EXISTS ml.bus_matching_intervals;\")\n", + "intervals_ddl = (\n", + " \"CREATE TABLE ml.bus_matching_intervals (\\n\"\n", + " \" device_id text NOT NULL,\\n\"\n", + " \" bus_id text NOT NULL,\\n\"\n", + " \" from_date date NOT NULL,\\n\"\n", + " \" to_date date NOT NULL,\\n\"\n", + " \" n_days integer NOT NULL,\\n\"\n", + " \" n_corrected integer NOT NULL,\\n\"\n", + " \" n_extended integer NOT NULL DEFAULT 0,\\n\"\n", + " \" PRIMARY KEY (device_id, from_date)\\n\"\n", + " \");\"\n", + ")\n", + "conn.execute(intervals_ddl)\n", + "\n", + "conn.execute(\"DROP TABLE IF EXISTS ml.bus_matching_smoothing_corrections;\")\n", + "corrections_ddl = (\n", + " \"CREATE TABLE ml.bus_matching_smoothing_corrections (\\n\"\n", + " \" device_id text NOT NULL,\\n\"\n", + " \" date date NOT NULL,\\n\"\n", + " \" from_bus text NOT NULL,\\n\"\n", + " \" to_bus text NOT NULL,\\n\"\n", + " \" PRIMARY KEY (device_id, date)\\n\"\n", + " \");\"\n", + ")\n", + "conn.execute(corrections_ddl)\n", + "conn.commit()\n", + "print(\"ml.bus_matching_intervals and ml.bus_matching_smoothing_corrections created\")" + ] + }, + { + "cell_type": "markdown", + "id": "ffdd4ebe", + "metadata": {}, + "source": [ + "## Run" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "13e5f454", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T15:04:44.006271Z", + "iopub.status.busy": "2026-08-23T15:04:44.006202Z", + "iopub.status.idle": "2026-08-23T15:04:44.801748Z", + "shell.execute_reply": "2026-08-23T15:04:44.801303Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "loaded 31963 assigned bus-days in 0.2s\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_1158474/1690290578.py:2: UserWarning: pandas only supports SQLAlchemy connectable (engine/connection) or database string URI or sqlite3 DBAPI2 connection. Other DBAPI2 objects are not tested. Please consider using SQLAlchemy.\n", + " assigned = pd.read_sql(\n", + "/tmp/ipykernel_1158474/1690290578.py:7: UserWarning: pandas only supports SQLAlchemy connectable (engine/connection) or database string URI or sqlite3 DBAPI2 connection. Other DBAPI2 objects are not tested. Please consider using SQLAlchemy.\n", + " trip_dates = pd.read_sql(\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "1476 intervals, 15 isolated-deviation corrections\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "1462 intervals after same-bus-stickiness extension, 376 extended days added\n" + ] + } + ], + "source": [ + "start = time.monotonic()\n", + "assigned = pd.read_sql(\n", + " \"SELECT bus_id, date, device_id FROM ml.bus_matching_global_assignment \"\n", + " \"WHERE device_id IS NOT NULL;\",\n", + " conn,\n", + ")\n", + "trip_dates = pd.read_sql(\n", + " \"SELECT DISTINCT bus_id, trip_date AS date FROM ml.trip_validity_final \"\n", + " \"WHERE is_valid;\",\n", + " conn,\n", + ")\n", + "print(f\"loaded {len(assigned)} assigned bus-days in {time.monotonic() - start:.1f}s\")\n", + "\n", + "intervals, corrections = smoothing.smooth_all_devices(assigned)\n", + "print(f\"{len(intervals)} intervals, {len(corrections)} isolated-deviation corrections\")\n", + "\n", + "bus_running_dates = trip_dates.groupby(\"bus_id\")[\"date\"].apply(list).to_dict()\n", + "device_owner = smoothing.build_device_owner(assigned, corrections)\n", + "intervals = smoothing.extend_to_bus_bounds(intervals, bus_running_dates, device_owner)\n", + "print(\n", + " f\"{len(intervals)} intervals after same-bus-stickiness extension, \"\n", + " f\"{intervals['n_extended'].sum()} extended days added\"\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "44bb3b68", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T15:04:44.802620Z", + "iopub.status.busy": "2026-08-23T15:04:44.802547Z", + "iopub.status.idle": "2026-08-23T15:04:44.812935Z", + "shell.execute_reply": "2026-08-23T15:04:44.812539Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "wrote 1462 intervals, 15 corrections\n" + ] + } + ], + "source": [ + "with (\n", + " conn.cursor() as cur,\n", + " cur.copy(\n", + " \"COPY ml.bus_matching_intervals \"\n", + " \"(device_id, bus_id, from_date, to_date, n_days, n_corrected, n_extended) \"\n", + " \"FROM STDIN\"\n", + " ) as copy,\n", + "):\n", + " for row in intervals.itertuples(index=False):\n", + " copy.write_row(row)\n", + "\n", + "with (\n", + " conn.cursor() as cur,\n", + " cur.copy(\n", + " \"COPY ml.bus_matching_smoothing_corrections \"\n", + " \"(device_id, date, from_bus, to_bus) FROM STDIN\"\n", + " ) as copy,\n", + "):\n", + " for row in corrections.itertuples(index=False):\n", + " copy.write_row(row)\n", + "\n", + "conn.commit()\n", + "print(f\"wrote {len(intervals)} intervals, {len(corrections)} corrections\")" + ] + }, + { + "cell_type": "markdown", + "id": "e0eefb58", + "metadata": {}, + "source": [ + "## Sanity checks" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "451a5bd9", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T15:04:44.813802Z", + "iopub.status.busy": "2026-08-23T15:04:44.813734Z", + "iopub.status.idle": "2026-08-23T15:04:45.145074Z", + "shell.execute_reply": "2026-08-23T15:04:45.144613Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "total intervals / distinct devices: (1462, 1440)\n", + "sum n_days / sum n_corrected / sum n_extended: (31963, 15, 376)\n", + "assigned bus-days in Section 9 output (should match sum n_days): (31963,)\n", + "overlapping same-device intervals (must be 0): (0,)\n", + "devices with more than one interval: 13\n", + "same bus, two different devices, overlapping intervals (must be 0): (0,)\n", + "devices with the most corrections (clusters worth investigating):\n", + " ('ep1-428103715', 2)\n", + " ('ep1-428104598', 1)\n", + " ('ep1-428113215', 1)\n", + " ('ep1-428103731', 1)\n", + " ('ep1-428103792', 1)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "buses with >=1 matched device: 1430 / 1730 total (240 structurally excluded as no_avl_data)\n" + ] + } + ], + "source": [ + "with conn.cursor() as cur:\n", + " cur.execute(\n", + " \"SELECT count(*), count(DISTINCT device_id) FROM ml.bus_matching_intervals;\"\n", + " )\n", + " print(\"total intervals / distinct devices:\", cur.fetchone())\n", + "\n", + " cur.execute(\n", + " \"SELECT sum(n_days), sum(n_corrected), sum(n_extended) \"\n", + " \"FROM ml.bus_matching_intervals;\"\n", + " )\n", + " print(\"sum n_days / sum n_corrected / sum n_extended:\", cur.fetchone())\n", + "\n", + " cur.execute(\n", + " \"SELECT count(*) FROM ml.bus_matching_global_assignment \"\n", + " \"WHERE device_id IS NOT NULL;\"\n", + " )\n", + " print(\n", + " \"assigned bus-days in Section 9 output (should match sum n_days):\",\n", + " cur.fetchone(),\n", + " )\n", + "\n", + " # No two intervals for the same device may overlap in [from_date, to_date].\n", + " overlap_sql = (\n", + " \"SELECT count(*) FROM ml.bus_matching_intervals a\\n\"\n", + " \"JOIN ml.bus_matching_intervals b\\n\"\n", + " \" ON a.device_id = b.device_id AND a.from_date < b.from_date\\n\"\n", + " \"WHERE a.to_date >= b.from_date;\"\n", + " )\n", + " cur.execute(overlap_sql)\n", + " print(\"overlapping same-device intervals (must be 0):\", cur.fetchone())\n", + "\n", + " cur.execute(\n", + " \"SELECT count(*) FROM ml.bus_matching_intervals \"\n", + " \"GROUP BY device_id HAVING count(*) > 1;\"\n", + " )\n", + " print(\"devices with more than one interval:\", len(cur.fetchall()))\n", + "\n", + " # Mirrors Section 9's own uniqueness check, at the interval level: a\n", + " # bus should never have two different devices with overlapping\n", + " # intervals either.\n", + " bus_overlap_sql = (\n", + " \"SELECT count(*) FROM ml.bus_matching_intervals a\\n\"\n", + " \"JOIN ml.bus_matching_intervals b\\n\"\n", + " \" ON a.bus_id = b.bus_id AND a.device_id < b.device_id\\n\"\n", + " \" AND a.from_date <= b.to_date AND b.from_date <= a.to_date;\"\n", + " )\n", + " cur.execute(bus_overlap_sql)\n", + " print(\n", + " \"same bus, two different devices, overlapping intervals (must be 0):\",\n", + " cur.fetchone(),\n", + " )\n", + "\n", + " cur.execute(\n", + " \"SELECT device_id, count(*) AS n FROM ml.bus_matching_smoothing_corrections \"\n", + " \"GROUP BY device_id ORDER BY n DESC LIMIT 5;\"\n", + " )\n", + " print(\"devices with the most corrections (clusters worth investigating):\")\n", + " for row in cur.fetchall():\n", + " print(\" \", row)\n", + "\n", + " cur.execute(\n", + " \"SELECT count(DISTINCT bus_id) FROM ml.trip_validity_final WHERE is_valid;\"\n", + " )\n", + " total_buses = cur.fetchone()[0]\n", + " cur.execute(\"SELECT count(DISTINCT bus_id) FROM ml.bus_matching_intervals;\")\n", + " matched_buses = cur.fetchone()[0]\n", + " cur.execute(\n", + " \"SELECT count(DISTINCT bus_id) FROM ml.bus_matching_global_assignment \"\n", + " \"WHERE method = %(m)s;\",\n", + " {\"m\": \"no_avl_data\"},\n", + " )\n", + " no_avl_buses = cur.fetchone()[0]\n", + " print(\n", + " f\"buses with >=1 matched device: {matched_buses} / {total_buses} total \"\n", + " f\"({no_avl_buses} structurally excluded as no_avl_data)\"\n", + " )" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "3187a745", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T15:04:45.146060Z", + "iopub.status.busy": "2026-08-23T15:04:45.145981Z", + "iopub.status.idle": "2026-08-23T15:04:45.153362Z", + "shell.execute_reply": "2026-08-23T15:04:45.153018Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "sample multi-interval devices:\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_1158474/2880245444.py:11: UserWarning: pandas only supports SQLAlchemy connectable (engine/connection) or database string URI or sqlite3 DBAPI2 connection. Other DBAPI2 objects are not tested. Please consider using SQLAlchemy.\n", + " sample = pd.read_sql(multi_sql, conn)\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
device_idbus_idfrom_dateto_daten_daysn_corrected
0ep1-163140320142122023-11-132023-11-1310
1ep1-163140320142122023-11-232023-11-2310
2ep1-163140320142122023-11-272023-11-2710
3ep1-428105094124502023-11-012023-11-27240
4ep1-428105094124502023-11-292023-11-3020
5ep1-428106248352262023-11-012023-11-0110
6ep1-428106248352262023-11-072023-11-0710
7ep1-428106248352262023-11-222023-11-2210
8ep1-428106248352262023-11-272023-11-2930
9ep1-428108384352262023-11-032023-11-0630
10ep1-428108384352262023-11-082023-11-2190
11ep1-428108384352262023-11-232023-11-2631
12ep1-428108384352262023-11-302023-11-3010
13ep1-428111973301592023-11-012023-11-1060
14ep1-428111973306152023-11-202023-11-2230
15ep1-428111973301592023-11-272023-11-3040
16ep1-428112620204512023-11-012023-11-12110
17ep1-428112620204532023-11-132023-11-1310
18ep1-428112620204512023-11-142023-11-30150
19ep1-428112808122142023-11-082023-11-21110
\n", + "
" + ], + "text/plain": [ + " device_id bus_id from_date to_date n_days n_corrected\n", + "0 ep1-163140320 14212 2023-11-13 2023-11-13 1 0\n", + "1 ep1-163140320 14212 2023-11-23 2023-11-23 1 0\n", + "2 ep1-163140320 14212 2023-11-27 2023-11-27 1 0\n", + "3 ep1-428105094 12450 2023-11-01 2023-11-27 24 0\n", + "4 ep1-428105094 12450 2023-11-29 2023-11-30 2 0\n", + "5 ep1-428106248 35226 2023-11-01 2023-11-01 1 0\n", + "6 ep1-428106248 35226 2023-11-07 2023-11-07 1 0\n", + "7 ep1-428106248 35226 2023-11-22 2023-11-22 1 0\n", + "8 ep1-428106248 35226 2023-11-27 2023-11-29 3 0\n", + "9 ep1-428108384 35226 2023-11-03 2023-11-06 3 0\n", + "10 ep1-428108384 35226 2023-11-08 2023-11-21 9 0\n", + "11 ep1-428108384 35226 2023-11-23 2023-11-26 3 1\n", + "12 ep1-428108384 35226 2023-11-30 2023-11-30 1 0\n", + "13 ep1-428111973 30159 2023-11-01 2023-11-10 6 0\n", + "14 ep1-428111973 30615 2023-11-20 2023-11-22 3 0\n", + "15 ep1-428111973 30159 2023-11-27 2023-11-30 4 0\n", + "16 ep1-428112620 20451 2023-11-01 2023-11-12 11 0\n", + "17 ep1-428112620 20453 2023-11-13 2023-11-13 1 0\n", + "18 ep1-428112620 20451 2023-11-14 2023-11-30 15 0\n", + "19 ep1-428112808 12214 2023-11-08 2023-11-21 11 0" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "multi_sql = (\n", + " \"SELECT device_id, bus_id, from_date, to_date, n_days, n_corrected\\n\"\n", + " \"FROM ml.bus_matching_intervals\\n\"\n", + " \"WHERE device_id IN (\\n\"\n", + " \" SELECT device_id FROM ml.bus_matching_intervals\\n\"\n", + " \" GROUP BY device_id HAVING count(*) > 1\\n\"\n", + " \")\\n\"\n", + " \"ORDER BY device_id, from_date\\n\"\n", + " \"LIMIT 20;\"\n", + ")\n", + "sample = pd.read_sql(multi_sql, conn)\n", + "print(\"sample multi-interval devices:\")\n", + "sample" + ] + } + ], + "metadata": { + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/ml/bus_matching_model/notebooks/08_final_output.ipynb b/ml/bus_matching_model/notebooks/08_final_output.ipynb new file mode 100644 index 0000000..e5156f3 --- /dev/null +++ b/ml/bus_matching_model/notebooks/08_final_output.ipynb @@ -0,0 +1,985 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "29462b3b", + "metadata": {}, + "source": [ + "# 08 - Final Output (Plan Section 13)\n", + "\n", + "One settled row per bus for the whole month (`ml.bus_matching_final_pairs`),\n", + "built from the pair-level layer (`pair_features.py` / `pair_model.py`)\n", + "that replaced `belief.py`'s hand-set trust weights. Every bus that ran\n", + "in November 2023 gets a row -- an explicit `method` says how it was\n", + "settled, following the same never-silently-drop convention Section 9's\n", + "`ml.bus_matching_global_assignment` already uses, just at the month\n", + "grain instead of the bus-date grain.\n", + "\n", + "**Split detection.** Labeling surfaced a real pattern the one-device-per-bus\n", + "assumption cannot represent on its own: a device genuinely gets swapped\n", + "for another mid-month. Four such buses were caught by hand while\n", + "labeling (12225, 12502, 35252, 35403) and marked `verdict='unsure'`\n", + "rather than forced into a single wrong answer. `final_output.detect_split`\n", + "tells a real swap apart from a genuine unknown purely from the day-score\n", + "time series -- one confident device, a single clean changeover, a\n", + "different confident device -- and is verified below to reproduce the\n", + "exact date boundaries found by hand on all four.\n", + "\n", + "**Coverage, confirmed live before this run**: of 1,481 in-scope buses\n", + "(1,730 that ran, minus 249 structurally excluded), 1,424 were resolved\n", + "at >=0.90 confidence with unanimous margin -- every resolved bus's\n", + "runner-up scored at least 0.9 below it, so \"confident\" and \"unambiguous\"\n", + "coincide in practice. The remaining tail is not unexplained: 48 buses\n", + "never got a single blocking candidate, and a handful have candidates but\n", + "no real evidence for any of them. Both get an explicit bucket below\n", + "rather than being left to look like silent failures.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "a53b4575", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-11T20:55:20.759424Z", + "iopub.status.busy": "2026-09-11T20:55:20.759348Z", + "iopub.status.idle": "2026-09-11T20:55:20.762468Z", + "shell.execute_reply": "2026-09-11T20:55:20.762124Z" + } + }, + "outputs": [], + "source": [ + "import os\n", + "import sys\n", + "from pathlib import Path\n", + "\n", + "_root = Path.cwd()\n", + "while not (_root / \"pyproject.toml\").exists():\n", + " _root = _root.parent\n", + "os.chdir(_root)\n", + "os.environ.setdefault(\"RAW_DATA_ROOT\", str(_root))\n", + "sys.path.insert(0, str(_root / \"ml\" / \"bus_matching_model\" / \"app\"))" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "e0418810", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-11T20:55:20.763426Z", + "iopub.status.busy": "2026-09-11T20:55:20.763359Z", + "iopub.status.idle": "2026-09-11T20:55:21.490847Z", + "shell.execute_reply": "2026-09-11T20:55:21.490452Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ml schema ready\n" + ] + } + ], + "source": [ + "import time\n", + "\n", + "import db\n", + "import exclusions\n", + "import final_output\n", + "import pair_features\n", + "import pair_model\n", + "import pandas as pd\n", + "import psycopg\n", + "import schema\n", + "import training\n", + "from features import DAY_FEATURE_NAMES\n", + "\n", + "from opa_database.config import settings\n", + "\n", + "conn = psycopg.connect(settings.db_dsn)\n", + "schema.ensure_schema(conn)\n", + "print(\"ml schema ready\")" + ] + }, + { + "cell_type": "markdown", + "id": "030592c6", + "metadata": {}, + "source": [ + "## Rebuild the day model, then the pair model\n", + "\n", + "Retrained fresh from current labels rather than loaded from a stale\n", + "run, for the same reason every verification query in this project has\n", + "done it this way: the day-score inputs to the pair layer must reflect\n", + "every trip label on file *right now*, not whatever existed when some\n", + "earlier run was saved.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "911d39dc", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-11T20:55:21.491850Z", + "iopub.status.busy": "2026-09-11T20:55:21.491708Z", + "iopub.status.idle": "2026-09-11T20:55:27.093686Z", + "shell.execute_reply": "2026-09-11T20:55:27.093203Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "day model trained on 1742 rows from 323 trip labels in 5.6s\n" + ] + } + ], + "source": [ + "start = time.monotonic()\n", + "\n", + "root = \"ml/bus_matching_model/artifacts\"\n", + "day = pd.concat(\n", + " [pd.read_parquet(f) for f in sorted(Path(root).glob(\"features_v2/*.parquet\"))],\n", + " ignore_index=True,\n", + ")\n", + "excluded = exclusions.excluded_bus_ids(conn)\n", + "day_in_scope = day[~day[\"bus_id\"].isin(excluded)]\n", + "\n", + "expanded = db.expand_labels_to_training_rows(conn, day_in_scope, None)\n", + "day_model = training.train_model(\n", + " expanded[DAY_FEATURE_NAMES], expanded[\"label\"].to_numpy()\n", + ")\n", + "# Scored on the FULL (unfiltered) frame, not just day_in_scope: split\n", + "# detection later needs day_score for every candidate of an unsure\n", + "# bus, and it is simplest to have one column that always covers\n", + "# whatever `day` is sliced to downstream.\n", + "day[\"day_score\"] = training.predict_positive_proba(day_model, day[DAY_FEATURE_NAMES])\n", + "day_in_scope = day[~day[\"bus_id\"].isin(excluded)]\n", + "\n", + "n_trip_labels = db.trip_label_counts(conn)[\"total\"]\n", + "print(\n", + " f\"day model trained on {len(expanded)} rows from {n_trip_labels} trip labels \"\n", + " f\"in {time.monotonic() - start:.1f}s\"\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "382e681c", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-11T20:55:27.094643Z", + "iopub.status.busy": "2026-09-11T20:55:27.094569Z", + "iopub.status.idle": "2026-09-11T20:55:30.660556Z", + "shell.execute_reply": "2026-09-11T20:55:30.660120Z" + } + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/victor/repos/opa-database/ml/bus_matching_model/app/pair_features.py:193: UserWarning: pandas only supports SQLAlchemy connectable (engine/connection) or database string URI or sqlite3 DBAPI2 connection. Other DBAPI2 objects are not tested. Please consider using SQLAlchemy.\n", + " dictionary = pd.read_sql(_DICTIONARY_SQL, conn)\n", + "/home/victor/repos/opa-database/ml/bus_matching_model/app/pair_features.py:213: UserWarning: pandas only supports SQLAlchemy connectable (engine/connection) or database string URI or sqlite3 DBAPI2 connection. Other DBAPI2 objects are not tested. Please consider using SQLAlchemy.\n", + " running = pd.read_sql(_BUS_RUNNING_DAYS_SQL, conn)\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/victor/repos/opa-database/ml/bus_matching_model/app/pair_model.py:87: UserWarning: pandas only supports SQLAlchemy connectable (engine/connection) or database string URI or sqlite3 DBAPI2 connection. Other DBAPI2 objects are not tested. Please consider using SQLAlchemy.\n", + " return pd.read_sql(\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "120180 candidate pairs, 87 pair labels (81 correct, 6 unsure)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "pair model: train=4660 test=2148 feats=73\n", + " holdout AUC=1.0000 Brier=0.0005 ECE=0.0005\n", + " 5-fold CV AUC=1.0000 (+/-0.0000) ECE=0.0003\n", + "pair model persisted as run #16\n" + ] + } + ], + "source": [ + "pairs = pair_features.build_pair_features(conn, day_in_scope, day_in_scope[\"day_score\"])\n", + "labels = pair_model.fetch_pair_labels(conn)\n", + "print(\n", + " f\"{len(pairs)} candidate pairs, {len(labels)} pair labels \"\n", + " f\"({(labels['verdict'] == 'correct').sum()} correct, \"\n", + " f\"{(labels['verdict'] == 'unsure').sum()} unsure)\"\n", + ")\n", + "\n", + "rows = pair_model.build_training_rows(pairs, labels)\n", + "pair_state = pair_model.train_pair_model(rows)\n", + "if pair_state is None:\n", + " msg = \"not enough labeled pairs to train the pair model\"\n", + " raise RuntimeError(msg)\n", + "\n", + "m, cv = pair_state[\"metrics\"], pair_state.get(\"cv\", {})\n", + "print(\n", + " f\"pair model: train={pair_state['n_train']} test={pair_state['n_test']} \"\n", + " f\"feats={len(pair_state['selected_features'])}\"\n", + ")\n", + "print(f\" holdout AUC={m['auc']:.4f} Brier={m['brier']:.4f} ECE={m['ece']:.4f}\")\n", + "if cv.get(\"n_folds\"):\n", + " print(\n", + " f\" {cv['n_folds']}-fold CV AUC={cv['auc_mean']:.4f}\"\n", + " f\" (+/-{cv['auc_std']:.4f}) ECE={cv['ece_mean']:.4f}\"\n", + " )\n", + "\n", + "run_id = pair_model.save_pair_run(\n", + " conn, pair_state, n_labeled_pairs=int(labels[\"bus_id\"].nunique())\n", + ")\n", + "conn.commit()\n", + "print(f\"pair model persisted as run #{run_id}\")" + ] + }, + { + "cell_type": "markdown", + "id": "793bee03", + "metadata": {}, + "source": [ + "## Build the final table\n", + "\n", + "`THRESHOLD` is the ship threshold picked from `precision_at_threshold` /\n", + "`audit_precision` while labeling, not a guess -- see the pair-labeler\n", + "UI's \"When to stop\" panel for the measured precision behind this\n", + "number.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "d7ef2abd", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-11T20:55:30.661398Z", + "iopub.status.busy": "2026-09-11T20:55:30.661324Z", + "iopub.status.idle": "2026-09-11T20:55:31.043738Z", + "shell.execute_reply": "2026-09-11T20:55:31.043415Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "1734 rows, 1730 distinct buses\n" + ] + }, + { + "data": { + "text/plain": [ + "method\n", + "pair_model 1345\n", + "excluded_no_avl 249\n", + "hand_confirmed 81\n", + "no_candidates 48\n", + "split_detected 8\n", + "needs_review 1\n", + "resolved_after_review 1\n", + "no_evidence 1\n", + "Name: count, dtype: int64" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "THRESHOLD = 0.90\n", + "\n", + "ranked = pair_model.rank_pairs(pairs, pair_state)\n", + "final_table = final_output.build_final_pairs(\n", + " conn, day, ranked, labels, excluded, threshold=THRESHOLD\n", + ")\n", + "print(f\"{len(final_table)} rows, {final_table['bus_id'].nunique()} distinct buses\")\n", + "final_table[\"method\"].value_counts()" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "4a592c57", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-11T20:55:31.044837Z", + "iopub.status.busy": "2026-09-11T20:55:31.044745Z", + "iopub.status.idle": "2026-09-11T20:55:31.063475Z", + "shell.execute_reply": "2026-09-11T20:55:31.063148Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "wrote 1734 rows to ml.bus_matching_final_pairs\n" + ] + } + ], + "source": [ + "final_output.save_final_pairs(conn, final_table)\n", + "conn.commit()\n", + "print(f\"wrote {len(final_table)} rows to ml.bus_matching_final_pairs\")" + ] + }, + { + "cell_type": "markdown", + "id": "14542071", + "metadata": {}, + "source": [ + "## Sanity checks\n" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "985cc87e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-11T20:55:31.064257Z", + "iopub.status.busy": "2026-09-11T20:55:31.064174Z", + "iopub.status.idle": "2026-09-11T20:55:31.328999Z", + "shell.execute_reply": "2026-09-11T20:55:31.328721Z" + } + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_25397/1301532072.py:1: UserWarning: pandas only supports SQLAlchemy connectable (engine/connection) or database string URI or sqlite3 DBAPI2 connection. Other DBAPI2 objects are not tested. Please consider using SQLAlchemy.\n", + " total_ran = pd.read_sql(\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "buses that ran: 1730\n", + "distinct buses in final table: 1730\n", + "MATCH (every bus that ran got exactly one bucket)\n", + "overlapping intervals for the same bus (must be 0): 0\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_25397/1301532072.py:4: UserWarning: pandas only supports SQLAlchemy connectable (engine/connection) or database string URI or sqlite3 DBAPI2 connection. Other DBAPI2 objects are not tested. Please consider using SQLAlchemy.\n", + " distinct_in_table = pd.read_sql(\n", + "/tmp/ipykernel_25397/1301532072.py:18: UserWarning: pandas only supports SQLAlchemy connectable (engine/connection) or database string URI or sqlite3 DBAPI2 connection. Other DBAPI2 objects are not tested. Please consider using SQLAlchemy.\n", + " overlap = pd.read_sql(\n" + ] + } + ], + "source": [ + "total_ran = pd.read_sql(\n", + " \"SELECT count(DISTINCT bus_id) FROM ml.trip_validity_final WHERE is_valid;\", conn\n", + ").iloc[0, 0]\n", + "distinct_in_table = pd.read_sql(\n", + " \"SELECT count(DISTINCT bus_id) FROM ml.bus_matching_final_pairs;\", conn\n", + ").iloc[0, 0]\n", + "print(f\"buses that ran: {total_ran}\")\n", + "print(f\"distinct buses in final table: {distinct_in_table}\")\n", + "print(\n", + " \"MATCH (every bus that ran got exactly one bucket)\"\n", + " if total_ran == distinct_in_table\n", + " else \"MISMATCH -- investigate before trusting this table\"\n", + ")\n", + "\n", + "# No bus should ever have overlapping date ranges across its rows --\n", + "# a real split has disjoint intervals, everything else is a single\n", + "# full-month row.\n", + "overlap = pd.read_sql(\n", + " \"\"\"\n", + " SELECT count(*) FROM ml.bus_matching_final_pairs a\n", + " JOIN ml.bus_matching_final_pairs b\n", + " ON a.bus_id = b.bus_id AND a.start_date < b.start_date\n", + " WHERE a.end_date >= b.start_date;\n", + " \"\"\",\n", + " conn,\n", + ").iloc[0, 0]\n", + "print(f\"overlapping intervals for the same bus (must be 0): {overlap}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "ae59d001", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-11T20:55:31.330035Z", + "iopub.status.busy": "2026-09-11T20:55:31.329959Z", + "iopub.status.idle": "2026-09-11T20:55:31.334814Z", + "shell.execute_reply": "2026-09-11T20:55:31.334509Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " method n_rows n_buses\n", + " pair_model 1345 1345\n", + " excluded_no_avl 249 249\n", + " hand_confirmed 81 81\n", + " no_candidates 48 48\n", + " split_detected 8 4\n", + " no_evidence 1 1\n", + "resolved_after_review 1 1\n", + " needs_review 1 1\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_25397/3437356920.py:2: UserWarning: pandas only supports SQLAlchemy connectable (engine/connection) or database string URI or sqlite3 DBAPI2 connection. Other DBAPI2 objects are not tested. Please consider using SQLAlchemy.\n", + " pd.read_sql(\n" + ] + } + ], + "source": [ + "print(\n", + " pd.read_sql(\n", + " \"SELECT method, count(*) AS n_rows, count(DISTINCT bus_id) AS n_buses \"\n", + " \"FROM ml.bus_matching_final_pairs GROUP BY method ORDER BY n_rows DESC;\",\n", + " conn,\n", + " ).to_string(index=False)\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "60a81591", + "metadata": {}, + "source": [ + "### Split detection, checked against the four buses found by hand\n", + "\n", + "Each should show two intervals whose boundary matches the day-score\n", + "cutover confirmed by hand while labeling: 12225 at Nov 20/21, 12502 at\n", + "Nov 13/14 (the one genuine overlap day, resolved to the higher score),\n", + "35252 at Nov 21/23 (no service on the 22nd), 35403 at Nov 16/17.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "75759539", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-11T20:55:31.335711Z", + "iopub.status.busy": "2026-09-11T20:55:31.335642Z", + "iopub.status.idle": "2026-09-11T20:55:31.338849Z", + "shell.execute_reply": "2026-09-11T20:55:31.338585Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "bus_id device_id start_date end_date n_days_with_data notes\n", + " 12225 ep1-428103715 2023-11-01 2023-11-20 13 \n", + " 12225 ep1-428108761 2023-11-21 2023-11-30 9 \n", + " 12502 ep1-428115555 2023-11-01 2023-11-13 8 \n", + " 12502 ep1-428112320 2023-11-14 2023-11-30 13 overlap day resolved to higher score\n", + " 35252 ep1-428106939 2023-11-01 2023-11-21 13 \n", + " 35252 ep1-428103933 2023-11-23 2023-11-30 6 \n", + " 35403 ep1-428108461 2023-11-01 2023-11-16 13 \n", + " 35403 ep1-428107060 2023-11-21 2023-11-30 8 \n", + "MATCH (all four hand-caught swaps auto-detected as splits)\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_25397/4274453616.py:2: UserWarning: pandas only supports SQLAlchemy connectable (engine/connection) or database string URI or sqlite3 DBAPI2 connection. Other DBAPI2 objects are not tested. Please consider using SQLAlchemy.\n", + " splits = pd.read_sql(\n" + ] + } + ], + "source": [ + "known_swaps = [\"12225\", \"12502\", \"35252\", \"35403\"]\n", + "splits = pd.read_sql(\n", + " \"SELECT bus_id, device_id, start_date, end_date, n_days_with_data, notes \"\n", + " \"FROM ml.bus_matching_final_pairs WHERE method = 'split_detected' \"\n", + " \"ORDER BY bus_id, start_date;\",\n", + " conn,\n", + ")\n", + "print(splits.to_string(index=False))\n", + "detected = set(splits[\"bus_id\"])\n", + "print(\n", + " \"MATCH (all four hand-caught swaps auto-detected as splits)\"\n", + " if detected == set(known_swaps)\n", + " else f\"MISMATCH -- expected {known_swaps}, detected {sorted(detected)}\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "853af41b", + "metadata": {}, + "source": [ + "### The remaining unresolved buckets\n", + "\n", + "`needs_review` is the honest residue: an `unsure` label where the\n", + "day-score pattern itself does not show a clean single-device or\n", + "clean-swap shape, so no automatic answer is defensible -- these need a\n", + "human look, same as any bus still below threshold.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "0d839825", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-11T20:55:31.339652Z", + "iopub.status.busy": "2026-09-11T20:55:31.339585Z", + "iopub.status.idle": "2026-09-11T20:55:31.343926Z", + "shell.execute_reply": "2026-09-11T20:55:31.343683Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "--- needs_review (1) ---\n", + "bus_id device_id notes\n", + " 20290 nan no confident days for any candidate\n", + "\n", + "--- resolved_after_review (1) ---\n", + "bus_id device_id notes\n", + " 35322 ep1-428105660 unsure label, but only one device was ever confident -- not actually ambiguous\n", + "\n", + "--- no_evidence (1) ---\n", + "bus_id device_id notes\n", + " 36975 nan \n", + "\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_25397/3100884984.py:2: UserWarning: pandas only supports SQLAlchemy connectable (engine/connection) or database string URI or sqlite3 DBAPI2 connection. Other DBAPI2 objects are not tested. Please consider using SQLAlchemy.\n", + " sub = pd.read_sql(\n", + "/tmp/ipykernel_25397/3100884984.py:2: UserWarning: pandas only supports SQLAlchemy connectable (engine/connection) or database string URI or sqlite3 DBAPI2 connection. Other DBAPI2 objects are not tested. Please consider using SQLAlchemy.\n", + " sub = pd.read_sql(\n", + "/tmp/ipykernel_25397/3100884984.py:2: UserWarning: pandas only supports SQLAlchemy connectable (engine/connection) or database string URI or sqlite3 DBAPI2 connection. Other DBAPI2 objects are not tested. Please consider using SQLAlchemy.\n", + " sub = pd.read_sql(\n" + ] + } + ], + "source": [ + "for method in (\"needs_review\", \"resolved_after_review\", \"no_evidence\"):\n", + " sub = pd.read_sql(\n", + " \"SELECT bus_id, device_id, notes FROM ml.bus_matching_final_pairs \"\n", + " \"WHERE method = %(m)s ORDER BY bus_id;\",\n", + " conn,\n", + " params={\"m\": method},\n", + " )\n", + " print(f\"--- {method} ({len(sub)}) ---\")\n", + " print(sub.to_string(index=False) if not sub.empty else \"(none)\")\n", + " print()" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "33cb0fab", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-11T20:55:31.344650Z", + "iopub.status.busy": "2026-09-11T20:55:31.344584Z", + "iopub.status.idle": "2026-09-11T20:55:31.348439Z", + "shell.execute_reply": "2026-09-11T20:55:31.348220Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "below_threshold: 0 buses -- real evidence, just not enough of it yet\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_25397/1867270476.py:1: UserWarning: pandas only supports SQLAlchemy connectable (engine/connection) or database string URI or sqlite3 DBAPI2 connection. Other DBAPI2 objects are not tested. Please consider using SQLAlchemy.\n", + " below = pd.read_sql(\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
bus_iddevice_idconfidencen_days_with_data
\n", + "
" + ], + "text/plain": [ + "Empty DataFrame\n", + "Columns: [bus_id, device_id, confidence, n_days_with_data]\n", + "Index: []" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "below = pd.read_sql(\n", + " \"SELECT bus_id, device_id, confidence, n_days_with_data \"\n", + " \"FROM ml.bus_matching_final_pairs WHERE method = 'below_threshold' \"\n", + " \"ORDER BY confidence DESC;\",\n", + " conn,\n", + ")\n", + "print(\n", + " f\"below_threshold: {len(below)} buses -- real evidence, just not enough of it yet\"\n", + ")\n", + "below" + ] + }, + { + "cell_type": "markdown", + "id": "7fb27b941602401d91542211134fc71a", + "metadata": {}, + "source": [ + "## The other half: unclaimed devices\n", + "\n", + "`bus_matching_final_pairs` is bus-centric -- for every bus, which\n", + "device. That leaves the reverse question unanswered: a device can be\n", + "genuinely active all month and still never be claimed, either because\n", + "it lost a competition for a bus another device won, or because\n", + "blocking never considered it for anyone. `device_coverage.py` answers\n", + "that, with the same never-silently-drop discipline as the bus-side\n", + "table.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "acae54e37e7d407bbb7b55eff062a284", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-11T20:55:31.349218Z", + "iopub.status.busy": "2026-09-11T20:55:31.349154Z", + "iopub.status.idle": "2026-09-11T20:56:05.380186Z", + "shell.execute_reply": "2026-09-11T20:56:05.379867Z" + } + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/victor/repos/opa-database/ml/bus_matching_model/app/device_coverage.py:75: UserWarning: pandas only supports SQLAlchemy connectable (engine/connection) or database string URI or sqlite3 DBAPI2 connection. Other DBAPI2 objects are not tested. Please consider using SQLAlchemy.\n", + " active = pd.read_sql(_ACTIVE_DEVICES_SQL, conn)\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/victor/repos/opa-database/ml/bus_matching_model/app/device_coverage.py:87: UserWarning: pandas only supports SQLAlchemy connectable (engine/connection) or database string URI or sqlite3 DBAPI2 connection. Other DBAPI2 objects are not tested. Please consider using SQLAlchemy.\n", + " candidates = pd.read_sql(_CANDIDATE_BUSES_SQL, conn)\n", + "/home/victor/repos/opa-database/ml/bus_matching_model/app/device_coverage.py:90: UserWarning: pandas only supports SQLAlchemy connectable (engine/connection) or database string URI or sqlite3 DBAPI2 connection. Other DBAPI2 objects are not tested. Please consider using SQLAlchemy.\n", + " dictionary = pd.read_sql(_DICTIONARY_BUSES_SQL, conn)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "58 active devices claimed by no bus\n" + ] + }, + { + "data": { + "text/plain": [ + "reason\n", + "never_blocked 44\n", + "lost_competition 14\n", + "Name: count, dtype: int64" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import device_coverage\n", + "\n", + "unclaimed = device_coverage.build_unclaimed_devices(conn, ranked, excluded)\n", + "print(f\"{len(unclaimed)} active devices claimed by no bus\")\n", + "unclaimed[\"reason\"].value_counts()" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "9a63283cbaf04dbcab1f6479b197f3a8", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-11T20:56:05.381172Z", + "iopub.status.busy": "2026-09-11T20:56:05.381091Z", + "iopub.status.idle": "2026-09-11T20:56:05.389365Z", + "shell.execute_reply": "2026-09-11T20:56:05.389180Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "wrote 58 rows to ml.bus_matching_unclaimed_devices\n" + ] + } + ], + "source": [ + "device_coverage.save_unclaimed_devices(conn, unclaimed)\n", + "conn.commit()\n", + "print(f\"wrote {len(unclaimed)} rows to ml.bus_matching_unclaimed_devices\")" + ] + }, + { + "cell_type": "markdown", + "id": "8dd0d8092fe74a7c96281538738b07e2", + "metadata": {}, + "source": [ + "A device with real activity and a dictionary hit that still lost is\n", + "worth a specific look -- it is exactly the kind of case the residual\n", + "pass should reconcile with the bus-side `no_evidence`/`needs_review`\n", + "rows, not just more unexplained absence.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "72eea5119410473aa328ad9291626812", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-11T20:56:05.390170Z", + "iopub.status.busy": "2026-09-11T20:56:05.390108Z", + "iopub.status.idle": "2026-09-11T20:56:05.394277Z", + "shell.execute_reply": "2026-09-11T20:56:05.394021Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "3 active, dictionary-backed, still-unclaimed devices\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
device_idn_pingsn_days_activereasondictionary_bus_idsbest_candidate_bus_idbest_candidate_score
17ep1-428106248171898lost_competition35450352260.000035
21ep1-428112540142086lost_competition12206123170.000060
25ep1-428113356110053lost_competition35230352300.988149
\n", + "
" + ], + "text/plain": [ + " device_id n_pings n_days_active reason \\\n", + "17 ep1-428106248 17189 8 lost_competition \n", + "21 ep1-428112540 14208 6 lost_competition \n", + "25 ep1-428113356 11005 3 lost_competition \n", + "\n", + " dictionary_bus_ids best_candidate_bus_id best_candidate_score \n", + "17 35450 35226 0.000035 \n", + "21 12206 12317 0.000060 \n", + "25 35230 35230 0.988149 " + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "MEANINGFUL_ACTIVITY_PINGS = 10_000 # a real, well-tracked device, not a one-off blip\n", + "\n", + "worth_a_look = unclaimed[\n", + " (unclaimed[\"in_dictionary\"]) & (unclaimed[\"n_pings\"] > MEANINGFUL_ACTIVITY_PINGS)\n", + "].sort_values(\"n_pings\", ascending=False)\n", + "print(f\"{len(worth_a_look)} active, dictionary-backed, still-unclaimed devices\")\n", + "worth_a_look[\n", + " [\n", + " \"device_id\",\n", + " \"n_pings\",\n", + " \"n_days_active\",\n", + " \"reason\",\n", + " \"dictionary_bus_ids\",\n", + " \"best_candidate_bus_id\",\n", + " \"best_candidate_score\",\n", + " ]\n", + "]" + ] + }, + { + "cell_type": "markdown", + "id": "5019fa20", + "metadata": {}, + "source": [ + "## Next\n", + "\n", + "- `needs_review` + `below_threshold` + `no_evidence` buses: candidates\n", + " for the residual-matching pass (exhaustive scoring of unassigned\n", + " buses against unassigned devices), not more queue labeling.\n", + "- `no_candidates` (48 buses): blocking found nothing at all -- same\n", + " residual pass, different starting point (no candidate to even score).\n", + "- `excluded_no_avl` (249 buses, all 67-prefix): structurally out of\n", + " scope, nothing to do.\n", + "- Unclaimed devices with real activity and a dictionary hit (see above) are a second, concrete entry point into the same residual pass -- not a separate problem.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "f443c66a", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-11T20:56:05.394963Z", + "iopub.status.busy": "2026-09-11T20:56:05.394902Z", + "iopub.status.idle": "2026-09-11T20:56:05.396201Z", + "shell.execute_reply": "2026-09-11T20:56:05.395987Z" + } + }, + "outputs": [], + "source": [ + "conn.close()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "opa-database (3.12.13)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/ml/bus_matching_model/scripts/build_features.py b/ml/bus_matching_model/scripts/build_features.py new file mode 100644 index 0000000..ae99e83 --- /dev/null +++ b/ml/bus_matching_model/scripts/build_features.py @@ -0,0 +1,259 @@ +"""Build the full month's per-`(bus, device, date)` Tier 1+2 feature table. + +Writes one Parquet per date to `artifacts/features_v2/`, the input to +both the day-level model and (aggregated) the pair-level model. + +**Parallel by date.** Dates are fully independent here -- each one pulls +its own AVL positions, trips, and fares and never looks at another -- +so this forks one worker per date across a process pool. The earlier +serial build of the smaller Tier 1 set took ~100 minutes; this +parallelizes that same work while also computing 8 more features. + +**Excludes no-AVL companies** (COOTRAPS, Fretcar -- confirmed live that +100% of their devices never ping, see notebook 06). Skipping their +~5,400 bus-dates up front is both faster and avoids generating feature +rows that could only ever be noise. + +Writes to `features_v2/` rather than overwriting `features/`, so the +current production results stay reproducible while the new pipeline is +validated alongside them. + +Usage:: + + uv run ml/bus_matching_model/scripts/build_features.py + uv run ml/bus_matching_model/scripts/build_features.py --dates 2023-11-01 + uv run ml/bus_matching_model/scripts/build_features.py --workers 4 +""" + +from __future__ import annotations + +import argparse +import datetime +import os +import sys +import time +from concurrent.futures import ProcessPoolExecutor, as_completed +from pathlib import Path + +import numpy as np +import pandas as pd +import psycopg + +_APP_DIR = Path(__file__).resolve().parents[1] / "app" +if str(_APP_DIR) not in sys.path: + sys.path.insert(0, str(_APP_DIR)) + +from features import ( # noqa: E402 + DAY_FEATURE_NAMES, + TripPositions, + compute_pair_day_features, + select_sample_trips, +) +from gtfs_cache import build_shape_cache # noqa: E402 + +from opa_database.config import settings # noqa: E402 + +OUTPUT_DIR = Path(__file__).resolve().parents[1] / "artifacts" / "features_v2" + +_NO_AVL_COMPANIES_SQL = """ + WITH company_ping_rates AS ( + SELECT + d.company, + count(DISTINCT d.device_id) AS n_devices, + count(DISTINCT d.device_id) FILTER ( + WHERE EXISTS ( + SELECT 1 FROM silver.avl_pings_y2023m11 p + WHERE p.device_id = d.device_id + ) + ) AS n_pinging + FROM silver.dictionary_device d + WHERE d.device_id IS NOT NULL + GROUP BY d.company + ), + no_avl AS ( + SELECT company FROM company_ping_rates + WHERE n_devices > 0 AND n_pinging = 0 + ), + normalized AS ( + SELECT regexp_replace(vehicle_number, '[^0-9]', '', 'g') AS digits + FROM silver.dictionary_device + WHERE company IN (SELECT company FROM no_avl) + ) + SELECT DISTINCT + CASE WHEN length(digits) < 5 THEN lpad(digits, 5, '0') ELSE digits END AS bus_id + FROM normalized + WHERE digits <> ''; +""" + +_TRIPS_SQL = """ + SELECT + f.trip_id, f.bus_id, f.route_id, t.route_direction, + extract(epoch FROM f.trip_start_timestamp)::float8 AS trip_start_timestamp, + extract(epoch FROM f.trip_end_timestamp)::float8 AS trip_end_timestamp, + f.gtfs_feed_version_date, f.gtfs_shape_id_i, f.gtfs_shape_id_v + FROM ml.trip_validity_final f + JOIN ml.trip_validity_trips t USING (trip_id) + WHERE f.is_valid AND f.trip_date = %(date)s; +""" + +_POSITIONS_SQL = """ + SELECT + device_id, + extract(epoch FROM metric_timestamp)::float8 AS epoch, + ST_X(ST_Transform(geom, 31984)) AS x, + ST_Y(ST_Transform(geom, 31984)) AS y, + speed, + route_id, + heading_degrees + FROM ml.bus_matching_avl_positions + WHERE metric_timestamp >= %(start)s AND metric_timestamp < %(end)s + AND device_id = ANY(%(devices)s) + ORDER BY device_id, epoch; +""" + +_FARES_SQL = """ + SELECT ff.trip_id, extract(epoch FROM ff.fare_tapped_at)::float8 AS epoch + FROM ml.trip_validity_fares_final ff + JOIN ml.trip_validity_final f USING (trip_id) + WHERE f.is_valid AND f.trip_date = %(date)s; +""" + +_CANDIDATES_SQL = """ + SELECT bus_id, device_id FROM ml.bus_matching_candidates WHERE date = %(date)s; +""" + + +def _positions_by_device(rows: pd.DataFrame) -> dict[str, TripPositions]: + """Split one date's bulk position pull into a per-device dict.""" + out: dict[str, TripPositions] = {} + for device_id, g in rows.groupby("device_id", sort=False): + out[device_id] = TripPositions( + epoch=g["epoch"].to_numpy(dtype=np.float64), + xy=np.column_stack( + [g["x"].to_numpy(dtype=np.float64), g["y"].to_numpy(dtype=np.float64)] + ), + speed_kmh=g["speed"].to_numpy(dtype=np.float64), + route_id=g["route_id"].to_numpy(dtype=object), + heading_deg=g["heading_degrees"].to_numpy(dtype=np.float64), + ) + return out + + +def build_one_date(trip_date: datetime.date) -> tuple[datetime.date, int, float]: + """Compute and write every candidate pair's features for one date. + + Args: + trip_date: The date to build. + + Returns: + `(trip_date, n_rows_written, elapsed_seconds)`. + + """ + started = time.monotonic() + with psycopg.connect(settings.db_dsn) as conn: + shape_cache = build_shape_cache(conn) + no_avl_buses = {r[0] for r in conn.execute(_NO_AVL_COMPANIES_SQL).fetchall()} + + candidates = pd.read_sql(_CANDIDATES_SQL, conn, params={"date": trip_date}) + candidates = candidates[~candidates["bus_id"].isin(no_avl_buses)] + if candidates.empty: + return trip_date, 0, time.monotonic() - started + + trips = pd.read_sql(_TRIPS_SQL, conn, params={"date": trip_date}) + trips = trips[~trips["bus_id"].isin(no_avl_buses)] + + fares = pd.read_sql(_FARES_SQL, conn, params={"date": trip_date}) + positions = pd.read_sql( + _POSITIONS_SQL, + conn, + params={ + "start": trip_date, + "end": trip_date + datetime.timedelta(days=1), + "devices": candidates["device_id"].unique().tolist(), + }, + ) + + fares_by_trip = { + trip_id: g["epoch"].to_numpy(dtype=np.float64) + for trip_id, g in fares.groupby("trip_id", sort=False) + } + device_positions = _positions_by_device(positions) + trips_by_bus = dict(list(trips.groupby("bus_id", sort=False))) + sampled_by_bus = { + bus_id: select_sample_trips(g) for bus_id, g in trips_by_bus.items() + } + + rows = [] + for bus_id, g in candidates.groupby("bus_id", sort=False): + all_trips = trips_by_bus.get(bus_id) + sampled = sampled_by_bus.get(bus_id) + if sampled is None or sampled.empty: + continue + for device_id in g["device_id"]: + feats = compute_pair_day_features( + sampled, + device_positions.get(device_id), + shape_cache, + fares_by_trip=fares_by_trip, + all_trips=all_trips, + ) + feats["bus_id"] = bus_id + feats["device_id"] = device_id + feats["date"] = trip_date + rows.append(feats) + + frame = pd.DataFrame( + rows, columns=[*DAY_FEATURE_NAMES, "bus_id", "device_id", "date"] + ) + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + frame.to_parquet(OUTPUT_DIR / f"date={trip_date}.parquet", index=False) + return trip_date, len(frame), time.monotonic() - started + + +def all_trip_dates() -> list[datetime.date]: + """Every date with at least one valid trip, ascending.""" + with psycopg.connect(settings.db_dsn) as conn: + return [ + r[0] + for r in conn.execute( + "SELECT DISTINCT trip_date FROM ml.trip_validity_final " + "WHERE is_valid ORDER BY trip_date;" + ).fetchall() + ] + + +def main() -> None: + """Build features for the requested dates, one process per date.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dates", nargs="*", help="ISO dates; default all") + parser.add_argument( + "--workers", + type=int, + default=min(8, (os.cpu_count() or 2)), + help="parallel worker processes (default: min(8, cpu_count))", + ) + args = parser.parse_args() + + dates = ( + [datetime.date.fromisoformat(d) for d in args.dates] + if args.dates + else all_trip_dates() + ) + print(f"building {len(dates)} dates with {args.workers} workers -> {OUTPUT_DIR}") + + started = time.monotonic() + total_rows = 0 + with ProcessPoolExecutor(max_workers=args.workers) as pool: + futures = {pool.submit(build_one_date, d): d for d in dates} + for done, future in enumerate(as_completed(futures), start=1): + trip_date, n_rows, elapsed = future.result() + total_rows += n_rows + print( + f"[{done}/{len(dates)}] {trip_date}: {n_rows} rows in {elapsed:.0f}s " + f"({time.monotonic() - started:.0f}s total)" + ) + print(f"done: {total_rows} rows in {time.monotonic() - started:.0f}s") + + +if __name__ == "__main__": + main() diff --git a/ml/trip_validity_model/app/__init__.py b/ml/trip_validity_model/app/__init__.py new file mode 100644 index 0000000..b0896fd --- /dev/null +++ b/ml/trip_validity_model/app/__init__.py @@ -0,0 +1 @@ +"""Trip Validity active learning labeling app.""" diff --git a/ml/trip_validity_model/app/calibration.py b/ml/trip_validity_model/app/calibration.py new file mode 100644 index 0000000..b3d5a26 --- /dev/null +++ b/ml/trip_validity_model/app/calibration.py @@ -0,0 +1,111 @@ +"""Platt scaling: a 1-D logistic regression recalibrating raw model probabilities.""" + +from __future__ import annotations + +import numpy as np +from sklearn.linear_model import LogisticRegression + +_EPS = 1e-6 + + +def _logit(probabilities: np.ndarray) -> np.ndarray: + clipped = np.clip(probabilities, _EPS, 1 - _EPS) + return np.log(clipped / (1 - clipped)) + + +class PlattCalibrator: + """Recalibrates raw model probabilities via logistic regression on their logit.""" + + def __init__(self) -> None: + """Initialize the underlying 1-D logistic regression, unfit.""" + self._model = LogisticRegression() + + def fit(self, raw_probabilities: np.ndarray, y_true: np.ndarray) -> PlattCalibrator: + """Fit the calibrator on a frozen calibration set. + + Args: + raw_probabilities: The base model's raw predicted probabilities. + y_true: The true labels for the same rows. + + Returns: + self, for chaining. + + """ + self._model.fit(_logit(raw_probabilities).reshape(-1, 1), y_true) + return self + + def predict(self, raw_probabilities: np.ndarray) -> np.ndarray: + """Recalibrate raw probabilities. + + Args: + raw_probabilities: The base model's raw predicted probabilities. + + Returns: + Calibrated probabilities of the positive class. + + """ + logits = _logit(raw_probabilities).reshape(-1, 1) + return self._model.predict_proba(logits)[:, 1] + + def to_params(self) -> dict[str, float]: + """Serialize the fitted coefficient/intercept for storage. + + Returns: + A dict with keys "coefficient" and "intercept". + + """ + return { + "coefficient": float(self._model.coef_[0, 0]), + "intercept": float(self._model.intercept_[0]), + } + + def to_sklearn_model(self) -> LogisticRegression: + """Return the underlying fitted sklearn model. + + For artifact storage: pickling this class directly ties the + artifact to *this exact* `PlattCalibrator` class object, which + breaks (`PicklingError`) if the `calibration` module gets + hot-reloaded (e.g. by Streamlit's file watcher during dev) + between when an instance was created and when it's pickled - + the reloaded module's class is a distinct object with the same + name. Storing/restoring the plain sklearn model instead + sidesteps that entirely. + + Returns: + The fitted `LogisticRegression`. + + """ + return self._model + + @classmethod + def from_params(cls, coefficient: float, intercept: float) -> PlattCalibrator: + """Reconstruct a calibrator from a coefficient/intercept saved via `to_params`. + + Args: + coefficient: The fitted logistic regression's coefficient. + intercept: The fitted logistic regression's intercept. + + Returns: + A `PlattCalibrator` ready to `predict`, without needing to re-`fit`. + + """ + model = LogisticRegression() + model.classes_ = np.array([False, True]) + model.coef_ = np.array([[coefficient]]) + model.intercept_ = np.array([intercept]) + return cls.from_sklearn_model(model) + + @classmethod + def from_sklearn_model(cls, model: LogisticRegression) -> PlattCalibrator: + """Reconstruct a calibrator from a model saved via `to_sklearn_model`. + + Args: + model: A previously fitted `LogisticRegression`. + + Returns: + A `PlattCalibrator` wrapping it. + + """ + calibrator = cls() + calibrator._model = model + return calibrator diff --git a/ml/trip_validity_model/app/db.py b/ml/trip_validity_model/app/db.py new file mode 100644 index 0000000..4e09b65 --- /dev/null +++ b/ml/trip_validity_model/app/db.py @@ -0,0 +1,396 @@ +"""Database access for the Trip Validity active learning app. + +Every query here only ever reads/writes the `ml` schema: the app's own +`ml.trip_validity_labels`/`ml.trip_validity_model_runs` tables, plus +read-only access to `ml.trip_validity_dataset`, +`ml.trip_validity_route_shapes`, and `ml.trip_validity_trip_positions` +from the notebooks pipeline. Nothing here ever touches `silver.*`. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any, Literal, LiteralString + +import pandas as pd +import psycopg +from features import ALL_FEATURES +from psycopg import sql + +from opa_database.config import settings + +if TYPE_CHECKING: + from collections.abc import Collection, Sequence + +LabelSet = Literal["calibration", "test", "train"] +SelectionSource = Literal["random", "uncertain"] + +_FEATURE_COLUMNS_SQL = sql.SQL(", ").join(sql.Identifier(c) for c in ALL_FEATURES) + + +def get_connection() -> psycopg.Connection: + """Open a new autocommit connection to the database. + + Returns: + An open connection, in autocommit mode so a single dropped query + can't leave the interactive Streamlit session's shared + connection stuck mid-transaction. + + """ + conn = psycopg.connect(settings.db_dsn) + conn.autocommit = True + return conn + + +def _fetch_frame( + conn: psycopg.Connection, + query: sql.Composed | LiteralString, + params: Sequence[Any] = (), +) -> pd.DataFrame: + with conn.cursor() as cur: + cur.execute(query, params) + rows = cur.fetchall() + columns = [d.name for d in cur.description or []] + return pd.DataFrame.from_records(rows, columns=columns) + + +def label_set_counts(conn: psycopg.Connection) -> dict[LabelSet, int]: + """Count current labels per set. + + Args: + conn: An open connection. + + Returns: + Counts keyed by "calibration", "test", "train" (0 if empty). + + """ + counts: dict[LabelSet, int] = {"calibration": 0, "test": 0, "train": 0} + with conn.cursor() as cur: + cur.execute( + "SELECT label_set, count(*) FROM ml.trip_validity_labels " + "GROUP BY label_set;" + ) + counts.update(dict(cur.fetchall())) + return counts + + +def fetch_random_unlabeled_trip_id( + conn: psycopg.Connection, *, exclude_trip_ids: Collection[int] = () +) -> int | None: + """Draw one uniformly random trip that is labelable and not yet labeled. + + "Labelable" means it has at least one actual AVL position row, not + just `avl_matched = true` (a matched trip can still have zero pings + inside its time window, which would render an empty, unlabelable map). + + Args: + conn: An open connection. + exclude_trip_ids: Additional trip_ids to exclude beyond what's + already labeled - e.g. this session's skipped trips, which + are deliberately never written to the database but shouldn't + resurface within the same run. + + Returns: + A `trip_id`, or `None` if every trip with AVL positions has been + labeled or excluded. + + """ + with conn.cursor() as cur: + cur.execute( + "SELECT d.trip_id FROM ml.trip_validity_dataset d " + "WHERE d.avl_matched " + "AND EXISTS (" + " SELECT 1 FROM ml.trip_validity_trip_positions p " + " WHERE p.trip_id = d.trip_id" + ") " + "AND NOT EXISTS (" + " SELECT 1 FROM ml.trip_validity_labels l WHERE l.trip_id = d.trip_id" + ") " + "AND NOT (d.trip_id = ANY(%s)) " + "ORDER BY random() LIMIT 1;", + (list(exclude_trip_ids),), + ) + row = cur.fetchone() + return row[0] if row else None + + +def is_unlabeled(conn: psycopg.Connection, trip_id: int) -> bool: + """Check whether a trip has not been labeled yet. + + Args: + conn: An open connection. + trip_id: The trip to check. + + Returns: + `True` if `trip_id` has no row in `ml.trip_validity_labels`. + + """ + with conn.cursor() as cur: + cur.execute( + "SELECT 1 FROM ml.trip_validity_labels WHERE trip_id = %s;", (trip_id,) + ) + return cur.fetchone() is None + + +def fetch_trip_row(conn: psycopg.Connection, trip_id: int) -> dict[str, Any] | None: + """Fetch one trip's identifiers plus every model feature column. + + Args: + conn: An open connection. + trip_id: The trip to fetch. + + Returns: + A dict of column name to value, or `None` if `trip_id` doesn't exist. + + """ + query = sql.SQL( + "SELECT trip_id, bus_id, route_id, route_direction, trip_date, " + "trip_hour, trip_opening_timestamp, trip_closing_timestamp, " + "gtfs_feed_version_date, gtfs_shape_id_i, gtfs_shape_id_v, " + "{features} FROM ml.trip_validity_dataset WHERE trip_id = %s;" + ).format(features=_FEATURE_COLUMNS_SQL) + with conn.cursor() as cur: + cur.execute(query, (trip_id,)) + row = cur.fetchone() + if row is None: + return None + columns = [d.name for d in cur.description or []] + return dict(zip(columns, row, strict=True)) + + +def fetch_trip_map_data(conn: psycopg.Connection, trip_id: int) -> dict[str, Any]: + """Fetch the GTFS shape(s) and AVL positions used to render a trip's map. + + Args: + conn: An open connection. + trip_id: The trip to fetch. + + Returns: + Dict with keys "shape_i", "shape_v" (each a `[lon, lat]` + coordinate list, or `None` if that direction has no matched + shape) and "positions" (a list of `(lon, lat, iso_timestamp)` + tuples ordered by time). + + """ + with conn.cursor() as cur: + cur.execute( + "SELECT " + " (SELECT ST_AsGeoJSON(rs.shape_geom) " + " FROM ml.trip_validity_route_shapes rs " + " WHERE rs.feed_version_date = d.gtfs_feed_version_date " + " AND rs.shape_id = d.gtfs_shape_id_i) AS shape_i_geojson, " + " (SELECT ST_AsGeoJSON(rs.shape_geom) " + " FROM ml.trip_validity_route_shapes rs " + " WHERE rs.feed_version_date = d.gtfs_feed_version_date " + " AND rs.shape_id = d.gtfs_shape_id_v) AS shape_v_geojson " + "FROM ml.trip_validity_dataset d WHERE d.trip_id = %s;", + (trip_id,), + ) + shape_row = cur.fetchone() + if shape_row is None: + msg = f"trip_id {trip_id} not found in ml.trip_validity_dataset" + raise ValueError(msg) + shape_i_geojson, shape_v_geojson = shape_row + + cur.execute( + "SELECT metric_timestamp, ST_X(geom), ST_Y(geom) " + "FROM ml.trip_validity_trip_positions " + "WHERE trip_id = %s ORDER BY metric_timestamp;", + (trip_id,), + ) + positions = [(lon, lat, ts.isoformat()) for ts, lon, lat in cur.fetchall()] + + def _coords(geojson_text: str | None) -> list[list[float]] | None: + if geojson_text is None: + return None + return json.loads(geojson_text)["coordinates"] + + return { + "shape_i": _coords(shape_i_geojson), + "shape_v": _coords(shape_v_geojson), + "positions": positions, + } + + +def insert_label( + conn: psycopg.Connection, + *, + trip_id: int, + label: bool, + label_set: LabelSet, + selection_source: SelectionSource, + predicted_probability: float | None, +) -> None: + """Record one label. + + Args: + conn: An open connection. + trip_id: The labeled trip. + label: `True` = valid trip, `False` = invalid trip. + label_set: Which frozen/training set this label belongs to. + selection_source: Whether this row was drawn at random or picked + for being the model's most uncertain prediction. + predicted_probability: The current model's calibrated probability + for this trip at label time, or `None` if no model exists yet. + + """ + with conn.cursor() as cur: + cur.execute( + "INSERT INTO ml.trip_validity_labels " + "(trip_id, label, label_set, selection_source, " + " predicted_probability_at_label_time) " + "VALUES (%s, %s, %s, %s, %s) ON CONFLICT (trip_id) DO NOTHING;", + (trip_id, label, label_set, selection_source, predicted_probability), + ) + + +def fetch_label_set(conn: psycopg.Connection, label_set: LabelSet) -> pd.DataFrame: + """Fetch every labeled row in one set, joined to its feature columns. + + Args: + conn: An open connection. + label_set: "calibration", "test", or "train". + + Returns: + A frame with `trip_id`, `label` (bool), and every column in + `ALL_FEATURES`. + + """ + query = sql.SQL( + "SELECT d.trip_id, l.label, {features} " + "FROM ml.trip_validity_labels l " + "JOIN ml.trip_validity_dataset d ON d.trip_id = l.trip_id " + "WHERE l.label_set = %s;" + ).format(features=_FEATURE_COLUMNS_SQL) + return _fetch_frame(conn, query, (label_set,)) + + +def fetch_unlabeled_pool( + conn: psycopg.Connection, + *, + labelable_only: bool, + exclude_trip_ids: Collection[int] = (), +) -> pd.DataFrame: + """Fetch every not-yet-labeled row's feature columns. + + Args: + conn: An open connection. + labelable_only: If `True`, restrict to trips that can actually be + shown on the labeling map (the active-learning candidate + pool) - `avl_matched` plus at least one real AVL position row, + since a matched trip can still have zero pings inside its + time window. If `False`, cover the full remaining dataset + (used for the interim/final confidence count over the whole + ~1M-row table). + exclude_trip_ids: Additional trip_ids to exclude beyond what's + already labeled - e.g. this session's skipped trips, so they + can't be ranked back into the active-learning candidate pool. + + Returns: + A frame with `trip_id` and every column in `ALL_FEATURES`. + + """ + filter_clause = ( + sql.SQL( + "AND d.avl_matched AND EXISTS (" + " SELECT 1 FROM ml.trip_validity_trip_positions p " + " WHERE p.trip_id = d.trip_id" + ")" + ) + if labelable_only + else sql.SQL("") + ) + query = sql.SQL( + "SELECT d.trip_id, {features} FROM ml.trip_validity_dataset d " + "WHERE NOT EXISTS (" + " SELECT 1 FROM ml.trip_validity_labels l WHERE l.trip_id = d.trip_id" + ") {filter} AND NOT (d.trip_id = ANY(%s));" + ).format(features=_FEATURE_COLUMNS_SQL, filter=filter_clause) + return _fetch_frame(conn, query, (list(exclude_trip_ids),)) + + +def insert_model_run(conn: psycopg.Connection, run: dict[str, Any]) -> int: + """Persist one training run's config, metrics, and artifact location. + + Args: + conn: An open connection. + run: Keys matching `ml.trip_validity_model_runs`'s columns + (`run_type`, `n_train_labels`, `hyperparameters`, + `selected_features`, `calibration_params`, `cv_brier_score`, + `test_auc`, `test_brier`, `test_log_loss`, `test_ece`, + `confident_90_count`, `confident_90_total`, `artifact_path`). + + Returns: + The new row's `run_id`. + + """ + with conn.cursor() as cur: + cur.execute( + "INSERT INTO ml.trip_validity_model_runs " + "(run_type, n_train_labels, hyperparameters, selected_features, " + " calibration_params, cv_brier_score, test_auc, test_brier, " + " test_log_loss, test_ece, confident_90_count, confident_90_total, " + " artifact_path) " + "VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) " + "RETURNING run_id;", + ( + run["run_type"], + run["n_train_labels"], + json.dumps(run["hyperparameters"]), + json.dumps(run["selected_features"]), + json.dumps(run["calibration_params"]) + if run.get("calibration_params") is not None + else None, + run.get("cv_brier_score"), + run.get("test_auc"), + run.get("test_brier"), + run.get("test_log_loss"), + run.get("test_ece"), + run.get("confident_90_count"), + run.get("confident_90_total"), + run["artifact_path"], + ), + ) + result = cur.fetchone() + if result is None: + msg = "INSERT ... RETURNING run_id unexpectedly returned no row" + raise RuntimeError(msg) + return result[0] + + +def fetch_model_runs(conn: psycopg.Connection) -> pd.DataFrame: + """Fetch every model run, oldest first, for charting metrics over time. + + Args: + conn: An open connection. + + Returns: + A frame with one row per run, columns matching + `ml.trip_validity_model_runs`. + + """ + return _fetch_frame( + conn, "SELECT * FROM ml.trip_validity_model_runs ORDER BY created_at;" + ) + + +def fetch_latest_model_run(conn: psycopg.Connection) -> dict[str, Any] | None: + """Fetch the most recent model run's full row. + + Args: + conn: An open connection. + + Returns: + A dict of column name to value, or `None` if no run exists yet. + + """ + with conn.cursor() as cur: + cur.execute( + "SELECT * FROM ml.trip_validity_model_runs " + "ORDER BY created_at DESC LIMIT 1;" + ) + row = cur.fetchone() + if row is None: + return None + columns = [d.name for d in cur.description or []] + return dict(zip(columns, row, strict=True)) diff --git a/ml/trip_validity_model/app/features.py b/ml/trip_validity_model/app/features.py new file mode 100644 index 0000000..635d8b8 --- /dev/null +++ b/ml/trip_validity_model/app/features.py @@ -0,0 +1,160 @@ +"""Canonical feature column list for the Trip Validity model. + +`CATEGORICAL_FEATURES` + `NUMERIC_FEATURES` together are every column of +`ml.trip_validity_dataset` at or after `trip_duration_seconds` (its 16th +column), plus columns added after the original 80-column build (see +`05_final_dataset.ipynb`): `gtfs_route_has_both_directions`, +`weekday_number`, `is_weekend`, `company_id`, +`trip_start_distance_to_nearest_garage_meters`, +`trip_end_distance_to_nearest_garage_meters`, +`route_i_straight_line_meters`, `route_i_straight_line_ratio`, +`route_v_straight_line_meters`, `route_v_straight_line_ratio`, and the +six `trip_start`/`trip_end` +`_distance_to_nearest_[open/closed_]terminal_meters` columns. Every +identifier and the `avl_matched`/`avl_match_source` columns are +deliberately excluded: AVL matching only decides which trips can be +*shown* on the labeling map (it gates +`ml.trip_validity_trip_positions`), it is not itself a model input - +the model must never see AVL data, by design. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import pandas as pd + +CATEGORICAL_FEATURES: list[str] = [ + "gtfs_route_has_both_directions", + # Treated as categorical (not a linear 1-7 ordinal) so a tree split + # can group non-contiguous days (e.g. {Sat, Sun}) in one step, + # instead of needing multiple threshold splits to approximate it. + "weekday_number", + "is_weekend", + # The operator registry code (e.g. "02", "67") - text, not a number + # (leading zero significant), and has no meaningful ordering, so a + # tree split needs to be able to group arbitrary subsets of + # companies rather than threshold a fake numeric ID. + "company_id", +] + +NUMERIC_FEATURES: list[str] = [ + "trip_duration_seconds", + "route_avg_trip_duration_seconds_loo", + "route_avg_trip_duration_n_trips_loo", + "trip_duration_ratio_to_route_avg", + "route_direction_avg_trip_duration_seconds_loo", + "route_direction_avg_trip_duration_n_trips_loo", + "trip_duration_ratio_to_route_direction_avg", + "route_hour_avg_trip_duration_seconds_loo", + "route_hour_avg_trip_duration_n_trips_loo", + "trip_duration_ratio_to_route_hour_avg", + "route_direction_hour_avg_trip_duration_seconds_loo", + "route_direction_hour_avg_trip_duration_n_trips_loo", + "trip_duration_ratio_to_route_direction_hour_avg", + "route_reverse_direction_avg_trip_duration_seconds", + "route_reverse_direction_n_trips", + "trip_duration_ratio_to_reverse_direction_avg", + "route_reverse_direction_hour_avg_trip_duration_seconds", + "route_reverse_direction_hour_n_trips", + "trip_duration_ratio_to_reverse_direction_hour_avg", + "route_i_scheduled_duration_avg_seconds", + "route_i_scheduled_duration_n_trips", + "trip_duration_ratio_to_scheduled_i", + "route_v_scheduled_duration_avg_seconds", + "route_v_scheduled_duration_n_trips", + "trip_duration_ratio_to_scheduled_v", + "route_i_scheduled_duration_avg_seconds_at_hour", + "route_i_scheduled_duration_n_trips_at_hour", + "trip_duration_ratio_to_scheduled_i_at_hour", + "route_v_scheduled_duration_avg_seconds_at_hour", + "route_v_scheduled_duration_n_trips_at_hour", + "trip_duration_ratio_to_scheduled_v_at_hour", + "trip_fare_count", + "fare_gap_avg_seconds", + "fare_gap_stddev_seconds", + "fare_gap_coefficient_of_variation", + "fare_gap_avg_ratio_to_duration", + "fare_span_seconds", + "fare_span_ratio_to_duration", + "trip_distance_meters", + "route_i_length_meters", + "trip_distance_ratio_to_route_i", + "route_v_length_meters", + "trip_distance_ratio_to_route_v", + "trip_points_standard_distance_meters", + "trip_cohesion_ratio_to_route_i", + "trip_cohesion_ratio_to_route_v", + "trip_start_distance_to_i_start_meters", + "trip_start_offset_ratio_to_i", + "trip_start_distance_to_v_start_meters", + "trip_start_offset_ratio_to_v", + "trip_end_distance_to_i_end_meters", + "trip_end_offset_ratio_to_i", + "trip_end_distance_to_v_end_meters", + "trip_end_offset_ratio_to_v", + "path_frechet_distance_to_i_meters", + "path_match_score_frechet_i", + "path_frechet_distance_to_v_meters", + "path_match_score_frechet_v", + "path_hausdorff_distance_to_i_meters", + "path_match_score_hausdorff_i", + "path_hausdorff_distance_to_v_meters", + "path_match_score_hausdorff_v", + "trip_progress_correlation_to_i", + "trip_progress_correlation_to_v", + "trip_progress_correlation_n_points", + # Distance (meters) from this trip's first/last geo-tagged AFC fare + # tap to the nearest garage of its operating company - deliberately + # built from fare taps, never AVL (see module docstring). NULL for + # trips with zero geo-tagged fares; LightGBM handles NaN natively. + "trip_start_distance_to_nearest_garage_meters", + "trip_end_distance_to_nearest_garage_meters", + # Straight-line (not road-following) distance between a route's own + # start/end point, and that distance as a fraction of the route's + # actual length (bounded [0, 1] by the triangle inequality) - a + # measure of how direct vs. winding/loop-shaped the route is. NULL + # for a direction with no matched GTFS shape. + "route_i_straight_line_meters", + "route_i_straight_line_ratio", + "route_v_straight_line_meters", + "route_v_straight_line_ratio", + # Distance (meters) from this trip's first/last geo-tagged AFC fare + # tap to the nearest bus terminal - any/open-only/closed-only, + # deliberately built from fare taps, never AVL (see module + # docstring). NULL for trips with zero geo-tagged fares. + "trip_start_distance_to_nearest_terminal_meters", + "trip_start_distance_to_nearest_open_terminal_meters", + "trip_start_distance_to_nearest_closed_terminal_meters", + "trip_end_distance_to_nearest_terminal_meters", + "trip_end_distance_to_nearest_open_terminal_meters", + "trip_end_distance_to_nearest_closed_terminal_meters", +] + +ALL_FEATURES: list[str] = [*CATEGORICAL_FEATURES, *NUMERIC_FEATURES] + + +def cast_feature_dtypes(df: pd.DataFrame) -> pd.DataFrame: + """Cast a raw feature frame to the dtypes LightGBM expects. + + Args: + df: Any frame containing a subset of `ALL_FEATURES` as columns, + with values as returned by psycopg (Python `None` for SQL + `NULL`). + + Returns: + The same frame with categorical columns cast to pandas + `category` dtype and numeric columns cast to `float64`, both of + which represent missing values as NaN natively rather than + needing imputation. + + """ + df = df.copy() + for col in CATEGORICAL_FEATURES: + if col in df.columns: + df[col] = df[col].astype("category") + for col in NUMERIC_FEATURES: + if col in df.columns: + df[col] = df[col].astype("float64") + return df diff --git a/ml/trip_validity_model/app/maps.py b/ml/trip_validity_model/app/maps.py new file mode 100644 index 0000000..ead00a3 --- /dev/null +++ b/ml/trip_validity_model/app/maps.py @@ -0,0 +1,134 @@ +"""Folium (Leaflet) map builders: one map per GTFS direction, with a graded AVL trail. + +Uses Folium/Leaflet rather than pydeck/deck.gl: this repo's other +active-learning labelers (`tools/trip_finder`, `tools/vehicle_identity_labeler` +on the `explore/vehicle-trip-matching` branch) already render OpenStreetMap +tiles successfully via Leaflet.js, whereas deck.gl's `TileLayer` needs a +`render_sub_layers` callback to actually composite raster tiles that +Python can't easily express — without it, the tiles fetch but never draw, +which is what produced solid-black maps. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING + +import folium + +if TYPE_CHECKING: + from collections.abc import Sequence + +_FORTALEZA_LATLON = (-3.7319, -38.5267) + +_ROUTE_COLOR = "#1e64c8" +_ROUTE_WEIGHT_PX = 6 +_START_COLOR = "#00aa00" +_END_COLOR = "#dc0000" +_MARKER_RADIUS_PX = 8 +_TRAIL_RADIUS_PX = 6 +_TRAIL_OUTLINE_COLOR = "#000000" +_TRAIL_OUTLINE_WEIGHT_PX = 1.5 +_MIN_POINTS_TO_FIT_BOUNDS = 2 + + +def _grayscale_trail( + positions: Sequence[tuple[float, float, str]], +) -> list[tuple[float, float, str, str]]: + """Turn `(lon, lat, iso_timestamp)` pings into `(lat, lon, hex_color, timestamp)`. + + Earliest ping is white, latest is black, everything else interpolated + linearly in between. + """ + if not positions: + return [] + timestamps = [datetime.fromisoformat(ts) for _, _, ts in positions] + t_min, t_max = min(timestamps), max(timestamps) + span = (t_max - t_min).total_seconds() or 1.0 + points = [] + for (lon, lat, ts), t in zip(positions, timestamps, strict=True): + fraction = (t - t_min).total_seconds() / span + shade = round(255 * (1 - fraction)) + points.append((lat, lon, f"#{shade:02x}{shade:02x}{shade:02x}", ts)) + return points + + +def build_direction_map( + shape_coordinates: list[list[float]] | None, + positions: Sequence[tuple[float, float, str]], +) -> folium.Map: + """Build one direction's map: route line + start/end markers + AVL trail. + + Args: + shape_coordinates: `[lon, lat]` pairs for the GTFS shape + LineString, or `None` if this direction has no matched + shape. + positions: `(lon, lat, iso_timestamp)` AVL pings for this trip, + ordered by time. + + Returns: + A Folium map, already fit to the data's bounding box (no fixed + zoom guess needed), ready for `streamlit_folium.st_folium`. + + """ + trail = _grayscale_trail(positions) + shape_latlon = [(lat, lon) for lon, lat in shape_coordinates or []] + trail_latlon = [(lat, lon) for lat, lon, _, _ in trail] + all_points = shape_latlon + trail_latlon + + center = list(all_points[0]) if all_points else list(_FORTALEZA_LATLON) + fmap = folium.Map( + location=center, + zoom_start=15 if len(all_points) < _MIN_POINTS_TO_FIT_BOUNDS else 13, + ) + + if shape_latlon: + folium.PolyLine( + locations=shape_latlon, + color=_ROUTE_COLOR, + weight=_ROUTE_WEIGHT_PX, + opacity=0.85, + ).add_to(fmap) + # tooltip= only shows on hover; permanent=True keeps the + # START/END label visible on the map at all times. + folium.CircleMarker( + location=shape_latlon[0], + radius=_MARKER_RADIUS_PX, + color=_TRAIL_OUTLINE_COLOR, + weight=1, + fill=True, + fill_color=_START_COLOR, + fill_opacity=1, + tooltip=folium.Tooltip("START", permanent=True, direction="top"), + ).add_to(fmap) + folium.CircleMarker( + location=shape_latlon[-1], + radius=_MARKER_RADIUS_PX, + color=_TRAIL_OUTLINE_COLOR, + weight=1, + fill=True, + fill_color=_END_COLOR, + fill_opacity=1, + tooltip=folium.Tooltip("END", permanent=True, direction="top"), + ).add_to(fmap) + + # Added last so it paints over the route line and start/end markers + # (Leaflet draws in add-order, later = on top, same as deck.gl). + for lat, lon, color, timestamp in trail: + folium.CircleMarker( + location=(lat, lon), + radius=_TRAIL_RADIUS_PX, + color=_TRAIL_OUTLINE_COLOR, + weight=_TRAIL_OUTLINE_WEIGHT_PX, + fill=True, + fill_color=color, + fill_opacity=0.9, + tooltip=timestamp, + ).add_to(fmap) + + if len(all_points) >= _MIN_POINTS_TO_FIT_BOUNDS: + lats = [p[0] for p in all_points] + lons = [p[1] for p in all_points] + fmap.fit_bounds([[min(lats), min(lons)], [max(lats), max(lons)]]) + + return fmap diff --git a/ml/trip_validity_model/app/metrics.py b/ml/trip_validity_model/app/metrics.py new file mode 100644 index 0000000..0106415 --- /dev/null +++ b/ml/trip_validity_model/app/metrics.py @@ -0,0 +1,78 @@ +"""Evaluation metrics for the Trip Validity model: AUC, Brier, log-loss, ECE.""" + +from __future__ import annotations + +import numpy as np +from sklearn.metrics import brier_score_loss, log_loss, roc_auc_score + + +def expected_calibration_error( + y_true: np.ndarray, y_prob: np.ndarray, *, n_bins: int = 10 +) -> float: + """Compute the expected calibration error via equal-width probability bins. + + Args: + y_true: True binary labels. + y_prob: Predicted probabilities of the positive class. + n_bins: Number of equal-width bins over `[0, 1]`. + + Returns: + The sample-weighted mean absolute gap between each bin's average + predicted probability and its actual positive rate. + + """ + bin_edges = np.linspace(0.0, 1.0, n_bins + 1) + bin_indices = np.clip(np.digitize(y_prob, bin_edges[1:-1]), 0, n_bins - 1) + total = len(y_true) + ece = 0.0 + for b in range(n_bins): + mask = bin_indices == b + if not mask.any(): + continue + bin_confidence = y_prob[mask].mean() + bin_accuracy = y_true[mask].mean() + ece += (mask.sum() / total) * abs(bin_confidence - bin_accuracy) + return float(ece) + + +def evaluate(y_true: np.ndarray, y_prob: np.ndarray) -> dict[str, float]: + """Compute every reported test-set metric at once. + + Args: + y_true: True binary labels. + y_prob: Calibrated predicted probabilities of the positive class. + + Returns: + Dict with keys "auc", "brier", "log_loss", "ece". "auc" is + `NaN` if `y_true` has only one class present (undefined + otherwise). + + """ + auc = ( + float(roc_auc_score(y_true, y_prob)) + if len(set(y_true.tolist())) > 1 + else float("nan") + ) + return { + "auc": auc, + "brier": float(brier_score_loss(y_true, y_prob)), + "log_loss": float(log_loss(y_true, y_prob, labels=[False, True])), + "ece": expected_calibration_error(y_true, y_prob), + } + + +def confident_prediction_count( + y_prob: np.ndarray, *, threshold: float = 0.9 +) -> tuple[int, int]: + """Count predictions confidently on either side of 0.5. + + Args: + y_prob: Calibrated predicted probabilities of the positive class. + threshold: Confidence cutoff, e.g. 0.9 = "at least 90% sure either way". + + Returns: + `(confident_count, total_count)`. + + """ + confident = (y_prob >= threshold) | (y_prob <= 1 - threshold) + return int(confident.sum()), len(y_prob) diff --git a/ml/trip_validity_model/app/registry.py b/ml/trip_validity_model/app/registry.py new file mode 100644 index 0000000..014ce9b --- /dev/null +++ b/ml/trip_validity_model/app/registry.py @@ -0,0 +1,123 @@ +"""Persists trained models/calibrators to disk and their metadata to Postgres.""" + +from __future__ import annotations + +import time +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import db +import joblib +from calibration import PlattCalibrator +from training import ModelConfig + +if TYPE_CHECKING: + import lightgbm as lgb + import psycopg + +ARTIFACTS_DIR = Path(__file__).resolve().parent.parent / "artifacts" + + +def save_run( + conn: psycopg.Connection, + *, + run_type: str, + n_train_labels: int, + model: lgb.LGBMClassifier, + calibrator: PlattCalibrator, + config: ModelConfig, + cv_brier_score: float | None, + test_metrics: dict[str, float], + confident_90_count: int, + confident_90_total: int, +) -> int: + """Serialize a trained model + calibrator to disk and record its metadata. + + Args: + conn: An open connection. + run_type: "cycle" or "milestone". + n_train_labels: Training pool size at this run. + model: The fitted LightGBM classifier. + calibrator: The fitted Platt calibrator. + config: The feature subset + hyperparameters used. + cv_brier_score: The milestone's CV score, or `None` for a plain cycle. + test_metrics: Output of `metrics.evaluate` on the frozen test set. + confident_90_count: Rows predicted with at least 90% confidence. + confident_90_total: Rows scored for the confidence count. + + Returns: + The new `ml.trip_validity_model_runs` row's `run_id`. + + """ + ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True) + artifact_path = ( + ARTIFACTS_DIR / f"run_{n_train_labels:04d}_{run_type}_{int(time.time())}.joblib" + ) + # Stored as plain data (dict/list) plus the calibrator's underlying + # third-party sklearn model, never a PlattCalibrator/ModelConfig + # instance directly - pickling those ties the artifact to *that + # exact* class object, which breaks if this app's own modules get + # hot-reloaded (e.g. by Streamlit's file watcher during dev) between + # when the instance was created and when it's pickled here. + joblib.dump( + { + "model": model, + "calibrator_model": calibrator.to_sklearn_model(), + "selected_features": config.selected_features, + "hyperparameters": config.hyperparameters, + }, + artifact_path, + ) + + return db.insert_model_run( + conn, + { + "run_type": run_type, + "n_train_labels": n_train_labels, + "hyperparameters": config.hyperparameters, + "selected_features": config.selected_features, + "calibration_params": calibrator.to_params(), + "cv_brier_score": cv_brier_score, + "test_auc": test_metrics["auc"], + "test_brier": test_metrics["brier"], + "test_log_loss": test_metrics["log_loss"], + "test_ece": test_metrics["ece"], + "confident_90_count": confident_90_count, + "confident_90_total": confident_90_total, + "artifact_path": str(artifact_path), + }, + ) + + +def load_run(run_row: dict[str, Any]) -> dict[str, Any]: + """Load a run's serialized model + calibrator + config from disk. + + Reconstructs fresh `PlattCalibrator`/`ModelConfig` instances from the + plain data `save_run` stored, using whichever class definitions are + currently loaded - so this is safe to call regardless of how many + times those modules have been hot-reloaded since the artifact was + written. + + Args: + run_row: A row dict as returned by `db.fetch_latest_model_run`. + + Returns: + Dict with keys "model", "calibrator", "config". + + """ + artifact = joblib.load(run_row["artifact_path"]) + if "calibrator_model" in artifact: + return { + "model": artifact["model"], + "calibrator": PlattCalibrator.from_sklearn_model( + artifact["calibrator_model"] + ), + "config": ModelConfig( + selected_features=artifact["selected_features"], + hyperparameters=artifact["hyperparameters"], + ), + } + # Backward compatibility: artifacts written before this module + # switched to storing plain data instead of pickled + # PlattCalibrator/ModelConfig instances directly. + return artifact diff --git a/ml/trip_validity_model/app/sampling.py b/ml/trip_validity_model/app/sampling.py new file mode 100644 index 0000000..2f34fd2 --- /dev/null +++ b/ml/trip_validity_model/app/sampling.py @@ -0,0 +1,264 @@ +"""Active-learning candidate draw logic: phase detection + uncertain/random mix.""" + +from __future__ import annotations + +import random +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import db +import numpy as np + +if TYPE_CHECKING: + from collections.abc import Collection + + import pandas as pd + import psycopg + +SEED_SIZE = 50 +CALIBRATION_SIZE = 50 +TEST_SIZE = 50 +# Active-phase draws split so training and calibration+test grow at the +# same overall rate, while calibration/test only ever receive genuinely +# random rows (never uncertainty-picked ones, which would bias +# evaluation): 1/3 uncertain -> train, and of the other 2/3 (random), +# 1/4 -> train (diversity) / 3/4 -> calibration or test, whichever is +# smaller. Overall that's exactly 1/3 + 2/3*1/4 = 1/2 -> train and +# 2/3*3/4 = 1/2 -> calibration+test. +UNCERTAIN_DRAW_PROBABILITY = 1 / 3 +RANDOM_DRAW_TRAIN_FRACTION = 1 / 4 + +# Once calibration and test are both capped, every remaining draw feeds +# train regardless (see the redirect-to-neediest-open-set logic below), +# so RANDOM_DRAW_TRAIN_FRACTION stops mattering - there's no more eval +# pool to protect from uncertainty bias. At that point the uncertain +# probability rises to 3/4, since diversity sampling was only there to +# keep train's *distribution* broad while calibration/test still needed +# feeding; once train is the only open set, leaning harder into the +# model's own uncertain cases is the better use of the remaining budget. +UNCERTAIN_DRAW_PROBABILITY_EVAL_CLOSED = 3 / 4 + +# Same reasoning applied to retrain cadence: once eval sets are closed +# every retrain is already a full "milestone" one (see +# landmark_crossed), so there's no separate lighter "cycle" retrain left +# to space further apart - retraining on fresher data more often is +# pure upside. Replaces the 15/50-label schedule entirely rather than +# tightening it, since 5 already divides evenly into both. +RETRAIN_INTERVAL_EVAL_CLOSED = 5 + +# Hard caps for the 500-label budget: 250 train + 125 calibration + 125 +# test. Once a set hits its cap it's excluded from selection entirely - +# draws that would've gone there get redirected to whichever open set +# is furthest below its own target, so the final split lands exactly on +# these numbers instead of just converging toward them. +TRAIN_CAP = 250 +CALIBRATION_CAP = 125 +TEST_CAP = 125 +_CAPS: dict[db.LabelSet, int] = { + "train": TRAIN_CAP, + "calibration": CALIBRATION_CAP, + "test": TEST_CAP, +} +_ALL_LABEL_SETS: tuple[db.LabelSet, ...] = ("train", "calibration", "test") + + +def eval_sets_closed(counts: dict[db.LabelSet, int]) -> bool: + """Check whether both eval sets are full. + + Args: + counts: Output of `db.label_set_counts`. + + Returns: + True once calibration and test have both reached their caps - + every draw feeds train from that point on, and (per + `landmark_crossed`) every retrain runs full optimization. + + """ + return counts["calibration"] >= CALIBRATION_CAP and counts["test"] >= TEST_CAP + + +@dataclass +class Candidate: + """One trip drawn for labeling.""" + + trip_id: int + label_set: db.LabelSet + selection_source: db.SelectionSource + + +def current_phase(counts: dict[db.LabelSet, int]) -> str: + """Determine which of the four labeling phases is currently active. + + Args: + counts: Output of `db.label_set_counts`. + + Returns: + One of "calibration", "test", "seed", "active". + + """ + if counts["calibration"] < CALIBRATION_SIZE: + return "calibration" + if counts["test"] < TEST_SIZE: + return "test" + if counts["train"] < SEED_SIZE: + return "seed" + return "active" + + +def _random_candidate( + conn: psycopg.Connection, + label_set: db.LabelSet, + exclude_trip_ids: Collection[int], +) -> Candidate | None: + trip_id = db.fetch_random_unlabeled_trip_id(conn, exclude_trip_ids=exclude_trip_ids) + if trip_id is None: + return None + return Candidate(trip_id=trip_id, label_set=label_set, selection_source="random") + + +def draw_candidate( + conn: psycopg.Connection, + uncertain_queue: list[int], + exclude_trip_ids: Collection[int] = (), +) -> Candidate | None: + """Draw the next trip to show the labeler. + + During the "calibration"/"test"/"seed" phases every draw is + uniformly random and feeds that phase's own set. During "active" + (see module docstring constants for the exact split), calibration + and test only ever grow from genuinely random draws - appended to + whichever currently has fewer rows, never reassigning a row already + labeled - so evaluation stays unbiased by the model's own picks. + Once a set hits its cap (`TRAIN_CAP`/`CALIBRATION_CAP`/`TEST_CAP`) + it stops receiving draws; anything that would've gone there is + redirected to whichever open set is furthest below its own target, + so the budget finishes at exactly 250/125/125 rather than merely + converging toward it. Once calibration and test are *both* capped, + the uncertain-draw probability itself rises from 1/3 to + `UNCERTAIN_DRAW_PROBABILITY_EVAL_CLOSED` (3/4), since every draw is + going to train regardless and there's no more eval pool left to + protect from uncertainty bias. `None` once all three are full (or, + given `exclude_trip_ids`, once everything left has been excluded). + + Args: + conn: An open connection. + uncertain_queue: Mutated in place — a trip_id popped from here + is removed. + exclude_trip_ids: Trip_ids to never draw, e.g. this session's + skipped trips. Deliberately not persisted to the database + (a skip means "pretend I never saw it"), so this only ever + reflects the current run - restarting the app clears it. + + Returns: + The next `Candidate`, or `None` if nothing is left to label. + + """ + counts = db.label_set_counts(conn) + phase = current_phase(counts) + + if phase == "calibration": + return _random_candidate(conn, "calibration", exclude_trip_ids) + if phase == "test": + return _random_candidate(conn, "test", exclude_trip_ids) + if phase == "seed": + return _random_candidate(conn, "train", exclude_trip_ids) + + # Remaining case: phase is "active". + open_sets = [s for s in _ALL_LABEL_SETS if counts[s] < _CAPS[s]] + if not open_sets: + return None + + uncertain_probability = ( + UNCERTAIN_DRAW_PROBABILITY_EVAL_CLOSED + if eval_sets_closed(counts) + else UNCERTAIN_DRAW_PROBABILITY + ) + if "train" in open_sets and random.random() < uncertain_probability: # noqa: S311 + while uncertain_queue: + candidate_id = uncertain_queue.pop(0) + if candidate_id in exclude_trip_ids: + continue + if db.is_unlabeled(conn, candidate_id): + return Candidate( + trip_id=candidate_id, + label_set="train", + selection_source="uncertain", + ) + # Queue exhausted between retrains: fall through to a random draw. + + if random.random() < RANDOM_DRAW_TRAIN_FRACTION: # noqa: S311 + natural_target: db.LabelSet = "train" + else: + natural_target = ( + "calibration" if counts["calibration"] <= counts["test"] else "test" + ) + + target = ( + natural_target if natural_target in open_sets else _neediest(open_sets, counts) + ) + return _random_candidate(conn, target, exclude_trip_ids) + + +def _neediest( + open_sets: list[db.LabelSet], counts: dict[db.LabelSet, int] +) -> db.LabelSet: + """Pick whichever open set is proportionally furthest below its own cap.""" + return min(open_sets, key=lambda s: counts[s] / _CAPS[s]) + + +def build_uncertain_queue( + unlabeled_pool: pd.DataFrame, + calibrated_probabilities: np.ndarray, + *, + top_n: int = 200, +) -> list[int]: + """Rank remaining candidates by distance from 0.5 and cache the most uncertain. + + Args: + unlabeled_pool: Must include a `trip_id` column, in the same row + order as `calibrated_probabilities`. + calibrated_probabilities: This model's calibrated P(valid) for + each row of `unlabeled_pool`. + top_n: How many of the most-uncertain trip_ids to cache. + + Returns: + `trip_id`s ordered most to least uncertain. + + """ + uncertainty = -np.abs(calibrated_probabilities - 0.5) + order = np.argsort(uncertainty)[::-1][:top_n] + return unlabeled_pool["trip_id"].to_numpy()[order].tolist() + + +def landmark_crossed(n_train_labels: int, counts: dict[db.LabelSet, int]) -> str | None: + """Check whether the training pool just crossed a retrain/retune landmark. + + Args: + n_train_labels: Training pool size *after* the label was added. + counts: Output of `db.label_set_counts`, used to check whether + both eval sets are already full (see `eval_sets_closed`). + + Returns: + Before calibration and test are both full: "milestone" every 50 + labels (this also covers the very first model, trained once the + 50-row seed pool is complete), "cycle" every 15 labels + otherwise. Once both eval sets are full: every draw feeds train + anyway (`draw_candidate`), so there's no more reason to hold + back full hyperparameter/feature optimization *or* retrain less + often - the 15/50 schedule is replaced entirely by a tighter + `RETRAIN_INTERVAL_EVAL_CLOSED`-label cadence (5), always + "milestone", never "cycle". `None` if no landmark was crossed + (or the seed pool isn't complete yet). + + """ + if n_train_labels < SEED_SIZE: + return None + if eval_sets_closed(counts): + return ( + "milestone" if n_train_labels % RETRAIN_INTERVAL_EVAL_CLOSED == 0 else None + ) + if n_train_labels % 50 == 0: + return "milestone" + if n_train_labels % 15 == 0: + return "cycle" + return None diff --git a/ml/trip_validity_model/app/schema.py b/ml/trip_validity_model/app/schema.py new file mode 100644 index 0000000..9b700ca --- /dev/null +++ b/ml/trip_validity_model/app/schema.py @@ -0,0 +1,66 @@ +"""DDL bootstrap for the active learning app's own tables. + +Only ever creates `ml.trip_validity_labels` and +`ml.trip_validity_model_runs`. Never touches `silver.*`, and the only +other `ml.*` object referenced is a read-only foreign key onto +`ml.trip_validity_dataset`, which belongs to the notebooks pipeline in +`ml/trip_validity_model/notebooks/`. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import psycopg + +_LABELS_DDL = """ +CREATE TABLE IF NOT EXISTS ml.trip_validity_labels ( + trip_id BIGINT PRIMARY KEY REFERENCES ml.trip_validity_dataset (trip_id), + label BOOLEAN NOT NULL, + label_set TEXT NOT NULL CHECK (label_set IN ('calibration', 'test', 'train')), + selection_source TEXT NOT NULL CHECK (selection_source IN ('random', 'uncertain')), + predicted_probability_at_label_time DOUBLE PRECISION, + labeled_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +""" + +_MODEL_RUNS_DDL = """ +CREATE TABLE IF NOT EXISTS ml.trip_validity_model_runs ( + run_id SERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + run_type TEXT NOT NULL CHECK (run_type IN ('cycle', 'milestone')), + n_train_labels INTEGER NOT NULL, + hyperparameters JSONB NOT NULL, + selected_features JSONB NOT NULL, + calibration_params JSONB, + cv_brier_score DOUBLE PRECISION, + test_auc DOUBLE PRECISION, + test_brier DOUBLE PRECISION, + test_log_loss DOUBLE PRECISION, + test_ece DOUBLE PRECISION, + confident_90_count INTEGER, + confident_90_total INTEGER, + artifact_path TEXT NOT NULL +); +""" + +_INDEXES_DDL = """ +CREATE INDEX IF NOT EXISTS trip_validity_labels_set_idx + ON ml.trip_validity_labels (label_set); +CREATE INDEX IF NOT EXISTS trip_validity_model_runs_created_at_idx + ON ml.trip_validity_model_runs (created_at); +""" + + +def ensure_schema(conn: psycopg.Connection) -> None: + """Create the active learning app's tables if they don't already exist. + + Args: + conn: An open connection to the database. + + """ + with conn.transaction(): + conn.execute(_LABELS_DDL) + conn.execute(_MODEL_RUNS_DDL) + conn.execute(_INDEXES_DDL) diff --git a/ml/trip_validity_model/app/streamlit_app.py b/ml/trip_validity_model/app/streamlit_app.py new file mode 100644 index 0000000..18dade9 --- /dev/null +++ b/ml/trip_validity_model/app/streamlit_app.py @@ -0,0 +1,423 @@ +"""Streamlit active-learning labeling UI for the Trip Validity model. + +Run with `uv run streamlit run ml/trip_validity_model/app/streamlit_app.py` +from the repo root. +""" + +from __future__ import annotations + +from datetime import timedelta, timezone +from typing import TYPE_CHECKING, Any + +import db +import maps +import metrics +import pandas as pd +import registry +import sampling +import streamlit as st +import training +from calibration import PlattCalibrator +from features import ALL_FEATURES, cast_feature_dtypes +from schema import ensure_schema +from streamlit_folium import st_folium + +if TYPE_CHECKING: + import datetime + + import psycopg + +FEATURE_PANEL_DEFAULTS = [ + "trip_duration_ratio_to_route_avg", + "trip_fare_count", + "fare_span_ratio_to_duration", + "trip_distance_ratio_to_route_i", + "trip_distance_ratio_to_route_v", + "path_match_score_frechet_i", + "path_match_score_frechet_v", +] + +# Folium/Leaflet maps render into an iframe, which (like every other +# HTML embed) has no way to size itself to "however much vertical space +# happens to be free" in pure Python/Streamlit - some pixel height has +# to be given explicitly. Width has no such limitation, so the map +# itself is rendered with use_container_width=True instead of a second +# hardcoded number. +MAP_HEIGHT_PX = 320 +_FORTALEZA_TZ = timezone(timedelta(hours=-3)) + +st.set_page_config(page_title="Trip Validity Labeler", layout="wide") + + +def _labeled_value(label: str, value: object) -> None: + st.caption(label) + st.markdown(f"**{value}**") + + +def _format_trip_window(opening: datetime.datetime, closing: datetime.datetime) -> str: + start = opening.astimezone(_FORTALEZA_TZ) + end = closing.astimezone(_FORTALEZA_TZ) + end_str = ( + end.strftime("%H:%M:%S") + if start.date() == end.date() + else end.strftime("%Y-%m-%d %H:%M:%S") + ) + return f"{start.strftime('%Y-%m-%d %H:%M:%S')} - {end_str}" + + +def _format_duration(seconds: float | None) -> str: + if seconds is None: + return "N/A" + return str(timedelta(seconds=int(seconds))) + + +@st.cache_resource +def get_connection() -> psycopg.Connection: + """Open (and cache across reruns) the app's single database connection. + + Returns: + An open, autocommit connection with the app's own tables ensured. + + """ + conn = db.get_connection() + ensure_schema(conn) + return conn + + +def _predict(model_state: dict[str, Any], row: dict[str, Any]) -> float: + selected_features = model_state["config"].selected_features + features = cast_feature_dtypes(pd.DataFrame([row]))[selected_features] + raw = training.predict_positive_proba(model_state["model"], features) + return float(model_state["calibrator"].predict(raw)[0]) + + +def _load_latest_model(conn: psycopg.Connection) -> dict[str, Any] | None: + run_row = db.fetch_latest_model_run(conn) + if run_row is None: + return None + loaded = registry.load_run(run_row) + loaded["run_row"] = run_row + return loaded + + +def _run_training_cycle( + conn: psycopg.Connection, run_type: str, n_train_labels: int +) -> None: + train = cast_feature_dtypes(db.fetch_label_set(conn, "train")) + calibration = cast_feature_dtypes(db.fetch_label_set(conn, "calibration")) + test = cast_feature_dtypes(db.fetch_label_set(conn, "test")) + + train_features, train_labels = train[ALL_FEATURES], train["label"].to_numpy() + + cv_brier = None + if run_type == "milestone": + result = training.run_milestone(train_features, train_labels) + config = result.config + cv_brier = result.cv_brier_score + else: + config = st.session_state.model_state["config"] + + model = training.train_final_model(train_features, train_labels, config) + + raw_calibration = training.predict_positive_proba( + model, calibration[config.selected_features] + ) + calibrator = PlattCalibrator().fit(raw_calibration, calibration["label"].to_numpy()) + + raw_test = training.predict_positive_proba(model, test[config.selected_features]) + calibrated_test = calibrator.predict(raw_test) + test_metrics = metrics.evaluate(test["label"].to_numpy(), calibrated_test) + + full_pool = cast_feature_dtypes(db.fetch_unlabeled_pool(conn, labelable_only=False)) + raw_full_pool = training.predict_positive_proba( + model, full_pool[config.selected_features] + ) + confident_count, confident_total = metrics.confident_prediction_count( + calibrator.predict(raw_full_pool) + ) + + registry.save_run( + conn, + run_type=run_type, + n_train_labels=n_train_labels, + model=model, + calibrator=calibrator, + config=config, + cv_brier_score=cv_brier, + test_metrics=test_metrics, + confident_90_count=confident_count, + confident_90_total=confident_total, + ) + + st.session_state.model_state = { + "model": model, + "calibrator": calibrator, + "config": config, + } + _rebuild_uncertain_queue(conn) + + +def _rebuild_uncertain_queue(conn: psycopg.Connection) -> None: + """Refresh the cached most-uncertain trip_ids from the current model. + + Called after every retrain, and lazily by `_ensure_candidate` whenever + the cache has run dry between retrains. The cache only ever lives in + `st.session_state`, not the database, so it starts empty again after + every app restart; without this, draws would silently fall back to + pure random until the next scheduled retrain instead of keeping the + usual uncertain/random mix. + + Args: + conn: An open connection. + + """ + model_state = st.session_state.model_state + if model_state is None: + st.session_state.uncertain_queue = [] + return + labelable_pool = cast_feature_dtypes( + db.fetch_unlabeled_pool( + conn, + labelable_only=True, + exclude_trip_ids=st.session_state.skipped_trip_ids, + ) + ) + if labelable_pool.empty: + st.session_state.uncertain_queue = [] + return + raw = training.predict_positive_proba( + model_state["model"], labelable_pool[model_state["config"].selected_features] + ) + calibrated = model_state["calibrator"].predict(raw) + st.session_state.uncertain_queue = sampling.build_uncertain_queue( + labelable_pool, calibrated + ) + + +def _ensure_candidate(conn: psycopg.Connection) -> None: + if st.session_state.get("candidate") is not None: + return + if ( + not st.session_state.uncertain_queue + and st.session_state.model_state is not None + ): + with st.spinner("Refreshing uncertainty ranking..."): + _rebuild_uncertain_queue(conn) + st.session_state.candidate = sampling.draw_candidate( + conn, st.session_state.uncertain_queue, st.session_state.skipped_trip_ids + ) + + +def _feature_panel(row: dict[str, Any], model_state: dict[str, Any] | None) -> None: + top_features = ( + model_state["config"].selected_features[:7] + if model_state is not None + else FEATURE_PANEL_DEFAULTS + ) + st.caption( + "Top model features" if model_state is not None else "Default feature preview" + ) + for name in top_features: + value = row.get(name) + display = f"{value:.3f}" if isinstance(value, float) else str(value) + _labeled_value(name, display) + + +def _render_metrics_history(conn: psycopg.Connection) -> None: + runs = db.fetch_model_runs(conn) + if runs.empty: + st.info( + "No trained model yet. Metrics appear once the 50-row seed " + "training pool is complete." + ) + return + st.subheader("Model metrics over time") + st.line_chart( + runs.set_index("n_train_labels")[["test_brier", "test_auc", "test_ece"]] + ) + + milestones = runs[runs["run_type"] == "milestone"] + if not milestones.empty: + st.caption("Milestone (real) metrics") + st.dataframe( + milestones[ + [ + "n_train_labels", + "cv_brier_score", + "test_auc", + "test_brier", + "test_log_loss", + "test_ece", + "confident_90_count", + "confident_90_total", + ] + ], + hide_index=True, + ) + + latest = runs.iloc[-1] + if latest["confident_90_total"]: + pct = 100 * latest["confident_90_count"] / latest["confident_90_total"] + st.metric("Predictions >=90% confident (latest run)", f"{pct:.1f}%") + + +def _render_maps(trip_id: int, map_data: dict[str, Any], n_positions: int) -> None: + has_i, has_v = bool(map_data["shape_i"]), bool(map_data["shape_v"]) + if has_i and has_v: + map_cols = st.columns(2) + elif has_i or has_v: + map_cols = st.columns(1) + else: + st.warning("No matched GTFS shape for this trip's route.") + return + + # Keyed on trip_id so Streamlit fully remounts the map component per + # trip instead of reusing the previous one's browser-side state + # (view position, etc.), which is what made the map look "stuck". + next_col = iter(map_cols) + if has_i: + with next(next_col): + st.caption(f"Direction I · {n_positions} AVL positions plotted") + st_folium( + maps.build_direction_map(map_data["shape_i"], map_data["positions"]), + height=MAP_HEIGHT_PX, + use_container_width=True, + returned_objects=[], + key=f"map_{trip_id}_i", + ) + if has_v: + with next(next_col): + st.caption(f"Direction V · {n_positions} AVL positions plotted") + st_folium( + maps.build_direction_map(map_data["shape_v"], map_data["positions"]), + height=MAP_HEIGHT_PX, + use_container_width=True, + returned_objects=[], + key=f"map_{trip_id}_v", + ) + + +def _render_trip_info( + row: dict[str, Any], n_positions: int, predicted_probability: float | None +) -> None: + _labeled_value("Bus", row["bus_id"]) + _labeled_value("Route", row["route_id"]) + _labeled_value( + "Trip window", + _format_trip_window( + row["trip_opening_timestamp"], row["trip_closing_timestamp"] + ), + ) + _labeled_value("Duration", _format_duration(row["trip_duration_seconds"])) + _labeled_value("Fares", row["trip_fare_count"]) + _labeled_value("AVL positions", n_positions) + if predicted_probability is not None: + _labeled_value("Calibrated P(valid)", f"{predicted_probability:.3f}") + + +def _handle_decision( + conn: psycopg.Connection, + candidate: sampling.Candidate, + *, + label: bool, + predicted_probability: float | None, +) -> None: + db.insert_label( + conn, + trip_id=candidate.trip_id, + label=label, + label_set=candidate.label_set, + selection_source=candidate.selection_source, + predicted_probability=predicted_probability, + ) + st.session_state.candidate = None + if candidate.label_set == "train": + counts = db.label_set_counts(conn) + n_train_labels = counts["train"] + run_type = sampling.landmark_crossed(n_train_labels, counts) + if run_type is not None: + with st.spinner(f"Running {run_type} retrain..."): + _run_training_cycle(conn, run_type, n_train_labels) + st.rerun() + + +def _render_decision_buttons( + conn: psycopg.Connection, + candidate: sampling.Candidate, + predicted_probability: float | None, +) -> None: + valid_col, invalid_col, skip_col = st.columns(3) + if valid_col.button("Valid trip", type="primary", width="stretch"): + _handle_decision( + conn, candidate, label=True, predicted_probability=predicted_probability + ) + if invalid_col.button("Invalid trip", width="stretch"): + _handle_decision( + conn, candidate, label=False, predicted_probability=predicted_probability + ) + if skip_col.button("Skip", width="stretch"): + st.session_state.skipped_trip_ids.add(candidate.trip_id) + st.session_state.candidate = None + st.rerun() + + +def main() -> None: + """Render the labeling UI and drive the active learning loop.""" + conn = get_connection() + if "model_state" not in st.session_state: + st.session_state.model_state = _load_latest_model(conn) + if "uncertain_queue" not in st.session_state: + st.session_state.uncertain_queue = [] + if "skipped_trip_ids" not in st.session_state: + # Session-only: never written to the database (a skip means + # "pretend I never saw it"), so this resets on every app restart. + st.session_state.skipped_trip_ids = set() + + st.subheader("Trip Validity — Active Learning Labeler") + + counts = db.label_set_counts(conn) + phase = sampling.current_phase(counts) + total_budget = sampling.TRAIN_CAP + sampling.CALIBRATION_CAP + sampling.TEST_CAP + total_labeled = counts["train"] + counts["calibration"] + counts["test"] + st.caption( + f"**{total_labeled}/{total_budget} labeled** " + f"({total_budget - total_labeled} to go) · " + f"Calibration {counts['calibration']}/{sampling.CALIBRATION_CAP} · " + f"Test {counts['test']}/{sampling.TEST_CAP} · " + f"Train {counts['train']}/{sampling.TRAIN_CAP} · Phase: {phase}" + ) + st.progress(min(total_labeled / total_budget, 1.0)) + + _ensure_candidate(conn) + candidate: sampling.Candidate | None = st.session_state.candidate + if candidate is None: + st.success("Nothing left to label.") + return + + row = db.fetch_trip_row(conn, candidate.trip_id) + if row is None: + st.session_state.candidate = None + st.rerun() + return + map_data = db.fetch_trip_map_data(conn, candidate.trip_id) + n_positions = len(map_data["positions"]) + + predicted_probability: float | None = None + if st.session_state.model_state is not None: + predicted_probability = _predict(st.session_state.model_state, row) + + _render_decision_buttons(conn, candidate, predicted_probability) + _render_maps(candidate.trip_id, map_data, n_positions) + + with st.expander("Model metrics over time"): + _render_metrics_history(conn) + + with st.sidebar: + st.subheader("Trip info") + _render_trip_info(row, n_positions, predicted_probability) + st.divider() + st.subheader("Features") + _feature_panel(row, st.session_state.model_state) + + +main() diff --git a/ml/trip_validity_model/app/training.py b/ml/trip_validity_model/app/training.py new file mode 100644 index 0000000..c8ac788 --- /dev/null +++ b/ml/trip_validity_model/app/training.py @@ -0,0 +1,257 @@ +"""LightGBM training: per-cycle retrain, and the periodic Optuna milestone. + +The milestone jointly tunes LightGBM hyperparameters and how many of the +training pool's features to keep, ranked by CV gain importance. A literal +exhaustive search over the ~66 candidate features is combinatorially +impossible (2**66 subsets); tuning a single "keep the top K by +importance" knob inside the same Optuna study is the tractable +equivalent. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import lightgbm as lgb +import numpy as np +import optuna +import pandas as pd +from sklearn.metrics import brier_score_loss +from sklearn.model_selection import StratifiedKFold, train_test_split + +optuna.logging.set_verbosity(optuna.logging.WARNING) + +N_CV_FOLDS = 5 +MAX_ESTIMATORS = 500 +EARLY_STOPPING_ROUNDS = 30 +EARLY_STOPPING_HOLDOUT_FRACTION = 0.2 +MIN_CLASS_MEMBERS_TO_STRATIFY = 2 + +_INT_SEARCH_SPACE: dict[str, tuple[int, int]] = { + "num_leaves": (7, 63), + "min_child_samples": (5, 40), +} +_FLOAT_SEARCH_SPACE: dict[str, tuple[float, float]] = { + "learning_rate": (0.01, 0.3), + "feature_fraction": (0.5, 1.0), + "bagging_fraction": (0.5, 1.0), + "lambda_l1": (0.0, 5.0), + "lambda_l2": (0.0, 5.0), +} + + +@dataclass +class ModelConfig: + """The LightGBM configuration active between milestones.""" + + selected_features: list[str] + hyperparameters: dict[str, Any] + + +@dataclass +class MilestoneResult: + """Output of a full Optuna hyperparameter + feature-selection retune.""" + + config: ModelConfig + cv_brier_score: float + feature_importance_ranking: list[str] + + +def predict_positive_proba( + model: lgb.LGBMClassifier, features: pd.DataFrame +) -> np.ndarray: + """Predict P(positive class) as a plain ndarray. + + LightGBM's sklearn-wrapper type stubs claim `predict_proba` can + return a sparse matrix, which it never does for a dense pandas + input; `np.asarray` here is purely to satisfy the type checker. + + Args: + model: A fitted LightGBM classifier. + features: Rows to score, columns matching what `model` was fit on. + + Returns: + The model's raw (uncalibrated) predicted probability of the + positive class. + + """ + return np.asarray(model.predict_proba(features))[:, 1] + + +def _stratify_target(y: np.ndarray) -> np.ndarray | None: + """Return `y` for `stratify=` if every class has enough members, else `None`.""" + _, counts = np.unique(y, return_counts=True) + return y if counts.min() >= MIN_CLASS_MEMBERS_TO_STRATIFY else None + + +def _fit_with_early_stopping( + features: pd.DataFrame, y: np.ndarray, hyperparameters: dict[str, Any] +) -> lgb.LGBMClassifier: + """Fit one model, holding out a slice purely to pick the tree count.""" + fit_features, stop_features, fit_y, stop_y = train_test_split( + features, + y, + test_size=EARLY_STOPPING_HOLDOUT_FRACTION, + random_state=42, + stratify=_stratify_target(y), + ) + model = lgb.LGBMClassifier( + objective="binary", n_estimators=MAX_ESTIMATORS, verbosity=-1, **hyperparameters + ) + model.fit( + fit_features, + fit_y, + eval_X=stop_features, + eval_y=stop_y, + callbacks=[lgb.early_stopping(EARLY_STOPPING_ROUNDS, verbose=False)], + ) + return model + + +def _cross_validate( + features: pd.DataFrame, y: np.ndarray, hyperparameters: dict[str, Any] +) -> tuple[float, int]: + """Run stratified CV, each fold with its own early-stopping split. + + Returns: + `(out_of_fold_brier_score, mean_best_iteration)`. + + """ + folds = StratifiedKFold(n_splits=N_CV_FOLDS, shuffle=True, random_state=42) + oof_predictions = np.empty(len(y)) + best_iterations = [] + for train_idx, valid_idx in folds.split(features, y): + model = _fit_with_early_stopping( + features.iloc[train_idx], y[train_idx], hyperparameters + ) + oof_predictions[valid_idx] = predict_positive_proba( + model, features.iloc[valid_idx] + ) + best_iterations.append(model.best_iteration_ or MAX_ESTIMATORS) + return float(brier_score_loss(y, oof_predictions)), int(np.mean(best_iterations)) + + +def _rank_features_by_importance(features: pd.DataFrame, y: np.ndarray) -> list[str]: + """Rank every candidate feature by mean CV gain importance. + + Args: + features: Full feature frame (every candidate column). + y: True labels. + + Returns: + Feature names ordered most to least important. + + """ + folds = StratifiedKFold(n_splits=N_CV_FOLDS, shuffle=True, random_state=42) + importances = pd.Series(0.0, index=features.columns) + for train_idx, _ in folds.split(features, y): + model = _fit_with_early_stopping(features.iloc[train_idx], y[train_idx], {}) + importances += pd.Series( + model.booster_.feature_importance(importance_type="gain"), + index=features.columns, + ) + return importances.sort_values(ascending=False).index.tolist() + + +def run_milestone( + features: pd.DataFrame, + y: np.ndarray, + *, + n_trials: int = 40, + timeout_seconds: float = 90, +) -> MilestoneResult: + """Jointly tune LightGBM hyperparameters and a top-K feature subset. + + Runs one baseline CV fit over every candidate feature to rank them by + gain importance, then an Optuna study (minimizing out-of-fold Brier + score) over both LightGBM hyperparameters and how many of the + top-ranked features to keep. + + Args: + features: Full feature frame for the current training pool. + y: Full training pool labels. + n_trials: Optuna trial budget. + timeout_seconds: Wall-clock budget, so this stays interactive. + + Returns: + The winning `ModelConfig` plus its CV score and the full + importance ranking (for the UI's "top features" display). + + """ + ranking = _rank_features_by_importance(features, y) + + def objective(trial: optuna.Trial) -> float: + top_k = trial.suggest_int("top_k", min(5, len(ranking)), len(ranking)) + hyperparameters = { + "num_leaves": trial.suggest_int( + "num_leaves", *_INT_SEARCH_SPACE["num_leaves"] + ), + "min_child_samples": trial.suggest_int( + "min_child_samples", *_INT_SEARCH_SPACE["min_child_samples"] + ), + "learning_rate": trial.suggest_float( + "learning_rate", *_FLOAT_SEARCH_SPACE["learning_rate"], log=True + ), + "feature_fraction": trial.suggest_float( + "feature_fraction", *_FLOAT_SEARCH_SPACE["feature_fraction"] + ), + "bagging_fraction": trial.suggest_float( + "bagging_fraction", *_FLOAT_SEARCH_SPACE["bagging_fraction"] + ), + "lambda_l1": trial.suggest_float( + "lambda_l1", *_FLOAT_SEARCH_SPACE["lambda_l1"] + ), + "lambda_l2": trial.suggest_float( + "lambda_l2", *_FLOAT_SEARCH_SPACE["lambda_l2"] + ), + } + oof_brier, n_estimators = _cross_validate( + features[ranking[:top_k]], y, hyperparameters + ) + trial.set_user_attr("n_estimators", n_estimators) + return oof_brier + + study = optuna.create_study(direction="minimize") + study.optimize(objective, n_trials=n_trials, timeout=timeout_seconds) + + best = study.best_trial + top_k = best.params["top_k"] + hyperparameters = {k: v for k, v in best.params.items() if k != "top_k"} + hyperparameters["n_estimators"] = best.user_attrs["n_estimators"] + + return MilestoneResult( + config=ModelConfig( + selected_features=ranking[:top_k], hyperparameters=hyperparameters + ), + cv_brier_score=study.best_value, + feature_importance_ranking=ranking, + ) + + +def train_final_model( + features: pd.DataFrame, y: np.ndarray, config: ModelConfig +) -> lgb.LGBMClassifier: + """Fit the model actually served for predictions, on the full training pool. + + Uses `config.hyperparameters["n_estimators"]` as a fixed tree count + (chosen by the last milestone's CV + early stopping) rather than + re-running early stopping, matching the "retrain from scratch, not + incremental" rule for regular cycles. + + Args: + features: Full training pool feature frame. + y: Full training pool labels. + config: The currently active feature subset + hyperparameters. + + Returns: + The fitted model. + + """ + hyperparameters = dict(config.hyperparameters) + n_estimators = hyperparameters.pop("n_estimators", MAX_ESTIMATORS) + model = lgb.LGBMClassifier( + objective="binary", n_estimators=n_estimators, verbosity=-1, **hyperparameters + ) + model.fit(features[config.selected_features], y) + return model diff --git a/ml/trip_validity_model/artifacts/final_lightgbm_model.joblib b/ml/trip_validity_model/artifacts/final_lightgbm_model.joblib new file mode 100644 index 0000000..ee5e5e9 Binary files /dev/null and b/ml/trip_validity_model/artifacts/final_lightgbm_model.joblib differ diff --git a/ml/trip_validity_model/artifacts/final_model_summary.json b/ml/trip_validity_model/artifacts/final_model_summary.json new file mode 100644 index 0000000..25d14d5 --- /dev/null +++ b/ml/trip_validity_model/artifacts/final_model_summary.json @@ -0,0 +1,149 @@ +{ + "sffs": { + "raw_selected_features": [ + "trip_duration_ratio_to_route_avg", + "trip_duration_ratio_to_scheduled_v", + "route_i_scheduled_duration_n_trips_at_hour" + ], + "n_features_selected": 3, + "n_accepted_steps": 3, + "final_cv_brier": 0.00016274825765656825, + "fallback_triggered": true + }, + "n_outer_rotations": 50, + "n_outer_splits": 5, + "n_outer_repeats": 10, + "outer_eval_metrics": { + "auc": { + "mean": 1.0, + "std": 0.0 + }, + "brier": { + "mean": 0.001229902043437382, + "std": 0.003009569150755684 + }, + "log_loss": { + "mean": 0.003854983796286678, + "std": 0.008097429984198413 + }, + "ece": { + "mean": 0.002323250196330344, + "std": 0.004075946174426725 + } + }, + "selected_features": [ + "gtfs_route_has_both_directions", + "weekday_number", + "is_weekend", + "trip_duration_seconds", + "route_avg_trip_duration_seconds_loo", + "route_avg_trip_duration_n_trips_loo", + "trip_duration_ratio_to_route_avg", + "route_direction_avg_trip_duration_seconds_loo", + "route_direction_avg_trip_duration_n_trips_loo", + "trip_duration_ratio_to_route_direction_avg", + "route_hour_avg_trip_duration_seconds_loo", + "route_hour_avg_trip_duration_n_trips_loo", + "trip_duration_ratio_to_route_hour_avg", + "route_direction_hour_avg_trip_duration_seconds_loo", + "route_direction_hour_avg_trip_duration_n_trips_loo", + "trip_duration_ratio_to_route_direction_hour_avg", + "route_reverse_direction_avg_trip_duration_seconds", + "route_reverse_direction_n_trips", + "trip_duration_ratio_to_reverse_direction_avg", + "route_reverse_direction_hour_avg_trip_duration_seconds", + "route_reverse_direction_hour_n_trips", + "trip_duration_ratio_to_reverse_direction_hour_avg", + "route_i_scheduled_duration_avg_seconds", + "route_i_scheduled_duration_n_trips", + "trip_duration_ratio_to_scheduled_i", + "route_v_scheduled_duration_avg_seconds", + "route_v_scheduled_duration_n_trips", + "trip_duration_ratio_to_scheduled_v", + "route_i_scheduled_duration_avg_seconds_at_hour", + "route_i_scheduled_duration_n_trips_at_hour", + "trip_duration_ratio_to_scheduled_i_at_hour", + "route_v_scheduled_duration_avg_seconds_at_hour", + "route_v_scheduled_duration_n_trips_at_hour", + "trip_duration_ratio_to_scheduled_v_at_hour", + "trip_fare_count", + "fare_gap_avg_seconds", + "fare_gap_stddev_seconds", + "fare_gap_coefficient_of_variation", + "fare_gap_avg_ratio_to_duration", + "fare_span_seconds", + "fare_span_ratio_to_duration", + "trip_distance_meters", + "route_i_length_meters", + "trip_distance_ratio_to_route_i", + "route_v_length_meters", + "trip_distance_ratio_to_route_v", + "trip_points_standard_distance_meters", + "trip_cohesion_ratio_to_route_i", + "trip_cohesion_ratio_to_route_v", + "trip_start_distance_to_i_start_meters", + "trip_start_offset_ratio_to_i", + "trip_start_distance_to_v_start_meters", + "trip_start_offset_ratio_to_v", + "trip_end_distance_to_i_end_meters", + "trip_end_offset_ratio_to_i", + "trip_end_distance_to_v_end_meters", + "trip_end_offset_ratio_to_v", + "path_frechet_distance_to_i_meters", + "path_match_score_frechet_i", + "path_frechet_distance_to_v_meters", + "path_match_score_frechet_v", + "path_hausdorff_distance_to_i_meters", + "path_match_score_hausdorff_i", + "path_hausdorff_distance_to_v_meters", + "path_match_score_hausdorff_v", + "trip_progress_correlation_to_i", + "trip_progress_correlation_to_v", + "trip_progress_correlation_n_points", + "trip_start_distance_to_nearest_garage_meters", + "trip_end_distance_to_nearest_garage_meters", + "route_i_straight_line_meters", + "route_i_straight_line_ratio", + "route_v_straight_line_meters", + "route_v_straight_line_ratio", + "trip_start_distance_to_nearest_terminal_meters", + "trip_start_distance_to_nearest_open_terminal_meters", + "trip_start_distance_to_nearest_closed_terminal_meters", + "trip_end_distance_to_nearest_terminal_meters", + "trip_end_distance_to_nearest_open_terminal_meters", + "trip_end_distance_to_nearest_closed_terminal_meters" + ], + "consensus_hyperparameters": { + "num_leaves": 30, + "min_child_samples": 22, + "learning_rate": 0.13990994418445057, + "feature_fraction": 0.733775259790791, + "bagging_fraction": 0.7797319207223575, + "lambda_l1": 2.711804476299991e-06, + "lambda_l2": 2.804643006967186e-06, + "n_estimators": 294 + }, + "decision_threshold": 0.845402884322555, + "unlabeled_pool_confidence": [ + { + "threshold": 0.7, + "n_confident": 939590, + "pct_confident": 0.9990451765466438 + }, + { + "threshold": 0.8, + "n_confident": 938893, + "pct_confident": 0.9983040719286158 + }, + { + "threshold": 0.9, + "n_confident": 937549, + "pct_confident": 0.9968750265819447 + }, + { + "threshold": 0.95, + "n_confident": 936525, + "pct_confident": 0.9957862301273381 + } + ] +} \ No newline at end of file diff --git a/ml/trip_validity_model/artifacts/run_0050_milestone_1786825139.joblib b/ml/trip_validity_model/artifacts/run_0050_milestone_1786825139.joblib new file mode 100644 index 0000000..967ad50 Binary files /dev/null and b/ml/trip_validity_model/artifacts/run_0050_milestone_1786825139.joblib differ diff --git a/ml/trip_validity_model/artifacts/run_0060_cycle_1786825784.joblib b/ml/trip_validity_model/artifacts/run_0060_cycle_1786825784.joblib new file mode 100644 index 0000000..a845216 Binary files /dev/null and b/ml/trip_validity_model/artifacts/run_0060_cycle_1786825784.joblib differ diff --git a/ml/trip_validity_model/artifacts/run_0075_cycle_1786826174.joblib b/ml/trip_validity_model/artifacts/run_0075_cycle_1786826174.joblib new file mode 100644 index 0000000..e313664 Binary files /dev/null and b/ml/trip_validity_model/artifacts/run_0075_cycle_1786826174.joblib differ diff --git a/ml/trip_validity_model/artifacts/run_0090_cycle_1786826572.joblib b/ml/trip_validity_model/artifacts/run_0090_cycle_1786826572.joblib new file mode 100644 index 0000000..8bd737e Binary files /dev/null and b/ml/trip_validity_model/artifacts/run_0090_cycle_1786826572.joblib differ diff --git a/ml/trip_validity_model/artifacts/run_0100_milestone_1786826860.joblib b/ml/trip_validity_model/artifacts/run_0100_milestone_1786826860.joblib new file mode 100644 index 0000000..426108b Binary files /dev/null and b/ml/trip_validity_model/artifacts/run_0100_milestone_1786826860.joblib differ diff --git a/ml/trip_validity_model/artifacts/run_0105_cycle_1786829274.joblib b/ml/trip_validity_model/artifacts/run_0105_cycle_1786829274.joblib new file mode 100644 index 0000000..c3cda11 Binary files /dev/null and b/ml/trip_validity_model/artifacts/run_0105_cycle_1786829274.joblib differ diff --git a/ml/trip_validity_model/artifacts/run_0120_cycle_1786830336.joblib b/ml/trip_validity_model/artifacts/run_0120_cycle_1786830336.joblib new file mode 100644 index 0000000..eddbae7 Binary files /dev/null and b/ml/trip_validity_model/artifacts/run_0120_cycle_1786830336.joblib differ diff --git a/ml/trip_validity_model/artifacts/run_0150_milestone_1786832148.joblib b/ml/trip_validity_model/artifacts/run_0150_milestone_1786832148.joblib new file mode 100644 index 0000000..b1e53a3 Binary files /dev/null and b/ml/trip_validity_model/artifacts/run_0150_milestone_1786832148.joblib differ diff --git a/ml/trip_validity_model/artifacts/run_0165_cycle_1786891757.joblib b/ml/trip_validity_model/artifacts/run_0165_cycle_1786891757.joblib new file mode 100644 index 0000000..d2a9314 Binary files /dev/null and b/ml/trip_validity_model/artifacts/run_0165_cycle_1786891757.joblib differ diff --git a/ml/trip_validity_model/artifacts/run_0180_cycle_1786892307.joblib b/ml/trip_validity_model/artifacts/run_0180_cycle_1786892307.joblib new file mode 100644 index 0000000..f973497 Binary files /dev/null and b/ml/trip_validity_model/artifacts/run_0180_cycle_1786892307.joblib differ diff --git a/ml/trip_validity_model/artifacts/run_0195_cycle_1786894114.joblib b/ml/trip_validity_model/artifacts/run_0195_cycle_1786894114.joblib new file mode 100644 index 0000000..5bf5c43 Binary files /dev/null and b/ml/trip_validity_model/artifacts/run_0195_cycle_1786894114.joblib differ diff --git a/ml/trip_validity_model/artifacts/run_0200_milestone_1786894223.joblib b/ml/trip_validity_model/artifacts/run_0200_milestone_1786894223.joblib new file mode 100644 index 0000000..0b9d285 Binary files /dev/null and b/ml/trip_validity_model/artifacts/run_0200_milestone_1786894223.joblib differ diff --git a/ml/trip_validity_model/artifacts/run_0205_milestone_1786894317.joblib b/ml/trip_validity_model/artifacts/run_0205_milestone_1786894317.joblib new file mode 100644 index 0000000..7641637 Binary files /dev/null and b/ml/trip_validity_model/artifacts/run_0205_milestone_1786894317.joblib differ diff --git a/ml/trip_validity_model/artifacts/run_0210_milestone_1786894416.joblib b/ml/trip_validity_model/artifacts/run_0210_milestone_1786894416.joblib new file mode 100644 index 0000000..7bfce59 Binary files /dev/null and b/ml/trip_validity_model/artifacts/run_0210_milestone_1786894416.joblib differ diff --git a/ml/trip_validity_model/artifacts/run_0215_milestone_1786894560.joblib b/ml/trip_validity_model/artifacts/run_0215_milestone_1786894560.joblib new file mode 100644 index 0000000..eac5cf4 Binary files /dev/null and b/ml/trip_validity_model/artifacts/run_0215_milestone_1786894560.joblib differ diff --git a/ml/trip_validity_model/artifacts/run_0220_milestone_1786899820.joblib b/ml/trip_validity_model/artifacts/run_0220_milestone_1786899820.joblib new file mode 100644 index 0000000..2abb403 Binary files /dev/null and b/ml/trip_validity_model/artifacts/run_0220_milestone_1786899820.joblib differ diff --git a/ml/trip_validity_model/artifacts/run_0225_milestone_1786899950.joblib b/ml/trip_validity_model/artifacts/run_0225_milestone_1786899950.joblib new file mode 100644 index 0000000..4a2cfef Binary files /dev/null and b/ml/trip_validity_model/artifacts/run_0225_milestone_1786899950.joblib differ diff --git a/ml/trip_validity_model/artifacts/run_0230_milestone_1786900149.joblib b/ml/trip_validity_model/artifacts/run_0230_milestone_1786900149.joblib new file mode 100644 index 0000000..ed2da97 Binary files /dev/null and b/ml/trip_validity_model/artifacts/run_0230_milestone_1786900149.joblib differ diff --git a/ml/trip_validity_model/artifacts/run_0235_milestone_1786901825.joblib b/ml/trip_validity_model/artifacts/run_0235_milestone_1786901825.joblib new file mode 100644 index 0000000..8c93337 Binary files /dev/null and b/ml/trip_validity_model/artifacts/run_0235_milestone_1786901825.joblib differ diff --git a/ml/trip_validity_model/artifacts/run_0240_milestone_1786902021.joblib b/ml/trip_validity_model/artifacts/run_0240_milestone_1786902021.joblib new file mode 100644 index 0000000..feee40f Binary files /dev/null and b/ml/trip_validity_model/artifacts/run_0240_milestone_1786902021.joblib differ diff --git a/ml/trip_validity_model/artifacts/run_0245_milestone_1786902199.joblib b/ml/trip_validity_model/artifacts/run_0245_milestone_1786902199.joblib new file mode 100644 index 0000000..fe56ccb Binary files /dev/null and b/ml/trip_validity_model/artifacts/run_0245_milestone_1786902199.joblib differ diff --git a/ml/trip_validity_model/artifacts/run_0250_milestone_1786902344.joblib b/ml/trip_validity_model/artifacts/run_0250_milestone_1786902344.joblib new file mode 100644 index 0000000..078b4ad Binary files /dev/null and b/ml/trip_validity_model/artifacts/run_0250_milestone_1786902344.joblib differ diff --git a/ml/trip_validity_model/notebooks/01_build_trip_tables.ipynb b/ml/trip_validity_model/notebooks/01_build_trip_tables.ipynb new file mode 100644 index 0000000..4671975 --- /dev/null +++ b/ml/trip_validity_model/notebooks/01_build_trip_tables.ipynb @@ -0,0 +1,668 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "df55004d", + "metadata": {}, + "source": [ + "# 01 - Build `ml.trip_validity_trips` and `ml.trip_validity_trip_fares`\n", + "\n", + "Builds the base tables for the Trip Validity model, sourced directly from\n", + "`silver.afc_boardings` (not from the earlier `validation` schema, which\n", + "was exploratory only).\n", + "\n", + "- `ml.trip_validity_trips`: one row per AFC trip (grouped by\n", + " vehicle + trip_opened_at + trip_closed_at), scoped to November 2023.\n", + "- `ml.trip_validity_trip_fares`: one row per fare tap belonging to a trip\n", + " in the table above, **including fares without GPS coordinates** (unlike\n", + " the earlier `validation.trip_points`, which only kept geo-tagged fares).\n", + " This is what lets every downstream notebook avoid ever touching `silver`\n", + " again for trip/fare-level data." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "2a26f393", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-14T21:09:31.122282Z", + "iopub.status.busy": "2026-08-14T21:09:31.122193Z", + "iopub.status.idle": "2026-08-14T21:09:31.150227Z", + "shell.execute_reply": "2026-08-14T21:09:31.149700Z" + } + }, + "outputs": [], + "source": [ + "import os\n", + "from pathlib import Path\n", + "\n", + "import psycopg" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "2879d2c2", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-14T21:09:31.151155Z", + "iopub.status.busy": "2026-08-14T21:09:31.151086Z", + "iopub.status.idle": "2026-08-14T21:09:31.153812Z", + "shell.execute_reply": "2026-08-14T21:09:31.153538Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "'/home/victor/repos/opa-database'" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Root regardless of the kernel's actual cwd (nbconvert defaults it to the\n", + "# notebook's own directory, not the repo root), so opa_database.config's\n", + "# relative `.env` lookup resolves correctly either way.\n", + "_root = Path.cwd()\n", + "while not (_root / \"pyproject.toml\").exists():\n", + " _root = _root.parent\n", + "os.chdir(_root)\n", + "\n", + "# Settings() requires raw_data_root to exist on disk (an ingest-pipeline\n", + "# concern) even though this notebook only needs db_dsn. Give it a\n", + "# placeholder that's guaranteed to exist, without touching .env or the\n", + "# shared config.py validator.\n", + "os.environ.setdefault(\"RAW_DATA_ROOT\", str(_root))" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "48445088", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-14T21:09:31.154681Z", + "iopub.status.busy": "2026-08-14T21:09:31.154613Z", + "iopub.status.idle": "2026-08-14T21:09:31.210919Z", + "shell.execute_reply": "2026-08-14T21:09:31.210543Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ml schema ready\n" + ] + } + ], + "source": [ + "from opa_database.config import settings\n", + "\n", + "TRIP_DATE_START = \"2023-11-01\"\n", + "TRIP_DATE_END = \"2023-12-01\"\n", + "\n", + "conn = psycopg.connect(settings.db_dsn)\n", + "conn.execute(\"CREATE SCHEMA IF NOT EXISTS ml;\")\n", + "conn.commit()\n", + "print(\"ml schema ready\")" + ] + }, + { + "cell_type": "markdown", + "id": "f6cf8cad", + "metadata": {}, + "source": [ + "## Stage 1 - `ml.trip_validity_trips`\n", + "\n", + "One row per trip, grouped from `silver.afc_boardings` by\n", + "`(service_date, vehicle_number, line_number, direction, trip_opened_at,\n", + "trip_closed_at)`. Verified earlier (against November 2023) that this key\n", + "alone is a clean 1:1 grouping — `line_number`/`direction`/`line_shift`\n", + "never vary within a `(vehicle_number, trip_opened_at, trip_closed_at)`\n", + "group, so including them in `GROUP BY` doesn't fragment trips further.\n", + "\n", + "`bus_id`/`route_id` are zero-padded presentation forms of\n", + "`vehicle_number`/`line_number` (`lpad` only when *shorter* than the\n", + "target width, never truncating longer values — plain `lpad` in Postgres\n", + "truncates on overlong input, which would silently corrupt 4+ digit line\n", + "numbers).\n", + "\n", + "`trip_duration_seconds` is `NULL`, not a garbage negative number, for the\n", + "handful of trips where `trip_closed_at` is the `1899-12-30` Delphi/OLE\n", + "zero-date sentinel (the AFC backend's way of saying \"closing time was\n", + "never recorded\") — confirmed earlier that this affects exactly 9 trips\n", + "in November 2023, all with `trip_closed_at < trip_opened_at`." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "9d08c3de", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-14T21:09:31.211850Z", + "iopub.status.busy": "2026-08-14T21:09:31.211781Z", + "iopub.status.idle": "2026-08-14T21:11:53.293136Z", + "shell.execute_reply": "2026-08-14T21:11:53.292706Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "trip_validity_trips rows: 940988\n" + ] + } + ], + "source": [ + "conn.execute(\"\"\"\n", + " DROP TABLE IF EXISTS ml.trip_validity_trips CASCADE;\n", + "\n", + " CREATE TABLE ml.trip_validity_trips (\n", + " trip_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n", + " bus_id text NOT NULL,\n", + " route_id text NOT NULL,\n", + " route_direction integer NOT NULL,\n", + " trip_date date NOT NULL,\n", + " trip_hour smallint NOT NULL,\n", + " trip_opening_timestamp timestamptz NOT NULL,\n", + " trip_closing_timestamp timestamptz NOT NULL,\n", + " trip_duration_seconds bigint,\n", + " trip_fare_count integer NOT NULL\n", + " );\n", + "\"\"\")\n", + "\n", + "conn.execute(\n", + " \"\"\"\n", + " INSERT INTO ml.trip_validity_trips (\n", + " bus_id, route_id, route_direction, trip_date, trip_hour,\n", + " trip_opening_timestamp, trip_closing_timestamp,\n", + " trip_duration_seconds, trip_fare_count\n", + " )\n", + " SELECT\n", + " CASE WHEN length(vehicle_number) < 5\n", + " THEN lpad(vehicle_number, 5, '0') ELSE vehicle_number END,\n", + " CASE WHEN length(line_number) < 3\n", + " THEN lpad(line_number, 3, '0') ELSE line_number END,\n", + " direction,\n", + " service_date,\n", + " EXTRACT(hour FROM trip_opened_at AT TIME ZONE 'America/Fortaleza')::smallint,\n", + " trip_opened_at,\n", + " trip_closed_at,\n", + " CASE WHEN trip_closed_at < trip_opened_at THEN NULL\n", + " ELSE EXTRACT(EPOCH FROM (trip_closed_at - trip_opened_at))::bigint\n", + " END,\n", + " count(*)\n", + " FROM silver.afc_boardings\n", + " WHERE service_date >= %(start)s AND service_date < %(end)s\n", + " GROUP BY service_date, vehicle_number, line_number, direction,\n", + " trip_opened_at, trip_closed_at;\n", + " \"\"\",\n", + " {\"start\": TRIP_DATE_START, \"end\": TRIP_DATE_END},\n", + ")\n", + "\n", + "conn.execute(\"\"\"\n", + " CREATE INDEX trip_validity_trips_bus_id_idx\n", + " ON ml.trip_validity_trips (bus_id);\n", + " CREATE INDEX trip_validity_trips_route_id_idx\n", + " ON ml.trip_validity_trips (route_id);\n", + " CREATE INDEX trip_validity_trips_trip_date_idx\n", + " ON ml.trip_validity_trips (trip_date);\n", + " CREATE INDEX trip_validity_trips_trip_hour_idx\n", + " ON ml.trip_validity_trips (trip_hour);\n", + " ANALYZE ml.trip_validity_trips;\n", + "\"\"\")\n", + "conn.commit()\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\"SELECT count(*) FROM ml.trip_validity_trips;\")\n", + " print(\"trip_validity_trips rows:\", cur.fetchone()[0])" + ] + }, + { + "cell_type": "markdown", + "id": "f3a370cc", + "metadata": {}, + "source": [ + "## Stage 2 - `ml.trip_validity_trip_fares`\n", + "\n", + "One row per fare tap (**all of them**, not just geo-tagged) belonging to\n", + "a trip above, joined back to `silver.afc_boardings` by the natural key\n", + "`(service_date, padded vehicle_number, trip_opened_at, trip_closed_at)` —\n", + "the only reliable rejoin path, since `trip_id` here is a plain sequential\n", + "identity with no formula back to the source.\n", + "\n", + "`event_id` gets a **partial** unique index (`WHERE event_id <> '0'`),\n", + "mirroring `silver.afc_boardings`'s own unique index: `'0'` is a sentinel\n", + "for \"no event id assigned\" and is not actually unique in the source data\n", + "(confirmed: 211,044 such rows in November 2023 among just the geo-tagged\n", + "subset alone). `fare_id` is the real surrogate primary key.\n", + "\n", + "`latitude`/`longitude`/`geom` are nullable — `geom` is a `STORED`\n", + "generated column exactly like `silver.afc_boardings`'s own, and\n", + "`ST_MakePoint`/`ST_SetSRID` are strict functions that already return\n", + "`NULL` on `NULL` input, so no `CASE` is needed for the ungeotagged rows." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "31ae39e2", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-14T21:11:53.294236Z", + "iopub.status.busy": "2026-08-14T21:11:53.294162Z", + "iopub.status.idle": "2026-08-14T21:13:29.826732Z", + "shell.execute_reply": "2026-08-14T21:13:29.826323Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "trip_validity_trip_fares rows: 15381356\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " of which geo-tagged: 12769470\n" + ] + } + ], + "source": [ + "conn.execute(\"\"\"\n", + " DROP TABLE IF EXISTS ml.trip_validity_trip_fares CASCADE;\n", + "\n", + " CREATE TABLE ml.trip_validity_trip_fares (\n", + " fare_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n", + " event_id text NOT NULL,\n", + " trip_id bigint NOT NULL REFERENCES ml.trip_validity_trips (trip_id),\n", + " boarding_at timestamptz NOT NULL,\n", + " latitude double precision,\n", + " longitude double precision,\n", + " geom geometry(Point, 4326) GENERATED ALWAYS AS (\n", + " ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)\n", + " ) STORED\n", + " );\n", + "\"\"\")\n", + "\n", + "conn.execute(\n", + " \"\"\"\n", + " INSERT INTO ml.trip_validity_trip_fares (\n", + " event_id, trip_id, boarding_at, latitude, longitude\n", + " )\n", + " SELECT\n", + " b.event_id,\n", + " t.trip_id,\n", + " b.boarding_at,\n", + " b.latitude,\n", + " b.longitude\n", + " FROM silver.afc_boardings b\n", + " JOIN ml.trip_validity_trips t\n", + " ON t.trip_date = b.service_date\n", + " AND t.bus_id = CASE WHEN length(b.vehicle_number) < 5\n", + " THEN lpad(b.vehicle_number, 5, '0') ELSE b.vehicle_number END\n", + " AND t.trip_opening_timestamp = b.trip_opened_at\n", + " AND t.trip_closing_timestamp = b.trip_closed_at\n", + " WHERE b.service_date >= %(start)s AND b.service_date < %(end)s;\n", + " \"\"\",\n", + " {\"start\": TRIP_DATE_START, \"end\": TRIP_DATE_END},\n", + ")\n", + "\n", + "conn.execute(\n", + " \"CREATE UNIQUE INDEX trip_validity_trip_fares_event_id_key \"\n", + " \"ON ml.trip_validity_trip_fares (event_id) WHERE event_id <> '0';\"\n", + ")\n", + "conn.execute(\"\"\"\n", + " CREATE INDEX trip_validity_trip_fares_trip_id_idx\n", + " ON ml.trip_validity_trip_fares (trip_id);\n", + " CREATE INDEX trip_validity_trip_fares_geom_idx\n", + " ON ml.trip_validity_trip_fares USING GIST (geom);\n", + " CREATE INDEX trip_validity_trip_fares_boarding_at_idx\n", + " ON ml.trip_validity_trip_fares (boarding_at);\n", + " ANALYZE ml.trip_validity_trip_fares;\n", + "\"\"\")\n", + "conn.commit()\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\"SELECT count(*) FROM ml.trip_validity_trip_fares;\")\n", + " print(\"trip_validity_trip_fares rows:\", cur.fetchone()[0])\n", + " cur.execute(\"\"\"\n", + " SELECT count(*) FROM ml.trip_validity_trip_fares WHERE geom IS NOT NULL;\n", + " \"\"\")\n", + " print(\" of which geo-tagged:\", cur.fetchone()[0])" + ] + }, + { + "cell_type": "markdown", + "id": "5ca1ab87", + "metadata": {}, + "source": [ + "## Stage 3 - backfill `trip_path` and `trip_fares_with_latlon` onto `trips`\n", + "\n", + "These depend on `trip_fares` existing, so they're added after the fact\n", + "rather than in the Stage 1 `CREATE TABLE`.\n", + "\n", + "- `trip_fares_with_latlon`: count of this trip's fares that have a\n", + " coordinate (`0` default, not `NULL`, for trips with none at all).\n", + "- `trip_path`: `ST_MakeLine(geom ORDER BY boarding_at)` over only the\n", + " geo-tagged fares — one row per trip, no row explosion. `NULL` when a\n", + " trip has 0 or 1 geo-tagged fares (a `LineString` needs ≥ 2 points;\n", + " confirmed this happens for real single-point trips, e.g. a trip with\n", + " exactly one geo-tagged fare tap)." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "a332b7e1", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-14T21:13:29.827911Z", + "iopub.status.busy": "2026-08-14T21:13:29.827821Z", + "iopub.status.idle": "2026-08-14T21:14:00.203758Z", + "shell.execute_reply": "2026-08-14T21:14:00.203369Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "sum(trip_fares_with_latlon): 12769470\n", + "trips with a non-null trip_path: 899299\n" + ] + } + ], + "source": [ + "conn.execute(\"\"\"\n", + " ALTER TABLE ml.trip_validity_trips\n", + " ADD COLUMN trip_fares_with_latlon integer NOT NULL DEFAULT 0;\n", + " ALTER TABLE ml.trip_validity_trips\n", + " ADD COLUMN trip_path geometry(LineString, 4326);\n", + "\"\"\")\n", + "\n", + "conn.execute(\"\"\"\n", + " UPDATE ml.trip_validity_trips t\n", + " SET trip_fares_with_latlon = f.n\n", + " FROM (\n", + " SELECT trip_id, count(*) AS n\n", + " FROM ml.trip_validity_trip_fares\n", + " WHERE geom IS NOT NULL\n", + " GROUP BY trip_id\n", + " ) f\n", + " WHERE f.trip_id = t.trip_id;\n", + "\"\"\")\n", + "\n", + "conn.execute(\"\"\"\n", + " UPDATE ml.trip_validity_trips t\n", + " SET trip_path = p.path\n", + " FROM (\n", + " SELECT trip_id, ST_MakeLine(geom ORDER BY boarding_at) AS path\n", + " FROM ml.trip_validity_trip_fares\n", + " WHERE geom IS NOT NULL\n", + " GROUP BY trip_id\n", + " ) p\n", + " WHERE p.trip_id = t.trip_id;\n", + "\"\"\")\n", + "\n", + "conn.execute(\"\"\"\n", + " CREATE INDEX trip_validity_trips_trip_path_gix\n", + " ON ml.trip_validity_trips USING GIST (trip_path);\n", + "\"\"\")\n", + "conn.execute(\"ANALYZE ml.trip_validity_trips;\")\n", + "conn.commit()\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\"\"\"\n", + " SELECT sum(trip_fares_with_latlon),\n", + " count(*) FILTER (WHERE trip_path IS NOT NULL)\n", + " FROM ml.trip_validity_trips;\n", + " \"\"\")\n", + " latlon_sum, paths = cur.fetchone()\n", + " print(\"sum(trip_fares_with_latlon):\", latlon_sum)\n", + " print(\"trips with a non-null trip_path:\", paths)" + ] + }, + { + "cell_type": "markdown", + "id": "d291b826", + "metadata": {}, + "source": [ + "## Column-level provenance comments\n", + "\n", + "Documented on the tables themselves (`COMMENT ON COLUMN`, queryable via\n", + "`\\d+` or `information_schema.columns` forever, independent of this\n", + "notebook) as well as here in markdown." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "c1332c6f", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-14T21:14:00.204886Z", + "iopub.status.busy": "2026-08-14T21:14:00.204814Z", + "iopub.status.idle": "2026-08-14T21:14:01.212574Z", + "shell.execute_reply": "2026-08-14T21:14:01.212170Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "comments applied\n" + ] + } + ], + "source": [ + "from psycopg import sql\n", + "\n", + "TRIPS_COMMENTS = {\n", + " \"trip_id\": (\n", + " \"Surrogate identity PK. No formula back to source; rejoin via the \"\n", + " \"natural key (trip_date, bus_id, trip_opening_timestamp, \"\n", + " \"trip_closing_timestamp) against silver.afc_boardings.\"\n", + " ),\n", + " \"bus_id\": (\n", + " \"silver.afc_boardings.vehicle_number, zero-padded to 5 chars \"\n", + " \"(never truncated if already longer).\"\n", + " ),\n", + " \"route_id\": (\n", + " \"silver.afc_boardings.line_number, zero-padded to a minimum of 3 \"\n", + " \"chars (never truncated if already longer).\"\n", + " ),\n", + " \"route_direction\": (\n", + " \"silver.afc_boardings.direction, as-is (0/1, AFC's own 'sentido').\"\n", + " ),\n", + " \"trip_date\": \"silver.afc_boardings.service_date, as-is.\",\n", + " \"trip_hour\": (\n", + " \"Hour (0-23) of trip_opening_timestamp converted to \"\n", + " \"America/Fortaleza local time.\"\n", + " ),\n", + " \"trip_opening_timestamp\": \"silver.afc_boardings.trip_opened_at, as-is (UTC).\",\n", + " \"trip_closing_timestamp\": \"silver.afc_boardings.trip_closed_at, as-is (UTC).\",\n", + " \"trip_duration_seconds\": (\n", + " \"trip_closing_timestamp - trip_opening_timestamp in seconds. NULL \"\n", + " \"when trip_closing_timestamp < trip_opening_timestamp (the \"\n", + " \"1899-12-30 Delphi zero-date sentinel meaning closing time was \"\n", + " \"never recorded).\"\n", + " ),\n", + " \"trip_fare_count\": (\n", + " \"count(*) of all fare taps (rows) on this trip from \"\n", + " \"silver.afc_boardings, geo-tagged or not.\"\n", + " ),\n", + " \"trip_fares_with_latlon\": (\n", + " \"count of this trip's rows in trip_fares with a non-null geom. 0 \"\n", + " \"(not NULL) when none.\"\n", + " ),\n", + " \"trip_path\": (\n", + " \"ST_MakeLine(geom ORDER BY boarding_at) over this trip's \"\n", + " \"geo-tagged fares in trip_fares. NULL when fewer than 2 \"\n", + " \"geo-tagged fares exist.\"\n", + " ),\n", + "}\n", + "\n", + "FARES_COMMENTS = {\n", + " \"fare_id\": \"Surrogate identity PK.\",\n", + " \"event_id\": (\n", + " \"silver.afc_boardings.event_id, as-is. Not globally unique here: \"\n", + " \"'0' is a sentinel for 'no event id assigned' shared by many \"\n", + " \"rows, so the unique index on this column excludes event_id = '0'.\"\n", + " ),\n", + " \"trip_id\": \"FK to ml.trip_validity_trips.trip_id.\",\n", + " \"boarding_at\": \"silver.afc_boardings.boarding_at, as-is (UTC).\",\n", + " \"latitude\": (\n", + " \"silver.afc_boardings.latitude, as-is. NULL when the validator \"\n", + " \"had no GPS fix at tap time.\"\n", + " ),\n", + " \"longitude\": (\n", + " \"silver.afc_boardings.longitude, as-is. NULL when the validator \"\n", + " \"had no GPS fix at tap time.\"\n", + " ),\n", + " \"geom\": (\n", + " \"Generated: ST_SetSRID(ST_MakePoint(longitude, latitude), 4326). \"\n", + " \"NULL whenever latitude/longitude are NULL.\"\n", + " ),\n", + "}\n", + "\n", + "\n", + "def comment_on_column(cur: psycopg.Cursor, table: str, col: str, text: str) -> None:\n", + " \"\"\"Apply a COMMENT ON COLUMN for one column via safe SQL composition.\"\"\"\n", + " cur.execute(\n", + " sql.SQL(\"COMMENT ON COLUMN ml.{}.{} IS {};\").format(\n", + " sql.Identifier(table), sql.Identifier(col), sql.Literal(text)\n", + " )\n", + " )\n", + "\n", + "\n", + "with conn.cursor() as cur:\n", + " for col, text in TRIPS_COMMENTS.items():\n", + " comment_on_column(cur, \"trip_validity_trips\", col, text)\n", + " for col, text in FARES_COMMENTS.items():\n", + " comment_on_column(cur, \"trip_validity_trip_fares\", col, text)\n", + "\n", + "conn.execute(\n", + " sql.SQL(\"COMMENT ON TABLE ml.trip_validity_trips IS {};\").format(\n", + " sql.Literal(\n", + " \"Trip Validity model: one row per AFC trip, November 2023, built directly \"\n", + " \"from silver.afc_boardings. See \"\n", + " \"ml/trip_validity_model/notebooks/01_build_trip_tables.ipynb.\"\n", + " )\n", + " )\n", + ")\n", + "conn.execute(\n", + " sql.SQL(\"COMMENT ON TABLE ml.trip_validity_trip_fares IS {};\").format(\n", + " sql.Literal(\n", + " \"Trip Validity model: one row per fare tap (all of them, not just \"\n", + " \"geo-tagged) on a trip in trip_validity_trips. See \"\n", + " \"ml/trip_validity_model/notebooks/01_build_trip_tables.ipynb.\"\n", + " )\n", + " )\n", + ")\n", + "conn.commit()\n", + "print(\"comments applied\")" + ] + }, + { + "cell_type": "markdown", + "id": "2f9548be", + "metadata": {}, + "source": [ + "## Verification" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "745662eb", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-14T21:14:01.213600Z", + "iopub.status.busy": "2026-08-14T21:14:01.213494Z", + "iopub.status.idle": "2026-08-14T21:14:02.033158Z", + "shell.execute_reply": "2026-08-14T21:14:02.032751Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "shape: (1, 5)\n", + "┌────────┬──────────┬───────────┬─────────────────────┬─────────────────────┐\n", + "│ trips ┆ fares ┆ geo_fares ┆ null_duration_trips ┆ sum_trip_fare_count │\n", + "│ --- ┆ --- ┆ --- ┆ --- ┆ --- │\n", + "│ i64 ┆ i64 ┆ i64 ┆ i64 ┆ i64 │\n", + "╞════════╪══════════╪═══════════╪═════════════════════╪═════════════════════╡\n", + "│ 940988 ┆ 15381356 ┆ 12769470 ┆ 9 ┆ 15381356 │\n", + "└────────┴──────────┴───────────┴─────────────────────┴─────────────────────┘\n", + "OK: trip_fare_count sums match trip_fares row count\n" + ] + } + ], + "source": [ + "import polars as pl\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\"\"\"\n", + " SELECT\n", + " (SELECT count(*) FROM ml.trip_validity_trips) AS trips,\n", + " (SELECT count(*) FROM ml.trip_validity_trip_fares) AS fares,\n", + " (SELECT count(*) FROM ml.trip_validity_trip_fares\n", + " WHERE geom IS NOT NULL) AS geo_fares,\n", + " (SELECT count(*) FROM ml.trip_validity_trips\n", + " WHERE trip_duration_seconds IS NULL) AS null_duration_trips,\n", + " (SELECT sum(trip_fare_count) FROM ml.trip_validity_trips)\n", + " AS sum_trip_fare_count;\n", + " \"\"\")\n", + " cols = [d.name for d in cur.description]\n", + " row = cur.fetchone()\n", + "\n", + "summary = pl.DataFrame([dict(zip(cols, row, strict=True))])\n", + "print(summary)\n", + "\n", + "# sanity: sum(trip_fare_count) across trips must equal total fare rows,\n", + "# and must equal the source row count for November 2023 (15,381,356, per\n", + "# the earlier validation.trips build).\n", + "if summary[\"fares\"][0] != summary[\"sum_trip_fare_count\"][0]:\n", + " msg = \"fare_count mismatch\"\n", + " raise AssertionError(msg)\n", + "print(\"OK: trip_fare_count sums match trip_fares row count\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (opa-database)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/ml/trip_validity_model/notebooks/02_gtfs_materialize.ipynb b/ml/trip_validity_model/notebooks/02_gtfs_materialize.ipynb new file mode 100644 index 0000000..e400da8 --- /dev/null +++ b/ml/trip_validity_model/notebooks/02_gtfs_materialize.ipynb @@ -0,0 +1,1028 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "5815fd52", + "metadata": {}, + "source": [ + "# 02 - Materialize matched GTFS data into `ml`\n", + "\n", + "The only notebook that touches `silver.gtfs_*`. Resolves the correct GTFS\n", + "feed/route/direction for every `(route_id, trip_date)` pair once, copies\n", + "the matched shapes and scheduled-trip durations into `ml`, and stores\n", + "which feed/route/shape was used for traceability. Every later notebook\n", + "(and any rebuild of `03_trip_metrics`) reads only from the three tables\n", + "built here plus `ml.trip_validity_trips` — `silver` is never touched\n", + "again after this notebook runs.\n", + "\n", + "Key facts established earlier by direct exploration, that this notebook\n", + "relies on:\n", + "\n", + "- GTFS `route_id` is always 4-digit zero-padded (`\"0031\"`), unrelated to\n", + " our own `route_id`'s minimum-3-digit padding — the correct join key is\n", + " `route_short_name` (the plain unpadded number, 1:1 with `route_id`),\n", + " normalized by stripping leading zeros from our own `route_id`.\n", + "- `gtfs_trips.direction_id` is 100% NULL in this data. Direction instead\n", + " lives as a literal suffix on `shape_id` itself: `shape0060-I` /\n", + " `shape0211-V` (confirmed: every shape_id in the Nov-2023 feed ends in\n", + " exactly `-I` or `-V`).\n", + "- `shape_dist_traveled` is unpopulated everywhere (both\n", + " `gtfs_shapes` and `gtfs_stop_times`) — shape length has to be computed\n", + " from the ordered points, not looked up.\n", + "- `stop_times.arrival_time`/`departure_time` are text and can exceed\n", + " `24:00:00` for late-night service (standard GTFS convention) — parsed\n", + " manually into seconds-from-midnight, not cast to a Postgres `time`.\n", + "- 30 of the 352 routes appearing in November 2023 trips never appear in\n", + " *any* GTFS feed, ever — those permanently resolve to NULL.\n", + "- A minority of routes (13 in the Nov-2023 feed) split direction into two\n", + " entirely separate `route_id`/`route_short_name` pairs instead of one\n", + " `route_id` with two shapes. This notebook does not attempt to\n", + " auto-link those sibling route numbers — it would be a heuristic guess,\n", + " not a real match — so those routes will show `has_both_directions =\n", + " false` even though the physical route has two directions." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "813cdc11", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T15:15:29.556157Z", + "iopub.status.busy": "2026-08-22T15:15:29.556086Z", + "iopub.status.idle": "2026-08-22T15:15:29.584803Z", + "shell.execute_reply": "2026-08-22T15:15:29.584364Z" + } + }, + "outputs": [], + "source": [ + "import os\n", + "from pathlib import Path\n", + "\n", + "import psycopg\n", + "from psycopg import sql" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "12d98e8b", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T15:15:29.585823Z", + "iopub.status.busy": "2026-08-22T15:15:29.585733Z", + "iopub.status.idle": "2026-08-22T15:15:29.588550Z", + "shell.execute_reply": "2026-08-22T15:15:29.588316Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "'/home/victor/repos/opa-database'" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "_root = Path.cwd()\n", + "while not (_root / \"pyproject.toml\").exists():\n", + " _root = _root.parent\n", + "os.chdir(_root)\n", + "os.environ.setdefault(\"RAW_DATA_ROOT\", str(_root))" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "62e157d3", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T15:15:29.589254Z", + "iopub.status.busy": "2026-08-22T15:15:29.589194Z", + "iopub.status.idle": "2026-08-22T15:15:29.637527Z", + "shell.execute_reply": "2026-08-22T15:15:29.637258Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "connected\n" + ] + } + ], + "source": [ + "from opa_database.config import settings\n", + "\n", + "conn = psycopg.connect(settings.db_dsn)\n", + "conn.execute(\"CREATE SCHEMA IF NOT EXISTS ml;\")\n", + "conn.commit()\n", + "print(\"connected\")" + ] + }, + { + "cell_type": "markdown", + "id": "041d46dc", + "metadata": {}, + "source": [ + "## Stage 1 - `ml.trip_validity_route_gtfs_match`\n", + "\n", + "One row per distinct `(route_id, trip_date)` pair actually present in\n", + "`ml.trip_validity_trips` (not per trip — a tiny lookup table, resolved\n", + "once). For each pair:\n", + "\n", + "1. Normalize our `route_id` by stripping leading zeros, and find every\n", + " `silver.gtfs_routes` row across *all* feeds whose `route_short_name`\n", + " matches.\n", + "2. Among those candidates, pick the one with `feed_version_date <=\n", + " trip_date` closest to `trip_date`; if none exists, the closest\n", + " `feed_version_date > trip_date`; if no candidates at all, NULL\n", + " (the route never appears in any feed).\n", + "3. From that resolved `(feed_version_date, gtfs_route_id)`, look up\n", + " which of the `-I`/`-V` shape suffixes actually have trips." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "50bf3fb4", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T15:15:29.638347Z", + "iopub.status.busy": "2026-08-22T15:15:29.638285Z", + "iopub.status.idle": "2026-08-22T15:16:03.004174Z", + "shell.execute_reply": "2026-08-22T15:16:03.003830Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "(9262, 352, 30, 8348)\n" + ] + } + ], + "source": [ + "conn.execute(\"\"\"\n", + " DROP TABLE IF EXISTS ml.trip_validity_route_gtfs_match CASCADE;\n", + "\n", + " CREATE TABLE ml.trip_validity_route_gtfs_match (\n", + " route_id text NOT NULL,\n", + " trip_date date NOT NULL,\n", + " gtfs_feed_version_date date,\n", + " gtfs_route_id text,\n", + " gtfs_route_short_name text,\n", + " gtfs_shape_id_i text,\n", + " gtfs_shape_id_v text,\n", + " gtfs_route_has_both_directions boolean,\n", + " PRIMARY KEY (route_id, trip_date)\n", + " );\n", + "\"\"\")\n", + "\n", + "conn.execute(\"\"\"\n", + " INSERT INTO ml.trip_validity_route_gtfs_match (\n", + " route_id, trip_date, gtfs_feed_version_date,\n", + " gtfs_route_id, gtfs_route_short_name\n", + " )\n", + " SELECT\n", + " rd.route_id,\n", + " rd.trip_date,\n", + " resolved.feed_version_date,\n", + " resolved.route_id,\n", + " resolved.route_short_name\n", + " FROM (\n", + " SELECT DISTINCT route_id, trip_date FROM ml.trip_validity_trips\n", + " ) rd\n", + " LEFT JOIN LATERAL (\n", + " SELECT r.feed_version_date, r.route_id, r.route_short_name\n", + " FROM silver.gtfs_routes r\n", + " WHERE r.route_short_name = ltrim(rd.route_id, '0')\n", + " ORDER BY\n", + " CASE WHEN r.feed_version_date <= rd.trip_date THEN 0 ELSE 1 END,\n", + " CASE WHEN r.feed_version_date <= rd.trip_date\n", + " THEN rd.trip_date - r.feed_version_date\n", + " ELSE r.feed_version_date - rd.trip_date\n", + " END ASC\n", + " LIMIT 1\n", + " ) resolved ON true;\n", + "\"\"\")\n", + "\n", + "conn.execute(\"\"\"\n", + " UPDATE ml.trip_validity_route_gtfs_match m\n", + " SET gtfs_shape_id_i = shapes.shape_id_i,\n", + " gtfs_shape_id_v = shapes.shape_id_v,\n", + " gtfs_route_has_both_directions = (\n", + " shapes.shape_id_i IS NOT NULL AND shapes.shape_id_v IS NOT NULL\n", + " )\n", + " FROM (\n", + " SELECT\n", + " feed_version_date,\n", + " route_id,\n", + " max(shape_id) FILTER (WHERE shape_id LIKE '%-I') AS shape_id_i,\n", + " max(shape_id) FILTER (WHERE shape_id LIKE '%-V') AS shape_id_v\n", + " FROM silver.gtfs_trips\n", + " WHERE shape_id IS NOT NULL\n", + " GROUP BY feed_version_date, route_id\n", + " ) shapes\n", + " WHERE shapes.feed_version_date = m.gtfs_feed_version_date\n", + " AND shapes.route_id = m.gtfs_route_id;\n", + "\"\"\")\n", + "\n", + "conn.execute(\"\"\"\n", + " CREATE INDEX trip_validity_route_gtfs_match_feed_idx\n", + " ON ml.trip_validity_route_gtfs_match (gtfs_feed_version_date, gtfs_route_id);\n", + "\"\"\")\n", + "conn.execute(\"ANALYZE ml.trip_validity_route_gtfs_match;\")\n", + "conn.commit()\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\"\"\"\n", + " SELECT\n", + " count(*) AS pairs,\n", + " count(DISTINCT route_id) AS routes,\n", + " count(DISTINCT route_id)\n", + " FILTER (WHERE gtfs_feed_version_date IS NULL) AS orphan_routes,\n", + " count(*)\n", + " FILTER (WHERE gtfs_route_has_both_directions) AS pairs_both_directions\n", + " FROM ml.trip_validity_route_gtfs_match;\n", + " \"\"\")\n", + " print(cur.fetchone())" + ] + }, + { + "cell_type": "markdown", + "id": "c0471882", + "metadata": {}, + "source": [ + "## Stage 2 - `ml.trip_validity_route_shapes`\n", + "\n", + "One row per distinct `(feed_version_date, shape_id)` actually referenced\n", + "by Stage 1 (not one row per raw shape point) — the geometry is built\n", + "once here from `silver.gtfs_shapes`'s ordered points via\n", + "`ST_MakeLine(geom ORDER BY shape_pt_sequence)`, so notebook 03 never\n", + "touches raw shape points.\n", + "\n", + "Stores both the original `EPSG:4326` geometry (for reference/mapping)\n", + "and a version pre-projected to `EPSG:31984` (SIRGAS2000 / UTM 24S, the\n", + "standard metric CRS for Ceará) — `ST_FrechetDistance`/\n", + "`ST_HausdorffDistance` have no `geography` overload, so this avoids\n", + "re-projecting on every trip comparison in notebook 03." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "1fa5baf4", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T15:16:03.005155Z", + "iopub.status.busy": "2026-08-22T15:16:03.005071Z", + "iopub.status.idle": "2026-08-22T15:16:05.505960Z", + "shell.execute_reply": "2026-08-22T15:16:05.505634Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "shapes total / I / V: (1864, 931, 933)\n" + ] + } + ], + "source": [ + "conn.execute(\"\"\"\n", + " DROP TABLE IF EXISTS ml.trip_validity_route_shapes CASCADE;\n", + "\n", + " CREATE TABLE ml.trip_validity_route_shapes (\n", + " feed_version_date date NOT NULL,\n", + " shape_id text NOT NULL,\n", + " route_short_name text,\n", + " direction text NOT NULL CHECK (direction IN ('I', 'V')),\n", + " shape_geom geometry(LineString, 4326),\n", + " shape_geom_metric geometry(LineString, 31984),\n", + " shape_length_meters double precision,\n", + " PRIMARY KEY (feed_version_date, shape_id)\n", + " );\n", + "\"\"\")\n", + "\n", + "conn.execute(\"\"\"\n", + " WITH needed_shapes AS (\n", + " SELECT DISTINCT gtfs_feed_version_date AS feed_version_date,\n", + " gtfs_shape_id_i AS shape_id,\n", + " gtfs_route_short_name AS route_short_name, 'I' AS direction\n", + " FROM ml.trip_validity_route_gtfs_match WHERE gtfs_shape_id_i IS NOT NULL\n", + " UNION\n", + " SELECT DISTINCT gtfs_feed_version_date, gtfs_shape_id_v,\n", + " gtfs_route_short_name, 'V'\n", + " FROM ml.trip_validity_route_gtfs_match WHERE gtfs_shape_id_v IS NOT NULL\n", + " )\n", + " INSERT INTO ml.trip_validity_route_shapes (\n", + " feed_version_date, shape_id, route_short_name, direction,\n", + " shape_geom, shape_geom_metric, shape_length_meters\n", + " )\n", + " SELECT\n", + " s.feed_version_date,\n", + " s.shape_id,\n", + " s.route_short_name,\n", + " s.direction,\n", + " line.geom,\n", + " ST_Transform(line.geom, 31984),\n", + " ST_Length(line.geom::geography)\n", + " FROM needed_shapes s\n", + " JOIN LATERAL (\n", + " SELECT ST_MakeLine(geom ORDER BY shape_pt_sequence) AS geom\n", + " FROM silver.gtfs_shapes\n", + " WHERE feed_version_date = s.feed_version_date AND shape_id = s.shape_id\n", + " ) line ON line.geom IS NOT NULL;\n", + "\"\"\")\n", + "\n", + "conn.execute(\"\"\"\n", + " CREATE INDEX trip_validity_route_shapes_geom_idx\n", + " ON ml.trip_validity_route_shapes USING GIST (shape_geom);\n", + "\"\"\")\n", + "conn.execute(\"ANALYZE ml.trip_validity_route_shapes;\")\n", + "conn.commit()\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\"\"\"\n", + " SELECT count(*),\n", + " count(*) FILTER (WHERE direction = 'I'),\n", + " count(*) FILTER (WHERE direction = 'V')\n", + " FROM ml.trip_validity_route_shapes;\n", + " \"\"\")\n", + " print(\"shapes total / I / V:\", cur.fetchone())" + ] + }, + { + "cell_type": "markdown", + "id": "98bcea42", + "metadata": {}, + "source": [ + "## Stage 3 - `ml.trip_validity_route_schedule`\n", + "\n", + "One row per scheduled GTFS trip (a *scheduled service run*, not one of\n", + "our AFC trips — different concept, same word, deliberately named\n", + "`gtfs_trip_id` here to keep the two unambiguous) on a matched route.\n", + "`scheduled_duration_seconds` is `MAX - MIN` of each stop's\n", + "`COALESCE(departure_time, arrival_time)`, parsed manually since GTFS\n", + "times are text and can exceed `24:00:00`.\n", + "\n", + "`ml.parse_gtfs_time` is a small helper function for that parsing, kept\n", + "in the `ml` schema since it's reusable and specific to GTFS's time\n", + "format." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "e2da3a7a", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T15:16:05.507041Z", + "iopub.status.busy": "2026-08-22T15:16:05.506959Z", + "iopub.status.idle": "2026-08-22T15:19:01.351680Z", + "shell.execute_reply": "2026-08-22T15:19:01.351272Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "scheduled trips / negative durations: (186540, 0)\n" + ] + } + ], + "source": [ + "conn.execute(\"\"\"\n", + " CREATE OR REPLACE FUNCTION ml.parse_gtfs_time(t text) RETURNS integer AS $$\n", + " SELECT split_part(t, ':', 1)::int * 3600\n", + " + split_part(t, ':', 2)::int * 60\n", + " + split_part(t, ':', 3)::int;\n", + " $$ LANGUAGE sql IMMUTABLE STRICT;\n", + "\"\"\")\n", + "\n", + "conn.execute(\"\"\"\n", + " DROP TABLE IF EXISTS ml.trip_validity_route_schedule CASCADE;\n", + "\n", + " CREATE TABLE ml.trip_validity_route_schedule (\n", + " feed_version_date date NOT NULL,\n", + " gtfs_route_id text NOT NULL,\n", + " direction text NOT NULL CHECK (direction IN ('I', 'V')),\n", + " gtfs_trip_id text NOT NULL,\n", + " scheduled_duration_seconds integer,\n", + " start_hour smallint,\n", + " PRIMARY KEY (feed_version_date, gtfs_trip_id)\n", + " );\n", + "\"\"\")\n", + "\n", + "conn.execute(\"\"\"\n", + " WITH needed_trips AS (\n", + " SELECT t.feed_version_date, t.trip_id AS gtfs_trip_id,\n", + " t.route_id AS gtfs_route_id, right(t.shape_id, 1) AS direction\n", + " FROM silver.gtfs_trips t\n", + " JOIN (\n", + " SELECT DISTINCT gtfs_feed_version_date AS feed_version_date, gtfs_route_id\n", + " FROM ml.trip_validity_route_gtfs_match\n", + " WHERE gtfs_route_id IS NOT NULL\n", + " ) nr\n", + " ON nr.feed_version_date = t.feed_version_date\n", + " AND nr.gtfs_route_id = t.route_id\n", + " WHERE t.shape_id LIKE '%-I' OR t.shape_id LIKE '%-V'\n", + " ),\n", + " trip_times AS (\n", + " SELECT\n", + " nt.feed_version_date, nt.gtfs_route_id, nt.direction, nt.gtfs_trip_id,\n", + " min(ml.parse_gtfs_time(coalesce(st.departure_time, st.arrival_time)))\n", + " AS start_sec,\n", + " max(ml.parse_gtfs_time(coalesce(st.departure_time, st.arrival_time)))\n", + " AS end_sec\n", + " FROM needed_trips nt\n", + " JOIN silver.gtfs_stop_times st\n", + " ON st.feed_version_date = nt.feed_version_date\n", + " AND st.trip_id = nt.gtfs_trip_id\n", + " GROUP BY nt.feed_version_date, nt.gtfs_route_id, nt.direction, nt.gtfs_trip_id\n", + " )\n", + " INSERT INTO ml.trip_validity_route_schedule (\n", + " feed_version_date, gtfs_route_id, direction, gtfs_trip_id,\n", + " scheduled_duration_seconds, start_hour\n", + " )\n", + " SELECT\n", + " feed_version_date, gtfs_route_id, direction, gtfs_trip_id,\n", + " end_sec - start_sec,\n", + " (start_sec / 3600) % 24\n", + " FROM trip_times\n", + " WHERE start_sec IS NOT NULL AND end_sec IS NOT NULL;\n", + "\"\"\")\n", + "\n", + "conn.execute(\"\"\"\n", + " CREATE INDEX trip_validity_route_schedule_lookup_idx\n", + " ON ml.trip_validity_route_schedule\n", + " (feed_version_date, gtfs_route_id, direction);\n", + " CREATE INDEX trip_validity_route_schedule_hour_idx\n", + " ON ml.trip_validity_route_schedule\n", + " (feed_version_date, gtfs_route_id, direction, start_hour);\n", + "\"\"\")\n", + "conn.execute(\"ANALYZE ml.trip_validity_route_schedule;\")\n", + "conn.commit()\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\"\"\"\n", + " SELECT count(*), count(*) FILTER (WHERE scheduled_duration_seconds < 0)\n", + " FROM ml.trip_validity_route_schedule;\n", + " \"\"\")\n", + " print(\"scheduled trips / negative durations:\", cur.fetchone())" + ] + }, + { + "cell_type": "markdown", + "id": "stage4-stops-md", + "metadata": {}, + "source": [ + "## Stage 4 - `ml.trip_validity_route_stops`\n", + "\n", + "One row per stop, per distinct stop-pattern *variant*, per\n", + "`(feed_version_date, shape_id)` already in `trip_validity_route_shapes`.\n", + "\n", + "Stops belong to individual GTFS trips, not to shapes - there's no\n", + "`direction_id` in this feed (100% NULL) and no field of any kind that\n", + "labels which \"version\" of a route's stop list a given trip follows,\n", + "so nothing here is recoverable from a single GTFS column. Checked\n", + "directly: for the 2023-11-10 feed, 600 of 617 shapes have every trip\n", + "agreeing on one single stop sequence, but 17 have trips that\n", + "genuinely disagree - a handful with 2 distinct sequences, a few with 3\n", + "or 4 - all confirmed to be the *same* route_id, direction, and physical\n", + "shape geometry (stops sit at distance 0 from the shape in every case),\n", + "just different raw `stop_id`s attached by the source data. No calendar,\n", + "time-of-day, or `service_id` pattern explains which trips get which\n", + "list.\n", + "\n", + "Rather than collapsing that away, every distinct pattern is kept as\n", + "its own `variant_id`, ranked by how many trips actually follow it\n", + "(`n_trips`, descending) - `variant_id = 1` is always the common case;\n", + "higher numbers exist only for the 17 affected shapes and are rare by\n", + "construction. Ties (found once so far: an exact 87-vs-87 split) are\n", + "broken by comparing the stop_id sequences themselves\n", + "(`ORDER BY n_trips DESC, stop_ids ASC`), so the ordering is\n", + "deterministic across reruns without inventing anything not already in\n", + "the data." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "stage4-stops-code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T15:19:01.352593Z", + "iopub.status.busy": "2026-08-22T15:19:01.352503Z", + "iopub.status.idle": "2026-08-22T15:19:23.812846Z", + "shell.execute_reply": "2026-08-22T15:19:23.812411Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "(64635, 1864, 1949, 3258)\n" + ] + } + ], + "source": [ + "conn.execute(\"\"\"\n", + " DROP TABLE IF EXISTS ml.trip_validity_route_stops CASCADE;\n", + "\n", + " CREATE TABLE ml.trip_validity_route_stops (\n", + " feed_version_date date NOT NULL,\n", + " shape_id text NOT NULL,\n", + " variant_id smallint NOT NULL,\n", + " n_trips integer NOT NULL,\n", + " stop_sequence integer NOT NULL,\n", + " stop_id text NOT NULL,\n", + " stop_name text,\n", + " stop_lat double precision NOT NULL,\n", + " stop_lon double precision NOT NULL,\n", + " geom geometry(Point, 4326) GENERATED ALWAYS AS (\n", + " ST_SetSRID(ST_MakePoint(stop_lon, stop_lat), 4326)\n", + " ) STORED,\n", + " PRIMARY KEY (feed_version_date, shape_id, variant_id, stop_sequence)\n", + " );\n", + "\"\"\")\n", + "\n", + "conn.execute(\"\"\"\n", + " WITH trip_patterns AS (\n", + " SELECT\n", + " t.feed_version_date,\n", + " t.shape_id,\n", + " t.trip_id,\n", + " array_agg(st.stop_id ORDER BY st.stop_sequence) AS stop_ids\n", + " FROM silver.gtfs_trips t\n", + " JOIN silver.gtfs_stop_times st\n", + " ON st.trip_id = t.trip_id AND st.feed_version_date = t.feed_version_date\n", + " JOIN ml.trip_validity_route_shapes rs\n", + " ON rs.feed_version_date = t.feed_version_date AND rs.shape_id = t.shape_id\n", + " GROUP BY t.feed_version_date, t.shape_id, t.trip_id\n", + " ),\n", + " distinct_patterns AS (\n", + " SELECT feed_version_date, shape_id, stop_ids, count(*) AS n_trips\n", + " FROM trip_patterns\n", + " GROUP BY feed_version_date, shape_id, stop_ids\n", + " ),\n", + " ranked_patterns AS (\n", + " SELECT\n", + " feed_version_date, shape_id, stop_ids, n_trips,\n", + " row_number() OVER (\n", + " PARTITION BY feed_version_date, shape_id\n", + " ORDER BY n_trips DESC, stop_ids ASC\n", + " ) AS variant_id\n", + " FROM distinct_patterns\n", + " )\n", + " INSERT INTO ml.trip_validity_route_stops (\n", + " feed_version_date, shape_id, variant_id, n_trips,\n", + " stop_sequence, stop_id, stop_name, stop_lat, stop_lon\n", + " )\n", + " SELECT\n", + " rp.feed_version_date,\n", + " rp.shape_id,\n", + " rp.variant_id,\n", + " rp.n_trips,\n", + " u.ord,\n", + " u.stop_id,\n", + " s.stop_name,\n", + " s.stop_lat,\n", + " s.stop_lon\n", + " FROM ranked_patterns rp\n", + " CROSS JOIN LATERAL unnest(rp.stop_ids) WITH ORDINALITY AS u(stop_id, ord)\n", + " JOIN silver.gtfs_stops s\n", + " ON s.stop_id = u.stop_id AND s.feed_version_date = rp.feed_version_date;\n", + "\"\"\")\n", + "\n", + "conn.execute(\"\"\"\n", + " CREATE INDEX trip_validity_route_stops_geom_idx\n", + " ON ml.trip_validity_route_stops USING GIST (geom);\n", + "\"\"\")\n", + "conn.execute(\"\"\"\n", + " CREATE INDEX trip_validity_route_stops_stop_id_idx\n", + " ON ml.trip_validity_route_stops (stop_id);\n", + "\"\"\")\n", + "conn.execute(\"ANALYZE ml.trip_validity_route_stops;\")\n", + "conn.commit()\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\"\"\"\n", + " SELECT\n", + " count(*) AS rows,\n", + " count(DISTINCT (feed_version_date, shape_id)) AS shapes_covered,\n", + " count(DISTINCT (feed_version_date, shape_id, variant_id)) AS shape_variants,\n", + " count(*) FILTER (WHERE variant_id > 1) AS rows_in_rare_variants\n", + " FROM ml.trip_validity_route_stops;\n", + " \"\"\")\n", + " print(cur.fetchone())" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "209cd6a9", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T15:19:23.814027Z", + "iopub.status.busy": "2026-08-22T15:19:23.813941Z", + "iopub.status.idle": "2026-08-22T15:19:23.820985Z", + "shell.execute_reply": "2026-08-22T15:19:23.820747Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "comments applied\n" + ] + } + ], + "source": [ + "def comment_on_column(cur: psycopg.Cursor, table: str, col: str, text: str) -> None:\n", + " \"\"\"Apply a COMMENT ON COLUMN for one column via safe SQL composition.\"\"\"\n", + " cur.execute(\n", + " sql.SQL(\"COMMENT ON COLUMN ml.{}.{} IS {};\").format(\n", + " sql.Identifier(table), sql.Identifier(col), sql.Literal(text)\n", + " )\n", + " )\n", + "\n", + "\n", + "MATCH_COMMENTS = {\n", + " \"route_id\": (\"ml.trip_validity_trips.route_id (our own zero-padded route number).\"),\n", + " \"trip_date\": \"ml.trip_validity_trips.trip_date.\",\n", + " \"gtfs_feed_version_date\": (\n", + " \"Resolved GTFS feed: closest feed_version_date <= trip_date among \"\n", + " \"feeds containing this route (by route_short_name); else closest \"\n", + " \"feed_version_date > trip_date; else NULL if the route never \"\n", + " \"appears in any feed.\"\n", + " ),\n", + " \"gtfs_route_id\": (\n", + " \"The matched silver.gtfs_routes.route_id (4-digit padded, NOT the \"\n", + " \"same padding scheme as our own route_id) in the resolved feed. \"\n", + " \"NULL if unresolved.\"\n", + " ),\n", + " \"gtfs_route_short_name\": (\n", + " \"The matched silver.gtfs_routes.route_short_name (unpadded) - the \"\n", + " \"actual join key used. Stored for traceability.\"\n", + " ),\n", + " \"gtfs_shape_id_i\": (\n", + " \"silver.gtfs_trips.shape_id ending in '-I' for this route+feed, if any.\"\n", + " ),\n", + " \"gtfs_shape_id_v\": (\n", + " \"silver.gtfs_trips.shape_id ending in '-V' for this route+feed, if any.\"\n", + " ),\n", + " \"gtfs_route_has_both_directions\": (\n", + " \"True iff both an -I and a -V shape exist for this route in the \"\n", + " \"resolved feed. Note: ~13 routes (in the Nov-2023 feed) split \"\n", + " \"direction into two separate route_ids instead of one route_id \"\n", + " \"with two shapes - those show false here even though the \"\n", + " \"physical route has two directions.\"\n", + " ),\n", + "}\n", + "SHAPES_COMMENTS = {\n", + " \"feed_version_date\": \"silver.gtfs_shapes/gtfs_trips.feed_version_date, as-is.\",\n", + " \"shape_id\": (\n", + " \"silver.gtfs_shapes.shape_id, as-is. Direction is encoded as a \"\n", + " \"literal '-I'/'-V' suffix by the source data.\"\n", + " ),\n", + " \"route_short_name\": (\n", + " \"Carried over from trip_validity_route_gtfs_match for traceability.\"\n", + " ),\n", + " \"direction\": \"'I' or 'V', parsed from the shape_id suffix.\",\n", + " \"shape_geom\": (\n", + " \"ST_MakeLine(geom ORDER BY shape_pt_sequence) over \"\n", + " \"silver.gtfs_shapes points for this shape_id. SRID 4326.\"\n", + " ),\n", + " \"shape_geom_metric\": (\n", + " \"shape_geom transformed to EPSG:31984 (SIRGAS2000 / UTM 24S), for \"\n", + " \"planar distance functions with no geography overload \"\n", + " \"(Frechet/Hausdorff).\"\n", + " ),\n", + " \"shape_length_meters\": (\n", + " \"ST_Length(shape_geom::geography) - the route's total length in this direction.\"\n", + " ),\n", + "}\n", + "STOPS_COMMENTS = {\n", + " \"feed_version_date\": \"silver.gtfs_trips/gtfs_stop_times.feed_version_date, as-is.\",\n", + " \"shape_id\": (\n", + " \"silver.gtfs_trips.shape_id, as-is (matches trip_validity_route_shapes).\"\n", + " ),\n", + " \"variant_id\": (\n", + " \"Rank of this stop pattern among every distinct pattern trips on \"\n", + " \"this shape actually follow, ORDER BY n_trips DESC (ties broken \"\n", + " \"by the stop_id sequence itself, for determinism) - 1 is always \"\n", + " \"the common case. Not a GTFS field: nothing in this feed \"\n", + " \"(direction_id, trip_headsign, block_id, service_id) \"\n", + " \"distinguishes these patterns, so this is invented purely to \"\n", + " \"organize what's actually in the data, not recovered from it.\"\n", + " ),\n", + " \"n_trips\": (\n", + " \"How many silver.gtfs_trips rows on this shape follow this exact \"\n", + " \"stop_id sequence. variant_id > 1 rows are rare by construction \"\n", + " \"(17 of 617 shapes in the Nov-2023 feed have more than one).\"\n", + " ),\n", + " \"stop_sequence\": (\n", + " \"1-based position in this variant's stop order - re-numbered \"\n", + " \"here, not copied from silver.gtfs_stop_times.stop_sequence \"\n", + " \"(which can have gaps).\"\n", + " ),\n", + " \"stop_id\": \"silver.gtfs_stops.stop_id, as-is.\",\n", + " \"stop_name\": \"silver.gtfs_stops.stop_name, as-is.\",\n", + " \"stop_lat\": \"silver.gtfs_stops.stop_lat, as-is.\",\n", + " \"stop_lon\": \"silver.gtfs_stops.stop_lon, as-is.\",\n", + " \"geom\": \"GENERATED ALWAYS AS ST_SetSRID(ST_MakePoint(stop_lon, stop_lat), 4326).\",\n", + "}\n", + "SCHEDULE_COMMENTS = {\n", + " \"feed_version_date\": \"silver.gtfs_trips.feed_version_date, as-is.\",\n", + " \"gtfs_route_id\": (\n", + " \"silver.gtfs_trips.route_id, as-is (the GTFS trip's own route, \"\n", + " \"not our route_id).\"\n", + " ),\n", + " \"direction\": \"'I' or 'V', parsed from silver.gtfs_trips.shape_id suffix.\",\n", + " \"gtfs_trip_id\": (\n", + " \"silver.gtfs_trips.trip_id, as-is. NOTE: a GTFS \"\n", + " \"scheduled-service-run id, unrelated to \"\n", + " \"ml.trip_validity_trips.trip_id (our AFC fare-tap trip id) - \"\n", + " \"different concept, same word in the GTFS spec.\"\n", + " ),\n", + " \"scheduled_duration_seconds\": (\n", + " \"MAX - MIN of ml.parse_gtfs_time(COALESCE(departure_time, \"\n", + " \"arrival_time)) across this scheduled trip's \"\n", + " \"silver.gtfs_stop_times rows.\"\n", + " ),\n", + " \"start_hour\": (\n", + " \"Hour bucket (0-23) of this scheduled trip's first stop time, \"\n", + " \"computed as (start_seconds / 3600) % 24 so GTFS's >24:00:00 \"\n", + " \"late-night convention still buckets to a comparable hour-of-day.\"\n", + " ),\n", + "}\n", + "\n", + "with conn.cursor() as cur:\n", + " for col, text in MATCH_COMMENTS.items():\n", + " comment_on_column(cur, \"trip_validity_route_gtfs_match\", col, text)\n", + " for col, text in SHAPES_COMMENTS.items():\n", + " comment_on_column(cur, \"trip_validity_route_shapes\", col, text)\n", + " for col, text in SCHEDULE_COMMENTS.items():\n", + " comment_on_column(cur, \"trip_validity_route_schedule\", col, text)\n", + " for col, text in STOPS_COMMENTS.items():\n", + " comment_on_column(cur, \"trip_validity_route_stops\", col, text)\n", + "\n", + "conn.execute(\n", + " sql.SQL(\"COMMENT ON FUNCTION ml.parse_gtfs_time(text) IS {};\").format(\n", + " sql.Literal(\n", + " \"Parses a GTFS HH:MM:SS time string (H may exceed 24 for \"\n", + " \"late-night service) into seconds-from-midnight. STRICT: NULL \"\n", + " \"in, NULL out.\"\n", + " )\n", + " )\n", + ")\n", + "TABLE_COMMENTS = [\n", + " (\n", + " \"trip_validity_route_gtfs_match\",\n", + " \"Trip Validity model: resolved GTFS feed/route/shape for every \"\n", + " \"(route_id, trip_date) pair in trip_validity_trips. See \"\n", + " \"ml/trip_validity_model/notebooks/02_gtfs_materialize.ipynb.\",\n", + " ),\n", + " (\n", + " \"trip_validity_route_shapes\",\n", + " \"Trip Validity model: built shape geometries + lengths for every \"\n", + " \"shape referenced by trip_validity_route_gtfs_match. See \"\n", + " \"ml/trip_validity_model/notebooks/02_gtfs_materialize.ipynb.\",\n", + " ),\n", + " (\n", + " \"trip_validity_route_schedule\",\n", + " \"Trip Validity model: parsed scheduled-trip durations for every \"\n", + " \"matched route in trip_validity_route_gtfs_match. See \"\n", + " \"ml/trip_validity_model/notebooks/02_gtfs_materialize.ipynb.\",\n", + " ),\n", + " (\n", + " \"trip_validity_route_stops\",\n", + " \"Trip Validity model: ordered stop locations per distinct \"\n", + " \"stop-pattern variant, for every shape in \"\n", + " \"trip_validity_route_shapes. See \"\n", + " \"ml/trip_validity_model/notebooks/02_gtfs_materialize.ipynb.\",\n", + " ),\n", + "]\n", + "for table, text in TABLE_COMMENTS:\n", + " conn.execute(\n", + " sql.SQL(\"COMMENT ON TABLE ml.{} IS {};\").format(\n", + " sql.Identifier(table), sql.Literal(text)\n", + " )\n", + " )\n", + "\n", + "conn.commit()\n", + "print(\"comments applied\")" + ] + }, + { + "cell_type": "markdown", + "id": "f28b3438", + "metadata": {}, + "source": [ + "## Verification" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "ad5307c4", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T15:19:23.821906Z", + "iopub.status.busy": "2026-08-22T15:19:23.821821Z", + "iopub.status.idle": "2026-08-22T15:19:23.914023Z", + "shell.execute_reply": "2026-08-22T15:19:23.913649Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "shape: (1, 6)\n", + "┌─────────────┬─────────────────┬───────────────┬────────┬─────────────────┬────────────────────┐\n", + "│ match_pairs ┆ distinct_routes ┆ orphan_routes ┆ shapes ┆ scheduled_trips ┆ negative_scheduled │\n", + "│ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- │\n", + "│ i64 ┆ i64 ┆ i64 ┆ i64 ┆ i64 ┆ i64 │\n", + "╞═════════════╪═════════════════╪═══════════════╪════════╪═════════════════╪════════════════════╡\n", + "│ 9262 ┆ 352 ┆ 30 ┆ 1864 ┆ 186540 ┆ 0 │\n", + "└─────────────┴─────────────────┴───────────────┴────────┴─────────────────┴────────────────────┘\n", + "OK: 30 of 352 routes have no GTFS match in any feed (expected 30)\n" + ] + } + ], + "source": [ + "import polars as pl\n", + "\n", + "EXPECTED_ROUTE_COUNT = 352 # distinct route_ids in November 2023 trips\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\"\"\"\n", + " SELECT\n", + " (SELECT count(*) FROM ml.trip_validity_route_gtfs_match) AS match_pairs,\n", + " (SELECT count(DISTINCT route_id) FROM ml.trip_validity_route_gtfs_match)\n", + " AS distinct_routes,\n", + " (SELECT count(DISTINCT route_id) FROM ml.trip_validity_route_gtfs_match\n", + " WHERE gtfs_feed_version_date IS NULL) AS orphan_routes,\n", + " (SELECT count(*) FROM ml.trip_validity_route_shapes) AS shapes,\n", + " (SELECT count(*) FROM ml.trip_validity_route_schedule) AS scheduled_trips,\n", + " (SELECT count(*) FROM ml.trip_validity_route_schedule\n", + " WHERE scheduled_duration_seconds < 0) AS negative_scheduled;\n", + " \"\"\")\n", + " cols = [d.name for d in cur.description]\n", + " row = cur.fetchone()\n", + "\n", + "summary = pl.DataFrame([dict(zip(cols, row, strict=True))])\n", + "print(summary)\n", + "\n", + "if summary[\"negative_scheduled\"][0] != 0:\n", + " msg = \"found negative scheduled durations - GTFS time parsing bug\"\n", + " raise AssertionError(msg)\n", + "if summary[\"distinct_routes\"][0] != EXPECTED_ROUTE_COUNT:\n", + " msg = f\"expected {EXPECTED_ROUTE_COUNT} distinct routes from November 2023 trips\"\n", + " raise AssertionError(msg)\n", + "print(\n", + " f\"OK: {summary['orphan_routes'][0]} of {summary['distinct_routes'][0]} routes \"\n", + " \"have no GTFS match in any feed (expected 30)\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "verify-stops-md", + "metadata": {}, + "source": [ + "### `ml.trip_validity_route_stops` checks" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "verify-stops-code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T15:19:23.914859Z", + "iopub.status.busy": "2026-08-22T15:19:23.914789Z", + "iopub.status.idle": "2026-08-22T15:19:30.077537Z", + "shell.execute_reply": "2026-08-22T15:19:30.077081Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "shape: (1, 4)\n", + "┌───────┬────────────────┬────────────────┬────────────────────────────┐\n", + "│ rows ┆ shapes_covered ┆ shape_variants ┆ shapes_with_extra_variants │\n", + "│ --- ┆ --- ┆ --- ┆ --- │\n", + "│ i64 ┆ i64 ┆ i64 ┆ i64 │\n", + "╞═══════╪════════════════╪════════════════╪════════════════════════════╡\n", + "│ 64635 ┆ 1864 ┆ 1949 ┆ 54 │\n", + "└───────┴────────────────┴────────────────┴────────────────────────────┘\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "OK: all 1864 shapes covered, no stops dropped, 54 shapes have more than one variant\n" + ] + } + ], + "source": [ + "with conn.cursor() as cur:\n", + " cur.execute(\"\"\"\n", + " SELECT count(DISTINCT (feed_version_date, shape_id))\n", + " FROM ml.trip_validity_route_shapes;\n", + " \"\"\")\n", + " EXPECTED_SHAPES_COVERED = cur.fetchone()[0]\n", + "\n", + " cur.execute(\"\"\"\n", + " SELECT\n", + " count(*) AS rows,\n", + " count(DISTINCT (feed_version_date, shape_id)) AS shapes_covered,\n", + " count(DISTINCT (feed_version_date, shape_id, variant_id)) AS shape_variants,\n", + " count(DISTINCT (feed_version_date, shape_id)) FILTER (WHERE variant_id > 1)\n", + " AS shapes_with_extra_variants\n", + " FROM ml.trip_validity_route_stops;\n", + " \"\"\")\n", + " cols = [d.name for d in cur.description]\n", + " stops_summary = pl.DataFrame([dict(zip(cols, cur.fetchone(), strict=True))])\n", + " print(stops_summary)\n", + "\n", + " cur.execute(\"\"\"\n", + " WITH trip_patterns AS (\n", + " SELECT t.feed_version_date, t.shape_id, t.trip_id,\n", + " array_agg(st.stop_id ORDER BY st.stop_sequence) AS stop_ids\n", + " FROM silver.gtfs_trips t\n", + " JOIN silver.gtfs_stop_times st\n", + " ON st.trip_id = t.trip_id AND st.feed_version_date = t.feed_version_date\n", + " JOIN ml.trip_validity_route_shapes rs\n", + " ON rs.feed_version_date = t.feed_version_date\n", + " AND rs.shape_id = t.shape_id\n", + " GROUP BY t.feed_version_date, t.shape_id, t.trip_id\n", + " ),\n", + " distinct_patterns AS (\n", + " SELECT feed_version_date, shape_id, stop_ids\n", + " FROM trip_patterns GROUP BY feed_version_date, shape_id, stop_ids\n", + " )\n", + " SELECT sum(array_length(stop_ids, 1)) FROM distinct_patterns;\n", + " \"\"\")\n", + " expected_rows = cur.fetchone()[0]\n", + "\n", + "if stops_summary[\"shapes_covered\"][0] != EXPECTED_SHAPES_COVERED:\n", + " msg = (\n", + " f\"expected all {EXPECTED_SHAPES_COVERED} route_shapes shapes covered, \"\n", + " f\"got {stops_summary['shapes_covered'][0]}\"\n", + " )\n", + " raise AssertionError(msg)\n", + "if stops_summary[\"rows\"][0] != expected_rows:\n", + " msg = (\n", + " f\"expected {expected_rows} stop rows (every stop_id resolved against \"\n", + " f\"silver.gtfs_stops), got {stops_summary['rows'][0]} - some stop_ids \"\n", + " \"silently dropped by the join to gtfs_stops\"\n", + " )\n", + " raise AssertionError(msg)\n", + "print(\n", + " f\"OK: all {EXPECTED_SHAPES_COVERED} shapes covered, no stops dropped, \"\n", + " f\"{stops_summary['shapes_with_extra_variants'][0]} shapes have \"\n", + " \"more than one variant\"\n", + ")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (opa-database)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/ml/trip_validity_model/notebooks/03_trip_metrics.ipynb b/ml/trip_validity_model/notebooks/03_trip_metrics.ipynb new file mode 100644 index 0000000..14cb2cd --- /dev/null +++ b/ml/trip_validity_model/notebooks/03_trip_metrics.ipynb @@ -0,0 +1,1342 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "a78335f1", + "metadata": {}, + "source": [ + "# 03 - Build `ml.trip_validity_trip_metrics`\n", + "\n", + "One row per trip, reading **only** from `ml.trip_validity_trips`,\n", + "`ml.trip_validity_trip_fares`, `ml.trip_validity_route_gtfs_match`,\n", + "`ml.trip_validity_route_shapes`, and `ml.trip_validity_route_schedule` —\n", + "never `silver`. Built as a sequence of independently-committed `UPDATE`\n", + "stages (mirroring notebooks 01/02) rather than one giant `INSERT`, so a\n", + "bug in one stage doesn't force redoing the others.\n", + "\n", + "Column-level provenance is documented via `COMMENT ON COLUMN` at the end,\n", + "same as the other two notebooks." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "3a42043d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-14T21:17:15.158829Z", + "iopub.status.busy": "2026-08-14T21:17:15.158759Z", + "iopub.status.idle": "2026-08-14T21:17:15.186340Z", + "shell.execute_reply": "2026-08-14T21:17:15.186086Z" + } + }, + "outputs": [], + "source": [ + "import os\n", + "from pathlib import Path\n", + "\n", + "import psycopg\n", + "from psycopg import sql" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "c51be309", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-14T21:17:15.187431Z", + "iopub.status.busy": "2026-08-14T21:17:15.187366Z", + "iopub.status.idle": "2026-08-14T21:17:15.189779Z", + "shell.execute_reply": "2026-08-14T21:17:15.189514Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "'/home/victor/repos/opa-database'" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "_root = Path.cwd()\n", + "while not (_root / \"pyproject.toml\").exists():\n", + " _root = _root.parent\n", + "os.chdir(_root)\n", + "os.environ.setdefault(\"RAW_DATA_ROOT\", str(_root))" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "b3ef4035", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-14T21:17:15.190492Z", + "iopub.status.busy": "2026-08-14T21:17:15.190432Z", + "iopub.status.idle": "2026-08-14T21:17:15.239343Z", + "shell.execute_reply": "2026-08-14T21:17:15.238960Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "connected\n" + ] + } + ], + "source": [ + "from opa_database.config import settings\n", + "\n", + "conn = psycopg.connect(settings.db_dsn)\n", + "print(\"connected\")" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "150bbb2e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-14T21:17:15.240339Z", + "iopub.status.busy": "2026-08-14T21:17:15.240267Z", + "iopub.status.idle": "2026-08-14T21:17:19.308507Z", + "shell.execute_reply": "2026-08-14T21:17:19.308138Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "base rows: 940988\n" + ] + } + ], + "source": [ + "conn.execute(\"\"\"\n", + " DROP TABLE IF EXISTS ml.trip_validity_trip_metrics CASCADE;\n", + "\n", + " CREATE TABLE ml.trip_validity_trip_metrics (\n", + " trip_id bigint PRIMARY KEY REFERENCES ml.trip_validity_trips (trip_id),\n", + " route_id text NOT NULL,\n", + " route_direction integer NOT NULL,\n", + " trip_duration_seconds bigint,\n", + " trip_fare_count integer NOT NULL,\n", + "\n", + " trip_distance_meters double precision,\n", + " trip_points_standard_distance_meters double precision,\n", + "\n", + " fare_gap_avg_seconds double precision,\n", + " fare_gap_stddev_seconds double precision,\n", + " fare_span_seconds double precision,\n", + "\n", + " gtfs_feed_version_date date,\n", + " gtfs_route_short_name text,\n", + " gtfs_route_has_both_directions boolean,\n", + " gtfs_shape_id_i text,\n", + " gtfs_shape_id_v text,\n", + "\n", + " trip_start_distance_to_i_start_meters double precision,\n", + " trip_start_distance_to_v_start_meters double precision,\n", + " trip_end_distance_to_i_end_meters double precision,\n", + " trip_end_distance_to_v_end_meters double precision,\n", + " path_frechet_distance_to_i_meters double precision,\n", + " path_frechet_distance_to_v_meters double precision,\n", + " path_hausdorff_distance_to_i_meters double precision,\n", + " path_hausdorff_distance_to_v_meters double precision,\n", + "\n", + " route_i_length_meters double precision,\n", + " route_v_length_meters double precision,\n", + "\n", + " route_i_scheduled_duration_avg_seconds double precision,\n", + " route_i_scheduled_duration_n_trips integer,\n", + " route_v_scheduled_duration_avg_seconds double precision,\n", + " route_v_scheduled_duration_n_trips integer,\n", + " route_i_scheduled_duration_avg_seconds_at_hour double precision,\n", + " route_i_scheduled_duration_n_trips_at_hour integer,\n", + " route_v_scheduled_duration_avg_seconds_at_hour double precision,\n", + " route_v_scheduled_duration_n_trips_at_hour integer,\n", + "\n", + " route_avg_trip_duration_seconds_loo double precision,\n", + " route_avg_trip_duration_n_trips_loo integer,\n", + " route_direction_avg_trip_duration_seconds_loo double precision,\n", + " route_direction_avg_trip_duration_n_trips_loo integer,\n", + " route_hour_avg_trip_duration_seconds_loo double precision,\n", + " route_hour_avg_trip_duration_n_trips_loo integer,\n", + " route_direction_hour_avg_trip_duration_seconds_loo double precision,\n", + " route_direction_hour_avg_trip_duration_n_trips_loo integer\n", + " );\n", + "\"\"\")\n", + "\n", + "conn.execute(\"\"\"\n", + " INSERT INTO ml.trip_validity_trip_metrics (\n", + " trip_id, route_id, route_direction, trip_duration_seconds, trip_fare_count\n", + " )\n", + " SELECT trip_id, route_id, route_direction, trip_duration_seconds,\n", + " trip_fare_count\n", + " FROM ml.trip_validity_trips;\n", + "\"\"\")\n", + "conn.commit()\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\"SELECT count(*) FROM ml.trip_validity_trip_metrics;\")\n", + " print(\"base rows:\", cur.fetchone()[0])" + ] + }, + { + "cell_type": "markdown", + "id": "b722953e", + "metadata": {}, + "source": [ + "## Stage 1 - trip geometry: distance traveled and point cohesion\n", + "\n", + "`trip_points_standard_distance_meters` is the spatial-statistics analogue\n", + "of standard deviation: RMS distance of every geo-tagged fare from the\n", + "trip's own centroid. A trip with a single geo-tagged fare gets `0` (its\n", + "one point has zero distance from itself as centroid) — mathematically\n", + "valid, not an error case. `NULL` only when a trip has zero geo-tagged\n", + "fares (nothing to compute)." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "ab4e9896", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-14T21:17:19.309631Z", + "iopub.status.busy": "2026-08-14T21:17:19.309541Z", + "iopub.status.idle": "2026-08-14T21:17:51.983297Z", + "shell.execute_reply": "2026-08-14T21:17:51.982895Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "non-null trip_distance / cohesion: (899299, 899299)\n" + ] + } + ], + "source": [ + "conn.execute(\"\"\"\n", + " UPDATE ml.trip_validity_trip_metrics tm\n", + " SET trip_distance_meters = ST_Length(t.trip_path::geography)\n", + " FROM ml.trip_validity_trips t\n", + " WHERE t.trip_id = tm.trip_id;\n", + "\"\"\")\n", + "\n", + "conn.execute(\"\"\"\n", + " WITH trip_centroids AS (\n", + " SELECT trip_id, ST_Centroid(ST_Collect(geom)) AS centroid\n", + " FROM ml.trip_validity_trip_fares\n", + " WHERE geom IS NOT NULL\n", + " GROUP BY trip_id\n", + " ),\n", + " trip_cohesion AS (\n", + " SELECT f.trip_id,\n", + " sqrt(avg(\n", + " ST_Distance(f.geom::geography, c.centroid::geography) ^ 2\n", + " )) AS standard_distance\n", + " FROM ml.trip_validity_trip_fares f\n", + " JOIN trip_centroids c ON c.trip_id = f.trip_id\n", + " WHERE f.geom IS NOT NULL\n", + " GROUP BY f.trip_id\n", + " )\n", + " UPDATE ml.trip_validity_trip_metrics tm\n", + " SET trip_points_standard_distance_meters = tc.standard_distance\n", + " FROM trip_cohesion tc\n", + " WHERE tc.trip_id = tm.trip_id;\n", + "\"\"\")\n", + "conn.commit()\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\"\"\"\n", + " SELECT count(*) FILTER (WHERE trip_distance_meters IS NOT NULL),\n", + " count(*) FILTER (WHERE trip_points_standard_distance_meters IS NOT NULL)\n", + " FROM ml.trip_validity_trip_metrics;\n", + " \"\"\")\n", + " print(\"non-null trip_distance / cohesion:\", cur.fetchone())" + ] + }, + { + "cell_type": "markdown", + "id": "95df0bfd", + "metadata": {}, + "source": [ + "## Stage 2 - fare timing (all fares, not just geo-tagged)\n", + "\n", + "`fare_gap_avg_seconds`/`fare_gap_stddev_seconds` are `NULL` for a\n", + "1-fare trip (no gap exists to average). `fare_span_seconds` is `0`\n", + "(not `NULL`) for a 1-fare trip — a well-defined zero-length span." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "cc6a2741", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-14T21:17:51.984433Z", + "iopub.status.busy": "2026-08-14T21:17:51.984357Z", + "iopub.status.idle": "2026-08-14T21:18:04.393812Z", + "shell.execute_reply": "2026-08-14T21:18:04.393381Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "non-null gap avg / zero span / 1-fare trips: (653416, 287572, 287572)\n" + ] + } + ], + "source": [ + "conn.execute(\"\"\"\n", + " WITH ordered_fares AS (\n", + " SELECT trip_id, boarding_at,\n", + " boarding_at - LAG(boarding_at)\n", + " OVER (PARTITION BY trip_id ORDER BY boarding_at) AS gap\n", + " FROM ml.trip_validity_trip_fares\n", + " ),\n", + " fare_stats AS (\n", + " SELECT trip_id,\n", + " avg(EXTRACT(EPOCH FROM gap)) AS fare_gap_avg_seconds,\n", + " stddev(EXTRACT(EPOCH FROM gap)) AS fare_gap_stddev_seconds,\n", + " EXTRACT(EPOCH FROM (max(boarding_at) - min(boarding_at)))\n", + " AS fare_span_seconds\n", + " FROM ordered_fares\n", + " GROUP BY trip_id\n", + " )\n", + " UPDATE ml.trip_validity_trip_metrics tm\n", + " SET fare_gap_avg_seconds = fs.fare_gap_avg_seconds,\n", + " fare_gap_stddev_seconds = fs.fare_gap_stddev_seconds,\n", + " fare_span_seconds = fs.fare_span_seconds\n", + " FROM fare_stats fs\n", + " WHERE fs.trip_id = tm.trip_id;\n", + "\"\"\")\n", + "conn.commit()\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\"\"\"\n", + " SELECT count(*) FILTER (WHERE fare_gap_avg_seconds IS NOT NULL),\n", + " count(*) FILTER (WHERE fare_span_seconds = 0),\n", + " count(*) FILTER (WHERE trip_fare_count = 1)\n", + " FROM ml.trip_validity_trip_metrics;\n", + " \"\"\")\n", + " print(\"non-null gap avg / zero span / 1-fare trips:\", cur.fetchone())" + ] + }, + { + "cell_type": "markdown", + "id": "6afc019e", + "metadata": {}, + "source": [ + "## Stage 3 - GTFS resolution passthrough\n", + "\n", + "Straight copy from `ml.trip_validity_route_gtfs_match`, joined via\n", + "`(route_id, trip_date)` (through `ml.trip_validity_trips` for\n", + "`trip_date`, which isn't stored on this table). Everything downstream\n", + "that needs the resolved feed/shape reads it from here rather than\n", + "rejoining `route_gtfs_match` again." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "86af3dc7", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-14T21:18:04.394945Z", + "iopub.status.busy": "2026-08-14T21:18:04.394858Z", + "iopub.status.idle": "2026-08-14T21:18:08.501278Z", + "shell.execute_reply": "2026-08-14T21:18:08.500851Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "trips with resolved feed / with both directions: (934155, 891158)\n" + ] + } + ], + "source": [ + "conn.execute(\"\"\"\n", + " UPDATE ml.trip_validity_trip_metrics tm\n", + " SET gtfs_feed_version_date = m.gtfs_feed_version_date,\n", + " gtfs_route_short_name = m.gtfs_route_short_name,\n", + " gtfs_route_has_both_directions = m.gtfs_route_has_both_directions,\n", + " gtfs_shape_id_i = m.gtfs_shape_id_i,\n", + " gtfs_shape_id_v = m.gtfs_shape_id_v\n", + " FROM ml.trip_validity_trips t\n", + " JOIN ml.trip_validity_route_gtfs_match m\n", + " ON m.route_id = t.route_id AND m.trip_date = t.trip_date\n", + " WHERE t.trip_id = tm.trip_id;\n", + "\"\"\")\n", + "conn.commit()\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\"\"\"\n", + " SELECT count(*) FILTER (WHERE gtfs_feed_version_date IS NOT NULL),\n", + " count(*) FILTER (WHERE gtfs_route_has_both_directions)\n", + " FROM ml.trip_validity_trip_metrics;\n", + " \"\"\")\n", + " print(\"trips with resolved feed / with both directions:\", cur.fetchone())" + ] + }, + { + "cell_type": "markdown", + "id": "04e32e9a", + "metadata": {}, + "source": [ + "## Stage 4 - path-vs-route comparison (I and V, regardless of the row's own direction)\n", + "\n", + "Joins in `ml.trip_validity_route_shapes` via the shape ids set in Stage\n", + "3. `trip_path` is projected to `EPSG:31984` **once per row**, in its own\n", + "CTE, and reused for all four Frechet/Hausdorff calls below it — an\n", + "earlier version of this cell repeated the `ST_Transform` call inline for\n", + "each of the four, which Postgres does not automatically deduplicate\n", + "across a target list, quadrupling that part of the work for no reason.\n", + "`ST_FrechetDistance`/`ST_HausdorffDistance` have no `geography`\n", + "overload, which is why the projection is needed at all.\n", + "\n", + "All eight columns are `NULL` whenever `trip_path` is `NULL` or the\n", + "corresponding shape doesn't exist for this route — PostGIS's distance\n", + "functions are strict (`NULL` in, `NULL` out), so no extra `CASE` guards\n", + "are needed.\n", + "\n", + "This is the most compute-heavy stage by far: up to ~1.8M Frechet +\n", + "Hausdorff evaluations against shapes with ~100-300 points each. A\n", + "2,000-row timing sample came out to ~430μs/row, i.e. roughly 6-7 minutes\n", + "for all 899,299 trips with a path — there's no index that can accelerate\n", + "an arbitrary pairwise curve-distance calculation (unlike the GiST\n", + "indexes used elsewhere in this project for spatial *filtering*), so this\n", + "is just the real cost of the computation, not a stuck or misconfigured\n", + "query." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "86758341", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-14T21:18:08.502325Z", + "iopub.status.busy": "2026-08-14T21:18:08.502238Z", + "iopub.status.idle": "2026-08-14T21:22:25.251607Z", + "shell.execute_reply": "2026-08-14T21:22:25.251290Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "non-null frechet-to-I / frechet-to-V: (873954, 877493)\n" + ] + } + ], + "source": [ + "conn.execute(\"\"\"\n", + " WITH trip_paths AS (\n", + " SELECT tm2.trip_id, t.trip_path,\n", + " ST_Transform(t.trip_path, 31984) AS trip_path_metric\n", + " FROM ml.trip_validity_trip_metrics tm2\n", + " JOIN ml.trip_validity_trips t ON t.trip_id = tm2.trip_id\n", + " WHERE t.trip_path IS NOT NULL\n", + " ),\n", + " path_compare AS (\n", + " SELECT\n", + " tp.trip_id,\n", + " ST_Distance(\n", + " ST_StartPoint(tp.trip_path)::geography,\n", + " ST_StartPoint(si.shape_geom)::geography\n", + " ) AS d_start_i,\n", + " ST_Distance(\n", + " ST_StartPoint(tp.trip_path)::geography,\n", + " ST_StartPoint(sv.shape_geom)::geography\n", + " ) AS d_start_v,\n", + " ST_Distance(\n", + " ST_EndPoint(tp.trip_path)::geography,\n", + " ST_EndPoint(si.shape_geom)::geography\n", + " ) AS d_end_i,\n", + " ST_Distance(\n", + " ST_EndPoint(tp.trip_path)::geography,\n", + " ST_EndPoint(sv.shape_geom)::geography\n", + " ) AS d_end_v,\n", + " ST_FrechetDistance(tp.trip_path_metric, si.shape_geom_metric) AS frechet_i,\n", + " ST_FrechetDistance(tp.trip_path_metric, sv.shape_geom_metric) AS frechet_v,\n", + " ST_HausdorffDistance(tp.trip_path_metric, si.shape_geom_metric)\n", + " AS hausdorff_i,\n", + " ST_HausdorffDistance(tp.trip_path_metric, sv.shape_geom_metric)\n", + " AS hausdorff_v\n", + " FROM trip_paths tp\n", + " JOIN ml.trip_validity_trip_metrics tm2 ON tm2.trip_id = tp.trip_id\n", + " LEFT JOIN ml.trip_validity_route_shapes si\n", + " ON si.feed_version_date = tm2.gtfs_feed_version_date\n", + " AND si.shape_id = tm2.gtfs_shape_id_i\n", + " LEFT JOIN ml.trip_validity_route_shapes sv\n", + " ON sv.feed_version_date = tm2.gtfs_feed_version_date\n", + " AND sv.shape_id = tm2.gtfs_shape_id_v\n", + " )\n", + " UPDATE ml.trip_validity_trip_metrics tm\n", + " SET trip_start_distance_to_i_start_meters = x.d_start_i,\n", + " trip_start_distance_to_v_start_meters = x.d_start_v,\n", + " trip_end_distance_to_i_end_meters = x.d_end_i,\n", + " trip_end_distance_to_v_end_meters = x.d_end_v,\n", + " path_frechet_distance_to_i_meters = x.frechet_i,\n", + " path_frechet_distance_to_v_meters = x.frechet_v,\n", + " path_hausdorff_distance_to_i_meters = x.hausdorff_i,\n", + " path_hausdorff_distance_to_v_meters = x.hausdorff_v\n", + " FROM path_compare x\n", + " WHERE x.trip_id = tm.trip_id;\n", + "\"\"\")\n", + "conn.commit()\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\"\"\"\n", + " SELECT count(*) FILTER (WHERE path_frechet_distance_to_i_meters IS NOT NULL),\n", + " count(*) FILTER (WHERE path_frechet_distance_to_v_meters IS NOT NULL)\n", + " FROM ml.trip_validity_trip_metrics;\n", + " \"\"\")\n", + " print(\"non-null frechet-to-I / frechet-to-V:\", cur.fetchone())" + ] + }, + { + "cell_type": "markdown", + "id": "35c04c36", + "metadata": {}, + "source": [ + "## Stage 5 - route length and scheduled-duration aggregates\n", + "\n", + "`route_i/v_length_meters` come straight from `route_shapes`.\n", + "Scheduled-duration aggregates are computed once over\n", + "`ml.trip_validity_route_schedule` (grouped by `(feed, gtfs_route_id,\n", + "direction)` and, separately, `(feed, gtfs_route_id, direction,\n", + "start_hour)`), then joined onto every row — not recomputed per row.\n", + "Each average carries its own row-count column\n", + "(`..._n_trips[_at_hour]`); `NULL` when no scheduled GTFS trips exist for\n", + "that combination." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "a245a506", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-14T21:22:25.252733Z", + "iopub.status.busy": "2026-08-14T21:22:25.252647Z", + "iopub.status.idle": "2026-08-14T21:22:49.292056Z", + "shell.execute_reply": "2026-08-14T21:22:49.291632Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "non-null route_i_length / sched_avg / sched_avg_at_hour: (910838, 910838, 893070)\n" + ] + } + ], + "source": [ + "conn.execute(\"\"\"\n", + " UPDATE ml.trip_validity_trip_metrics tm\n", + " SET route_i_length_meters = si.shape_length_meters,\n", + " route_v_length_meters = sv.shape_length_meters\n", + " FROM ml.trip_validity_trip_metrics tm2\n", + " LEFT JOIN ml.trip_validity_route_shapes si\n", + " ON si.feed_version_date = tm2.gtfs_feed_version_date\n", + " AND si.shape_id = tm2.gtfs_shape_id_i\n", + " LEFT JOIN ml.trip_validity_route_shapes sv\n", + " ON sv.feed_version_date = tm2.gtfs_feed_version_date\n", + " AND sv.shape_id = tm2.gtfs_shape_id_v\n", + " WHERE tm2.trip_id = tm.trip_id;\n", + "\"\"\")\n", + "\n", + "conn.execute(\"\"\"\n", + " WITH schedule_overall AS (\n", + " SELECT feed_version_date, gtfs_route_id, direction,\n", + " avg(scheduled_duration_seconds) AS avg_dur, count(*) AS n\n", + " FROM ml.trip_validity_route_schedule\n", + " GROUP BY feed_version_date, gtfs_route_id, direction\n", + " ),\n", + " schedule_hour AS (\n", + " SELECT feed_version_date, gtfs_route_id, direction, start_hour,\n", + " avg(scheduled_duration_seconds) AS avg_dur, count(*) AS n\n", + " FROM ml.trip_validity_route_schedule\n", + " GROUP BY feed_version_date, gtfs_route_id, direction, start_hour\n", + " ),\n", + " row_context AS (\n", + " SELECT tm2.trip_id, tm2.gtfs_feed_version_date, t.trip_hour, m.gtfs_route_id\n", + " FROM ml.trip_validity_trip_metrics tm2\n", + " JOIN ml.trip_validity_trips t ON t.trip_id = tm2.trip_id\n", + " JOIN ml.trip_validity_route_gtfs_match m\n", + " ON m.route_id = t.route_id AND m.trip_date = t.trip_date\n", + " ),\n", + " sched_join AS (\n", + " SELECT\n", + " rc.trip_id,\n", + " so_i.avg_dur AS route_i_avg, so_i.n AS route_i_n,\n", + " so_v.avg_dur AS route_v_avg, so_v.n AS route_v_n,\n", + " sh_i.avg_dur AS route_i_avg_hr, sh_i.n AS route_i_n_hr,\n", + " sh_v.avg_dur AS route_v_avg_hr, sh_v.n AS route_v_n_hr\n", + " FROM row_context rc\n", + " LEFT JOIN schedule_overall so_i\n", + " ON so_i.feed_version_date = rc.gtfs_feed_version_date\n", + " AND so_i.gtfs_route_id = rc.gtfs_route_id AND so_i.direction = 'I'\n", + " LEFT JOIN schedule_overall so_v\n", + " ON so_v.feed_version_date = rc.gtfs_feed_version_date\n", + " AND so_v.gtfs_route_id = rc.gtfs_route_id AND so_v.direction = 'V'\n", + " LEFT JOIN schedule_hour sh_i\n", + " ON sh_i.feed_version_date = rc.gtfs_feed_version_date\n", + " AND sh_i.gtfs_route_id = rc.gtfs_route_id AND sh_i.direction = 'I'\n", + " AND sh_i.start_hour = rc.trip_hour\n", + " LEFT JOIN schedule_hour sh_v\n", + " ON sh_v.feed_version_date = rc.gtfs_feed_version_date\n", + " AND sh_v.gtfs_route_id = rc.gtfs_route_id AND sh_v.direction = 'V'\n", + " AND sh_v.start_hour = rc.trip_hour\n", + " )\n", + " UPDATE ml.trip_validity_trip_metrics tm\n", + " SET route_i_scheduled_duration_avg_seconds = sj.route_i_avg,\n", + " route_i_scheduled_duration_n_trips = sj.route_i_n,\n", + " route_v_scheduled_duration_avg_seconds = sj.route_v_avg,\n", + " route_v_scheduled_duration_n_trips = sj.route_v_n,\n", + " route_i_scheduled_duration_avg_seconds_at_hour = sj.route_i_avg_hr,\n", + " route_i_scheduled_duration_n_trips_at_hour = sj.route_i_n_hr,\n", + " route_v_scheduled_duration_avg_seconds_at_hour = sj.route_v_avg_hr,\n", + " route_v_scheduled_duration_n_trips_at_hour = sj.route_v_n_hr\n", + " FROM sched_join sj\n", + " WHERE sj.trip_id = tm.trip_id;\n", + "\"\"\")\n", + "conn.commit()\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\"\"\"\n", + " SELECT count(*) FILTER (WHERE route_i_length_meters IS NOT NULL),\n", + " count(*) FILTER (\n", + " WHERE route_i_scheduled_duration_avg_seconds IS NOT NULL\n", + " ),\n", + " count(*) FILTER (\n", + " WHERE route_i_scheduled_duration_avg_seconds_at_hour IS NOT NULL\n", + " )\n", + " FROM ml.trip_validity_trip_metrics;\n", + " \"\"\")\n", + " print(\"non-null route_i_length / sched_avg / sched_avg_at_hour:\", cur.fetchone())" + ] + }, + { + "cell_type": "markdown", + "id": "a5d09e5b", + "metadata": {}, + "source": [ + "## Stage 6 - leave-one-out historical trip-duration aggregates\n", + "\n", + "For each of the four groupings (route / route+direction / route+hour /\n", + "route+direction+hour), the average `trip_duration_seconds` **excludes\n", + "the row's own value** — computed via `SUM`/`COUNT` window functions over\n", + "the whole group, then subtracting the row's own contribution before\n", + "dividing. `COUNT`/`SUM` already skip `NULL` durations (the 9\n", + "Delphi-sentinel trips), so a row with a `NULL` own-duration has nothing\n", + "to subtract — its LOO average is just the plain group average.\n", + "\n", + "`NULL` (not `0` or an error) whenever removing the row's own\n", + "contribution leaves zero other trips in that group — e.g. a route with\n", + "only one trip all month. Each average has a matching `..._n_trips_loo`\n", + "column recording exactly how many *other* trips it was computed from, so\n", + "`NULL` vs. \"computed from 1 trip\" vs. \"computed from 500 trips\" stays\n", + "distinguishable downstream.\n", + "\n", + "This is unrelated to train/test leakage (no split exists yet on this\n", + "dataset) — it's purely about not letting a trip's own duration count\n", + "toward \"the average duration for trips like this,\" at the user's\n", + "request." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "96e687b1", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-14T21:22:49.293216Z", + "iopub.status.busy": "2026-08-14T21:22:49.293112Z", + "iopub.status.idle": "2026-08-14T21:22:58.937148Z", + "shell.execute_reply": "2026-08-14T21:22:58.936772Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "route-level LOO nulls / finest-grain LOO nulls / min n used: (8, 238, 0)\n" + ] + } + ], + "source": [ + "conn.execute(\"\"\"\n", + " WITH loo AS (\n", + " SELECT\n", + " trip_id, trip_duration_seconds,\n", + " SUM(trip_duration_seconds) OVER (PARTITION BY route_id) AS route_sum,\n", + " COUNT(trip_duration_seconds) OVER (PARTITION BY route_id) AS route_n,\n", + " SUM(trip_duration_seconds)\n", + " OVER (PARTITION BY route_id, route_direction) AS route_dir_sum,\n", + " COUNT(trip_duration_seconds)\n", + " OVER (PARTITION BY route_id, route_direction) AS route_dir_n,\n", + " SUM(trip_duration_seconds)\n", + " OVER (PARTITION BY route_id, trip_hour) AS route_hour_sum,\n", + " COUNT(trip_duration_seconds)\n", + " OVER (PARTITION BY route_id, trip_hour) AS route_hour_n,\n", + " SUM(trip_duration_seconds)\n", + " OVER (PARTITION BY route_id, route_direction, trip_hour)\n", + " AS route_dir_hour_sum,\n", + " COUNT(trip_duration_seconds)\n", + " OVER (PARTITION BY route_id, route_direction, trip_hour)\n", + " AS route_dir_hour_n\n", + " FROM ml.trip_validity_trips\n", + " ),\n", + " loo_calc AS (\n", + " SELECT\n", + " trip_id,\n", + " CASE WHEN trip_duration_seconds IS NULL\n", + " THEN CASE WHEN route_n > 0\n", + " THEN route_sum::double precision / route_n END\n", + " ELSE CASE WHEN route_n > 1\n", + " THEN (route_sum - trip_duration_seconds)\n", + " ::double precision / (route_n - 1)\n", + " END\n", + " END AS route_avg_loo,\n", + " CASE WHEN trip_duration_seconds IS NULL\n", + " THEN route_n ELSE route_n - 1 END AS route_n_loo,\n", + "\n", + " CASE WHEN trip_duration_seconds IS NULL\n", + " THEN CASE WHEN route_dir_n > 0\n", + " THEN route_dir_sum::double precision / route_dir_n END\n", + " ELSE CASE WHEN route_dir_n > 1\n", + " THEN (route_dir_sum - trip_duration_seconds)\n", + " ::double precision / (route_dir_n - 1)\n", + " END\n", + " END AS route_dir_avg_loo,\n", + " CASE WHEN trip_duration_seconds IS NULL\n", + " THEN route_dir_n ELSE route_dir_n - 1 END AS route_dir_n_loo,\n", + "\n", + " CASE WHEN trip_duration_seconds IS NULL\n", + " THEN CASE WHEN route_hour_n > 0\n", + " THEN route_hour_sum::double precision / route_hour_n END\n", + " ELSE CASE WHEN route_hour_n > 1\n", + " THEN (route_hour_sum - trip_duration_seconds)\n", + " ::double precision / (route_hour_n - 1)\n", + " END\n", + " END AS route_hour_avg_loo,\n", + " CASE WHEN trip_duration_seconds IS NULL\n", + " THEN route_hour_n ELSE route_hour_n - 1 END AS route_hour_n_loo,\n", + "\n", + " CASE WHEN trip_duration_seconds IS NULL\n", + " THEN CASE WHEN route_dir_hour_n > 0\n", + " THEN route_dir_hour_sum::double precision\n", + " / route_dir_hour_n END\n", + " ELSE CASE WHEN route_dir_hour_n > 1\n", + " THEN (route_dir_hour_sum - trip_duration_seconds)\n", + " ::double precision / (route_dir_hour_n - 1)\n", + " END\n", + " END AS route_dir_hour_avg_loo,\n", + " CASE WHEN trip_duration_seconds IS NULL\n", + " THEN route_dir_hour_n ELSE route_dir_hour_n - 1 END\n", + " AS route_dir_hour_n_loo\n", + " FROM loo\n", + " )\n", + " UPDATE ml.trip_validity_trip_metrics tm\n", + " SET route_avg_trip_duration_seconds_loo = lc.route_avg_loo,\n", + " route_avg_trip_duration_n_trips_loo = lc.route_n_loo,\n", + " route_direction_avg_trip_duration_seconds_loo = lc.route_dir_avg_loo,\n", + " route_direction_avg_trip_duration_n_trips_loo = lc.route_dir_n_loo,\n", + " route_hour_avg_trip_duration_seconds_loo = lc.route_hour_avg_loo,\n", + " route_hour_avg_trip_duration_n_trips_loo = lc.route_hour_n_loo,\n", + " route_direction_hour_avg_trip_duration_seconds_loo = lc.route_dir_hour_avg_loo,\n", + " route_direction_hour_avg_trip_duration_n_trips_loo = lc.route_dir_hour_n_loo\n", + " FROM loo_calc lc\n", + " WHERE lc.trip_id = tm.trip_id;\n", + "\"\"\")\n", + "conn.commit()\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\"\"\"\n", + " SELECT\n", + " count(*) FILTER (\n", + " WHERE route_avg_trip_duration_seconds_loo IS NULL\n", + " ) AS route_loo_null,\n", + " count(*) FILTER (\n", + " WHERE route_direction_hour_avg_trip_duration_seconds_loo IS NULL\n", + " ) AS finest_loo_null,\n", + " min(route_avg_trip_duration_n_trips_loo) AS min_n\n", + " FROM ml.trip_validity_trip_metrics;\n", + " \"\"\")\n", + " print(\n", + " \"route-level LOO nulls / finest-grain LOO nulls / min n used:\",\n", + " cur.fetchone(),\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "1e055b07", + "metadata": {}, + "source": [ + "## Stage 7 - directional progress correlation\n", + "\n", + "For each trip, does its sequence of geo-tagged fares move steadily\n", + "*forward* along the official route shape as time passes, *backward*, or\n", + "neither? Computed as the Pearson correlation between each point's\n", + "position along the shape (`ST_LineLocatePoint`, a 0-to-1 fraction from\n", + "the shape's start to its end) and the point's timestamp\n", + "(`boarding_at`), across the trip's geo-tagged fares - one correlation\n", + "against the `-I` shape, one against `-V`, from the same set of points.\n", + "\n", + "`+1` = perfect steady forward progress along that shape. `-1` = perfect\n", + "steady progress in reverse. Near `0` = no consistent relationship\n", + "(stationary, erratic, wrong route, or GPS noise dominating).\n", + "\n", + "`trip_progress_correlation_n_points` records how many geo-tagged points\n", + "went into *both* correlations (same input points for I and V, only the\n", + "projection target differs) - a correlation from 2 points is always a\n", + "meaningless exact ±1 (a line through 2 points is always \"perfectly\n", + "correlated\"), so this column exists specifically to let you judge how\n", + "much to trust the correlation value next to it. Both correlations are\n", + "also `NULL` on their own if the corresponding shape (`gtfs_shape_id_i`/\n", + "`_v`) was never matched - already visible from that column, not\n", + "duplicated here." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "2e58633d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-14T21:22:58.938354Z", + "iopub.status.busy": "2026-08-14T21:22:58.938264Z", + "iopub.status.idle": "2026-08-14T21:24:06.563672Z", + "shell.execute_reply": "2026-08-14T21:24:06.563238Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "non-null corr_i / corr_v / trips with <=2 points (unreliable): (577407, 586638, 323385)\n" + ] + } + ], + "source": [ + "conn.execute(\"\"\"\n", + " ALTER TABLE ml.trip_validity_trip_metrics\n", + " ADD COLUMN trip_progress_correlation_to_i double precision,\n", + " ADD COLUMN trip_progress_correlation_to_v double precision,\n", + " ADD COLUMN trip_progress_correlation_n_points integer;\n", + "\"\"\")\n", + "\n", + "conn.execute(\"\"\"\n", + " WITH point_stats AS (\n", + " SELECT\n", + " f.trip_id,\n", + " count(*) AS n_points,\n", + " corr(\n", + " ST_LineLocatePoint(si.shape_geom, f.geom),\n", + " extract(epoch FROM f.boarding_at)\n", + " ) AS corr_i,\n", + " corr(\n", + " ST_LineLocatePoint(sv.shape_geom, f.geom),\n", + " extract(epoch FROM f.boarding_at)\n", + " ) AS corr_v\n", + " FROM ml.trip_validity_trip_fares f\n", + " JOIN ml.trip_validity_trip_metrics tm2 ON tm2.trip_id = f.trip_id\n", + " LEFT JOIN ml.trip_validity_route_shapes si\n", + " ON si.feed_version_date = tm2.gtfs_feed_version_date\n", + " AND si.shape_id = tm2.gtfs_shape_id_i\n", + " LEFT JOIN ml.trip_validity_route_shapes sv\n", + " ON sv.feed_version_date = tm2.gtfs_feed_version_date\n", + " AND sv.shape_id = tm2.gtfs_shape_id_v\n", + " WHERE f.geom IS NOT NULL\n", + " GROUP BY f.trip_id\n", + " )\n", + " UPDATE ml.trip_validity_trip_metrics tm\n", + " SET trip_progress_correlation_to_i = ps.corr_i,\n", + " trip_progress_correlation_to_v = ps.corr_v,\n", + " trip_progress_correlation_n_points = ps.n_points\n", + " FROM point_stats ps\n", + " WHERE ps.trip_id = tm.trip_id;\n", + "\"\"\")\n", + "conn.commit()\n", + "\n", + "MIN_RELIABLE_POINTS = 2 # a correlation from <=2 points is a meaningless exact +-1\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\n", + " \"\"\"\n", + " SELECT count(*) FILTER (WHERE trip_progress_correlation_to_i IS NOT NULL),\n", + " count(*) FILTER (WHERE trip_progress_correlation_to_v IS NOT NULL),\n", + " count(*) FILTER (WHERE trip_progress_correlation_n_points <= %(n)s)\n", + " FROM ml.trip_validity_trip_metrics;\n", + " \"\"\",\n", + " {\"n\": MIN_RELIABLE_POINTS},\n", + " )\n", + " print(\n", + " \"non-null corr_i / corr_v / trips with <=2 points (unreliable):\",\n", + " cur.fetchone(),\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "c623dc48", + "metadata": {}, + "source": [ + "## Stage 8 - reverse-direction historical trip-duration averages\n", + "\n", + "Companions to Stage 6's leave-one-out columns, but comparing this trip\n", + "against trips on the same route going the *opposite* `route_direction`\n", + "instead of the same one. Not `_loo`-suffixed since there's nothing to\n", + "leave out - a trip in direction `0` can never appear in direction `1`'s\n", + "group in the first place, so this is a plain average, not a\n", + "self-exclusion. `NULL` (with `n_trips = NULL`, not `0`) when the route\n", + "has no trips at all going the reverse direction (e.g. a one-way-only\n", + "route)." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "ba48db37", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-14T21:24:06.564595Z", + "iopub.status.busy": "2026-08-14T21:24:06.564505Z", + "iopub.status.idle": "2026-08-14T21:24:15.338700Z", + "shell.execute_reply": "2026-08-14T21:24:15.338304Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "non-null reverse-direction avg / reverse-direction+hour avg: (897435, 891548)\n" + ] + } + ], + "source": [ + "conn.execute(\"\"\"\n", + " ALTER TABLE ml.trip_validity_trip_metrics\n", + " ADD COLUMN route_reverse_direction_avg_trip_duration_seconds\n", + " double precision,\n", + " ADD COLUMN route_reverse_direction_n_trips integer,\n", + " ADD COLUMN route_reverse_direction_hour_avg_trip_duration_seconds\n", + " double precision,\n", + " ADD COLUMN route_reverse_direction_hour_n_trips integer;\n", + "\"\"\")\n", + "\n", + "conn.execute(\"\"\"\n", + " WITH dir_stats AS (\n", + " SELECT route_id, route_direction,\n", + " avg(trip_duration_seconds) AS avg_dur,\n", + " count(trip_duration_seconds) AS n\n", + " FROM ml.trip_validity_trips\n", + " GROUP BY route_id, route_direction\n", + " ),\n", + " dir_hour_stats AS (\n", + " SELECT route_id, route_direction, trip_hour,\n", + " avg(trip_duration_seconds) AS avg_dur,\n", + " count(trip_duration_seconds) AS n\n", + " FROM ml.trip_validity_trips\n", + " GROUP BY route_id, route_direction, trip_hour\n", + " )\n", + " UPDATE ml.trip_validity_trip_metrics tm\n", + " SET route_reverse_direction_avg_trip_duration_seconds = ds.avg_dur,\n", + " route_reverse_direction_n_trips = ds.n,\n", + " route_reverse_direction_hour_avg_trip_duration_seconds = dhs.avg_dur,\n", + " route_reverse_direction_hour_n_trips = dhs.n\n", + " FROM ml.trip_validity_trips t\n", + " LEFT JOIN dir_stats ds\n", + " ON ds.route_id = t.route_id\n", + " AND ds.route_direction = (\n", + " CASE WHEN t.route_direction = 0 THEN 1 WHEN t.route_direction = 1 THEN 0 END\n", + " )\n", + " LEFT JOIN dir_hour_stats dhs\n", + " ON dhs.route_id = t.route_id\n", + " AND dhs.route_direction = (\n", + " CASE WHEN t.route_direction = 0 THEN 1 WHEN t.route_direction = 1 THEN 0 END\n", + " )\n", + " AND dhs.trip_hour = t.trip_hour\n", + " WHERE t.trip_id = tm.trip_id;\n", + "\"\"\")\n", + "conn.commit()\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\"\"\"\n", + " SELECT count(*) FILTER (\n", + " WHERE route_reverse_direction_avg_trip_duration_seconds IS NOT NULL\n", + " ),\n", + " count(*) FILTER (\n", + " WHERE route_reverse_direction_hour_avg_trip_duration_seconds\n", + " IS NOT NULL\n", + " )\n", + " FROM ml.trip_validity_trip_metrics;\n", + " \"\"\")\n", + " print(\n", + " \"non-null reverse-direction avg / reverse-direction+hour avg:\",\n", + " cur.fetchone(),\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "6512ce9c", + "metadata": {}, + "source": [ + "## Column-level provenance comments" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "301847bd", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-14T21:24:15.339875Z", + "iopub.status.busy": "2026-08-14T21:24:15.339791Z", + "iopub.status.idle": "2026-08-14T21:24:15.348093Z", + "shell.execute_reply": "2026-08-14T21:24:15.347765Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "comments applied\n" + ] + } + ], + "source": [ + "def comment_on_column(cur: psycopg.Cursor, table: str, col: str, text: str) -> None:\n", + " \"\"\"Apply a COMMENT ON COLUMN for one column via safe SQL composition.\"\"\"\n", + " cur.execute(\n", + " sql.SQL(\"COMMENT ON COLUMN ml.{}.{} IS {};\").format(\n", + " sql.Identifier(table), sql.Identifier(col), sql.Literal(text)\n", + " )\n", + " )\n", + "\n", + "\n", + "METRICS_COMMENTS = {\n", + " \"trip_id\": \"= ml.trip_validity_trips.trip_id (FK, PK here too).\",\n", + " \"route_id\": \"Copied from ml.trip_validity_trips.route_id.\",\n", + " \"route_direction\": (\n", + " \"Copied from ml.trip_validity_trips.route_direction (this trip's \"\n", + " \"own observed AFC direction).\"\n", + " ),\n", + " \"trip_duration_seconds\": (\n", + " \"Copied from ml.trip_validity_trips.trip_duration_seconds.\"\n", + " ),\n", + " \"trip_fare_count\": \"Copied from ml.trip_validity_trips.trip_fare_count.\",\n", + " \"trip_distance_meters\": (\n", + " \"ST_Length(trip_path::geography) from ml.trip_validity_trips. \"\n", + " \"NULL if trip_path is NULL.\"\n", + " ),\n", + " \"trip_points_standard_distance_meters\": (\n", + " \"RMS distance of this trip's geo-tagged fares \"\n", + " \"(ml.trip_validity_trip_fares) from their own centroid. 0 for a \"\n", + " \"single geo-tagged fare, NULL for zero.\"\n", + " ),\n", + " \"fare_gap_avg_seconds\": (\n", + " \"Mean gap between consecutive boarding_at values (ALL fares, \"\n", + " \"ml.trip_validity_trip_fares), ordered by boarding_at. NULL if \"\n", + " \"only 1 fare.\"\n", + " ),\n", + " \"fare_gap_stddev_seconds\": (\n", + " \"Sample stddev of the same gaps. NULL if fewer than 2 gaps (i.e. \"\n", + " \"fewer than 3 fares) exist.\"\n", + " ),\n", + " \"fare_span_seconds\": (\n", + " \"max(boarding_at) - min(boarding_at) across all fares. 0 (not \"\n", + " \"NULL) for a 1-fare trip.\"\n", + " ),\n", + " \"gtfs_feed_version_date\": (\n", + " \"From ml.trip_validity_route_gtfs_match, resolved by (route_id, trip_date).\"\n", + " ),\n", + " \"gtfs_route_short_name\": (\n", + " \"From ml.trip_validity_route_gtfs_match - the actual GTFS join \"\n", + " \"key used, stored for traceability.\"\n", + " ),\n", + " \"gtfs_route_has_both_directions\": (\n", + " \"From ml.trip_validity_route_gtfs_match. NULL means the route \"\n", + " \"never matched any GTFS feed at all; false means it matched but \"\n", + " \"only one direction has a shape; true means both directions \"\n", + " \"have a shape.\"\n", + " ),\n", + " \"gtfs_shape_id_i\": \"From ml.trip_validity_route_gtfs_match.\",\n", + " \"gtfs_shape_id_v\": \"From ml.trip_validity_route_gtfs_match.\",\n", + " \"trip_start_distance_to_i_start_meters\": (\n", + " \"ST_Distance (geography) between ST_StartPoint(trip_path) and \"\n", + " \"ST_StartPoint of the matched -I shape's geometry \"\n", + " \"(ml.trip_validity_route_shapes). Regardless of this trip's own \"\n", + " \"route_direction.\"\n", + " ),\n", + " \"trip_start_distance_to_v_start_meters\": \"Same, against the -V shape.\",\n", + " \"trip_end_distance_to_i_end_meters\": (\n", + " \"ST_Distance (geography) between ST_EndPoint(trip_path) and \"\n", + " \"ST_EndPoint of the matched -I shape's geometry.\"\n", + " ),\n", + " \"trip_end_distance_to_v_end_meters\": \"Same, against the -V shape.\",\n", + " \"path_frechet_distance_to_i_meters\": (\n", + " \"ST_FrechetDistance between trip_path and the -I shape, both \"\n", + " \"projected to EPSG:31984 (SIRGAS2000/UTM 24S) first (no \"\n", + " \"geography overload exists for this function).\"\n", + " ),\n", + " \"path_frechet_distance_to_v_meters\": \"Same, against the -V shape.\",\n", + " \"path_hausdorff_distance_to_i_meters\": (\n", + " \"ST_HausdorffDistance between trip_path and the -I shape, both \"\n", + " \"projected to EPSG:31984 first.\"\n", + " ),\n", + " \"path_hausdorff_distance_to_v_meters\": \"Same, against the -V shape.\",\n", + " \"route_i_length_meters\": (\n", + " \"ml.trip_validity_route_shapes.shape_length_meters for the matched -I shape.\"\n", + " ),\n", + " \"route_v_length_meters\": \"Same, for the matched -V shape.\",\n", + " \"route_i_scheduled_duration_avg_seconds\": (\n", + " \"avg(scheduled_duration_seconds) over \"\n", + " \"ml.trip_validity_route_schedule for this row's resolved (feed, \"\n", + " \"gtfs_route_id, direction='I'). NULL if no scheduled GTFS trips \"\n", + " \"exist for it.\"\n", + " ),\n", + " \"route_i_scheduled_duration_n_trips\": \"Row count backing the average above.\",\n", + " \"route_v_scheduled_duration_avg_seconds\": (\n", + " \"Same as route_i_scheduled_duration_avg_seconds, direction='V'.\"\n", + " ),\n", + " \"route_v_scheduled_duration_n_trips\": \"Row count backing the average above.\",\n", + " \"route_i_scheduled_duration_avg_seconds_at_hour\": (\n", + " \"Same as route_i_scheduled_duration_avg_seconds, additionally \"\n", + " \"filtered to GTFS scheduled trips whose start_hour matches this \"\n", + " \"row's trip_hour.\"\n", + " ),\n", + " \"route_i_scheduled_duration_n_trips_at_hour\": (\n", + " \"Row count backing the average above.\"\n", + " ),\n", + " \"route_v_scheduled_duration_avg_seconds_at_hour\": (\n", + " \"Same as route_i_scheduled_duration_avg_seconds_at_hour, direction='V'.\"\n", + " ),\n", + " \"route_v_scheduled_duration_n_trips_at_hour\": (\n", + " \"Row count backing the average above.\"\n", + " ),\n", + " \"route_avg_trip_duration_seconds_loo\": (\n", + " \"Leave-one-out mean trip_duration_seconds across all \"\n", + " \"ml.trip_validity_trips sharing this row's route_id (this row's \"\n", + " \"own value excluded). NULL if no other trips exist in the group.\"\n", + " ),\n", + " \"route_avg_trip_duration_n_trips_loo\": (\n", + " \"Count of other trips the average above was computed from (0 if \"\n", + " \"none - explains a NULL average).\"\n", + " ),\n", + " \"route_direction_avg_trip_duration_seconds_loo\": (\n", + " \"Same, grouped by (route_id, route_direction).\"\n", + " ),\n", + " \"route_direction_avg_trip_duration_n_trips_loo\": (\n", + " \"Count backing the average above.\"\n", + " ),\n", + " \"route_hour_avg_trip_duration_seconds_loo\": (\n", + " \"Same, grouped by (route_id, trip_hour).\"\n", + " ),\n", + " \"route_hour_avg_trip_duration_n_trips_loo\": \"Count backing the average above.\",\n", + " \"route_direction_hour_avg_trip_duration_seconds_loo\": (\n", + " \"Same, grouped by (route_id, route_direction, trip_hour).\"\n", + " ),\n", + " \"route_direction_hour_avg_trip_duration_n_trips_loo\": (\n", + " \"Count backing the average above.\"\n", + " ),\n", + " \"trip_progress_correlation_to_i\": (\n", + " \"Pearson correlation (corr()) between ST_LineLocatePoint(-I \"\n", + " \"shape, point) and extract(epoch from boarding_at), across this \"\n", + " \"trip's geo-tagged fares in ml.trip_validity_trip_fares. +1 = \"\n", + " \"steady forward progress along the -I shape as time passes, -1 \"\n", + " \"= steady progress in reverse, ~0 = no consistent relationship. \"\n", + " \"NULL if gtfs_shape_id_i is NULL or fewer than 2 geo-tagged \"\n", + " \"fares exist.\"\n", + " ),\n", + " \"trip_progress_correlation_to_v\": (\n", + " \"Same as trip_progress_correlation_to_i, against the -V shape.\"\n", + " ),\n", + " \"trip_progress_correlation_n_points\": (\n", + " \"Count of geo-tagged fares used for BOTH correlations above \"\n", + " \"(same input points, different projection target). A value of \"\n", + " \"2 means the correlation is a meaningless exact +-1 (a line \"\n", + " \"through 2 points is always perfectly correlated) - use this to \"\n", + " \"judge how much to trust the two correlation columns.\"\n", + " ),\n", + " \"route_reverse_direction_avg_trip_duration_seconds\": (\n", + " \"avg(trip_duration_seconds) across ml.trip_validity_trips \"\n", + " \"sharing this row's route_id but with the OPPOSITE \"\n", + " \"route_direction (0<->1). Not leave-one-out - this trip can \"\n", + " \"never be a member of that group. NULL if the route has no \"\n", + " \"trips going the reverse direction at all.\"\n", + " ),\n", + " \"route_reverse_direction_n_trips\": (\n", + " \"Row count backing the average above (NULL, not 0, when the average is NULL).\"\n", + " ),\n", + " \"route_reverse_direction_hour_avg_trip_duration_seconds\": (\n", + " \"Same as route_reverse_direction_avg_trip_duration_seconds, \"\n", + " \"additionally restricted to the opposite-direction trips whose \"\n", + " \"trip_hour matches this row's own trip_hour.\"\n", + " ),\n", + " \"route_reverse_direction_hour_n_trips\": \"Row count backing the average above.\",\n", + "}\n", + "\n", + "with conn.cursor() as cur:\n", + " for col, text in METRICS_COMMENTS.items():\n", + " comment_on_column(cur, \"trip_validity_trip_metrics\", col, text)\n", + "\n", + "conn.execute(\n", + " sql.SQL(\"COMMENT ON TABLE ml.trip_validity_trip_metrics IS {};\").format(\n", + " sql.Literal(\n", + " \"Trip Validity model: one feature row per trip, built entirely from \"\n", + " \"ml.trip_validity_trips/trip_fares/route_gtfs_match/route_shapes/\"\n", + " \"route_schedule - never touches silver. See \"\n", + " \"ml/trip_validity_model/notebooks/03_trip_metrics.ipynb.\"\n", + " )\n", + " )\n", + ")\n", + "conn.commit()\n", + "print(\"comments applied\")" + ] + }, + { + "cell_type": "markdown", + "id": "64da6261", + "metadata": {}, + "source": [ + "## Indexes and verification" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "7b4313f1", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-14T21:24:15.348915Z", + "iopub.status.busy": "2026-08-14T21:24:15.348850Z", + "iopub.status.idle": "2026-08-14T21:24:16.858414Z", + "shell.execute_reply": "2026-08-14T21:24:16.858004Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "shape: (1, 9)\n", + "┌────────┬────────────┬────────────┬───────────┬───┬───────────┬───────────┬───────────┬───────────┐\n", + "│ rows ┆ has_distan ┆ has_cohesi ┆ has_gtfs_ ┆ … ┆ has_sched ┆ has_route ┆ has_progr ┆ has_rever │\n", + "│ --- ┆ ce ┆ on ┆ match ┆ ┆ _avg ┆ _loo ┆ ess_corr ┆ se_dir │\n", + "│ i64 ┆ --- ┆ --- ┆ --- ┆ ┆ --- ┆ --- ┆ --- ┆ --- │\n", + "│ ┆ i64 ┆ i64 ┆ i64 ┆ ┆ i64 ┆ i64 ┆ i64 ┆ i64 │\n", + "╞════════╪════════════╪════════════╪═══════════╪═══╪═══════════╪═══════════╪═══════════╪═══════════╡\n", + "│ 940988 ┆ 899299 ┆ 899299 ┆ 934155 ┆ … ┆ 934155 ┆ 940980 ┆ 605112 ┆ 897435 │\n", + "└────────┴────────────┴────────────┴───────────┴───┴───────────┴───────────┴───────────┴───────────┘\n", + "OK: row count matches trip_validity_trips\n" + ] + } + ], + "source": [ + "import polars as pl\n", + "\n", + "conn.execute(\"\"\"\n", + " CREATE INDEX trip_validity_trip_metrics_route_id_idx\n", + " ON ml.trip_validity_trip_metrics (route_id);\n", + " CREATE INDEX trip_validity_trip_metrics_route_direction_idx\n", + " ON ml.trip_validity_trip_metrics (route_id, route_direction);\n", + " ANALYZE ml.trip_validity_trip_metrics;\n", + "\"\"\")\n", + "conn.commit()\n", + "\n", + "EXPECTED_TRIP_COUNT = 940988 # ml.trip_validity_trips row count\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\"\"\"\n", + " SELECT\n", + " count(*) AS rows,\n", + " count(*) FILTER (WHERE trip_distance_meters IS NOT NULL) AS has_distance,\n", + " count(*) FILTER (\n", + " WHERE trip_points_standard_distance_meters IS NOT NULL\n", + " ) AS has_cohesion,\n", + " count(*) FILTER (\n", + " WHERE gtfs_feed_version_date IS NOT NULL\n", + " ) AS has_gtfs_match,\n", + " count(*) FILTER (\n", + " WHERE path_frechet_distance_to_i_meters IS NOT NULL\n", + " OR path_frechet_distance_to_v_meters IS NOT NULL\n", + " ) AS has_frechet,\n", + " count(*) FILTER (\n", + " WHERE route_i_scheduled_duration_avg_seconds IS NOT NULL\n", + " OR route_v_scheduled_duration_avg_seconds IS NOT NULL\n", + " ) AS has_sched_avg,\n", + " count(*) FILTER (\n", + " WHERE route_avg_trip_duration_seconds_loo IS NOT NULL\n", + " ) AS has_route_loo,\n", + " count(*) FILTER (\n", + " WHERE trip_progress_correlation_to_i IS NOT NULL\n", + " OR trip_progress_correlation_to_v IS NOT NULL\n", + " ) AS has_progress_corr,\n", + " count(*) FILTER (\n", + " WHERE route_reverse_direction_avg_trip_duration_seconds IS NOT NULL\n", + " ) AS has_reverse_dir\n", + " FROM ml.trip_validity_trip_metrics;\n", + " \"\"\")\n", + " cols = [d.name for d in cur.description]\n", + " row = cur.fetchone()\n", + "\n", + "summary = pl.DataFrame([dict(zip(cols, row, strict=True))])\n", + "print(summary)\n", + "if summary[\"rows\"][0] != EXPECTED_TRIP_COUNT:\n", + " msg = \"row count mismatch against trip_validity_trips\"\n", + " raise AssertionError(msg)\n", + "print(\"OK: row count matches trip_validity_trips\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (opa-database)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/ml/trip_validity_model/notebooks/04_avl_positions.ipynb b/ml/trip_validity_model/notebooks/04_avl_positions.ipynb new file mode 100644 index 0000000..8fe2624 --- /dev/null +++ b/ml/trip_validity_model/notebooks/04_avl_positions.ipynb @@ -0,0 +1,2346 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "1f724ff4", + "metadata": {}, + "source": [ + "# 04 - Match AFC buses to AVL vehicles and materialize trip positions\n", + "\n", + "Bridges `ml.trip_validity_trips.bus_id` (AFC's `vehicle_number`, 5-digit\n", + "zero-padded) to `silver.avl_pings`' own vehicle identity, then\n", + "materializes every trip's GPS trace for fast per-trip lookup later\n", + "(map rendering). Two new tables, both read by `05_final_dataset.ipynb`:\n", + "\n", + "- `ml.trip_validity_bus_avl_match`: one row per distinct `bus_id`, the\n", + " resolved AVL identity (if any) and which crosswalk resolved it.\n", + "- `ml.trip_validity_trip_positions`: one row per AVL ping belonging to\n", + " a matched trip, within that trip's own `[trip_opening_timestamp,\n", + " trip_closing_timestamp]` window - no padding.\n", + "\n", + "## The two crosswalks\n", + "\n", + "`silver.avl_pings` identifies a vehicle by `vehicle_id` (integer) *or*\n", + "`device_id` (text, e.g. `\"ep1-428113843\"`, the GPS hardware unit's own\n", + "id). Two silver reference tables bridge AFC's `bus_id` to one or the\n", + "other, confirmed live against the DB (this notebook is the only one\n", + "touching `silver.dictionary_vehicle`/`silver.dictionary_device`/\n", + "`silver.avl_pings`):\n", + "\n", + "- `silver.dictionary_vehicle` (4,530 rows, 1 snapshot): `cod_veiculo`\n", + " is free-text and needs stripping to digits only (`\"02018v\"` ->\n", + " `\"02018\"`, `\"02008 - Desativado\"` -> `\"02008\"`; 2,122/4,530 rows carry\n", + " this kind of noise) before it lines up with `bus_id`'s own padding.\n", + " `id_veiculo` is always plain-integer text and equals\n", + " `avl_pings.vehicle_id` once cast.\n", + "- `silver.dictionary_device` (2,219 rows, 1 snapshot): `codigo` is kept\n", + " only where it's a plain integer already (`codigo ~ '^[0-9]+$'`,\n", + " 2,177/2,219 rows qualify - the rest are dropped, not cleaned, since\n", + " they're not vehicle numbers at all) and `device_id` is non-null.\n", + " `device_id` matches `avl_pings.device_id` directly, same format,\n", + " confirmed by spot-check - no transform needed.\n", + "\n", + "Neither crosswalk guarantees a unique `bus_id`: `dictionary_vehicle`\n", + "has known bus-reassignment noise (documented on\n", + "`contracts/vehicle_dictionary.py`), and a few `dictionary_device` rows\n", + "share a `codigo`. Per instruction, duplicates are resolved by picking\n", + "one arbitrarily but deterministically (`DISTINCT ON`), not by trying to\n", + "decide which is \"correct\" - that's out of scope here.\n", + "\n", + "**Priority when both crosswalks resolve the same `bus_id`**:\n", + "`dictionary_device` wins (its `device_id` is used); `dictionary_vehicle`\n", + "is only used as a fallback when no `dictionary_device` match exists.\n", + "\n", + "## Why this needs new, covering indexes first\n", + "\n", + "`silver.avl_pings` is **1.6 billion rows** across all of 2023;\n", + "November 2023 alone (the only period `ml.trip_validity_trips` covers)\n", + "is a single 21 GB partition (`avl_pings_y2023m11`) with\n", + "**130,957,355 rows**, both confirmed live. Today it's indexed only on\n", + "`geom` (GiST) and `metric_timestamp` (btree) - nothing on `vehicle_id`\n", + "or `device_id`, so matching ~941K trips by vehicle+time would mean\n", + "scanning the whole partition. `src/opa_database/silver/avl.py` now\n", + "defines two composite indexes, `(vehicle_id, metric_timestamp)` and\n", + "`(device_id, metric_timestamp)`, added to its `_INDEXES` tuple so every\n", + "*future* monthly AVL load gets them automatically - but that doesn't\n", + "retroactively touch the already-loaded November 2023 partition, so\n", + "Stage 0 below backfills the same two indexes onto it directly, once,\n", + "using the exact index names/definitions `IndexSpec` would generate\n", + "(`{partition}_{suffix}`) so a future reload of this same partition\n", + "would recreate indistinguishable indexes rather than duplicates.\n", + "\n", + "Both indexes also carry `latitude`/`longitude`/`speed`/`odometer` as\n", + "`INCLUDE` columns (confirmed live via `EXPLAIN (ANALYZE, BUFFERS)`\n", + "during development): without them, Postgres still has to fetch the\n", + "heap page for every matching row just to read those four columns, and\n", + "since pings for the same vehicle aren't stored contiguously (insert\n", + "order is global ingestion order, not per-vehicle), that's effectively\n", + "random I/O across a 21 GB table - the dominant real cost of Stage 2's\n", + "insert. With them, the whole query is answerable from the index alone\n", + "(an index-only scan), skipping the heap entirely." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "9d16a38c", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-15T17:21:02.734984Z", + "iopub.status.busy": "2026-08-15T17:21:02.734916Z", + "iopub.status.idle": "2026-08-15T17:21:02.762419Z", + "shell.execute_reply": "2026-08-15T17:21:02.762119Z" + } + }, + "outputs": [], + "source": [ + "import os\n", + "from pathlib import Path\n", + "\n", + "import psycopg\n", + "from psycopg import sql" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "a2b0c65a", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-15T17:21:02.763506Z", + "iopub.status.busy": "2026-08-15T17:21:02.763440Z", + "iopub.status.idle": "2026-08-15T17:21:02.765928Z", + "shell.execute_reply": "2026-08-15T17:21:02.765666Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "'/home/victor/repos/opa-database'" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "_root = Path.cwd()\n", + "while not (_root / \"pyproject.toml\").exists():\n", + " _root = _root.parent\n", + "os.chdir(_root)\n", + "os.environ.setdefault(\"RAW_DATA_ROOT\", str(_root))" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "319c561e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-15T17:21:02.766630Z", + "iopub.status.busy": "2026-08-15T17:21:02.766568Z", + "iopub.status.idle": "2026-08-15T17:21:02.810767Z", + "shell.execute_reply": "2026-08-15T17:21:02.810408Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "connected\n" + ] + } + ], + "source": [ + "from opa_database.config import settings\n", + "\n", + "conn = psycopg.connect(settings.db_dsn)\n", + "print(\"connected\")" + ] + }, + { + "cell_type": "markdown", + "id": "146b59bb", + "metadata": {}, + "source": [ + "## Stage 0 - backfill the covering indexes onto `avl_pings_y2023m11`\n", + "\n", + "`CREATE INDEX CONCURRENTLY` avoids locking the partition against other\n", + "users of this shared database, but can't run inside a transaction block\n", + "- this needs its own `autocommit=True` connection, separate from `conn`\n", + "used everywhere else in this notebook. `IF NOT EXISTS` makes this safe\n", + "to re-run. Expect real wall-clock time here: two btree indexes over\n", + "130,957,355 rows, wider than a plain composite index because of the\n", + "`INCLUDE` columns." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "5e6e6bb1", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-15T17:21:02.811598Z", + "iopub.status.busy": "2026-08-15T17:21:02.811530Z", + "iopub.status.idle": "2026-08-15T17:21:02.818063Z", + "shell.execute_reply": "2026-08-15T17:21:02.817774Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "avl_pings_y2023m11_vehicle_ts_idx: 0.0s\n", + "avl_pings_y2023m11_device_ts_idx: 0.0s\n", + "index backfill done\n" + ] + } + ], + "source": [ + "import time\n", + "\n", + "autocommit_conn = psycopg.connect(settings.db_dsn, autocommit=True)\n", + "\n", + "_INCLUDE_COLS = \"INCLUDE (latitude, longitude, speed, odometer)\"\n", + "\n", + "for index_name, definition in (\n", + " (\n", + " \"avl_pings_y2023m11_vehicle_ts_idx\",\n", + " f\"(vehicle_id, metric_timestamp) {_INCLUDE_COLS}\",\n", + " ),\n", + " (\n", + " \"avl_pings_y2023m11_device_ts_idx\",\n", + " f\"(device_id, metric_timestamp) {_INCLUDE_COLS}\",\n", + " ),\n", + "):\n", + " start = time.monotonic()\n", + " autocommit_conn.execute(\n", + " sql.SQL(\n", + " \"CREATE INDEX CONCURRENTLY IF NOT EXISTS {name} \"\n", + " \"ON silver.avl_pings_y2023m11 {definition}\"\n", + " ).format(name=sql.Identifier(index_name), definition=sql.SQL(definition))\n", + " )\n", + " print(f\"{index_name}: {time.monotonic() - start:.1f}s\")\n", + "\n", + "autocommit_conn.close()\n", + "print(\"index backfill done\")" + ] + }, + { + "cell_type": "markdown", + "id": "2dbec14f", + "metadata": {}, + "source": [ + "## Stage 1 - `ml.trip_validity_bus_avl_match`\n", + "\n", + "One row per distinct `bus_id` actually present in\n", + "`ml.trip_validity_trips` (thousands of rows, not hundreds of millions -\n", + "cheap). Two candidate CTEs, each deduplicated to one row per normalized\n", + "bus_id via `DISTINCT ON` (arbitrary but deterministic tie-break, per\n", + "instruction - it doesn't matter which duplicate wins), then combined\n", + "with `dictionary_device` preferred over `dictionary_vehicle`." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "95d08c78", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-15T17:21:02.818843Z", + "iopub.status.busy": "2026-08-15T17:21:02.818781Z", + "iopub.status.idle": "2026-08-15T17:21:02.887150Z", + "shell.execute_reply": "2026-08-15T17:21:02.886782Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "total / via_device / via_vehicle / unmatched: (1730, 1461, 204, 65)\n" + ] + } + ], + "source": [ + "conn.execute(\"\"\"\n", + " DROP TABLE IF EXISTS ml.trip_validity_bus_avl_match CASCADE;\n", + "\n", + " CREATE TABLE ml.trip_validity_bus_avl_match (\n", + " bus_id text PRIMARY KEY,\n", + " avl_matched boolean NOT NULL,\n", + " avl_match_source text CHECK (\n", + " avl_match_source IN\n", + " ('dictionary_vehicle', 'dictionary_device')\n", + " ),\n", + " avl_vehicle_id integer,\n", + " avl_device_id text\n", + " );\n", + "\"\"\")\n", + "\n", + "conn.execute(\"\"\"\n", + " WITH vehicle_candidates AS (\n", + " SELECT DISTINCT ON (bus_id) bus_id, id_veiculo::integer AS avl_vehicle_id\n", + " FROM (\n", + " SELECT\n", + " CASE WHEN length(norm) < 5 THEN lpad(norm, 5, '0') ELSE norm END\n", + " AS bus_id,\n", + " id_veiculo\n", + " FROM (\n", + " SELECT regexp_replace(cod_veiculo, '[^0-9]', '', 'g') AS norm,\n", + " id_veiculo\n", + " FROM silver.dictionary_vehicle\n", + " ) normalized\n", + " WHERE norm <> ''\n", + " ) padded\n", + " ORDER BY bus_id, id_veiculo\n", + " ),\n", + " device_candidates AS (\n", + " SELECT DISTINCT ON (bus_id) bus_id, device_id AS avl_device_id\n", + " FROM (\n", + " SELECT\n", + " CASE WHEN length(codigo) < 5 THEN lpad(codigo, 5, '0') ELSE codigo END\n", + " AS bus_id,\n", + " device_id\n", + " FROM silver.dictionary_device\n", + " WHERE codigo ~ '^[0-9]+$' AND device_id IS NOT NULL\n", + " ) padded\n", + " ORDER BY bus_id, device_id\n", + " ),\n", + " distinct_bus_ids AS (\n", + " SELECT DISTINCT bus_id FROM ml.trip_validity_trips\n", + " )\n", + " INSERT INTO ml.trip_validity_bus_avl_match (\n", + " bus_id, avl_matched, avl_match_source, avl_vehicle_id, avl_device_id\n", + " )\n", + " SELECT\n", + " b.bus_id,\n", + " (dc.avl_device_id IS NOT NULL OR vc.avl_vehicle_id IS NOT NULL) AS avl_matched,\n", + " CASE\n", + " WHEN dc.avl_device_id IS NOT NULL THEN 'dictionary_device'\n", + " WHEN vc.avl_vehicle_id IS NOT NULL THEN 'dictionary_vehicle'\n", + " END AS avl_match_source,\n", + " CASE WHEN dc.avl_device_id IS NULL THEN vc.avl_vehicle_id END AS avl_vehicle_id,\n", + " dc.avl_device_id\n", + " FROM distinct_bus_ids b\n", + " LEFT JOIN device_candidates dc ON dc.bus_id = b.bus_id\n", + " LEFT JOIN vehicle_candidates vc ON vc.bus_id = b.bus_id;\n", + "\"\"\")\n", + "conn.commit()\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\"\"\"\n", + " SELECT\n", + " count(*) AS total,\n", + " count(*) FILTER (\n", + " WHERE avl_match_source = 'dictionary_device'\n", + " ) AS via_device,\n", + " count(*) FILTER (\n", + " WHERE avl_match_source = 'dictionary_vehicle'\n", + " ) AS via_vehicle,\n", + " count(*) FILTER (WHERE NOT avl_matched) AS unmatched\n", + " FROM ml.trip_validity_bus_avl_match;\n", + " \"\"\")\n", + " print(\"total / via_device / via_vehicle / unmatched:\", cur.fetchone())" + ] + }, + { + "cell_type": "markdown", + "id": "b7c07c33", + "metadata": {}, + "source": [ + "## Stage 2 - `ml.trip_validity_trip_positions`\n", + "\n", + "One row per AVL ping within a matched trip's exact\n", + "`[trip_opening_timestamp, trip_closing_timestamp]` window.\n", + "`PRIMARY KEY (trip_id, metric_timestamp)` alone gives fast,\n", + "naturally time-ordered per-trip retrieval - the exact \"join from the\n", + "dataset to get this trip's locations\" access pattern - so no separate\n", + "`trip_id`-only index is needed. A GiST index on `geom` is added too,\n", + "matching this project's convention for point/line geometry columns, for\n", + "any future spatial (not just per-trip) queries.\n", + "\n", + "The 9 trips with the `1899-12-30` Delphi zero-date sentinel as\n", + "`trip_closing_timestamp` naturally produce zero rows here (`closing <\n", + "opening` is an empty window) - no special-casing needed.\n", + "\n", + "`silver.avl_pings` itself isn't unique on `(vehicle_id/device_id,\n", + "metric_timestamp)` - confirmed live, e.g. two literally-identical rows\n", + "for device `ep1-428115569` at `2023-11-22 22:18:23+00` - so the insert\n", + "below uses `ON CONFLICT (trip_id, metric_timestamp) DO NOTHING` to keep\n", + "just one arbitrarily (doesn't matter which, since the DB doesn't say\n", + "which copy is \"real\"). This is cheaper than an explicit\n", + "`DISTINCT ON`/dedup pass, which would force sorting the entire result\n", + "set before insert even though real duplicates are rare - `ON CONFLICT`\n", + "only pays a cost on an actual collision, checked via the primary key\n", + "index incrementally as rows stream in." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "79fcc151", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-15T17:21:02.888223Z", + "iopub.status.busy": "2026-08-15T17:21:02.888135Z", + "iopub.status.idle": "2026-08-15T17:21:02.900974Z", + "shell.execute_reply": "2026-08-15T17:21:02.900607Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ml.trip_validity_trip_positions created\n" + ] + } + ], + "source": [ + "conn.execute(\"\"\"\n", + " DROP TABLE IF EXISTS ml.trip_validity_trip_positions CASCADE;\n", + "\n", + " CREATE TABLE ml.trip_validity_trip_positions (\n", + " trip_id bigint NOT NULL\n", + " REFERENCES ml.trip_validity_trips (trip_id),\n", + " metric_timestamp timestamptz NOT NULL,\n", + " latitude double precision NOT NULL,\n", + " longitude double precision NOT NULL,\n", + " speed integer NOT NULL,\n", + " odometer bigint NOT NULL,\n", + " geom geometry(Point, 4326) GENERATED ALWAYS AS (\n", + " ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)\n", + " ) STORED,\n", + " PRIMARY KEY (trip_id, metric_timestamp)\n", + " );\n", + "\"\"\")\n", + "conn.commit()\n", + "print(\"ml.trip_validity_trip_positions created\")" + ] + }, + { + "cell_type": "markdown", + "id": "e4a44baf", + "metadata": {}, + "source": [ + "### Timing sample before the full run\n", + "\n", + "Same honesty convention as `03_trip_metrics.ipynb`'s heaviest stage:\n", + "time the exact join pattern against a random 2,000-trip sample first via\n", + "a temp table, then extrapolate to all matched trips, rather than just\n", + "starting the full run and hoping.\n", + "\n", + "**Two planner traps found the hard way while building this notebook,\n", + "both confirmed live via `EXPLAIN (ANALYZE, BUFFERS)`** - documented here\n", + "since the fix looks unusual without the reasoning:\n", + "\n", + "1. A single query with `... AND (device_id = X OR vehicle_id = Y) ...`\n", + " (mirroring `avl_match_source` picking one branch per row) makes\n", + " Postgres unable to generate a parameterized index path for either\n", + " branch once the comparison values come from a real outer table\n", + " (not a literal/`VALUES` list) - it falls back to sequentially\n", + " scanning the entire multi-hundred-million-row `avl_pings` as the\n", + " *outer* side of the join and materializing the small trip sample as\n", + " the *inner* side, once per `avl_pings` row. Fixed by splitting into\n", + " a `UNION ALL` of two branches, each with exactly one equality\n", + " condition (`device_id = X` in one, `vehicle_id = Y` in the other) -\n", + " `avl_match_source` guarantees they're mutually exclusive, so\n", + " `UNION ALL` never double-counts.\n", + "2. Even with the OR gone, Postgres still chooses a `Hash Join` over\n", + " the *entire* partition instead of a nested loop with an indexed,\n", + " per-row lookup - because it can only estimate `device_id`/\n", + " `vehicle_id` equality selectivity on its own (poor: each vehicle\n", + " has ~59K pings across all of November), not combined with a\n", + " per-row time window it has no histogram for (each trip's real\n", + " window is tiny). `SET LOCAL enable_hashjoin = off` /\n", + " `enable_mergejoin = off` (scoped to just this transaction, reverted\n", + " automatically on commit) forces the only remaining option: a nested\n", + " loop using the new composite indexes per row. Confirmed via\n", + " `EXPLAIN ANALYZE` on a 200-trip sample: 214ms total (most of it\n", + " one-time JIT compilation), vs. 23+ minutes still running when this\n", + " was first tried without either fix - long enough that it had to be\n", + " cancelled via `pg_cancel_backend` rather than left to finish." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "794397ed", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-15T17:21:02.901895Z", + "iopub.status.busy": "2026-08-15T17:21:02.901829Z", + "iopub.status.idle": "2026-08-15T17:21:03.358363Z", + "shell.execute_reply": "2026-08-15T17:21:03.357941Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2000 trips -> 172031 positions in 0.15s\n", + "total matched trips: 931005\n", + "estimated full run: 1.2 minutes\n" + ] + } + ], + "source": [ + "SAMPLE_SIZE = 2000\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\n", + " \"\"\"\n", + " CREATE TEMP TABLE avl_timing_sample AS\n", + " SELECT t.trip_id, t.trip_opening_timestamp, t.trip_closing_timestamp,\n", + " m.avl_match_source, m.avl_vehicle_id, m.avl_device_id\n", + " FROM ml.trip_validity_trips t\n", + " JOIN ml.trip_validity_bus_avl_match m\n", + " ON m.bus_id = t.bus_id AND m.avl_matched\n", + " ORDER BY random()\n", + " LIMIT %(sample_size)s;\n", + " \"\"\",\n", + " {\"sample_size\": SAMPLE_SIZE},\n", + " )\n", + " cur.execute(\"ANALYZE avl_timing_sample;\")\n", + " cur.execute(\"SET LOCAL enable_hashjoin = off;\")\n", + " cur.execute(\"SET LOCAL enable_mergejoin = off;\")\n", + "\n", + " start = time.monotonic()\n", + " cur.execute(\"\"\"\n", + " SELECT count(*) FROM (\n", + " SELECT p.metric_timestamp\n", + " FROM avl_timing_sample s\n", + " JOIN silver.avl_pings p\n", + " ON p.device_id = s.avl_device_id\n", + " AND p.metric_timestamp >= s.trip_opening_timestamp\n", + " AND p.metric_timestamp <= s.trip_closing_timestamp\n", + " AND p.metric_timestamp >= '2023-11-01'\n", + " AND p.metric_timestamp < '2023-12-02'\n", + " WHERE s.avl_match_source = 'dictionary_device'\n", + " UNION ALL\n", + " SELECT p.metric_timestamp\n", + " FROM avl_timing_sample s\n", + " JOIN silver.avl_pings p\n", + " ON p.vehicle_id = s.avl_vehicle_id\n", + " AND p.metric_timestamp >= s.trip_opening_timestamp\n", + " AND p.metric_timestamp <= s.trip_closing_timestamp\n", + " AND p.metric_timestamp >= '2023-11-01'\n", + " AND p.metric_timestamp < '2023-12-02'\n", + " WHERE s.avl_match_source = 'dictionary_vehicle'\n", + " ) x;\n", + " \"\"\")\n", + " sample_positions = cur.fetchone()[0]\n", + " sample_elapsed = time.monotonic() - start\n", + "\n", + " cur.execute(\"\"\"\n", + " SELECT count(*) FROM ml.trip_validity_trips t\n", + " JOIN ml.trip_validity_bus_avl_match m\n", + " ON m.bus_id = t.bus_id AND m.avl_matched;\n", + " \"\"\")\n", + " total_matched_trips = cur.fetchone()[0]\n", + "\n", + " cur.execute(\"DROP TABLE avl_timing_sample;\")\n", + "conn.commit()\n", + "\n", + "per_trip_seconds = sample_elapsed / SAMPLE_SIZE\n", + "estimated_total_seconds = per_trip_seconds * total_matched_trips\n", + "print(f\"{SAMPLE_SIZE} trips -> {sample_positions} positions in {sample_elapsed:.2f}s\")\n", + "print(f\"total matched trips: {total_matched_trips}\")\n", + "print(f\"estimated full run: {estimated_total_seconds / 60:.1f} minutes\")" + ] + }, + { + "cell_type": "markdown", + "id": "db6963a3", + "metadata": {}, + "source": [ + "### Full run\n", + "\n", + "Runs in batches over the matched trips (not one giant statement), each\n", + "batch a small `INSERT ... SELECT ... UNION ALL ...` (one branch per\n", + "match source, `ON CONFLICT DO NOTHING` for the rare duplicate ping)\n", + "with `SET LOCAL enable_hashjoin = off` / `enable_mergejoin = off`\n", + "scoped to just that batch's transaction - see the timing-sample cell\n", + "above for why both are needed to get Postgres to actually use the\n", + "covering indexes via a nested loop, instead of sequentially scanning\n", + "the whole partition. The static `metric_timestamp >= '2023-11-01' AND <\n", + "'2023-12-02'` bound (redundant with the per-row window, which is always\n", + "inside November 2023) guarantees partition pruning down to just\n", + "`avl_pings_y2023m11` at plan time.\n", + "\n", + "Batching exists for progress visibility, not correctness: a single\n", + "`INSERT` gives no way to check progress mid-run short of guessing from\n", + "`EXPLAIN`'s row estimates, which was a real problem earlier - a run\n", + "just looks identically \"still going\" whether it's 10% done or 95% done.\n", + "Each batch's query is tagged with a SQL comment (`/* batch i/N */`),\n", + "so progress is checkable anytime from *outside* this notebook via\n", + "`SELECT query FROM pg_stat_activity WHERE ...` - no separate log file\n", + "or table needed, and it's the same live-inspection approach already\n", + "used throughout this notebook's development.\n", + "\n", + "**A third planner trap, found while testing this exact batched form**:\n", + "the obvious way to pass a batch is `WHERE t.trip_id = ANY(%(batch)s)`.\n", + "That alone silently degrades - psycopg infers the *narrowest* array\n", + "type that fits the values (`smallint[]`, since trip_ids fit), compared\n", + "against `trip_id`'s real type `bigint`, pushing the check from an\n", + "`Index Cond` to an un-indexed `Filter`. Casting explicitly\n", + "(`ANY(%(batch)s::bigint[])`) fixes *that* mismatch but uncovers a\n", + "second, deeper issue: even with the right type, Postgres treats a bound\n", + "array parameter as opaque at plan time and can choose to sequentially\n", + "scan all ~941K trips per batch rather than use the primary key index -\n", + "confirmed live via `EXPLAIN (ANALYZE, BUFFERS)`, a 6.5-second scan for\n", + "one branch of one 5,000-row batch. Batch 1 stalled for 9+ minutes on\n", + "the original form with no error, only found by comparing the *actual*\n", + "psycopg-parameterized query's plan (via a throwaway script) against the\n", + "literal-array version that worked fine.\n", + "\n", + "The fix that actually works: build each batch as an inline `VALUES`\n", + "CTE (`WITH b (...) AS (VALUES (...), (...), ...)`), with the batch's\n", + "own rows composed as literals via `sql.Literal` (safe against\n", + "injection - values are DB data, not attacker input, but composed\n", + "properly regardless). A `VALUES` list is real, visible data to the\n", + "planner, not an opaque parameter, so it gets treated like the earlier\n", + "temp-table tests that worked - confirmed via the same script: the exact\n", + "5,000-row batch that took 6.5s+ per branch with `ANY()` completed in\n", + "0.14s total as a `VALUES` CTE." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "1c541f63", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-15T17:21:03.359245Z", + "iopub.status.busy": "2026-08-15T17:21:03.359169Z", + "iopub.status.idle": "2026-08-15T17:21:04.497913Z", + "shell.execute_reply": "2026-08-15T17:21:04.497484Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "931005 matched trips, 187 batches of up to 5000\n" + ] + } + ], + "source": [ + "with conn.cursor() as cur:\n", + " cur.execute(\"\"\"\n", + " SELECT t.trip_id, t.trip_opening_timestamp, t.trip_closing_timestamp,\n", + " m.avl_match_source, m.avl_vehicle_id, m.avl_device_id\n", + " FROM ml.trip_validity_trips t\n", + " JOIN ml.trip_validity_bus_avl_match m\n", + " ON m.bus_id = t.bus_id AND m.avl_matched\n", + " ORDER BY t.trip_id;\n", + " \"\"\")\n", + " matched_trips = cur.fetchall()\n", + "\n", + "BATCH_SIZE = 5000\n", + "batches = [\n", + " matched_trips[i : i + BATCH_SIZE] for i in range(0, len(matched_trips), BATCH_SIZE)\n", + "]\n", + "print(\n", + " f\"{len(matched_trips)} matched trips, {len(batches)} batches of up to {BATCH_SIZE}\"\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "f1e0e83f", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-15T17:21:04.498749Z", + "iopub.status.busy": "2026-08-15T17:21:04.498671Z", + "iopub.status.idle": "2026-08-15T17:29:00.444313Z", + "shell.execute_reply": "2026-08-15T17:29:00.443893Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 1/187: 2.3s (total 0.0min, ETA 7.2min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 2/187: 2.3s (total 0.1min, ETA 7.1min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 3/187: 2.3s (total 0.1min, ETA 7.0min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 4/187: 2.3s (total 0.2min, ETA 7.0min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 5/187: 2.3s (total 0.2min, ETA 7.0min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 6/187: 2.3s (total 0.2min, ETA 6.9min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 7/187: 2.3s (total 0.3min, ETA 6.9min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 8/187: 2.2s (total 0.3min, ETA 6.8min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 9/187: 2.2s (total 0.3min, ETA 6.7min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 10/187: 2.2s (total 0.4min, ETA 6.7min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 11/187: 2.3s (total 0.4min, ETA 6.7min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 12/187: 2.3s (total 0.5min, ETA 6.6min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 13/187: 2.2s (total 0.5min, ETA 6.6min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 14/187: 2.2s (total 0.5min, ETA 6.5min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 15/187: 2.2s (total 0.6min, ETA 6.4min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 16/187: 2.2s (total 0.6min, ETA 6.4min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 17/187: 2.1s (total 0.6min, ETA 6.3min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 18/187: 2.2s (total 0.7min, ETA 6.3min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 19/187: 2.1s (total 0.7min, ETA 6.2min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 20/187: 2.2s (total 0.7min, ETA 6.2min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 21/187: 2.2s (total 0.8min, ETA 6.1min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 22/187: 2.1s (total 0.8min, ETA 6.1min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 23/187: 2.1s (total 0.8min, ETA 6.0min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 24/187: 2.1s (total 0.9min, ETA 6.0min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 25/187: 2.2s (total 0.9min, ETA 6.0min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 26/187: 2.2s (total 1.0min, ETA 5.9min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 27/187: 2.1s (total 1.0min, ETA 5.9min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 28/187: 2.2s (total 1.0min, ETA 5.8min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 29/187: 2.1s (total 1.1min, ETA 5.8min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 30/187: 2.2s (total 1.1min, ETA 5.8min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 31/187: 2.1s (total 1.1min, ETA 5.7min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 32/187: 2.2s (total 1.2min, ETA 5.7min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 33/187: 2.2s (total 1.2min, ETA 5.6min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 34/187: 2.2s (total 1.2min, ETA 5.6min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 35/187: 2.2s (total 1.3min, ETA 5.6min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 36/187: 2.2s (total 1.3min, ETA 5.5min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 37/187: 2.2s (total 1.4min, ETA 5.5min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 38/187: 2.2s (total 1.4min, ETA 5.5min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 39/187: 2.2s (total 1.4min, ETA 5.4min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 40/187: 2.1s (total 1.5min, ETA 5.4min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 41/187: 2.1s (total 1.5min, ETA 5.3min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 42/187: 2.2s (total 1.5min, ETA 5.3min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 43/187: 2.2s (total 1.6min, ETA 5.3min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 44/187: 2.2s (total 1.6min, ETA 5.2min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 45/187: 2.1s (total 1.6min, ETA 5.2min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 46/187: 2.1s (total 1.7min, ETA 5.2min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 47/187: 2.2s (total 1.7min, ETA 5.1min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 48/187: 3.9s (total 1.8min, ETA 5.2min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 49/187: 2.1s (total 1.8min, ETA 5.1min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 50/187: 2.1s (total 1.9min, ETA 5.1min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 51/187: 2.2s (total 1.9min, ETA 5.0min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 52/187: 2.1s (total 1.9min, ETA 5.0min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 53/187: 2.1s (total 2.0min, ETA 5.0min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 54/187: 2.1s (total 2.0min, ETA 4.9min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 55/187: 2.2s (total 2.0min, ETA 4.9min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 56/187: 2.2s (total 2.1min, ETA 4.8min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 57/187: 2.3s (total 2.1min, ETA 4.8min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 58/187: 2.2s (total 2.1min, ETA 4.8min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 59/187: 2.2s (total 2.2min, ETA 4.7min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 60/187: 2.2s (total 2.2min, ETA 4.7min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 61/187: 2.3s (total 2.3min, ETA 4.7min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 62/187: 2.2s (total 2.3min, ETA 4.6min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 63/187: 2.1s (total 2.3min, ETA 4.6min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 64/187: 2.2s (total 2.4min, ETA 4.5min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 65/187: 2.1s (total 2.4min, ETA 4.5min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 66/187: 2.2s (total 2.4min, ETA 4.5min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 67/187: 2.2s (total 2.5min, ETA 4.4min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 68/187: 2.2s (total 2.5min, ETA 4.4min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 69/187: 2.2s (total 2.5min, ETA 4.4min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 70/187: 2.1s (total 2.6min, ETA 4.3min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 71/187: 2.2s (total 2.6min, ETA 4.3min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 72/187: 2.2s (total 2.7min, ETA 4.2min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 73/187: 2.2s (total 2.7min, ETA 4.2min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 74/187: 2.2s (total 2.7min, ETA 4.2min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 75/187: 2.2s (total 2.8min, ETA 4.1min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 76/187: 2.1s (total 2.8min, ETA 4.1min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 77/187: 2.2s (total 2.8min, ETA 4.1min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 78/187: 2.2s (total 2.9min, ETA 4.0min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 79/187: 2.2s (total 2.9min, ETA 4.0min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 80/187: 2.2s (total 2.9min, ETA 3.9min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 81/187: 2.2s (total 3.0min, ETA 3.9min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 82/187: 2.2s (total 3.0min, ETA 3.9min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 83/187: 2.2s (total 3.1min, ETA 3.8min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 84/187: 2.3s (total 3.1min, ETA 3.8min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 85/187: 2.2s (total 3.1min, ETA 3.8min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 86/187: 2.1s (total 3.2min, ETA 3.7min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 87/187: 2.2s (total 3.2min, ETA 3.7min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 88/187: 2.3s (total 3.2min, ETA 3.6min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 89/187: 2.2s (total 3.3min, ETA 3.6min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 90/187: 2.2s (total 3.3min, ETA 3.6min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 91/187: 2.1s (total 3.3min, ETA 3.5min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 92/187: 2.2s (total 3.4min, ETA 3.5min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 93/187: 2.2s (total 3.4min, ETA 3.5min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 94/187: 2.1s (total 3.5min, ETA 3.4min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 95/187: 2.2s (total 3.5min, ETA 3.4min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 96/187: 2.2s (total 3.5min, ETA 3.3min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 97/187: 2.2s (total 3.6min, ETA 3.3min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 98/187: 2.1s (total 3.6min, ETA 3.3min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 99/187: 2.2s (total 3.6min, ETA 3.2min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 100/187: 2.2s (total 3.7min, ETA 3.2min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 101/187: 2.1s (total 3.7min, ETA 3.2min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 102/187: 2.2s (total 3.7min, ETA 3.1min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 103/187: 2.1s (total 3.8min, ETA 3.1min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 104/187: 2.2s (total 3.8min, ETA 3.1min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 105/187: 2.3s (total 3.9min, ETA 3.0min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 106/187: 2.2s (total 3.9min, ETA 3.0min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 107/187: 2.2s (total 3.9min, ETA 2.9min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 108/187: 2.2s (total 4.0min, ETA 2.9min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 109/187: 2.2s (total 4.0min, ETA 2.9min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 110/187: 2.2s (total 4.0min, ETA 2.8min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 111/187: 2.3s (total 4.1min, ETA 2.8min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 112/187: 2.2s (total 4.1min, ETA 2.8min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 113/187: 2.2s (total 4.2min, ETA 2.7min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 114/187: 2.2s (total 4.2min, ETA 2.7min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 115/187: 2.3s (total 4.2min, ETA 2.6min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 116/187: 2.2s (total 4.3min, ETA 2.6min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 117/187: 2.2s (total 4.3min, ETA 2.6min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 118/187: 2.2s (total 4.3min, ETA 2.5min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 119/187: 2.2s (total 4.4min, ETA 2.5min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 120/187: 2.2s (total 4.4min, ETA 2.5min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 121/187: 2.2s (total 4.4min, ETA 2.4min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 122/187: 2.2s (total 4.5min, ETA 2.4min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 123/187: 2.1s (total 4.5min, ETA 2.4min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 124/187: 2.2s (total 4.6min, ETA 2.3min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 125/187: 2.2s (total 4.6min, ETA 2.3min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 126/187: 2.2s (total 4.6min, ETA 2.2min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 127/187: 2.2s (total 4.7min, ETA 2.2min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 128/187: 2.2s (total 4.7min, ETA 2.2min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 129/187: 2.2s (total 4.7min, ETA 2.1min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 130/187: 2.1s (total 4.8min, ETA 2.1min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 131/187: 2.2s (total 4.8min, ETA 2.1min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 132/187: 2.1s (total 4.8min, ETA 2.0min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 133/187: 2.2s (total 4.9min, ETA 2.0min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 134/187: 2.1s (total 4.9min, ETA 1.9min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 135/187: 2.2s (total 5.0min, ETA 1.9min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 136/187: 2.2s (total 5.0min, ETA 1.9min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 137/187: 2.2s (total 5.0min, ETA 1.8min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 138/187: 2.2s (total 5.1min, ETA 1.8min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 139/187: 2.2s (total 5.1min, ETA 1.8min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 140/187: 2.2s (total 5.1min, ETA 1.7min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 141/187: 2.2s (total 5.2min, ETA 1.7min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 142/187: 2.1s (total 5.2min, ETA 1.6min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 143/187: 2.2s (total 5.2min, ETA 1.6min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 144/187: 2.2s (total 5.3min, ETA 1.6min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 145/187: 2.2s (total 5.3min, ETA 1.5min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 146/187: 2.2s (total 5.4min, ETA 1.5min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 147/187: 2.2s (total 5.4min, ETA 1.5min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 148/187: 2.2s (total 5.4min, ETA 1.4min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 149/187: 2.2s (total 5.5min, ETA 1.4min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 150/187: 2.2s (total 5.5min, ETA 1.4min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 151/187: 2.2s (total 5.5min, ETA 1.3min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 152/187: 2.1s (total 5.6min, ETA 1.3min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 153/187: 2.2s (total 5.6min, ETA 1.2min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 154/187: 2.2s (total 5.6min, ETA 1.2min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 155/187: 2.2s (total 5.7min, ETA 1.2min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 156/187: 2.2s (total 5.7min, ETA 1.1min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 157/187: 2.1s (total 5.8min, ETA 1.1min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 158/187: 2.2s (total 5.8min, ETA 1.1min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 159/187: 2.2s (total 5.8min, ETA 1.0min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 160/187: 2.2s (total 5.9min, ETA 1.0min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 161/187: 2.2s (total 5.9min, ETA 1.0min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 162/187: 2.2s (total 5.9min, ETA 0.9min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 163/187: 2.3s (total 6.0min, ETA 0.9min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 164/187: 2.2s (total 6.0min, ETA 0.8min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 165/187: 2.1s (total 6.0min, ETA 0.8min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 166/187: 2.1s (total 6.1min, ETA 0.8min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 167/187: 2.2s (total 6.1min, ETA 0.7min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 168/187: 2.2s (total 6.2min, ETA 0.7min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 169/187: 2.1s (total 6.2min, ETA 0.7min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 170/187: 2.2s (total 6.2min, ETA 0.6min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 171/187: 2.2s (total 6.3min, ETA 0.6min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 172/187: 2.1s (total 6.3min, ETA 0.5min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 173/187: 2.1s (total 6.3min, ETA 0.5min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 174/187: 2.1s (total 6.4min, ETA 0.5min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 175/187: 2.2s (total 6.4min, ETA 0.4min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 176/187: 2.2s (total 6.4min, ETA 0.4min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 177/187: 2.2s (total 6.5min, ETA 0.4min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 178/187: 2.1s (total 6.5min, ETA 0.3min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 179/187: 2.3s (total 6.6min, ETA 0.3min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 180/187: 2.2s (total 6.6min, ETA 0.3min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 181/187: 2.2s (total 6.6min, ETA 0.2min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 182/187: 2.2s (total 6.7min, ETA 0.2min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 183/187: 2.2s (total 6.7min, ETA 0.1min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 184/187: 2.2s (total 6.7min, ETA 0.1min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 185/187: 2.3s (total 6.8min, ETA 0.1min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 186/187: 2.3s (total 6.8min, ETA 0.0min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "batch 187/187: 0.4s (total 6.8min, ETA 0.0min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "positions inserted in 6.8 minutes total\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "index created, table analyzed\n" + ] + } + ], + "source": [ + "def build_batch_insert(\n", + " batch: list[tuple], batch_num: int, n_batches: int\n", + ") -> sql.Composed:\n", + " \"\"\"Build one batch's INSERT as a VALUES CTE, not an ANY() array parameter.\n", + "\n", + " psycopg's array-parameter binding turned out to be a planner trap in\n", + " its own right (see markdown above) - a VALUES CTE with the batch's\n", + " rows composed as literals keeps the data visible to the planner at\n", + " plan time, which is what actually gets the covering-index nested\n", + " loop chosen.\n", + " \"\"\"\n", + " values_rows = sql.SQL(\", \").join(\n", + " sql.SQL(\"({}, {}, {}, {}, {}, {})\").format(\n", + " sql.Literal(trip_id),\n", + " sql.Literal(opened),\n", + " sql.Literal(closed),\n", + " sql.Literal(match_source),\n", + " sql.Literal(vehicle_id),\n", + " sql.Literal(device_id),\n", + " )\n", + " for trip_id, opened, closed, match_source, vehicle_id, device_id in batch\n", + " )\n", + " comment = sql.SQL(f\"/* trip_positions batch {batch_num}/{n_batches} */\\n\")\n", + " body = sql.SQL(\"\"\"\n", + " WITH b (trip_id, trip_opening_timestamp, trip_closing_timestamp,\n", + " avl_match_source, avl_vehicle_id, avl_device_id) AS (\n", + " VALUES {values}\n", + " )\n", + " INSERT INTO ml.trip_validity_trip_positions (\n", + " trip_id, metric_timestamp, latitude, longitude, speed, odometer\n", + " )\n", + " SELECT b.trip_id, p.metric_timestamp, p.latitude, p.longitude,\n", + " p.speed, p.odometer\n", + " FROM b\n", + " JOIN silver.avl_pings p\n", + " ON p.device_id = b.avl_device_id\n", + " AND p.metric_timestamp >= b.trip_opening_timestamp\n", + " AND p.metric_timestamp <= b.trip_closing_timestamp\n", + " AND p.metric_timestamp >= '2023-11-01'\n", + " AND p.metric_timestamp < '2023-12-02'\n", + " WHERE b.avl_match_source = 'dictionary_device'\n", + "\n", + " UNION ALL\n", + "\n", + " SELECT b.trip_id, p.metric_timestamp, p.latitude, p.longitude,\n", + " p.speed, p.odometer\n", + " FROM b\n", + " JOIN silver.avl_pings p\n", + " ON p.vehicle_id = b.avl_vehicle_id\n", + " AND p.metric_timestamp >= b.trip_opening_timestamp\n", + " AND p.metric_timestamp <= b.trip_closing_timestamp\n", + " AND p.metric_timestamp >= '2023-11-01'\n", + " AND p.metric_timestamp < '2023-12-02'\n", + " WHERE b.avl_match_source = 'dictionary_vehicle'\n", + "\n", + " ON CONFLICT (trip_id, metric_timestamp) DO NOTHING;\n", + " \"\"\").format(values=values_rows)\n", + " return comment + body\n", + "\n", + "\n", + "overall_start = time.monotonic()\n", + "for i, batch in enumerate(batches, start=1):\n", + " batch_start = time.monotonic()\n", + " with conn.cursor() as cur:\n", + " cur.execute(\"SET LOCAL enable_hashjoin = off;\")\n", + " cur.execute(\"SET LOCAL enable_mergejoin = off;\")\n", + " cur.execute(\"SET LOCAL jit = off;\")\n", + " cur.execute(build_batch_insert(batch, i, len(batches)))\n", + " conn.commit()\n", + "\n", + " elapsed_total = time.monotonic() - overall_start\n", + " avg_per_batch = elapsed_total / i\n", + " eta_minutes = avg_per_batch * (len(batches) - i) / 60\n", + " print(\n", + " f\"batch {i}/{len(batches)}: {time.monotonic() - batch_start:.1f}s \"\n", + " f\"(total {elapsed_total / 60:.1f}min, ETA {eta_minutes:.1f}min)\",\n", + " flush=True,\n", + " )\n", + "\n", + "print(\n", + " f\"positions inserted in {(time.monotonic() - overall_start) / 60:.1f} minutes total\"\n", + ")\n", + "\n", + "conn.execute(\"\"\"\n", + " CREATE INDEX trip_validity_trip_positions_geom_idx\n", + " ON ml.trip_validity_trip_positions USING GIST (geom);\n", + " ANALYZE ml.trip_validity_trip_positions;\n", + "\"\"\")\n", + "conn.commit()\n", + "print(\"index created, table analyzed\")" + ] + }, + { + "cell_type": "markdown", + "id": "351d9687", + "metadata": {}, + "source": [ + "## Column-level provenance comments" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "12cbc0c3", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-15T17:29:00.445689Z", + "iopub.status.busy": "2026-08-15T17:29:00.445617Z", + "iopub.status.idle": "2026-08-15T17:29:00.451029Z", + "shell.execute_reply": "2026-08-15T17:29:00.450730Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "comments applied\n" + ] + } + ], + "source": [ + "def comment_on_column(cur: psycopg.Cursor, table: str, col: str, text: str) -> None:\n", + " \"\"\"Apply a COMMENT ON COLUMN for one column via safe SQL composition.\"\"\"\n", + " cur.execute(\n", + " sql.SQL(\"COMMENT ON COLUMN ml.{}.{} IS {};\").format(\n", + " sql.Identifier(table), sql.Identifier(col), sql.Literal(text)\n", + " )\n", + " )\n", + "\n", + "\n", + "MATCH_COMMENTS = {\n", + " \"bus_id\": \"= ml.trip_validity_trips.bus_id. PK here too.\",\n", + " \"avl_matched\": (\n", + " \"True iff this bus_id resolved to an AVL vehicle_id or device_id \"\n", + " \"via either crosswalk. False (never NULL) otherwise.\"\n", + " ),\n", + " \"avl_match_source\": (\n", + " \"Which silver crosswalk resolved the match: 'dictionary_device' \"\n", + " \"(preferred when both match) or 'dictionary_vehicle' (fallback, \"\n", + " \"used only when no dictionary_device match exists). NULL iff \"\n", + " \"avl_matched is false.\"\n", + " ),\n", + " \"avl_vehicle_id\": (\n", + " \"silver.avl_pings.vehicle_id to join positions on, from \"\n", + " \"silver.dictionary_vehicle.id_veiculo. Populated only when \"\n", + " \"avl_match_source = 'dictionary_vehicle'.\"\n", + " ),\n", + " \"avl_device_id\": (\n", + " \"silver.avl_pings.device_id to join positions on, from \"\n", + " \"silver.dictionary_device.device_id. Populated only when \"\n", + " \"avl_match_source = 'dictionary_device'.\"\n", + " ),\n", + "}\n", + "POSITIONS_COMMENTS = {\n", + " \"trip_id\": \"= ml.trip_validity_trips.trip_id (FK). Part of the PK.\",\n", + " \"metric_timestamp\": (\n", + " \"silver.avl_pings.metric_timestamp (UTC), as-is. Part of the PK \"\n", + " \"- PRIMARY KEY (trip_id, metric_timestamp) makes per-trip \"\n", + " \"retrieval a single ordered index range scan.\"\n", + " ),\n", + " \"latitude\": \"silver.avl_pings.latitude, as-is.\",\n", + " \"longitude\": \"silver.avl_pings.longitude, as-is.\",\n", + " \"speed\": \"silver.avl_pings.speed, as-is.\",\n", + " \"odometer\": \"silver.avl_pings.odometer, as-is.\",\n", + " \"geom\": (\n", + " \"GENERATED ALWAYS AS ST_SetSRID(ST_MakePoint(longitude, \"\n", + " \"latitude), 4326) STORED, same convention as silver.avl_pings' \"\n", + " \"own geom column.\"\n", + " ),\n", + "}\n", + "\n", + "with conn.cursor() as cur:\n", + " for col, text in MATCH_COMMENTS.items():\n", + " comment_on_column(cur, \"trip_validity_bus_avl_match\", col, text)\n", + " for col, text in POSITIONS_COMMENTS.items():\n", + " comment_on_column(cur, \"trip_validity_trip_positions\", col, text)\n", + "\n", + "conn.execute(\n", + " sql.SQL(\"COMMENT ON TABLE ml.trip_validity_bus_avl_match IS {};\").format(\n", + " sql.Literal(\n", + " \"One row per distinct bus_id in ml.trip_validity_trips, \"\n", + " \"resolved (if possible) to an AVL vehicle_id or device_id via \"\n", + " \"silver.dictionary_device (preferred) or \"\n", + " \"silver.dictionary_vehicle (fallback). See \"\n", + " \"ml/trip_validity_model/notebooks/04_avl_positions.ipynb.\"\n", + " )\n", + " )\n", + ")\n", + "conn.execute(\n", + " sql.SQL(\"COMMENT ON TABLE ml.trip_validity_trip_positions IS {};\").format(\n", + " sql.Literal(\n", + " \"One row per AVL ping within a matched trip's exact \"\n", + " \"[trip_opening_timestamp, trip_closing_timestamp] window, no \"\n", + " \"padding. See ml/trip_validity_model/notebooks/\"\n", + " \"04_avl_positions.ipynb.\"\n", + " )\n", + " )\n", + ")\n", + "conn.commit()\n", + "print(\"comments applied\")" + ] + }, + { + "cell_type": "markdown", + "id": "eddfc524", + "metadata": {}, + "source": [ + "## Verification" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "8c533590", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-15T17:29:00.451981Z", + "iopub.status.busy": "2026-08-15T17:29:00.451917Z", + "iopub.status.idle": "2026-08-15T17:29:22.929899Z", + "shell.execute_reply": "2026-08-15T17:29:22.929429Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "distinct trip bus_ids vs. match table rows: 1730 1730\n", + "bus rows with avl_matched/avl_match_source disagreement: 0\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "position rows belonging to an unmatched trip: 0\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "total positions: 80567162, trips with >=1 position: 655401, avg positions/trip: 122.9\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "spot-check trip 557298: window [2023-11-16 12:04:42+00:00, 2023-11-21 16:57:58+00:00], 18866 pings spanning [2023-11-16 12:04:43+00:00, 2023-11-21 16:57:47+00:00]\n", + "all checks passed\n" + ] + } + ], + "source": [ + "with conn.cursor() as cur:\n", + " cur.execute(\"\"\"\n", + " SELECT count(DISTINCT bus_id) FROM ml.trip_validity_trips;\n", + " \"\"\")\n", + " distinct_trip_bus_ids = cur.fetchone()[0]\n", + " cur.execute(\"SELECT count(*) FROM ml.trip_validity_bus_avl_match;\")\n", + " match_rows = cur.fetchone()[0]\n", + " print(\n", + " \"distinct trip bus_ids vs. match table rows:\",\n", + " distinct_trip_bus_ids,\n", + " match_rows,\n", + " )\n", + " if distinct_trip_bus_ids != match_rows:\n", + " msg = \"bus_avl_match doesn't cover every distinct bus_id in trips\"\n", + " raise AssertionError(msg)\n", + "\n", + " # avl_match_source must be NULL exactly when avl_matched is false\n", + " cur.execute(\"\"\"\n", + " SELECT count(*) FROM ml.trip_validity_bus_avl_match\n", + " WHERE avl_matched <> (avl_match_source IS NOT NULL);\n", + " \"\"\")\n", + " inconsistent = cur.fetchone()[0]\n", + " print(\"bus rows with avl_matched/avl_match_source disagreement:\", inconsistent)\n", + " if inconsistent != 0:\n", + " msg = \"avl_matched and avl_match_source disagree on some rows\"\n", + " raise AssertionError(msg)\n", + "\n", + " # positions must only exist for matched trips\n", + " cur.execute(\"\"\"\n", + " SELECT count(*) FROM ml.trip_validity_trip_positions pos\n", + " JOIN ml.trip_validity_trips t ON t.trip_id = pos.trip_id\n", + " JOIN ml.trip_validity_bus_avl_match m ON m.bus_id = t.bus_id\n", + " WHERE NOT m.avl_matched;\n", + " \"\"\")\n", + " orphan_positions = cur.fetchone()[0]\n", + " print(\"position rows belonging to an unmatched trip:\", orphan_positions)\n", + " if orphan_positions != 0:\n", + " msg = \"found position rows for unmatched trips\"\n", + " raise AssertionError(msg)\n", + "\n", + " cur.execute(\"SELECT count(*) FROM ml.trip_validity_trip_positions;\")\n", + " total_positions = cur.fetchone()[0]\n", + " cur.execute(\"\"\"\n", + " SELECT count(DISTINCT trip_id) FROM ml.trip_validity_trip_positions;\n", + " \"\"\")\n", + " trips_with_positions = cur.fetchone()[0]\n", + " print(\n", + " f\"total positions: {total_positions}, \"\n", + " f\"trips with >=1 position: {trips_with_positions}, \"\n", + " f\"avg positions/trip: {total_positions / trips_with_positions:.1f}\"\n", + " )\n", + "\n", + " # spot-check: a real matched trip's positions stay within its own window\n", + " cur.execute(\"\"\"\n", + " SELECT t.trip_id, t.trip_opening_timestamp, t.trip_closing_timestamp,\n", + " min(pos.metric_timestamp), max(pos.metric_timestamp), count(*)\n", + " FROM ml.trip_validity_trip_positions pos\n", + " JOIN ml.trip_validity_trips t ON t.trip_id = pos.trip_id\n", + " GROUP BY t.trip_id, t.trip_opening_timestamp, t.trip_closing_timestamp\n", + " ORDER BY count(*) DESC\n", + " LIMIT 1;\n", + " \"\"\")\n", + " trip_id, opened, closed, first_ping, last_ping, n = cur.fetchone()\n", + " print(\n", + " f\"spot-check trip {trip_id}: window [{opened}, {closed}], \"\n", + " f\"{n} pings spanning [{first_ping}, {last_ping}]\"\n", + " )\n", + " if first_ping < opened or last_ping > closed:\n", + " msg = \"a trip's positions fall outside its own window\"\n", + " raise AssertionError(msg)\n", + "\n", + "print(\"all checks passed\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (opa-database)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/ml/trip_validity_model/notebooks/05_final_dataset.ipynb b/ml/trip_validity_model/notebooks/05_final_dataset.ipynb new file mode 100644 index 0000000..7e4e1f7 --- /dev/null +++ b/ml/trip_validity_model/notebooks/05_final_dataset.ipynb @@ -0,0 +1,2169 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cc37a28d", + "metadata": {}, + "source": [ + "# 05 - Build `ml.trip_validity_dataset`\n", + "\n", + "The final, ML-ready table for the Trip Validity model: every identifier,\n", + "every raw metric from `ml.trip_validity_trip_metrics`, and every\n", + "relative/normalized column surveyed and agreed on beforehand - 80\n", + "columns total (15 identifiers + 40 existing metrics/counts + 25 new\n", + "relative columns).\n", + "\n", + "Built with a single `CREATE TABLE ... AS SELECT` from\n", + "`ml.trip_validity_trip_metrics` joined to `ml.trip_validity_trips` (for\n", + "`bus_id`/`trip_date`/`trip_hour`/`trip_opening_timestamp`/\n", + "`trip_closing_timestamp`, which live on `trips` but weren't carried onto\n", + "`trip_metrics`) and left-joined to `ml.trip_validity_bus_avl_match`\n", + "(built in `04_avl_positions.ipynb`, for `avl_matched`/`avl_match_source`)\n", + "- no expensive spatial recomputation, just arithmetic on already-materialized\n", + "columns plus two cheap key lookups, so this should run in seconds, not\n", + "minutes.\n", + "\n", + "**Column order**: identifiers first, then five themed groups (duration,\n", + "fares, geometry, path match, directional progress), each ordered raw →\n", + "count → relative, so a raw value and the ratio computed from it always\n", + "sit next to each other.\n", + "\n", + "**Two conventions applied uniformly**:\n", + "- Every ratio divides through `NULLIF(denominator, 0)`, since Postgres's\n", + " floating-point division does not error on divide-by-zero - it silently\n", + " returns `Infinity`, which would sit in the table looking like a real\n", + " number and quietly break anything downstream.\n", + "- The four `path_match_score_*` columns are clipped to `[0, 1]` via\n", + " `GREATEST(0, ...)`- but `GREATEST`/`LEAST` in Postgres **ignore NULL\n", + " arguments** rather than propagating them (unlike plain arithmetic,\n", + " which is NULL-safe), so `GREATEST(0, NULL)` silently returns `0`, not\n", + " `NULL`. Left unguarded, a trip with no matched shape would wrongly\n", + " show a match score of `0` (\"worst possible match\") instead of `NULL`\n", + " (\"not applicable\"). Each of those four columns is wrapped in a `CASE`\n", + " that checks whether the *unclipped* ratio is `NULL` first, and only\n", + " applies `GREATEST` when it's a real number." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "0de7ebd3", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-16T17:31:35.692722Z", + "iopub.status.busy": "2026-08-16T17:31:35.692645Z", + "iopub.status.idle": "2026-08-16T17:31:35.719924Z", + "shell.execute_reply": "2026-08-16T17:31:35.719543Z" + } + }, + "outputs": [], + "source": [ + "import os\n", + "from pathlib import Path\n", + "\n", + "import psycopg\n", + "from psycopg import sql" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "11205ea3", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-16T17:31:35.721120Z", + "iopub.status.busy": "2026-08-16T17:31:35.721053Z", + "iopub.status.idle": "2026-08-16T17:31:35.723825Z", + "shell.execute_reply": "2026-08-16T17:31:35.723496Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "'/home/victor/repos/opa-database'" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "_root = Path.cwd()\n", + "while not (_root / \"pyproject.toml\").exists():\n", + " _root = _root.parent\n", + "os.chdir(_root)\n", + "os.environ.setdefault(\"RAW_DATA_ROOT\", str(_root))" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "929d0ff3", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-16T17:31:35.724748Z", + "iopub.status.busy": "2026-08-16T17:31:35.724685Z", + "iopub.status.idle": "2026-08-16T17:31:35.769214Z", + "shell.execute_reply": "2026-08-16T17:31:35.768873Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "connected\n" + ] + } + ], + "source": [ + "from opa_database.config import settings\n", + "\n", + "conn = psycopg.connect(settings.db_dsn)\n", + "print(\"connected\")" + ] + }, + { + "cell_type": "markdown", + "id": "96b7770e", + "metadata": {}, + "source": [ + "## Build the table" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "8c98022e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-16T17:31:35.770236Z", + "iopub.status.busy": "2026-08-16T17:31:35.770167Z", + "iopub.status.idle": "2026-08-16T17:31:38.077478Z", + "shell.execute_reply": "2026-08-16T17:31:38.077017Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "rows: 940988\n", + "columns: 80\n" + ] + } + ], + "source": [ + "conn.execute(\"DROP TABLE IF EXISTS ml.trip_validity_dataset CASCADE;\")\n", + "\n", + "conn.execute(\"\"\"\n", + " CREATE TABLE ml.trip_validity_dataset AS\n", + " SELECT\n", + " -- ===== Identifiers =====\n", + " tm.trip_id,\n", + " t.bus_id,\n", + " tm.route_id,\n", + " tm.route_direction,\n", + " t.trip_date,\n", + " t.trip_hour,\n", + " t.trip_opening_timestamp,\n", + " t.trip_closing_timestamp,\n", + " tm.gtfs_feed_version_date,\n", + " tm.gtfs_route_short_name,\n", + " tm.gtfs_shape_id_i,\n", + " tm.gtfs_shape_id_v,\n", + " tm.gtfs_route_has_both_directions,\n", + "\n", + " -- ===== AVL position matching =====\n", + " COALESCE(bm.avl_matched, false) AS avl_matched,\n", + " bm.avl_match_source,\n", + "\n", + " -- ===== Duration vs. expectations =====\n", + " tm.trip_duration_seconds,\n", + "\n", + " tm.route_avg_trip_duration_seconds_loo,\n", + " tm.route_avg_trip_duration_n_trips_loo,\n", + " tm.trip_duration_seconds\n", + " / NULLIF(tm.route_avg_trip_duration_seconds_loo, 0)\n", + " AS trip_duration_ratio_to_route_avg,\n", + "\n", + " tm.route_direction_avg_trip_duration_seconds_loo,\n", + " tm.route_direction_avg_trip_duration_n_trips_loo,\n", + " tm.trip_duration_seconds\n", + " / NULLIF(tm.route_direction_avg_trip_duration_seconds_loo, 0)\n", + " AS trip_duration_ratio_to_route_direction_avg,\n", + "\n", + " tm.route_hour_avg_trip_duration_seconds_loo,\n", + " tm.route_hour_avg_trip_duration_n_trips_loo,\n", + " tm.trip_duration_seconds\n", + " / NULLIF(tm.route_hour_avg_trip_duration_seconds_loo, 0)\n", + " AS trip_duration_ratio_to_route_hour_avg,\n", + "\n", + " tm.route_direction_hour_avg_trip_duration_seconds_loo,\n", + " tm.route_direction_hour_avg_trip_duration_n_trips_loo,\n", + " tm.trip_duration_seconds\n", + " / NULLIF(tm.route_direction_hour_avg_trip_duration_seconds_loo, 0)\n", + " AS trip_duration_ratio_to_route_direction_hour_avg,\n", + "\n", + " tm.route_reverse_direction_avg_trip_duration_seconds,\n", + " tm.route_reverse_direction_n_trips,\n", + " tm.trip_duration_seconds\n", + " / NULLIF(tm.route_reverse_direction_avg_trip_duration_seconds, 0)\n", + " AS trip_duration_ratio_to_reverse_direction_avg,\n", + "\n", + " tm.route_reverse_direction_hour_avg_trip_duration_seconds,\n", + " tm.route_reverse_direction_hour_n_trips,\n", + " tm.trip_duration_seconds\n", + " / NULLIF(tm.route_reverse_direction_hour_avg_trip_duration_seconds, 0)\n", + " AS trip_duration_ratio_to_reverse_direction_hour_avg,\n", + "\n", + " tm.route_i_scheduled_duration_avg_seconds,\n", + " tm.route_i_scheduled_duration_n_trips,\n", + " tm.trip_duration_seconds\n", + " / NULLIF(tm.route_i_scheduled_duration_avg_seconds, 0)\n", + " AS trip_duration_ratio_to_scheduled_i,\n", + "\n", + " tm.route_v_scheduled_duration_avg_seconds,\n", + " tm.route_v_scheduled_duration_n_trips,\n", + " tm.trip_duration_seconds\n", + " / NULLIF(tm.route_v_scheduled_duration_avg_seconds, 0)\n", + " AS trip_duration_ratio_to_scheduled_v,\n", + "\n", + " tm.route_i_scheduled_duration_avg_seconds_at_hour,\n", + " tm.route_i_scheduled_duration_n_trips_at_hour,\n", + " tm.trip_duration_seconds\n", + " / NULLIF(tm.route_i_scheduled_duration_avg_seconds_at_hour, 0)\n", + " AS trip_duration_ratio_to_scheduled_i_at_hour,\n", + "\n", + " tm.route_v_scheduled_duration_avg_seconds_at_hour,\n", + " tm.route_v_scheduled_duration_n_trips_at_hour,\n", + " tm.trip_duration_seconds\n", + " / NULLIF(tm.route_v_scheduled_duration_avg_seconds_at_hour, 0)\n", + " AS trip_duration_ratio_to_scheduled_v_at_hour,\n", + "\n", + " -- ===== Fare activity =====\n", + " tm.trip_fare_count,\n", + " tm.fare_gap_avg_seconds,\n", + " tm.fare_gap_stddev_seconds,\n", + " tm.fare_gap_stddev_seconds / NULLIF(tm.fare_gap_avg_seconds, 0)\n", + " AS fare_gap_coefficient_of_variation,\n", + " tm.fare_gap_avg_seconds / NULLIF(tm.trip_duration_seconds, 0)\n", + " AS fare_gap_avg_ratio_to_duration,\n", + " tm.fare_span_seconds,\n", + " tm.fare_span_seconds / NULLIF(tm.trip_duration_seconds, 0)\n", + " AS fare_span_ratio_to_duration,\n", + "\n", + " -- ===== Trip geometry vs. route length =====\n", + " tm.trip_distance_meters,\n", + " tm.route_i_length_meters,\n", + " tm.trip_distance_meters / NULLIF(tm.route_i_length_meters, 0)\n", + " AS trip_distance_ratio_to_route_i,\n", + " tm.route_v_length_meters,\n", + " tm.trip_distance_meters / NULLIF(tm.route_v_length_meters, 0)\n", + " AS trip_distance_ratio_to_route_v,\n", + " tm.trip_points_standard_distance_meters,\n", + " tm.trip_points_standard_distance_meters\n", + " / NULLIF(tm.route_i_length_meters, 0)\n", + " AS trip_cohesion_ratio_to_route_i,\n", + " tm.trip_points_standard_distance_meters\n", + " / NULLIF(tm.route_v_length_meters, 0)\n", + " AS trip_cohesion_ratio_to_route_v,\n", + "\n", + " -- ===== Path shape match =====\n", + " tm.trip_start_distance_to_i_start_meters,\n", + " tm.trip_start_distance_to_i_start_meters\n", + " / NULLIF(tm.route_i_length_meters, 0)\n", + " AS trip_start_offset_ratio_to_i,\n", + " tm.trip_start_distance_to_v_start_meters,\n", + " tm.trip_start_distance_to_v_start_meters\n", + " / NULLIF(tm.route_v_length_meters, 0)\n", + " AS trip_start_offset_ratio_to_v,\n", + " tm.trip_end_distance_to_i_end_meters,\n", + " tm.trip_end_distance_to_i_end_meters\n", + " / NULLIF(tm.route_i_length_meters, 0)\n", + " AS trip_end_offset_ratio_to_i,\n", + " tm.trip_end_distance_to_v_end_meters,\n", + " tm.trip_end_distance_to_v_end_meters\n", + " / NULLIF(tm.route_v_length_meters, 0)\n", + " AS trip_end_offset_ratio_to_v,\n", + "\n", + " tm.path_frechet_distance_to_i_meters,\n", + " CASE WHEN 1 - tm.path_frechet_distance_to_i_meters\n", + " / NULLIF(tm.route_i_length_meters, 0) IS NULL\n", + " THEN NULL\n", + " ELSE GREATEST(0, 1 - tm.path_frechet_distance_to_i_meters\n", + " / NULLIF(tm.route_i_length_meters, 0))\n", + " END AS path_match_score_frechet_i,\n", + "\n", + " tm.path_frechet_distance_to_v_meters,\n", + " CASE WHEN 1 - tm.path_frechet_distance_to_v_meters\n", + " / NULLIF(tm.route_v_length_meters, 0) IS NULL\n", + " THEN NULL\n", + " ELSE GREATEST(0, 1 - tm.path_frechet_distance_to_v_meters\n", + " / NULLIF(tm.route_v_length_meters, 0))\n", + " END AS path_match_score_frechet_v,\n", + "\n", + " tm.path_hausdorff_distance_to_i_meters,\n", + " CASE WHEN 1 - tm.path_hausdorff_distance_to_i_meters\n", + " / NULLIF(tm.route_i_length_meters, 0) IS NULL\n", + " THEN NULL\n", + " ELSE GREATEST(0, 1 - tm.path_hausdorff_distance_to_i_meters\n", + " / NULLIF(tm.route_i_length_meters, 0))\n", + " END AS path_match_score_hausdorff_i,\n", + "\n", + " tm.path_hausdorff_distance_to_v_meters,\n", + " CASE WHEN 1 - tm.path_hausdorff_distance_to_v_meters\n", + " / NULLIF(tm.route_v_length_meters, 0) IS NULL\n", + " THEN NULL\n", + " ELSE GREATEST(0, 1 - tm.path_hausdorff_distance_to_v_meters\n", + " / NULLIF(tm.route_v_length_meters, 0))\n", + " END AS path_match_score_hausdorff_v,\n", + "\n", + " -- ===== Directional progress =====\n", + " tm.trip_progress_correlation_to_i,\n", + " tm.trip_progress_correlation_to_v,\n", + " tm.trip_progress_correlation_n_points\n", + "\n", + " FROM ml.trip_validity_trip_metrics tm\n", + " JOIN ml.trip_validity_trips t ON t.trip_id = tm.trip_id\n", + " LEFT JOIN ml.trip_validity_bus_avl_match bm ON bm.bus_id = t.bus_id\n", + " ORDER BY tm.trip_id;\n", + "\"\"\")\n", + "conn.commit()\n", + "\n", + "EXPECTED_TRIP_COUNT = 940988\n", + "EXPECTED_COLUMN_COUNT = 80\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\"SELECT count(*) FROM ml.trip_validity_dataset;\")\n", + " row_count = cur.fetchone()[0]\n", + " print(\"rows:\", row_count)\n", + " cur.execute(\"\"\"\n", + " SELECT count(*) FROM information_schema.columns\n", + " WHERE table_schema = 'ml' AND table_name = 'trip_validity_dataset';\n", + " \"\"\")\n", + " column_count = cur.fetchone()[0]\n", + " print(\"columns:\", column_count)\n", + "\n", + "if row_count != EXPECTED_TRIP_COUNT:\n", + " msg = \"row count mismatch against trip_validity_trips\"\n", + " raise AssertionError(msg)\n", + "if column_count != EXPECTED_COLUMN_COUNT:\n", + " msg = \"column count mismatch against the plan\"\n", + " raise AssertionError(msg)" + ] + }, + { + "cell_type": "markdown", + "id": "fe4d7f77", + "metadata": {}, + "source": [ + "## Constraints and indexes\n", + "\n", + "`CREATE TABLE ... AS SELECT` doesn't carry over constraints, so adding\n", + "them explicitly: the primary key (and FK back to `trip_validity_trips`,\n", + "for traceability), `NOT NULL` on the columns known to always be\n", + "populated (mirroring their source tables' own guarantees), and indexes\n", + "on the columns most likely to be used for filtering/sampling during\n", + "active learning." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "dfd7bd53", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-16T17:31:38.078399Z", + "iopub.status.busy": "2026-08-16T17:31:38.078326Z", + "iopub.status.idle": "2026-08-16T17:31:40.917637Z", + "shell.execute_reply": "2026-08-16T17:31:40.917188Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "constraints and indexes applied\n" + ] + } + ], + "source": [ + "conn.execute(\"\"\"\n", + " ALTER TABLE ml.trip_validity_dataset\n", + " ADD PRIMARY KEY (trip_id),\n", + " ADD FOREIGN KEY (trip_id) REFERENCES ml.trip_validity_trips (trip_id),\n", + " ALTER COLUMN bus_id SET NOT NULL,\n", + " ALTER COLUMN route_id SET NOT NULL,\n", + " ALTER COLUMN route_direction SET NOT NULL,\n", + " ALTER COLUMN trip_date SET NOT NULL,\n", + " ALTER COLUMN trip_hour SET NOT NULL,\n", + " ALTER COLUMN trip_opening_timestamp SET NOT NULL,\n", + " ALTER COLUMN trip_closing_timestamp SET NOT NULL,\n", + " ALTER COLUMN avl_matched SET NOT NULL,\n", + " ALTER COLUMN trip_fare_count SET NOT NULL;\n", + "\"\"\")\n", + "\n", + "conn.execute(\"\"\"\n", + " CREATE INDEX trip_validity_dataset_route_id_idx\n", + " ON ml.trip_validity_dataset (route_id);\n", + " CREATE INDEX trip_validity_dataset_route_direction_idx\n", + " ON ml.trip_validity_dataset (route_id, route_direction);\n", + " CREATE INDEX trip_validity_dataset_bus_id_idx\n", + " ON ml.trip_validity_dataset (bus_id);\n", + " CREATE INDEX trip_validity_dataset_trip_date_idx\n", + " ON ml.trip_validity_dataset (trip_date);\n", + " ANALYZE ml.trip_validity_dataset;\n", + "\"\"\")\n", + "conn.commit()\n", + "print(\"constraints and indexes applied\")" + ] + }, + { + "cell_type": "markdown", + "id": "79a553cd", + "metadata": {}, + "source": [ + "## Column-level provenance comments" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "f1c6b5c7", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-16T17:31:40.918728Z", + "iopub.status.busy": "2026-08-16T17:31:40.918639Z", + "iopub.status.idle": "2026-08-16T17:31:40.929179Z", + "shell.execute_reply": "2026-08-16T17:31:40.928827Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "comments applied for 80 columns\n" + ] + } + ], + "source": [ + "def comment_on_column(cur: psycopg.Cursor, table: str, col: str, text: str) -> None:\n", + " \"\"\"Apply a COMMENT ON COLUMN for one column via safe SQL composition.\"\"\"\n", + " cur.execute(\n", + " sql.SQL(\"COMMENT ON COLUMN ml.{}.{} IS {};\").format(\n", + " sql.Identifier(table), sql.Identifier(col), sql.Literal(text)\n", + " )\n", + " )\n", + "\n", + "\n", + "NULLIF_NOTE = (\n", + " \" Ratio divides through NULLIF(denominator, 0), so a zero denominator \"\n", + " \"gives NULL rather than the Infinity Postgres would otherwise \"\n", + " \"silently produce.\"\n", + ")\n", + "CLIP_NOTE = (\n", + " \" Clipped to [0, 1] via GREATEST(0, ...), NULL-safely (unlike bare \"\n", + " \"GREATEST, which would turn a NULL input into 0).\"\n", + ")\n", + "\n", + "DATASET_COMMENTS = {\n", + " # Identifiers\n", + " \"trip_id\": (\n", + " \"PK. = ml.trip_validity_trips.trip_id / ml.trip_validity_trip_metrics.trip_id.\"\n", + " ),\n", + " \"bus_id\": \"From ml.trip_validity_trips.bus_id.\",\n", + " \"route_id\": \"From ml.trip_validity_trip_metrics.route_id.\",\n", + " \"route_direction\": (\n", + " \"From ml.trip_validity_trip_metrics.route_direction (this trip's \"\n", + " \"own observed AFC direction).\"\n", + " ),\n", + " \"trip_date\": \"From ml.trip_validity_trips.trip_date.\",\n", + " \"trip_hour\": \"From ml.trip_validity_trips.trip_hour.\",\n", + " \"trip_opening_timestamp\": (\n", + " \"From ml.trip_validity_trips.trip_opening_timestamp (UTC). Start \"\n", + " \"of the AVL position window used to build \"\n", + " \"ml.trip_validity_trip_positions.\"\n", + " ),\n", + " \"trip_closing_timestamp\": (\n", + " \"From ml.trip_validity_trips.trip_closing_timestamp (UTC). End of \"\n", + " \"the AVL position window. May be the 1899-12-30 Delphi zero-date \"\n", + " \"sentinel (see trip_duration_seconds) - such trips have no \"\n", + " \"positions, not garbage ones, since closing < opening yields an \"\n", + " \"empty window.\"\n", + " ),\n", + " \"gtfs_feed_version_date\": (\n", + " \"From ml.trip_validity_trip_metrics (originally \"\n", + " \"ml.trip_validity_route_gtfs_match).\"\n", + " ),\n", + " \"gtfs_route_short_name\": (\n", + " \"From ml.trip_validity_trip_metrics - the GTFS join key actually used.\"\n", + " ),\n", + " \"gtfs_shape_id_i\": \"From ml.trip_validity_trip_metrics.\",\n", + " \"gtfs_shape_id_v\": \"From ml.trip_validity_trip_metrics.\",\n", + " \"gtfs_route_has_both_directions\": (\n", + " \"From ml.trip_validity_trip_metrics. NULL = route never matched \"\n", + " \"any GTFS feed; false = matched but only one direction has a \"\n", + " \"shape; true = both directions have a shape.\"\n", + " ),\n", + " \"avl_matched\": (\n", + " \"From ml.trip_validity_bus_avl_match, keyed by bus_id (not \"\n", + " \"trip_id - every trip on the same bus shares the same match). \"\n", + " \"True iff this trip's bus_id was resolved to an AVL vehicle_id \"\n", + " \"or device_id, i.e. ml.trip_validity_trip_positions can have rows \"\n", + " \"for this trip. False (never NULL) otherwise.\"\n", + " ),\n", + " \"avl_match_source\": (\n", + " \"Which silver crosswalk resolved the match: \"\n", + " \"'dictionary_device' (silver.dictionary_device.codigo -> \"\n", + " \"device_id, preferred when both match) or 'dictionary_vehicle' \"\n", + " \"(silver.dictionary_vehicle.cod_veiculo -> id_veiculo, used only \"\n", + " \"when no dictionary_device match exists). NULL iff avl_matched \"\n", + " \"is false.\"\n", + " ),\n", + " # Duration\n", + " \"trip_duration_seconds\": (\n", + " \"From ml.trip_validity_trip_metrics. NULL for the 9 trips with \"\n", + " \"the 1899-12-30 Delphi zero-date sentinel closing time.\"\n", + " ),\n", + " \"route_avg_trip_duration_seconds_loo\": (\n", + " \"Leave-one-out mean duration across other trips on this route.\"\n", + " ),\n", + " \"route_avg_trip_duration_n_trips_loo\": \"Count backing the average above.\",\n", + " \"trip_duration_ratio_to_route_avg\": (\n", + " \"trip_duration_seconds / route_avg_trip_duration_seconds_loo. \"\n", + " \"1.0 = exactly average; >1 = slower; <1 = faster.\" + NULLIF_NOTE\n", + " ),\n", + " \"route_direction_avg_trip_duration_seconds_loo\": (\n", + " \"Same as route_avg_trip_duration_seconds_loo, grouped by \"\n", + " \"(route_id, route_direction).\"\n", + " ),\n", + " \"route_direction_avg_trip_duration_n_trips_loo\": (\n", + " \"Count backing the average above.\"\n", + " ),\n", + " \"trip_duration_ratio_to_route_direction_avg\": (\n", + " \"trip_duration_seconds / \"\n", + " \"route_direction_avg_trip_duration_seconds_loo.\" + NULLIF_NOTE\n", + " ),\n", + " \"route_hour_avg_trip_duration_seconds_loo\": (\n", + " \"Same, grouped by (route_id, trip_hour).\"\n", + " ),\n", + " \"route_hour_avg_trip_duration_n_trips_loo\": \"Count backing the average above.\",\n", + " \"trip_duration_ratio_to_route_hour_avg\": (\n", + " \"trip_duration_seconds / route_hour_avg_trip_duration_seconds_loo.\"\n", + " + NULLIF_NOTE\n", + " ),\n", + " \"route_direction_hour_avg_trip_duration_seconds_loo\": (\n", + " \"Same, grouped by (route_id, route_direction, trip_hour).\"\n", + " ),\n", + " \"route_direction_hour_avg_trip_duration_n_trips_loo\": (\n", + " \"Count backing the average above.\"\n", + " ),\n", + " \"trip_duration_ratio_to_route_direction_hour_avg\": (\n", + " \"trip_duration_seconds / \"\n", + " \"route_direction_hour_avg_trip_duration_seconds_loo.\" + NULLIF_NOTE\n", + " ),\n", + " \"route_reverse_direction_avg_trip_duration_seconds\": (\n", + " \"Mean duration of trips on this route going the OPPOSITE \"\n", + " \"route_direction. Not leave-one-out (this trip can't be a \"\n", + " \"member of that group).\"\n", + " ),\n", + " \"route_reverse_direction_n_trips\": \"Count backing the average above.\",\n", + " \"trip_duration_ratio_to_reverse_direction_avg\": (\n", + " \"trip_duration_seconds / \"\n", + " \"route_reverse_direction_avg_trip_duration_seconds. How this \"\n", + " \"trip compares to the OTHER direction's typical duration.\" + NULLIF_NOTE\n", + " ),\n", + " \"route_reverse_direction_hour_avg_trip_duration_seconds\": (\n", + " \"Same as route_reverse_direction_avg_trip_duration_seconds, \"\n", + " \"restricted to the opposite direction's trips at this row's \"\n", + " \"trip_hour.\"\n", + " ),\n", + " \"route_reverse_direction_hour_n_trips\": \"Count backing the average above.\",\n", + " \"trip_duration_ratio_to_reverse_direction_hour_avg\": (\n", + " \"trip_duration_seconds / \"\n", + " \"route_reverse_direction_hour_avg_trip_duration_seconds.\" + NULLIF_NOTE\n", + " ),\n", + " \"route_i_scheduled_duration_avg_seconds\": (\n", + " \"GTFS-scheduled average duration for direction I on this \"\n", + " \"route/feed (from silver.gtfs_stop_times, via \"\n", + " \"ml.trip_validity_route_schedule). What the TIMETABLE says, not \"\n", + " \"other real trips.\"\n", + " ),\n", + " \"route_i_scheduled_duration_n_trips\": (\n", + " \"Count of scheduled GTFS trips backing the average above.\"\n", + " ),\n", + " \"trip_duration_ratio_to_scheduled_i\": (\n", + " \"trip_duration_seconds / route_i_scheduled_duration_avg_seconds. \"\n", + " \"Actual vs. planned.\" + NULLIF_NOTE\n", + " ),\n", + " \"route_v_scheduled_duration_avg_seconds\": (\n", + " \"Same as route_i_scheduled_duration_avg_seconds, direction V.\"\n", + " ),\n", + " \"route_v_scheduled_duration_n_trips\": \"Count backing the average above.\",\n", + " \"trip_duration_ratio_to_scheduled_v\": (\n", + " \"trip_duration_seconds / route_v_scheduled_duration_avg_seconds.\" + NULLIF_NOTE\n", + " ),\n", + " \"route_i_scheduled_duration_avg_seconds_at_hour\": (\n", + " \"Same as route_i_scheduled_duration_avg_seconds, restricted to \"\n", + " \"scheduled GTFS trips whose start_hour matches this row's \"\n", + " \"trip_hour.\"\n", + " ),\n", + " \"route_i_scheduled_duration_n_trips_at_hour\": (\"Count backing the average above.\"),\n", + " \"trip_duration_ratio_to_scheduled_i_at_hour\": (\n", + " \"trip_duration_seconds / \"\n", + " \"route_i_scheduled_duration_avg_seconds_at_hour. Actual vs. \"\n", + " \"planned, hour-matched.\" + NULLIF_NOTE\n", + " ),\n", + " \"route_v_scheduled_duration_avg_seconds_at_hour\": (\n", + " \"Same as route_i_scheduled_duration_avg_seconds_at_hour, direction V.\"\n", + " ),\n", + " \"route_v_scheduled_duration_n_trips_at_hour\": (\"Count backing the average above.\"),\n", + " \"trip_duration_ratio_to_scheduled_v_at_hour\": (\n", + " \"trip_duration_seconds / \"\n", + " \"route_v_scheduled_duration_avg_seconds_at_hour.\" + NULLIF_NOTE\n", + " ),\n", + " # Fares\n", + " \"trip_fare_count\": (\n", + " \"From ml.trip_validity_trip_metrics. count(*) of all fare taps \"\n", + " \"on this trip, geo-tagged or not.\"\n", + " ),\n", + " \"fare_gap_avg_seconds\": (\n", + " \"Mean gap between consecutive fare taps, ordered by boarding_at. \"\n", + " \"NULL if only 1 fare.\"\n", + " ),\n", + " \"fare_gap_stddev_seconds\": (\n", + " \"Sample stddev of the same gaps. NULL if fewer than 2 gaps exist.\"\n", + " ),\n", + " \"fare_gap_coefficient_of_variation\": (\n", + " \"fare_gap_stddev_seconds / fare_gap_avg_seconds. Standard \"\n", + " \"normalized-dispersion measure: were the gaps steady or erratic, \"\n", + " \"independent of trip length.\" + NULLIF_NOTE\n", + " ),\n", + " \"fare_gap_avg_ratio_to_duration\": (\n", + " \"fare_gap_avg_seconds / trip_duration_seconds. What fraction of \"\n", + " \"the whole trip is 'typical time between taps'.\" + NULLIF_NOTE\n", + " ),\n", + " \"fare_span_seconds\": (\n", + " \"max(boarding_at) - min(boarding_at) across all fares. 0 (not \"\n", + " \"NULL) for a 1-fare trip.\"\n", + " ),\n", + " \"fare_span_ratio_to_duration\": (\n", + " \"fare_span_seconds / trip_duration_seconds. What fraction of \"\n", + " \"the trip's duration actually had fare activity.\" + NULLIF_NOTE\n", + " ),\n", + " # Geometry\n", + " \"trip_distance_meters\": (\n", + " \"ST_Length(trip_path::geography). NULL if trip_path is NULL \"\n", + " \"(fewer than 2 geo-tagged fares).\"\n", + " ),\n", + " \"route_i_length_meters\": (\n", + " \"Official route length, direction I (ml.trip_validity_route_shapes).\"\n", + " ),\n", + " \"trip_distance_ratio_to_route_i\": (\n", + " \"trip_distance_meters / route_i_length_meters. Did the GPS trace \"\n", + " \"cover roughly the whole route, or just a fraction of it?\" + NULLIF_NOTE\n", + " ),\n", + " \"route_v_length_meters\": \"Official route length, direction V.\",\n", + " \"trip_distance_ratio_to_route_v\": (\n", + " \"trip_distance_meters / route_v_length_meters.\" + NULLIF_NOTE\n", + " ),\n", + " \"trip_points_standard_distance_meters\": (\n", + " \"RMS distance of this trip's geo-tagged fares from their own \"\n", + " \"centroid (spatial-statistics standard distance). 0 for a \"\n", + " \"single point, NULL for zero.\"\n", + " ),\n", + " \"trip_cohesion_ratio_to_route_i\": (\n", + " \"trip_points_standard_distance_meters / route_i_length_meters. \"\n", + " \"Is the point spread large or small relative to how long this \"\n", + " \"route actually is?\" + NULLIF_NOTE\n", + " ),\n", + " \"trip_cohesion_ratio_to_route_v\": (\n", + " \"trip_points_standard_distance_meters / route_v_length_meters.\" + NULLIF_NOTE\n", + " ),\n", + " # Path match\n", + " \"trip_start_distance_to_i_start_meters\": (\n", + " \"ST_Distance (geography) between the trip's first GPS point and \"\n", + " \"the official I-direction route's start point.\"\n", + " ),\n", + " \"trip_start_offset_ratio_to_i\": (\n", + " \"trip_start_distance_to_i_start_meters / route_i_length_meters.\" + NULLIF_NOTE\n", + " ),\n", + " \"trip_start_distance_to_v_start_meters\": (\n", + " \"Same as trip_start_distance_to_i_start_meters, direction V.\"\n", + " ),\n", + " \"trip_start_offset_ratio_to_v\": (\n", + " \"trip_start_distance_to_v_start_meters / route_v_length_meters.\" + NULLIF_NOTE\n", + " ),\n", + " \"trip_end_distance_to_i_end_meters\": (\n", + " \"ST_Distance (geography) between the trip's last GPS point and \"\n", + " \"the official I-direction route's end point.\"\n", + " ),\n", + " \"trip_end_offset_ratio_to_i\": (\n", + " \"trip_end_distance_to_i_end_meters / route_i_length_meters.\" + NULLIF_NOTE\n", + " ),\n", + " \"trip_end_distance_to_v_end_meters\": (\n", + " \"Same as trip_end_distance_to_i_end_meters, direction V.\"\n", + " ),\n", + " \"trip_end_offset_ratio_to_v\": (\n", + " \"trip_end_distance_to_v_end_meters / route_v_length_meters.\" + NULLIF_NOTE\n", + " ),\n", + " \"path_frechet_distance_to_i_meters\": (\n", + " \"ST_FrechetDistance between the trip's path and the I-direction \"\n", + " \"shape (both projected to EPSG:31984 first). Order-aware path \"\n", + " \"similarity distance, meters.\"\n", + " ),\n", + " \"path_match_score_frechet_i\": (\n", + " \"1 - (path_frechet_distance_to_i_meters / route_i_length_meters).\"\n", + " + NULLIF_NOTE\n", + " + CLIP_NOTE\n", + " ),\n", + " \"path_frechet_distance_to_v_meters\": (\n", + " \"Same as path_frechet_distance_to_i_meters, direction V.\"\n", + " ),\n", + " \"path_match_score_frechet_v\": (\n", + " \"1 - (path_frechet_distance_to_v_meters / route_v_length_meters).\"\n", + " + NULLIF_NOTE\n", + " + CLIP_NOTE\n", + " ),\n", + " \"path_hausdorff_distance_to_i_meters\": (\n", + " \"ST_HausdorffDistance between the trip's path and the \"\n", + " \"I-direction shape (both projected to EPSG:31984 first). \"\n", + " \"Order-agnostic 'largest gap' path similarity distance, meters.\"\n", + " ),\n", + " \"path_match_score_hausdorff_i\": (\n", + " \"1 - (path_hausdorff_distance_to_i_meters / route_i_length_meters).\"\n", + " + NULLIF_NOTE\n", + " + CLIP_NOTE\n", + " ),\n", + " \"path_hausdorff_distance_to_v_meters\": (\n", + " \"Same as path_hausdorff_distance_to_i_meters, direction V.\"\n", + " ),\n", + " \"path_match_score_hausdorff_v\": (\n", + " \"1 - (path_hausdorff_distance_to_v_meters / route_v_length_meters).\"\n", + " + NULLIF_NOTE\n", + " + CLIP_NOTE\n", + " ),\n", + " # Directional progress\n", + " \"trip_progress_correlation_to_i\": (\n", + " \"Pearson correlation between position-along-the-I-shape \"\n", + " \"(ST_LineLocatePoint) and boarding_at, across this trip's \"\n", + " \"geo-tagged fares. +1 = steady forward progress, -1 = steady \"\n", + " \"reverse progress, ~0 = no consistent relationship. Already \"\n", + " \"bounded [-1, 1] by construction - not further normalized.\"\n", + " ),\n", + " \"trip_progress_correlation_to_v\": (\n", + " \"Same as trip_progress_correlation_to_i, direction V.\"\n", + " ),\n", + " \"trip_progress_correlation_n_points\": (\n", + " \"Count of geo-tagged fares used for both correlations above. A \"\n", + " \"value of 2 means the correlation is a meaningless exact +-1 (a \"\n", + " \"line through 2 points is always 'perfectly correlated') - use \"\n", + " \"this to judge how much to trust the two correlation columns.\"\n", + " ),\n", + "}\n", + "\n", + "with conn.cursor() as cur:\n", + " for col, text in DATASET_COMMENTS.items():\n", + " comment_on_column(cur, \"trip_validity_dataset\", col, text)\n", + "\n", + "conn.execute(\n", + " sql.SQL(\"COMMENT ON TABLE ml.trip_validity_dataset IS {};\").format(\n", + " sql.Literal(\n", + " \"Trip Validity model: the final ML-ready dataset. One row per \"\n", + " \"trip, 80 columns (identifiers + raw metrics from \"\n", + " \"ml.trip_validity_trip_metrics + every relative/normalized \"\n", + " \"column). See ml/trip_validity_model/notebooks/\"\n", + " \"05_final_dataset.ipynb.\"\n", + " )\n", + " )\n", + ")\n", + "conn.commit()\n", + "print(f\"comments applied for {len(DATASET_COMMENTS)} columns\")" + ] + }, + { + "cell_type": "markdown", + "id": "7793068f", + "metadata": {}, + "source": [ + "## Verification\n", + "\n", + "Row/column counts, a check that no ratio silently produced `Infinity`\n", + "(would mean a `NULLIF` guard was missed somewhere), and a hand-check of\n", + "one duration ratio against its raw inputs." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "d49f3c57", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-16T17:31:40.930001Z", + "iopub.status.busy": "2026-08-16T17:31:40.929937Z", + "iopub.status.idle": "2026-08-16T17:31:41.427894Z", + "shell.execute_reply": "2026-08-16T17:31:41.427445Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "rows: 940988\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "rows with an Infinity value across all 25 ratio columns: 0\n", + "path_match_score rows out of [0,1]: 0\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "rows where a NULL frechet distance wrongly produced a non-null score: 0\n", + "hand-check trip 1: 1158 / 1545.5891959798994 = 0.749229 (stored: 0.749229)\n", + "all checks passed\n" + ] + } + ], + "source": [ + "RATIO_COLUMNS = [\n", + " \"trip_duration_ratio_to_route_avg\",\n", + " \"trip_duration_ratio_to_route_direction_avg\",\n", + " \"trip_duration_ratio_to_route_hour_avg\",\n", + " \"trip_duration_ratio_to_route_direction_hour_avg\",\n", + " \"trip_duration_ratio_to_reverse_direction_avg\",\n", + " \"trip_duration_ratio_to_reverse_direction_hour_avg\",\n", + " \"trip_duration_ratio_to_scheduled_i\",\n", + " \"trip_duration_ratio_to_scheduled_v\",\n", + " \"trip_duration_ratio_to_scheduled_i_at_hour\",\n", + " \"trip_duration_ratio_to_scheduled_v_at_hour\",\n", + " \"fare_gap_coefficient_of_variation\",\n", + " \"fare_gap_avg_ratio_to_duration\",\n", + " \"fare_span_ratio_to_duration\",\n", + " \"trip_distance_ratio_to_route_i\",\n", + " \"trip_distance_ratio_to_route_v\",\n", + " \"trip_cohesion_ratio_to_route_i\",\n", + " \"trip_cohesion_ratio_to_route_v\",\n", + " \"trip_start_offset_ratio_to_i\",\n", + " \"trip_start_offset_ratio_to_v\",\n", + " \"trip_end_offset_ratio_to_i\",\n", + " \"trip_end_offset_ratio_to_v\",\n", + " \"path_match_score_frechet_i\",\n", + " \"path_match_score_frechet_v\",\n", + " \"path_match_score_hausdorff_i\",\n", + " \"path_match_score_hausdorff_v\",\n", + "]\n", + "EXPECTED_RATIO_COLUMN_COUNT = 25\n", + "FLOAT_TOLERANCE = 1e-9\n", + "\n", + "if len(RATIO_COLUMNS) != EXPECTED_RATIO_COLUMN_COUNT:\n", + " msg = (\n", + " f\"expected {EXPECTED_RATIO_COLUMN_COUNT} new ratio columns, \"\n", + " f\"listed {len(RATIO_COLUMNS)}\"\n", + " )\n", + " raise AssertionError(msg)\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\"SELECT count(*) FROM ml.trip_validity_dataset;\")\n", + " print(\"rows:\", cur.fetchone()[0])\n", + "\n", + " # no silent Infinity anywhere - built via safe sql.Identifier\n", + " # composition, not raw string interpolation, even though\n", + " # RATIO_COLUMNS is a fixed, hardcoded list (not user input)\n", + " infinity_check = sql.SQL(\" + \").join(\n", + " sql.SQL(\n", + " \"count(*) FILTER (WHERE {0} = 'Infinity'::double precision \"\n", + " \"OR {0} = '-Infinity'::double precision)\"\n", + " ).format(sql.Identifier(c))\n", + " for c in RATIO_COLUMNS\n", + " )\n", + " cur.execute(\n", + " sql.SQL(\"SELECT {} FROM ml.trip_validity_dataset;\").format(infinity_check)\n", + " )\n", + " n_infinite = cur.fetchone()[0]\n", + " print(\"rows with an Infinity value across all 25 ratio columns:\", n_infinite)\n", + " if n_infinite != 0:\n", + " msg = \"found Infinity - a NULLIF guard was missed\"\n", + " raise AssertionError(msg)\n", + "\n", + " # path_match_score stays in [0, 1] wherever it's not NULL\n", + " cur.execute(\"\"\"\n", + " SELECT count(*) FROM ml.trip_validity_dataset\n", + " WHERE (path_match_score_frechet_i IS NOT NULL\n", + " AND (path_match_score_frechet_i < 0\n", + " OR path_match_score_frechet_i > 1))\n", + " OR (path_match_score_frechet_v IS NOT NULL\n", + " AND (path_match_score_frechet_v < 0\n", + " OR path_match_score_frechet_v > 1))\n", + " OR (path_match_score_hausdorff_i IS NOT NULL\n", + " AND (path_match_score_hausdorff_i < 0\n", + " OR path_match_score_hausdorff_i > 1))\n", + " OR (path_match_score_hausdorff_v IS NOT NULL\n", + " AND (path_match_score_hausdorff_v < 0\n", + " OR path_match_score_hausdorff_v > 1));\n", + " \"\"\")\n", + " out_of_range = cur.fetchone()[0]\n", + " print(\"path_match_score rows out of [0,1]:\", out_of_range)\n", + " if out_of_range != 0:\n", + " msg = \"path_match_score found out of [0,1] range\"\n", + " raise AssertionError(msg)\n", + "\n", + " # NULL-safety check: path_match_score must be NULL exactly when\n", + " # frechet_i is NULL, never a stray 0\n", + " cur.execute(\"\"\"\n", + " SELECT count(*) FROM ml.trip_validity_dataset\n", + " WHERE path_frechet_distance_to_i_meters IS NULL\n", + " AND path_match_score_frechet_i IS NOT NULL;\n", + " \"\"\")\n", + " leaked_zero = cur.fetchone()[0]\n", + " print(\n", + " \"rows where a NULL frechet distance wrongly produced a non-null score:\",\n", + " leaked_zero,\n", + " )\n", + " if leaked_zero != 0:\n", + " msg = \"a NULL frechet distance wrongly produced a non-null score\"\n", + " raise AssertionError(msg)\n", + "\n", + " # hand-check one ratio against its raw inputs, on a real row\n", + " cur.execute(\"\"\"\n", + " SELECT trip_id, trip_duration_seconds,\n", + " route_avg_trip_duration_seconds_loo,\n", + " trip_duration_ratio_to_route_avg\n", + " FROM ml.trip_validity_dataset\n", + " WHERE trip_duration_ratio_to_route_avg IS NOT NULL\n", + " ORDER BY trip_id LIMIT 1;\n", + " \"\"\")\n", + " trip_id, dur, avg, ratio = cur.fetchone()\n", + " print(\n", + " f\"hand-check trip {trip_id}: {dur} / {avg} = {dur / avg:.6f} \"\n", + " f\"(stored: {ratio:.6f})\"\n", + " )\n", + " if abs(dur / avg - ratio) >= FLOAT_TOLERANCE:\n", + " msg = \"hand-check ratio does not match stored value\"\n", + " raise AssertionError(msg)\n", + "\n", + "print(\"all checks passed\")" + ] + }, + { + "cell_type": "markdown", + "id": "2a67d187", + "metadata": {}, + "source": [ + "## Querying: GTFS route shape + AVL positions for a trip\n", + "\n", + "Both joins are PK lookups (confirmed ~5ms/~0ms via `EXPLAIN ANALYZE`),\n", + "so this stays fast regardless of table size - no need to touch\n", + "`silver` or scan anything:\n", + "\n", + "```sql\n", + "SELECT\n", + " d.trip_id, d.bus_id, d.route_id, d.avl_matched,\n", + " rs.shape_geom AS gtfs_route_geom, -- official route line (LineString)\n", + " pos.metric_timestamp, pos.geom AS bus_position\n", + "FROM ml.trip_validity_dataset d\n", + "LEFT JOIN ml.trip_validity_route_shapes rs\n", + " ON rs.feed_version_date = d.gtfs_feed_version_date\n", + " AND rs.shape_id = COALESCE(d.gtfs_shape_id_i, d.gtfs_shape_id_v)\n", + "LEFT JOIN ml.trip_validity_trip_positions pos\n", + " ON pos.trip_id = d.trip_id\n", + "WHERE d.trip_id = 12345\n", + "ORDER BY pos.metric_timestamp;\n", + "```\n", + "\n", + "Both are `LEFT JOIN`s on purpose: `rs` can be empty (~30 routes never\n", + "matched any GTFS feed) and `pos` can be empty (`avl_matched = false`,\n", + "or matched but no pings actually fell in the trip's window) - check\n", + "`gtfs_feed_version_date`/`avl_matched` if you need to tell \"no route\" /\n", + "\"no positions\" apart from \"not queried yet\". `COALESCE(..._i, ..._v)`\n", + "picks a default when `gtfs_route_has_both_directions` is true; use\n", + "`path_match_score_frechet_i`/`_v` (already computed) to pick whichever\n", + "shape this trip's *own* GPS path actually matches better, instead." + ] + }, + { + "cell_type": "markdown", + "id": "e9e2caa2", + "metadata": {}, + "source": [ + "## Adding weekday/weekend features\n", + "\n", + "For the Trip Validity active-learning model: `weekday_number` and\n", + "`is_weekend`, both derived from the already-present `trip_date` via\n", + "`GENERATED ALWAYS AS ... STORED` - the same pattern this project\n", + "already uses for the PostGIS geometry columns in silver. Postgres\n", + "computes the value for every existing row as part of the `ALTER\n", + "TABLE` itself, so there's no separate backfill/update step, and the\n", + "columns can never drift out of sync with `trip_date` since Postgres\n", + "recomputes them automatically on any (hypothetical, trip_date is\n", + "otherwise never updated) change.\n", + "\n", + "`weekday_number` uses ISO 8601 numbering (`EXTRACT(ISODOW FROM\n", + "trip_date)`: 1 = Monday ... 7 = Sunday) rather than Postgres's default\n", + "`DOW` (0 = Sunday ... 6 = Saturday), to avoid the Sunday/Monday-as-0\n", + "ambiguity. `is_weekend` is `true` for Saturday/Sunday (6, 7)." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "f917e2e5", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-16T17:31:41.428811Z", + "iopub.status.busy": "2026-08-16T17:31:41.428737Z", + "iopub.status.idle": "2026-08-16T17:31:44.959950Z", + "shell.execute_reply": "2026-08-16T17:31:44.959480Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "weekday_number/is_weekend added\n" + ] + } + ], + "source": [ + "conn.execute(\"\"\"\n", + " ALTER TABLE ml.trip_validity_dataset\n", + " ADD COLUMN IF NOT EXISTS weekday_number SMALLINT\n", + " GENERATED ALWAYS AS (EXTRACT(ISODOW FROM trip_date)::smallint) STORED,\n", + " ADD COLUMN IF NOT EXISTS is_weekend BOOLEAN\n", + " GENERATED ALWAYS AS (EXTRACT(ISODOW FROM trip_date) IN (6, 7)) STORED;\n", + "\"\"\")\n", + "\n", + "conn.execute(\n", + " sql.SQL(\"COMMENT ON COLUMN ml.trip_validity_dataset.weekday_number IS {};\").format(\n", + " sql.Literal(\n", + " \"GENERATED ALWAYS AS (EXTRACT(ISODOW FROM trip_date)::smallint) STORED. \"\n", + " \"ISO 8601 weekday: 1 = Monday ... 7 = Sunday (not Postgres's default \"\n", + " \"DOW, which is 0 = Sunday).\"\n", + " )\n", + " )\n", + ")\n", + "conn.execute(\n", + " sql.SQL(\"COMMENT ON COLUMN ml.trip_validity_dataset.is_weekend IS {};\").format(\n", + " sql.Literal(\n", + " \"GENERATED ALWAYS AS (EXTRACT(ISODOW FROM trip_date) IN (6, 7)) STORED. \"\n", + " \"True for Saturday/Sunday.\"\n", + " )\n", + " )\n", + ")\n", + "conn.commit()\n", + "print(\"weekday_number/is_weekend added\")" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "a7a3df93", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-16T17:31:44.960863Z", + "iopub.status.busy": "2026-08-16T17:31:44.960793Z", + "iopub.status.idle": "2026-08-16T17:31:44.964723Z", + "shell.execute_reply": "2026-08-16T17:31:44.964328Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "columns: 82\n", + "(datetime.date(2023, 11, 9), 4, False)\n", + "(datetime.date(2023, 11, 10), 5, False)\n", + "(datetime.date(2023, 11, 9), 4, False)\n", + "all checks passed\n" + ] + } + ], + "source": [ + "EXPECTED_COLUMN_COUNT_AFTER_WEEKDAY = 82\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\"\"\"\n", + " SELECT count(*) FROM information_schema.columns\n", + " WHERE table_schema = 'ml' AND table_name = 'trip_validity_dataset';\n", + " \"\"\")\n", + " column_count = cur.fetchone()[0]\n", + " print(\"columns:\", column_count)\n", + " if column_count != EXPECTED_COLUMN_COUNT_AFTER_WEEKDAY:\n", + " msg = \"column count mismatch after adding weekday_number/is_weekend\"\n", + " raise AssertionError(msg)\n", + "\n", + " cur.execute(\"\"\"\n", + " SELECT trip_date, weekday_number, is_weekend\n", + " FROM ml.trip_validity_dataset ORDER BY trip_id LIMIT 3;\n", + " \"\"\")\n", + " for row in cur.fetchall():\n", + " print(row)\n", + "\n", + "print(\"all checks passed\")" + ] + }, + { + "cell_type": "markdown", + "id": "74731807", + "metadata": {}, + "source": [ + "## Adding garage-distance and company_id features\n", + "\n", + "Two more engineered features for the Trip Validity model: per trip, the\n", + "distance from the trip's **first** and **last geo-tagged AFC fare tap**\n", + "(never AVL - only what a rider's card actually tapped, at a real lat/lon)\n", + "to the **nearest bus garage of that trip's operating company**. Several\n", + "companies run more than one garage (Vega: Jacarecanga + Messejana;\n", + "COOTRAPS: Autran Nunes + Jangurussu; Santa Maria has three nearby points\n", + "on file), so \"nearest\" takes `MIN()` across every known garage for that\n", + "company, not just its registered address.\n", + "\n", + "Garage coordinates are **hard-coded** below (`GARAGES`), taken from\n", + "manually verified Google Maps pins (preferred over this project's own\n", + "OSM/Nominatim geocoding of each company's registered postal address,\n", + "which for two companies was off by 1.9-3 km because the address didn't\n", + "resolve to an exact building in OSM). There's no silver/bronze table for\n", + "this - it's external reference data with no upstream feed - so unlike\n", + "everything else in this pipeline it can't be reloaded from source; if a\n", + "company opens or closes a garage, `GARAGES` needs a manual edit.\n", + "\n", + "A trip's first/last geo-tagged fare are read straight from\n", + "`ml.trip_validity_trip_fares` (`geom IS NOT NULL`, ordered by\n", + "`boarding_at`) rather than from `trip_path`: `trip_path` is `NULL` for\n", + "any trip with fewer than 2 geo-tagged fares (see\n", + "`01_build_trip_tables.ipynb`), which would silently drop every\n", + "single-tap trip's distance instead of just collapsing its start/end\n", + "distance to the same value.\n", + "\n", + "`company_id` (the 2-digit `bus_id` prefix, e.g. `\"02\"` - leading zero\n", + "significant, it's the operator's registry code, not a number) is a\n", + "`GENERATED ALWAYS AS (LEFT(bus_id, 2)) STORED` column, same pattern as\n", + "`weekday_number`/`is_weekend` above: a pure function of an existing\n", + "column, so Postgres backfills and keeps it in sync automatically.\n", + "\n", + "**Idempotent by construction**, so re-running just this cell or the\n", + "whole notebook end-to-end has the same effect: `ADD COLUMN IF NOT\n", + "EXISTS` for all three columns, and the `UPDATE` recomputes every row's\n", + "value fresh from `trip_fares`/`GARAGES` rather than incrementing\n", + "anything." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "2aa8365e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-16T17:31:44.965428Z", + "iopub.status.busy": "2026-08-16T17:31:44.965364Z", + "iopub.status.idle": "2026-08-16T17:31:44.967637Z", + "shell.execute_reply": "2026-08-16T17:31:44.967354Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "16 garage locations across 11 companies\n" + ] + } + ], + "source": [ + "# (company_id, garage_lat, garage_lon). Hard-coded - see markdown above for\n", + "# why this can't be sourced from silver/bronze. Cross-checked against Google\n", + "# Maps; some companies have more than one garage on file, which is fine, the\n", + "# UPDATE below takes MIN() distance across every row sharing a company_id.\n", + "GARAGES = [\n", + " (\"02\", -3.8028810192744027, -38.50341401619763), # Auto Viação Fortaleza\n", + " (\"12\", -3.7802332041766857, -38.5695361429177), # Auto Viação São José\n", + " (\"14\", -3.777246225871815, -38.568915329478166), # Siará Grande\n", + " (\"20\", -3.7215167402645686, -38.56100820100305), # Santa Maria (1)\n", + " (\"20\", -3.722886669415705, -38.556716242307836), # Santa Maria (2)\n", + " (\"20\", -3.7208313342318156, -38.56014978601272), # Santa Maria (3)\n", + " (\"21\", -3.704725956287296, -38.591196041413085), # Transportes Urbanos Aliança\n", + " (\"26\", -3.8332275953596895, -38.56380935858968), # Maraponga Transportes\n", + " (\"30\", -3.8072609664553823, -38.46878190037298), # Viação Urbana\n", + " (\"35\", -3.7134518990877323, -38.54376025481672), # Vega (Jacarecanga)\n", + " (\"35\", -3.8357607212799074, -38.49499371747953), # Vega (Messejana)\n", + " (\"36\", -3.746841903663143, -38.54357720067353), # Santa Cecília (1)\n", + " (\"36\", -3.7468204919231662, -38.54360938738769), # Santa Cecília (2)\n", + " (\"42\", -3.8035024356582543, -38.53854911444907), # Auto Viação Dragão do Mar\n", + " (\"67\", -3.7459634650123714, -38.598652172526734), # COOTRAPS (Autran Nunes)\n", + " (\"67\", -3.8424012552430677, -38.51473780117739), # COOTRAPS (Jangurussu)\n", + "]\n", + "\n", + "n_companies = len({company_id for company_id, _, _ in GARAGES})\n", + "print(f\"{len(GARAGES)} garage locations across {n_companies} companies\")" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "e0df9def", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-16T17:31:44.968388Z", + "iopub.status.busy": "2026-08-16T17:31:44.968324Z", + "iopub.status.idle": "2026-08-16T17:31:48.465391Z", + "shell.execute_reply": "2026-08-16T17:31:48.464950Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "company_id added\n" + ] + } + ], + "source": [ + "conn.execute(\"\"\"\n", + " ALTER TABLE ml.trip_validity_dataset\n", + " ADD COLUMN IF NOT EXISTS company_id TEXT\n", + " GENERATED ALWAYS AS (LEFT(bus_id, 2)) STORED,\n", + " ALTER COLUMN company_id SET NOT NULL;\n", + "\"\"\")\n", + "conn.execute(\n", + " sql.SQL(\"COMMENT ON COLUMN ml.trip_validity_dataset.company_id IS {};\").format(\n", + " sql.Literal(\n", + " \"GENERATED ALWAYS AS (LEFT(bus_id, 2)) STORED. The 2-digit operator \"\n", + " \"registry code the bus belongs to (e.g. '02' = Auto Viação Fortaleza, \"\n", + " \"'67' = COOTRAPS) - leading zero significant, not a number. See \"\n", + " \"GARAGES in this notebook for the code -> company mapping.\"\n", + " )\n", + " )\n", + ")\n", + "conn.commit()\n", + "print(\"company_id added\")" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "ae23ac85", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-16T17:31:48.466208Z", + "iopub.status.busy": "2026-08-16T17:31:48.466139Z", + "iopub.status.idle": "2026-08-16T17:32:12.247404Z", + "shell.execute_reply": "2026-08-16T17:32:12.247132Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "trip_start_distance_to_nearest_garage_meters, trip_end_distance_to_nearest_garage_meters populated\n" + ] + } + ], + "source": [ + "conn.execute(\"\"\"\n", + " ALTER TABLE ml.trip_validity_dataset\n", + " ADD COLUMN IF NOT EXISTS trip_start_distance_to_nearest_garage_meters\n", + " DOUBLE PRECISION,\n", + " ADD COLUMN IF NOT EXISTS trip_end_distance_to_nearest_garage_meters\n", + " DOUBLE PRECISION;\n", + "\"\"\")\n", + "\n", + "garage_values = sql.SQL(\", \").join(\n", + " sql.SQL(\"({}, {}, {})\").format(\n", + " sql.Literal(company_id), sql.Literal(lat), sql.Literal(lon)\n", + " )\n", + " for company_id, lat, lon in GARAGES\n", + ")\n", + "\n", + "update_query = sql.SQL(\"\"\"\n", + " WITH garages (company_id, garage_lat, garage_lon) AS (\n", + " VALUES {garage_values}\n", + " ),\n", + " -- one row per trip: its earliest/latest geo-tagged fare, never AVL\n", + " first_geo_fare AS (\n", + " SELECT DISTINCT ON (trip_id) trip_id, geom AS first_geom\n", + " FROM ml.trip_validity_trip_fares\n", + " WHERE geom IS NOT NULL\n", + " ORDER BY trip_id, boarding_at ASC\n", + " ),\n", + " last_geo_fare AS (\n", + " SELECT DISTINCT ON (trip_id) trip_id, geom AS last_geom\n", + " FROM ml.trip_validity_trip_fares\n", + " WHERE geom IS NOT NULL\n", + " ORDER BY trip_id, boarding_at DESC\n", + " ),\n", + " distances AS (\n", + " SELECT\n", + " t.trip_id,\n", + " MIN(ST_Distance(\n", + " ff.first_geom::geography,\n", + " ST_SetSRID(ST_MakePoint(g.garage_lon, g.garage_lat), 4326)::geography\n", + " )) AS start_dist,\n", + " MIN(ST_Distance(\n", + " lf.last_geom::geography,\n", + " ST_SetSRID(ST_MakePoint(g.garage_lon, g.garage_lat), 4326)::geography\n", + " )) AS end_dist\n", + " FROM ml.trip_validity_trips t\n", + " LEFT JOIN first_geo_fare ff ON ff.trip_id = t.trip_id\n", + " LEFT JOIN last_geo_fare lf ON lf.trip_id = t.trip_id\n", + " JOIN garages g ON g.company_id = LEFT(t.bus_id, 2)\n", + " WHERE ff.first_geom IS NOT NULL OR lf.last_geom IS NOT NULL\n", + " GROUP BY t.trip_id\n", + " )\n", + " UPDATE ml.trip_validity_dataset d\n", + " SET trip_start_distance_to_nearest_garage_meters = dist.start_dist,\n", + " trip_end_distance_to_nearest_garage_meters = dist.end_dist\n", + " FROM distances dist\n", + " WHERE dist.trip_id = d.trip_id;\n", + "\"\"\").format(garage_values=garage_values)\n", + "\n", + "conn.execute(update_query)\n", + "\n", + "conn.execute(\n", + " sql.SQL(\n", + " \"COMMENT ON COLUMN ml.trip_validity_dataset.\"\n", + " \"trip_start_distance_to_nearest_garage_meters IS {};\"\n", + " ).format(\n", + " sql.Literal(\n", + " \"ST_Distance (geography, meters) between this trip's FIRST \"\n", + " \"geo-tagged AFC fare tap (ml.trip_validity_trip_fares, earliest \"\n", + " \"boarding_at with geom IS NOT NULL - never an AVL position) and \"\n", + " \"the closest entry in GARAGES (this notebook) sharing this trip's \"\n", + " \"company_id. NULL when the trip has zero geo-tagged fares, or its \"\n", + " \"bus_id's 2-digit prefix isn't in GARAGES.\"\n", + " )\n", + " )\n", + ")\n", + "conn.execute(\n", + " sql.SQL(\n", + " \"COMMENT ON COLUMN ml.trip_validity_dataset.\"\n", + " \"trip_end_distance_to_nearest_garage_meters IS {};\"\n", + " ).format(\n", + " sql.Literal(\n", + " \"Same as trip_start_distance_to_nearest_garage_meters, but from \"\n", + " \"this trip's LAST geo-tagged AFC fare tap (latest boarding_at \"\n", + " \"with geom IS NOT NULL) instead of its first.\"\n", + " )\n", + " )\n", + ")\n", + "conn.commit()\n", + "print(\n", + " \"trip_start_distance_to_nearest_garage_meters, \"\n", + " \"trip_end_distance_to_nearest_garage_meters populated\"\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "4ccc8636", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-16T17:32:12.248408Z", + "iopub.status.busy": "2026-08-16T17:32:12.248333Z", + "iopub.status.idle": "2026-08-16T17:32:12.753779Z", + "shell.execute_reply": "2026-08-16T17:32:12.753413Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "columns: 85\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "distinct company_id values: ['02', '12', '14', '20', '21', '26', '30', '35', '36', '42', '67']\n", + "rows with a negative garage distance: 0\n", + "rows where only one of start/end is NULL: 0\n", + "rows with a populated garage distance: 899299\n", + "(1, '35128', '35', Decimal('1767.2'), Decimal('1491.9'))\n", + "(2, '20489', '20', Decimal('3469.1'), Decimal('3011.3'))\n", + "(3, '20597', '20', Decimal('3154.0'), Decimal('3154.0'))\n", + "all checks passed\n" + ] + } + ], + "source": [ + "EXPECTED_COLUMN_COUNT_AFTER_GARAGE_FEATURES = 85\n", + "EXPECTED_COMPANY_IDS = {c for c, _, _ in GARAGES}\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\"\"\"\n", + " SELECT count(*) FROM information_schema.columns\n", + " WHERE table_schema = 'ml' AND table_name = 'trip_validity_dataset';\n", + " \"\"\")\n", + " column_count = cur.fetchone()[0]\n", + " print(\"columns:\", column_count)\n", + " if column_count != EXPECTED_COLUMN_COUNT_AFTER_GARAGE_FEATURES:\n", + " msg = \"column count mismatch after adding garage-distance features\"\n", + " raise AssertionError(msg)\n", + "\n", + " # company_id never falls outside the companies GARAGES actually covers\n", + " cur.execute(\"SELECT DISTINCT company_id FROM ml.trip_validity_dataset;\")\n", + " seen_company_ids = {row[0] for row in cur.fetchall()}\n", + " unknown_companies = seen_company_ids - EXPECTED_COMPANY_IDS\n", + " print(\"distinct company_id values:\", sorted(seen_company_ids))\n", + " if unknown_companies:\n", + " msg = f\"company_id(s) with no entry in GARAGES: {unknown_companies}\"\n", + " raise AssertionError(msg)\n", + "\n", + " # both distance columns: never negative, never populated one-without-the-other\n", + " cur.execute(\"\"\"\n", + " SELECT\n", + " count(*) FILTER (WHERE trip_start_distance_to_nearest_garage_meters < 0\n", + " OR trip_end_distance_to_nearest_garage_meters < 0),\n", + " count(*) FILTER (\n", + " WHERE (trip_start_distance_to_nearest_garage_meters IS NULL)\n", + " != (trip_end_distance_to_nearest_garage_meters IS NULL)\n", + " ),\n", + " count(*) FILTER (WHERE trip_start_distance_to_nearest_garage_meters\n", + " IS NOT NULL)\n", + " FROM ml.trip_validity_dataset;\n", + " \"\"\")\n", + " n_negative, n_mismatched_null, n_populated = cur.fetchone()\n", + " print(\"rows with a negative garage distance:\", n_negative)\n", + " print(\"rows where only one of start/end is NULL:\", n_mismatched_null)\n", + " print(\"rows with a populated garage distance:\", n_populated)\n", + " if n_negative != 0:\n", + " msg = \"found a negative garage distance\"\n", + " raise AssertionError(msg)\n", + " if n_mismatched_null != 0:\n", + " msg = \"start/end garage distance NULL-ness disagree for some rows\"\n", + " raise AssertionError(msg)\n", + "\n", + " cur.execute(\"\"\"\n", + " SELECT trip_id, bus_id, company_id,\n", + " round(trip_start_distance_to_nearest_garage_meters::numeric, 1),\n", + " round(trip_end_distance_to_nearest_garage_meters::numeric, 1)\n", + " FROM ml.trip_validity_dataset\n", + " WHERE trip_start_distance_to_nearest_garage_meters IS NOT NULL\n", + " ORDER BY trip_id LIMIT 3;\n", + " \"\"\")\n", + " for row in cur.fetchall():\n", + " print(row)\n", + "\n", + "print(\"all checks passed\")" + ] + }, + { + "cell_type": "markdown", + "id": "bec78507", + "metadata": {}, + "source": [ + "## Adding route straight-line distance features\n", + "\n", + "One more engineered feature: the straight-line (\"as the crow flies\")\n", + "distance between a route's own start and end point, for both the `-I`\n", + "and `-V` shapes - distinct from `route_i_length_meters`/\n", + "`route_v_length_meters`, which are the shape's actual road-following\n", + "length (`ST_Length`). Computed directly from `ml.trip_validity_route_shapes`\n", + "(built once already, in `02_gtfs_materialize.ipynb`) via\n", + "`ST_Distance(ST_StartPoint(shape_geom), ST_EndPoint(shape_geom))` - no\n", + "need to touch or re-run any of the earlier notebooks, this only reads\n", + "a table that already exists.\n", + "\n", + "`route_i_straight_line_ratio`/`route_v_straight_line_ratio` (=\n", + "straight-line / actual length) come along too, same raw-then-ratio\n", + "convention as the rest of this dataset: close to `1` means a fairly\n", + "direct route, close to `0` means a winding or loop-shaped one. No\n", + "`GREATEST(0, ...)` clipping needed here (unlike `path_match_score_*`)\n", + "- straight-line distance between two points can never exceed the\n", + "length of *any* path connecting them (triangle inequality), so this\n", + "ratio is bounded to `[0, 1]` by construction, not just by convention;\n", + "the verification cell below checks that holds in practice too rather\n", + "than assuming it.\n", + "\n", + "Per-route, not per-trip, so this is computed once per distinct\n", + "`(feed_version_date, shape_id)` in a CTE, then joined onto every trip\n", + "via the `gtfs_shape_id_i`/`gtfs_shape_id_v` this dataset already\n", + "carries - cheap regardless of table size. `NULL` for a direction with\n", + "no matched shape (same as `route_i_length_meters` already is), and\n", + "`ADD COLUMN IF NOT EXISTS` + an unconditional `UPDATE` keep this\n", + "idempotent like every other section here." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "c2012a78", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-16T17:32:12.754687Z", + "iopub.status.busy": "2026-08-16T17:32:12.754618Z", + "iopub.status.idle": "2026-08-16T17:32:31.715957Z", + "shell.execute_reply": "2026-08-16T17:32:31.715677Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "route_i_straight_line_meters, route_i_straight_line_ratio, route_v_straight_line_meters, route_v_straight_line_ratio populated\n" + ] + } + ], + "source": [ + "conn.execute(\"\"\"\n", + " ALTER TABLE ml.trip_validity_dataset\n", + " ADD COLUMN IF NOT EXISTS route_i_straight_line_meters DOUBLE PRECISION,\n", + " ADD COLUMN IF NOT EXISTS route_i_straight_line_ratio DOUBLE PRECISION,\n", + " ADD COLUMN IF NOT EXISTS route_v_straight_line_meters DOUBLE PRECISION,\n", + " ADD COLUMN IF NOT EXISTS route_v_straight_line_ratio DOUBLE PRECISION;\n", + "\"\"\")\n", + "\n", + "conn.execute(\"\"\"\n", + " WITH shape_straight_line AS (\n", + " SELECT\n", + " feed_version_date,\n", + " shape_id,\n", + " ST_Distance(\n", + " ST_StartPoint(shape_geom)::geography,\n", + " ST_EndPoint(shape_geom)::geography\n", + " ) AS straight_line_meters\n", + " FROM ml.trip_validity_route_shapes\n", + " ),\n", + " trip_shape_straight_lines AS (\n", + " SELECT\n", + " d.trip_id,\n", + " si.straight_line_meters AS i_straight_line_meters,\n", + " sv.straight_line_meters AS v_straight_line_meters\n", + " FROM ml.trip_validity_dataset d\n", + " LEFT JOIN shape_straight_line si\n", + " ON si.feed_version_date = d.gtfs_feed_version_date\n", + " AND si.shape_id = d.gtfs_shape_id_i\n", + " LEFT JOIN shape_straight_line sv\n", + " ON sv.feed_version_date = d.gtfs_feed_version_date\n", + " AND sv.shape_id = d.gtfs_shape_id_v\n", + " )\n", + " UPDATE ml.trip_validity_dataset d\n", + " SET route_i_straight_line_meters = t.i_straight_line_meters,\n", + " route_i_straight_line_ratio =\n", + " t.i_straight_line_meters / NULLIF(d.route_i_length_meters, 0),\n", + " route_v_straight_line_meters = t.v_straight_line_meters,\n", + " route_v_straight_line_ratio =\n", + " t.v_straight_line_meters / NULLIF(d.route_v_length_meters, 0)\n", + " FROM trip_shape_straight_lines t\n", + " WHERE t.trip_id = d.trip_id;\n", + "\"\"\")\n", + "\n", + "conn.execute(\n", + " sql.SQL(\n", + " \"COMMENT ON COLUMN ml.trip_validity_dataset.route_i_straight_line_meters IS {};\"\n", + " ).format(\n", + " sql.Literal(\n", + " \"ST_Distance (geography, meters) between ST_StartPoint and \"\n", + " \"ST_EndPoint of the matched -I shape's geometry \"\n", + " \"(ml.trip_validity_route_shapes.shape_geom) - straight-line, \"\n", + " \"not the road-following route_i_length_meters. NULL when no \"\n", + " \"-I shape was matched (see gtfs_shape_id_i).\"\n", + " )\n", + " )\n", + ")\n", + "conn.execute(\n", + " sql.SQL(\n", + " \"COMMENT ON COLUMN ml.trip_validity_dataset.route_i_straight_line_ratio IS {};\"\n", + " ).format(\n", + " sql.Literal(\n", + " \"route_i_straight_line_meters / route_i_length_meters. Bounded \"\n", + " \"[0, 1] by the triangle inequality, not by clipping: 1 = a \"\n", + " \"perfectly direct route, near 0 = winding or loop-shaped. \"\n", + " \"Ratio divides through NULLIF(denominator, 0), so a zero \"\n", + " \"denominator gives NULL rather than the Infinity Postgres \"\n", + " \"would otherwise silently produce.\"\n", + " )\n", + " )\n", + ")\n", + "conn.execute(\n", + " sql.SQL(\n", + " \"COMMENT ON COLUMN ml.trip_validity_dataset.route_v_straight_line_meters IS {};\"\n", + " ).format(sql.Literal(\"Same as route_i_straight_line_meters, direction V.\"))\n", + ")\n", + "conn.execute(\n", + " sql.SQL(\n", + " \"COMMENT ON COLUMN ml.trip_validity_dataset.route_v_straight_line_ratio IS {};\"\n", + " ).format(\n", + " sql.Literal(\n", + " \"Same as route_i_straight_line_ratio, direction V. Ratio \"\n", + " \"divides through NULLIF(denominator, 0), so a zero \"\n", + " \"denominator gives NULL rather than the Infinity Postgres \"\n", + " \"would otherwise silently produce.\"\n", + " )\n", + " )\n", + ")\n", + "conn.commit()\n", + "print(\n", + " \"route_i_straight_line_meters, route_i_straight_line_ratio, \"\n", + " \"route_v_straight_line_meters, route_v_straight_line_ratio populated\"\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "eacc3e92", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-16T17:32:31.716908Z", + "iopub.status.busy": "2026-08-16T17:32:31.716839Z", + "iopub.status.idle": "2026-08-16T17:32:32.343326Z", + "shell.execute_reply": "2026-08-16T17:32:32.343009Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "columns: 89\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "rows where i meters/ratio NULL-ness disagree: 0\n", + "rows where v meters/ratio NULL-ness disagree: 0\n", + "straight_line_ratio rows out of [0,1]: 0\n", + "(1, 'shape0102-I', 'shape0102-V', Decimal('4693.4'), Decimal('5644.6'), Decimal('0.831'))\n", + "(2, 'shape0101-I', 'shape0101-V', Decimal('7109.6'), Decimal('9326.1'), Decimal('0.762'))\n", + "(3, 'shape0225-I', 'shape0225-V', Decimal('3330.0'), Decimal('4818.9'), Decimal('0.691'))\n", + "all checks passed\n" + ] + } + ], + "source": [ + "EXPECTED_COLUMN_COUNT_AFTER_STRAIGHT_LINE = 89\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\"\"\"\n", + " SELECT count(*) FROM information_schema.columns\n", + " WHERE table_schema = 'ml' AND table_name = 'trip_validity_dataset';\n", + " \"\"\")\n", + " column_count = cur.fetchone()[0]\n", + " print(\"columns:\", column_count)\n", + " if column_count != EXPECTED_COLUMN_COUNT_AFTER_STRAIGHT_LINE:\n", + " msg = \"column count mismatch after adding straight-line features\"\n", + " raise AssertionError(msg)\n", + "\n", + " # meters/ratio NULL-ness stays symmetric within each direction\n", + " cur.execute(\"\"\"\n", + " SELECT\n", + " count(*) FILTER (\n", + " WHERE (route_i_straight_line_meters IS NULL)\n", + " != (route_i_straight_line_ratio IS NULL)\n", + " ),\n", + " count(*) FILTER (\n", + " WHERE (route_v_straight_line_meters IS NULL)\n", + " != (route_v_straight_line_ratio IS NULL)\n", + " )\n", + " FROM ml.trip_validity_dataset;\n", + " \"\"\")\n", + " i_mismatch, v_mismatch = cur.fetchone()\n", + " print(\"rows where i meters/ratio NULL-ness disagree:\", i_mismatch)\n", + " print(\"rows where v meters/ratio NULL-ness disagree:\", v_mismatch)\n", + " if i_mismatch != 0 or v_mismatch != 0:\n", + " msg = \"straight-line meters/ratio NULL-ness disagree for some rows\"\n", + " raise AssertionError(msg)\n", + "\n", + " # the triangle-inequality bound actually holds, not just assumed\n", + " cur.execute(\"\"\"\n", + " SELECT count(*) FROM ml.trip_validity_dataset\n", + " WHERE (route_i_straight_line_ratio IS NOT NULL\n", + " AND (route_i_straight_line_ratio < 0\n", + " OR route_i_straight_line_ratio > 1))\n", + " OR (route_v_straight_line_ratio IS NOT NULL\n", + " AND (route_v_straight_line_ratio < 0\n", + " OR route_v_straight_line_ratio > 1));\n", + " \"\"\")\n", + " out_of_range = cur.fetchone()[0]\n", + " print(\"straight_line_ratio rows out of [0,1]:\", out_of_range)\n", + " if out_of_range != 0:\n", + " msg = \"straight_line_ratio found out of [0,1] range\"\n", + " raise AssertionError(msg)\n", + "\n", + " cur.execute(\"\"\"\n", + " SELECT trip_id, gtfs_shape_id_i, gtfs_shape_id_v,\n", + " round(route_i_straight_line_meters::numeric, 1),\n", + " round(route_i_length_meters::numeric, 1),\n", + " round(route_i_straight_line_ratio::numeric, 3)\n", + " FROM ml.trip_validity_dataset\n", + " WHERE route_i_straight_line_ratio IS NOT NULL\n", + " ORDER BY trip_id LIMIT 3;\n", + " \"\"\")\n", + " for row in cur.fetchall():\n", + " print(row)\n", + "\n", + "print(\"all checks passed\")" + ] + }, + { + "cell_type": "markdown", + "id": "f4a1c6e0", + "metadata": {}, + "source": [ + "## Adding terminal-distance features\n", + "\n", + "Six more features: distance from the trip's first and last geo-tagged\n", + "AFC fare tap (again, never AVL) to the nearest bus terminal - three\n", + "variants each (any terminal, nearest *open* terminal, nearest *closed*\n", + "terminal), same \"first/last fare, never AVL\" basis as the garage\n", + "distances above, just against a different reference set and without a\n", + "company restriction (terminals are shared city infrastructure, not\n", + "tied to one operator, so every trip compares against all of them, not\n", + "just its own company's).\n", + "\n", + "Terminal coordinates are **hard-coded** below (`TERMINALS`), this time\n", + "sourced directly from ETUFOR's (Empresa de Transporte Urbano de\n", + "Fortaleza) own published terminal registry (2024), not manually pinned\n", + "- the data came with lat/lon and a `tipologia` field already attached.\n", + "`tipologia` is `\"Terminal Fechado\"` (closed - an enclosed, gated\n", + "facility) or `\"Terminal Aberto\"` (open - an unenclosed plaza/praça);\n", + "mapped here to a plain `is_closed` boolean. Same caveat as `GARAGES`:\n", + "no upstream feed, so a new/closed terminal needs a manual edit here.\n", + "\n", + "Structurally the same pattern as the garage-distance UPDATE:\n", + "`first_geo_fare`/`last_geo_fare` CTEs (identical definitions - not\n", + "touching `trip_path`, for the same single-tap-trip reason as before),\n", + "cross-joined against `TERMINALS` this time (no per-company join key),\n", + "with the open/closed variants computed via `MIN(...) FILTER (WHERE\n", + "...)` in the same aggregation pass as the unconditional minimum rather\n", + "than three separate scans. `NULL` when the trip has zero geo-tagged\n", + "fares, same as every other fare-tap-based feature here. Idempotent by\n", + "the same `ADD COLUMN IF NOT EXISTS` + unconditional `UPDATE`\n", + "construction." + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "d3b8f271", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-16T17:32:32.344358Z", + "iopub.status.busy": "2026-08-16T17:32:32.344275Z", + "iopub.status.idle": "2026-08-16T17:32:32.346710Z", + "shell.execute_reply": "2026-08-16T17:32:32.346428Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "11 terminals (4 open, 7 closed)\n" + ] + } + ], + "source": [ + "# (name, terminal_lat, terminal_lon, is_closed). Hard-coded from ETUFOR's\n", + "# own 2024 terminal registry (came with lat/lon + tipologia attached, not\n", + "# manually pinned like GARAGES). \"Terminal Fechado\" (closed, an enclosed\n", + "# gated facility) -> True, \"Terminal Aberto\" (open plaza) -> False.\n", + "TERMINALS = [\n", + " (\"Conjunto Ceará\", -3.772986, -38.607545, True),\n", + " (\"Papicu\", -3.738568, -38.484761, True),\n", + " (\"Parangaba\", -3.775878, -38.56358, True),\n", + " (\"Antonio Bezerra\", -3.73752, -38.584548, True),\n", + " (\"Siqueira\", -3.789302, -38.586853, True),\n", + " (\"Lagoa\", -3.771151, -38.569879, True),\n", + " (\"Messejana\", -3.831156, -38.502458, True),\n", + " (\"Washington Soares\", -3.81501801194818, -38.4808807997053, False),\n", + " (\"Sagrado Coração de Jesus\", -3.732300515031093, -38.52671674368621, False),\n", + " (\"Jose Walter\", -3.8222248574044375, -38.55851841148358, False),\n", + " (\"José de Alencar\", -3.726297474312544, -38.53210776782962, False),\n", + "]\n", + "\n", + "n_open = sum(1 for _, _, _, closed in TERMINALS if not closed)\n", + "n_closed = sum(1 for _, _, _, closed in TERMINALS if closed)\n", + "print(f\"{len(TERMINALS)} terminals ({n_open} open, {n_closed} closed)\")" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "b9e02a5c", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-16T17:32:32.347452Z", + "iopub.status.busy": "2026-08-16T17:32:32.347371Z", + "iopub.status.idle": "2026-08-16T17:33:35.417114Z", + "shell.execute_reply": "2026-08-16T17:33:35.416779Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "trip_start/end_distance_to_nearest_[open/closed_]terminal_meters populated\n" + ] + } + ], + "source": [ + "conn.execute(\"\"\"\n", + " ALTER TABLE ml.trip_validity_dataset\n", + " ADD COLUMN IF NOT EXISTS trip_start_distance_to_nearest_terminal_meters\n", + " DOUBLE PRECISION,\n", + " ADD COLUMN IF NOT EXISTS trip_start_distance_to_nearest_open_terminal_meters\n", + " DOUBLE PRECISION,\n", + " ADD COLUMN IF NOT EXISTS trip_start_distance_to_nearest_closed_terminal_meters\n", + " DOUBLE PRECISION,\n", + " ADD COLUMN IF NOT EXISTS trip_end_distance_to_nearest_terminal_meters\n", + " DOUBLE PRECISION,\n", + " ADD COLUMN IF NOT EXISTS trip_end_distance_to_nearest_open_terminal_meters\n", + " DOUBLE PRECISION,\n", + " ADD COLUMN IF NOT EXISTS trip_end_distance_to_nearest_closed_terminal_meters\n", + " DOUBLE PRECISION;\n", + "\"\"\")\n", + "\n", + "terminal_values = sql.SQL(\", \").join(\n", + " sql.SQL(\"({}, {}, {}, {})\").format(\n", + " sql.Literal(name), sql.Literal(lat), sql.Literal(lon), sql.Literal(is_closed)\n", + " )\n", + " for name, lat, lon, is_closed in TERMINALS\n", + ")\n", + "\n", + "update_query = sql.SQL(\"\"\"\n", + " WITH terminals (name, terminal_lat, terminal_lon, is_closed) AS (\n", + " VALUES {terminal_values}\n", + " ),\n", + " first_geo_fare AS (\n", + " SELECT DISTINCT ON (trip_id) trip_id, geom AS first_geom\n", + " FROM ml.trip_validity_trip_fares\n", + " WHERE geom IS NOT NULL\n", + " ORDER BY trip_id, boarding_at ASC\n", + " ),\n", + " last_geo_fare AS (\n", + " SELECT DISTINCT ON (trip_id) trip_id, geom AS last_geom\n", + " FROM ml.trip_validity_trip_fares\n", + " WHERE geom IS NOT NULL\n", + " ORDER BY trip_id, boarding_at DESC\n", + " ),\n", + " distances AS (\n", + " SELECT\n", + " t.trip_id,\n", + " MIN(ST_Distance(ff.first_geom::geography, term_point.geog))\n", + " AS start_dist_any,\n", + " MIN(ST_Distance(ff.first_geom::geography, term_point.geog))\n", + " FILTER (WHERE NOT term.is_closed) AS start_dist_open,\n", + " MIN(ST_Distance(ff.first_geom::geography, term_point.geog))\n", + " FILTER (WHERE term.is_closed) AS start_dist_closed,\n", + " MIN(ST_Distance(lf.last_geom::geography, term_point.geog))\n", + " AS end_dist_any,\n", + " MIN(ST_Distance(lf.last_geom::geography, term_point.geog))\n", + " FILTER (WHERE NOT term.is_closed) AS end_dist_open,\n", + " MIN(ST_Distance(lf.last_geom::geography, term_point.geog))\n", + " FILTER (WHERE term.is_closed) AS end_dist_closed\n", + " FROM ml.trip_validity_trips t\n", + " LEFT JOIN first_geo_fare ff ON ff.trip_id = t.trip_id\n", + " LEFT JOIN last_geo_fare lf ON lf.trip_id = t.trip_id\n", + " CROSS JOIN terminals term\n", + " CROSS JOIN LATERAL (\n", + " SELECT ST_SetSRID(\n", + " ST_MakePoint(term.terminal_lon, term.terminal_lat), 4326\n", + " )::geography AS geog\n", + " ) term_point\n", + " WHERE ff.first_geom IS NOT NULL OR lf.last_geom IS NOT NULL\n", + " GROUP BY t.trip_id\n", + " )\n", + " UPDATE ml.trip_validity_dataset d\n", + " SET trip_start_distance_to_nearest_terminal_meters = dist.start_dist_any,\n", + " trip_start_distance_to_nearest_open_terminal_meters = dist.start_dist_open,\n", + " trip_start_distance_to_nearest_closed_terminal_meters = dist.start_dist_closed,\n", + " trip_end_distance_to_nearest_terminal_meters = dist.end_dist_any,\n", + " trip_end_distance_to_nearest_open_terminal_meters = dist.end_dist_open,\n", + " trip_end_distance_to_nearest_closed_terminal_meters = dist.end_dist_closed\n", + " FROM distances dist\n", + " WHERE dist.trip_id = d.trip_id;\n", + "\"\"\").format(terminal_values=terminal_values)\n", + "\n", + "conn.execute(update_query)\n", + "\n", + "terminal_col_comments = [\n", + " (\n", + " \"trip_start_distance_to_nearest_terminal_meters\",\n", + " \"ST_Distance (geography, meters) between this trip's FIRST \"\n", + " \"geo-tagged AFC fare tap (ml.trip_validity_trip_fares, earliest \"\n", + " \"boarding_at with geom IS NOT NULL - never an AVL position) and \"\n", + " \"the closest entry in TERMINALS (this notebook), any tipologia. \"\n", + " \"NULL when the trip has zero geo-tagged fares.\",\n", + " ),\n", + " (\n", + " \"trip_start_distance_to_nearest_open_terminal_meters\",\n", + " \"Same as trip_start_distance_to_nearest_terminal_meters, \"\n", + " \"restricted to TERMINALS entries with is_closed = false \"\n", + " \"('Terminal Aberto'). NULL if the trip has zero geo-tagged fares.\",\n", + " ),\n", + " (\n", + " \"trip_start_distance_to_nearest_closed_terminal_meters\",\n", + " \"Same as trip_start_distance_to_nearest_terminal_meters, \"\n", + " \"restricted to TERMINALS entries with is_closed = true \"\n", + " \"('Terminal Fechado'). NULL if the trip has zero geo-tagged fares.\",\n", + " ),\n", + " (\n", + " \"trip_end_distance_to_nearest_terminal_meters\",\n", + " \"Same as trip_start_distance_to_nearest_terminal_meters, but from \"\n", + " \"this trip's LAST geo-tagged AFC fare tap (latest boarding_at \"\n", + " \"with geom IS NOT NULL) instead of its first.\",\n", + " ),\n", + " (\n", + " \"trip_end_distance_to_nearest_open_terminal_meters\",\n", + " \"Same as trip_start_distance_to_nearest_open_terminal_meters, but \"\n", + " \"from this trip's LAST geo-tagged AFC fare tap instead of its \"\n", + " \"first.\",\n", + " ),\n", + " (\n", + " \"trip_end_distance_to_nearest_closed_terminal_meters\",\n", + " \"Same as trip_start_distance_to_nearest_closed_terminal_meters, \"\n", + " \"but from this trip's LAST geo-tagged AFC fare tap instead of its \"\n", + " \"first.\",\n", + " ),\n", + "]\n", + "for col, text in terminal_col_comments:\n", + " conn.execute(\n", + " sql.SQL(\"COMMENT ON COLUMN ml.trip_validity_dataset.{} IS {};\").format(\n", + " sql.Identifier(col), sql.Literal(text)\n", + " )\n", + " )\n", + "\n", + "conn.commit()\n", + "print(\"trip_start/end_distance_to_nearest_[open/closed_]terminal_meters populated\")" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "a1c74d9e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-16T17:33:35.418037Z", + "iopub.status.busy": "2026-08-16T17:33:35.417962Z", + "iopub.status.idle": "2026-08-16T17:33:36.314952Z", + "shell.execute_reply": "2026-08-16T17:33:36.314647Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "columns: 95\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "rows where a restricted-subset minimum beats the overall minimum: 0\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "rows where any/open/closed NULL-ness disagree: 0\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "rows with a negative terminal distance: 0\n", + "(1, Decimal('434.9'), Decimal('434.9'), Decimal('5402.3'))\n", + "(2, Decimal('3170.7'), Decimal('6676.3'), Decimal('3170.7'))\n", + "(3, Decimal('62.7'), Decimal('5976.1'), Decimal('62.7'))\n", + "all checks passed\n" + ] + } + ], + "source": [ + "EXPECTED_COLUMN_COUNT_AFTER_TERMINAL_FEATURES = 95\n", + "EXPECTED_TERMINAL_COUNT = 11\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\"\"\"\n", + " SELECT count(*) FROM information_schema.columns\n", + " WHERE table_schema = 'ml' AND table_name = 'trip_validity_dataset';\n", + " \"\"\")\n", + " column_count = cur.fetchone()[0]\n", + " print(\"columns:\", column_count)\n", + " if column_count != EXPECTED_COLUMN_COUNT_AFTER_TERMINAL_FEATURES:\n", + " msg = \"column count mismatch after adding terminal-distance features\"\n", + " raise AssertionError(msg)\n", + "\n", + " if len(TERMINALS) != EXPECTED_TERMINAL_COUNT:\n", + " msg = f\"expected {EXPECTED_TERMINAL_COUNT} terminals, found {len(TERMINALS)}\"\n", + " raise AssertionError(msg)\n", + "\n", + " # a restricted (open-only/closed-only) minimum can never beat the\n", + " # unrestricted \"any terminal\" minimum - it's a minimum over a subset\n", + " cur.execute(\"\"\"\n", + " SELECT count(*) FROM ml.trip_validity_dataset\n", + " WHERE trip_start_distance_to_nearest_open_terminal_meters\n", + " < trip_start_distance_to_nearest_terminal_meters\n", + " OR trip_start_distance_to_nearest_closed_terminal_meters\n", + " < trip_start_distance_to_nearest_terminal_meters\n", + " OR trip_end_distance_to_nearest_open_terminal_meters\n", + " < trip_end_distance_to_nearest_terminal_meters\n", + " OR trip_end_distance_to_nearest_closed_terminal_meters\n", + " < trip_end_distance_to_nearest_terminal_meters;\n", + " \"\"\")\n", + " impossible = cur.fetchone()[0]\n", + " print(\n", + " \"rows where a restricted-subset minimum beats the overall minimum:\",\n", + " impossible,\n", + " )\n", + " if impossible != 0:\n", + " msg = \"an open/closed-restricted distance is smaller than the overall minimum\"\n", + " raise AssertionError(msg)\n", + "\n", + " # any/open/closed all come from the same distances CTE row, so their\n", + " # NULL-ness must agree (all populated or all NULL) - TERMINALS has at\n", + " # least one open and one closed entry, so this isn't coincidental\n", + " cur.execute(\"\"\"\n", + " SELECT count(*) FROM ml.trip_validity_dataset\n", + " WHERE (trip_start_distance_to_nearest_terminal_meters IS NULL)\n", + " != (trip_start_distance_to_nearest_open_terminal_meters IS NULL)\n", + " OR (trip_start_distance_to_nearest_terminal_meters IS NULL)\n", + " != (trip_start_distance_to_nearest_closed_terminal_meters IS NULL)\n", + " OR (trip_end_distance_to_nearest_terminal_meters IS NULL)\n", + " != (trip_end_distance_to_nearest_open_terminal_meters IS NULL)\n", + " OR (trip_end_distance_to_nearest_terminal_meters IS NULL)\n", + " != (trip_end_distance_to_nearest_closed_terminal_meters IS NULL);\n", + " \"\"\")\n", + " null_mismatch = cur.fetchone()[0]\n", + " print(\"rows where any/open/closed NULL-ness disagree:\", null_mismatch)\n", + " if null_mismatch != 0:\n", + " msg = \"any/open/closed terminal distance NULL-ness disagree\"\n", + " raise AssertionError(msg)\n", + "\n", + " cur.execute(\"\"\"\n", + " SELECT count(*) FILTER (\n", + " WHERE trip_start_distance_to_nearest_terminal_meters < 0\n", + " OR trip_end_distance_to_nearest_terminal_meters < 0\n", + " )\n", + " FROM ml.trip_validity_dataset;\n", + " \"\"\")\n", + " n_negative = cur.fetchone()[0]\n", + " print(\"rows with a negative terminal distance:\", n_negative)\n", + " if n_negative != 0:\n", + " msg = \"found a negative terminal distance\"\n", + " raise AssertionError(msg)\n", + "\n", + " cur.execute(\"\"\"\n", + " SELECT trip_id,\n", + " round(trip_start_distance_to_nearest_terminal_meters::numeric, 1),\n", + " round(trip_start_distance_to_nearest_open_terminal_meters::numeric, 1),\n", + " round(trip_start_distance_to_nearest_closed_terminal_meters::numeric, 1)\n", + " FROM ml.trip_validity_dataset\n", + " WHERE trip_start_distance_to_nearest_terminal_meters IS NOT NULL\n", + " ORDER BY trip_id LIMIT 3;\n", + " \"\"\")\n", + " for row in cur.fetchall():\n", + " print(row)\n", + "\n", + "print(\"all checks passed\")" + ] + }, + { + "cell_type": "markdown", + "id": "0a6df93a", + "metadata": {}, + "source": [ + "## Refresh the table-level comment\n", + "\n", + "`COMMENT ON TABLE` was set once, right after the original 80-column\n", + "build, and never updated as `weekday_number`/`is_weekend`, then\n", + "`company_id`/the garage-distance columns, then the route\n", + "straight-line columns, then the terminal-distance columns were added\n", + "afterward - overwriting it here so `\\d+ ml.trip_validity_dataset`\n", + "reflects the table's actual final shape instead of a stale \"80\n", + "columns\"." + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "755e48e2", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-16T17:33:36.315959Z", + "iopub.status.busy": "2026-08-16T17:33:36.315884Z", + "iopub.status.idle": "2026-08-16T17:33:36.318715Z", + "shell.execute_reply": "2026-08-16T17:33:36.318499Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "table comment refreshed\n" + ] + } + ], + "source": [ + "conn.execute(\n", + " sql.SQL(\"COMMENT ON TABLE ml.trip_validity_dataset IS {};\").format(\n", + " sql.Literal(\n", + " \"Trip Validity model: the final ML-ready dataset. One row per \"\n", + " \"trip, 95 columns - the original 80 (identifiers + raw metrics \"\n", + " \"from ml.trip_validity_trip_metrics + every relative/normalized \"\n", + " \"column) plus weekday_number/is_weekend, company_id/\"\n", + " \"trip_start_distance_to_nearest_garage_meters/\"\n", + " \"trip_end_distance_to_nearest_garage_meters, \"\n", + " \"route_i_straight_line_meters/route_i_straight_line_ratio/\"\n", + " \"route_v_straight_line_meters/route_v_straight_line_ratio, and \"\n", + " \"trip_start/end_distance_to_nearest_[open/closed_]terminal_meters \"\n", + " \"(6 columns), all added afterward. See \"\n", + " \"ml/trip_validity_model/notebooks/05_final_dataset.ipynb.\"\n", + " )\n", + " )\n", + ")\n", + "conn.commit()\n", + "print(\"table comment refreshed\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (opa-database)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/ml/trip_validity_model/notebooks/06_model_sweep.ipynb b/ml/trip_validity_model/notebooks/06_model_sweep.ipynb new file mode 100644 index 0000000..d5fbc57 --- /dev/null +++ b/ml/trip_validity_model/notebooks/06_model_sweep.ipynb @@ -0,0 +1,1840 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "title", + "metadata": {}, + "source": [ + "# 06 - Final model sweep: SFFS feature selection + nested repeated CV (LightGBM only)\n", + "\n", + "**Purpose**: two stages. Stage 1 finds one feature set via Sequential\n", + "Forward Floating Selection (SFFS) over the *full* candidate list, once,\n", + "up front - context-aware selection that can catch a feature that's\n", + "useless alone but valuable once another is already present, which a\n", + "solo-importance ranking is blind to. Stage 2 fixes that feature set and\n", + "runs the full 50-rotation nested repeated CV design to tune\n", + "hyperparameters, get an honest performance estimate, and build a\n", + "leak-free calibrator - decoupled from feature selection so it stays\n", + "fast despite going back up to 50 rotations.\n", + "\n", + "**Read-only, no permanence in the database**: every SQL statement in\n", + "this notebook is a `SELECT`. Nothing is written to Postgres, no table\n", + "is created or altered, and the live labeling app's own artifacts\n", + "(`ml/trip_validity_model/artifacts/`) are never touched. The one thing\n", + "this notebook *does* write is its own final model + summary, saved\n", + "locally to `ml/trip_validity_model/notebooks/06_artifacts/` (last\n", + "section) so it's actually committed to the repo.\n", + "\n", + "**Every long-running cell prints progress as it goes** - this is a hard\n", + "requirement, not a nice-to-have. Stage 1 prints every add/remove step\n", + "(feature, new CV score, running feature count, elapsed time); Stage 2\n", + "prints one line per completed rotation (rotation/repeat/fold, inner CV\n", + "best Brier, outer-eval Brier, elapsed time). Nothing should run silently\n", + "for more than a few seconds.\n", + "\n", + "**The rule that holds throughout**: `uncertain_pool` (the\n", + "active-learning-sourced labels) is only ever on the *training* side -\n", + "never a validation fold, never calibration, never final evaluation.\n", + "`clean_pool` (the random-sourced labels) rotates between training and\n", + "evaluation roles in Stage 2's outer loop; a given row is never both at\n", + "once within the same rotation.\n", + "\n", + "## Why two stages instead of one combined search\n", + "\n", + "The previous version of this notebook folded feature selection into\n", + "every outer rotation's Optuna search (a boolean per candidate, alongside\n", + "the hyperparameters) - correct in spirit, but with ~85 candidates that's\n", + "a ~93-dimensional search space paid **50 times over**, and even after\n", + "pre-filtering to a smaller candidate pool it stayed slow enough that a\n", + "smoke test at deliberately tiny settings was still grinding after\n", + "several minutes. Moving feature selection into a single upfront SFFS\n", + "pass changes the cost structure entirely: it's still a real, visibly\n", + "expensive stage (potentially thousands of CV fits, ~10-20 minutes at\n", + "full scale) - but it's a **one-time** cost. Stage 2's 50 rotations then\n", + "only ever tune 8 hyperparameters against a *fixed* feature set, which is\n", + "a small enough search space to converge in 50-100 trials instead of\n", + "150-200, and each rotation itself is fast. Total expected runtime at the\n", + "real defaults: SFFS (~10-20 min) + 50 lightweight rotations (~a few\n", + "minutes) + plotting/calibration (seconds) - comfortably under an hour.\n", + "\n", + "**A note on SFFS's \"floating\" step**: this implementation only accepts\n", + "a removal if it *strictly improves* the CV score beyond `SFFS_TOLERANCE`\n", + "- not merely \"doesn't get worse,\" which is the more common textbook\n", + "phrasing. The stricter rule guarantees the score never regresses between\n", + "steps and can't oscillate (repeatedly add-then-remove the same feature),\n", + "at the cost of being slightly more conservative about pruning genuinely\n", + "redundant features." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "imports", + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "import os\n", + "import sys\n", + "import time\n", + "from collections import defaultdict\n", + "from collections.abc import Callable, Iterator\n", + "from pathlib import Path\n", + "\n", + "import joblib\n", + "import lightgbm as lgb\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "import optuna\n", + "import pandas as pd\n", + "import psycopg\n", + "from psycopg import sql\n", + "from sklearn.calibration import CalibrationDisplay\n", + "from sklearn.metrics import (\n", + " ConfusionMatrixDisplay,\n", + " PrecisionRecallDisplay,\n", + " RocCurveDisplay,\n", + " precision_recall_curve,\n", + ")\n", + "from sklearn.model_selection import RepeatedStratifiedKFold, StratifiedKFold\n", + "\n", + "optuna.logging.set_verbosity(optuna.logging.WARNING) # one line per trial is too noisy" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "root", + "metadata": {}, + "outputs": [], + "source": [ + "_root = Path.cwd()\n", + "while not (_root / \"pyproject.toml\").exists():\n", + " _root = _root.parent\n", + "os.chdir(_root)\n", + "os.environ.setdefault(\"RAW_DATA_ROOT\", str(_root))\n", + "sys.path.insert(0, str(_root / \"ml/trip_validity_model/app\"))" + ] + }, + { + "cell_type": "markdown", + "id": "connect-md", + "metadata": {}, + "source": [ + "## Connect (read-only) and import the app's own feature list, metrics, and calibrator" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "connect", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "connected - read-only from here on, this notebook never writes to the database\n", + "81 candidate features (4 categorical, 77 numeric)\n" + ] + } + ], + "source": [ + "import features\n", + "import metrics\n", + "from calibration import PlattCalibrator\n", + "\n", + "from opa_database.config import settings\n", + "\n", + "conn = psycopg.connect(settings.db_dsn)\n", + "print(\"connected - read-only from here on, this notebook never writes to the database\")\n", + "print(\n", + " f\"{len(features.ALL_FEATURES)} candidate features \"\n", + " f\"({len(features.CATEGORICAL_FEATURES)} categorical, \"\n", + " f\"{len(features.NUMERIC_FEATURES)} numeric)\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "config-md", + "metadata": {}, + "source": [ + "## Every tunable knob, in one place\n", + "\n", + "**Stage 1 (SFFS)**: `STAGE1_HYPERPARAMS` is a single fixed LightGBM\n", + "config used for every SFFS evaluation - jointly tuning hyperparameters\n", + "at every step is exactly what would make this stage intractable.\n", + "`STAGE1_N_SPLITS=3` (lighter than Stage 2's 5, since this is a\n", + "screening pass, not the honest estimate). `SFFS_TOLERANCE` is the\n", + "minimum Brier improvement needed to accept an add/remove step;\n", + "`SFFS_FLOATING_EVERY` controls how often a removal is attempted;\n", + "`SFFS_MAX_STEPS` is a safety bound on total steps. `MIN_SFFS_FEATURES` is a floor on trusting SFFS's own answer - if it converges to fewer features than this, Stage 2 falls back to (almost) the full candidate list instead (see the cell right after the SFFS trajectory plot for why).\n", + "\n", + "**Stage 2 (nested repeated CV)**: `N_OUTER_SPLITS x N_OUTER_REPEATS`\n", + "outer rotations (5x10=50 by default); `N_INNER_SPLITS` inner CV folds\n", + "per rotation; `N_INNER_TRIALS` / `INNER_TIMEOUT_SECONDS` bound each\n", + "rotation's hyperparameter-only Optuna search, whichever comes first -\n", + "75 trials is enough now that there's no feature mask to search\n", + "alongside the 8 hyperparameters.\n", + "\n", + "**Shared**: `OUTER_SEED` / `INNER_SEED` / `CV_SEED` for\n", + "reproducibility. `OUTER_SEED` also seeds each rotation's Optuna\n", + "sampler, offset by `rotation_id`." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "config", + "metadata": {}, + "outputs": [], + "source": [ + "# Stage 1: SFFS feature selection (runs once, upfront)\n", + "STAGE1_HYPERPARAMS = {\"num_leaves\": 31, \"learning_rate\": 0.05, \"n_estimators\": 200}\n", + "STAGE1_N_SPLITS = 3\n", + "SFFS_TOLERANCE = 1e-4\n", + "SFFS_FLOATING_EVERY = 5\n", + "SFFS_MAX_STEPS = 200\n", + "MIN_SFFS_FEATURES = 5\n", + "\n", + "# Stage 2: nested repeated CV for hyperparameters, evaluation, and calibration\n", + "N_OUTER_SPLITS = 5\n", + "N_OUTER_REPEATS = 10\n", + "N_INNER_SPLITS = 5\n", + "N_INNER_TRIALS = 75\n", + "INNER_TIMEOUT_SECONDS = 180\n", + "\n", + "OUTER_SEED = 42\n", + "INNER_SEED = 123\n", + "CV_SEED = 42" + ] + }, + { + "cell_type": "markdown", + "id": "load-md", + "metadata": {}, + "source": [ + "## Load every labeled row and the remaining unlabeled pool\n", + "\n", + "LightGBM handles pandas `category` dtype and `NaN` natively, so\n", + "`features.cast_feature_dtypes` is all the casting this notebook\n", + "needs." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "load-labeled", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "500 labeled rows\n", + "label_set selection_source\n", + "calibration random 125\n", + "test random 125\n", + "train random 126\n", + " uncertain 124\n", + "dtype: int64\n" + ] + } + ], + "source": [ + "feature_columns_sql = sql.SQL(\", \").join(\n", + " sql.SQL(\"d.{}\").format(sql.Identifier(c)) for c in features.ALL_FEATURES\n", + ")\n", + "\n", + "labeled_query = sql.SQL(\"\"\"\n", + " SELECT l.trip_id, l.label, l.label_set, l.selection_source, {feature_columns}\n", + " FROM ml.trip_validity_labels l\n", + " JOIN ml.trip_validity_dataset d ON d.trip_id = l.trip_id\n", + "\"\"\").format(feature_columns=feature_columns_sql)\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(labeled_query)\n", + " cols = [c.name for c in cur.description]\n", + " labeled = pd.DataFrame(cur.fetchall(), columns=cols)\n", + "\n", + "labeled = features.cast_feature_dtypes(labeled)\n", + "\n", + "EXPECTED_LABELED_COUNT = 500\n", + "if len(labeled) != EXPECTED_LABELED_COUNT:\n", + " msg = f\"expected {EXPECTED_LABELED_COUNT} labeled rows, got {len(labeled)}\"\n", + " raise AssertionError(msg)\n", + "\n", + "print(f\"{len(labeled)} labeled rows\")\n", + "print(labeled.groupby([\"label_set\", \"selection_source\"]).size())" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "load-unlabeled", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "940488 unlabeled rows remaining\n" + ] + } + ], + "source": [ + "unlabeled_query = sql.SQL(\"\"\"\n", + " SELECT d.trip_id, {feature_columns}\n", + " FROM ml.trip_validity_dataset d\n", + " WHERE NOT EXISTS (\n", + " SELECT 1 FROM ml.trip_validity_labels l WHERE l.trip_id = d.trip_id\n", + " )\n", + "\"\"\").format(feature_columns=feature_columns_sql)\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(unlabeled_query)\n", + " cols = [c.name for c in cur.description]\n", + " unlabeled = pd.DataFrame(cur.fetchall(), columns=cols)\n", + "\n", + "unlabeled = features.cast_feature_dtypes(unlabeled)\n", + "print(f\"{len(unlabeled)} unlabeled rows remaining\")" + ] + }, + { + "cell_type": "markdown", + "id": "pools-md", + "metadata": {}, + "source": [ + "## Split into the clean (random) and uncertain (active-learning) pools\n", + "\n", + "`clean_pool` rotates through Stage 2's outer training and outer\n", + "evaluation roles; `uncertain_pool` is added to every fold's (Stage 1\n", + "and Stage 2 alike) training side, in full, and never appears anywhere\n", + "else." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "pools", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "clean_pool 376 rows, 69.4% valid\n", + "uncertain_pool 124 rows, 54.8% valid\n", + "\n", + "uncertain_pool is training-side only throughout this notebook - never a validation fold, never calibration, never final evaluation.\n" + ] + } + ], + "source": [ + "clean_pool = labeled[labeled[\"selection_source\"] == \"random\"].reset_index(drop=True)\n", + "uncertain_pool = labeled[labeled[\"selection_source\"] == \"uncertain\"].reset_index(\n", + " drop=True\n", + ")\n", + "\n", + "print(\n", + " f\"clean_pool {len(clean_pool):4d} rows, {clean_pool['label'].mean():.1%} valid\"\n", + ")\n", + "print(\n", + " f\"uncertain_pool {len(uncertain_pool):4d} rows, \"\n", + " f\"{uncertain_pool['label'].mean():.1%} valid\"\n", + ")\n", + "print(\n", + " \"\\nuncertain_pool is training-side only throughout this notebook - \"\n", + " \"never a validation fold, never calibration, never final evaluation.\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "cv-harness-md", + "metadata": {}, + "source": [ + "## Shared cross-validation harness (used by both stages)\n", + "\n", + "`cv_folds` splits `clean_df` into `n_splits` folds; each fold's training\n", + "side is the other clean folds plus the *entire* `extra_train_df` (never\n", + "held out), each fold's validation side is only ever clean rows - the\n", + "same train-on-everything/validate-on-clean-only rule everywhere in this\n", + "notebook. `cv_score` wraps it into one number per config: fit fresh on\n", + "each fold's training side, predict on its validation side, average\n", + "Brier (and the other three metrics) across folds. Stage 1 calls this\n", + "with a fixed hyperparameter dict and a varying feature list; Stage 2\n", + "calls it with a fixed feature list and varying hyperparameters." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "cv-harness", + "metadata": {}, + "outputs": [], + "source": [ + "def cv_folds(\n", + " clean_df: pd.DataFrame,\n", + " extra_train_df: pd.DataFrame,\n", + " feature_cols: list[str],\n", + " n_splits: int,\n", + " seed: int,\n", + ") -> Iterator[tuple[pd.DataFrame, pd.Series, pd.DataFrame, pd.Series]]:\n", + " \"\"\"Yield (train_features, train_labels, valid_features, valid_labels) per fold.\n", + "\n", + " clean_df is split into n_splits folds; each fold's training side is\n", + " the other folds of clean_df plus the *entire* extra_train_df (never\n", + " held out); each fold's validation side is only ever clean_df rows.\n", + " \"\"\"\n", + " skf = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=seed)\n", + " clean_features = clean_df[feature_cols]\n", + " clean_labels = clean_df[\"label\"]\n", + " extra_features = extra_train_df[feature_cols]\n", + " extra_labels = extra_train_df[\"label\"]\n", + " for train_idx, valid_idx in skf.split(clean_features, clean_labels):\n", + " train_features = pd.concat(\n", + " [clean_features.iloc[train_idx], extra_features], ignore_index=True\n", + " )\n", + " train_labels = pd.concat(\n", + " [clean_labels.iloc[train_idx], extra_labels], ignore_index=True\n", + " )\n", + " valid_features = clean_features.iloc[valid_idx]\n", + " valid_labels = clean_labels.iloc[valid_idx]\n", + " yield train_features, train_labels, valid_features, valid_labels\n", + "\n", + "\n", + "def lightgbm_build_model(hyperparams: dict) -> lgb.LGBMClassifier:\n", + " \"\"\"Build an unfit LightGBM classifier from a hyperparameter dict.\"\"\"\n", + " return lgb.LGBMClassifier(\n", + " objective=\"binary\", verbosity=-1, random_state=CV_SEED, **hyperparams\n", + " )\n", + "\n", + "\n", + "def cv_score(\n", + " hyperparams: dict,\n", + " feature_cols: list[str],\n", + " clean_df: pd.DataFrame,\n", + " extra_train_df: pd.DataFrame,\n", + " n_splits: int,\n", + " seed: int = INNER_SEED,\n", + ") -> pd.DataFrame:\n", + " \"\"\"Mean/std of each metric across CV folds for one LightGBM config.\"\"\"\n", + " fold_metrics = []\n", + " for train_features, train_labels, valid_features, valid_labels in cv_folds(\n", + " clean_df, extra_train_df, feature_cols, n_splits, seed\n", + " ):\n", + " model = lightgbm_build_model(hyperparams)\n", + " model.fit(train_features, train_labels)\n", + " proba = model.predict_proba(valid_features)[:, 1]\n", + " fold_metrics.append(metrics.evaluate(valid_labels.to_numpy(), proba))\n", + " return pd.DataFrame(fold_metrics)" + ] + }, + { + "cell_type": "markdown", + "id": "sffs-md", + "metadata": {}, + "source": [ + "## Stage 1: Sequential Forward Floating Selection\n", + "\n", + "Starts empty. At each step, every remaining candidate is evaluated by\n", + "adding it to the current set and scoring the union via `cv_score`\n", + "(`STAGE1_HYPERPARAMS`, `STAGE1_N_SPLITS`-fold CV); whichever improves\n", + "the mean CV Brier score the most - beyond `SFFS_TOLERANCE` - gets added.\n", + "Every `SFFS_FLOATING_EVERY` additions, it tries removing each\n", + "already-selected feature in turn, accepting a removal only if it\n", + "strictly improves the score (see the design note at the top of the\n", + "notebook on why \"strictly improves,\" not \"doesn't get worse\"). Stops\n", + "when no addition clears the tolerance, or at `SFFS_MAX_STEPS` as a\n", + "safety bound.\n", + "\n", + "This is the notebook's expensive stage - each step evaluates every\n", + "remaining candidate, so it prints a line for every accepted add/remove\n", + "as it goes, not just a final result." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "sffs", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[SFFS step 1] + trip_duration_ratio_to_route_avg brier=0.0118 n_features= 1 6s elapsed\n", + "[SFFS step 2] + trip_duration_ratio_to_scheduled_v brier=0.0013 n_features= 2 15s elapsed\n", + "[SFFS step 3] + route_i_scheduled_duration_n_trips_at_hour brier=0.0002 n_features= 3 25s elapsed\n", + "[SFFS step 4] no addition improves beyond tolerance (3 features, brier=0.0002) - stopping. 35s elapsed\n", + "\n", + "Stage 1 done: 3 features selected, final brier=0.0002, 35s total\n", + "\n", + "selected features: ['trip_duration_ratio_to_route_avg', 'trip_duration_ratio_to_scheduled_v', 'route_i_scheduled_duration_n_trips_at_hour']\n" + ] + } + ], + "source": [ + "def _sffs_best_addition(\n", + " selected: list[str],\n", + " remaining: list[str],\n", + " current_score: float,\n", + " hyperparams: dict,\n", + " clean_df: pd.DataFrame,\n", + " extra_train_df: pd.DataFrame,\n", + " n_splits: int,\n", + " seed: int,\n", + " tolerance: float,\n", + ") -> tuple[str | None, float]:\n", + " \"\"\"Find the remaining candidate whose addition improves current_score the most.\"\"\"\n", + " best_candidate = None\n", + " best_score = current_score\n", + " for candidate in remaining:\n", + " trial_features = [*selected, candidate]\n", + " scores = cv_score(\n", + " hyperparams, trial_features, clean_df, extra_train_df, n_splits, seed\n", + " )\n", + " trial_score = float(scores[\"brier\"].mean())\n", + " if trial_score < best_score - tolerance:\n", + " best_score = trial_score\n", + " best_candidate = candidate\n", + " return best_candidate, best_score\n", + "\n", + "\n", + "def _sffs_best_removal(\n", + " selected: list[str],\n", + " current_score: float,\n", + " hyperparams: dict,\n", + " clean_df: pd.DataFrame,\n", + " extra_train_df: pd.DataFrame,\n", + " n_splits: int,\n", + " seed: int,\n", + " tolerance: float,\n", + ") -> tuple[str | None, float]:\n", + " \"\"\"Find the selected feature whose removal improves current_score the most.\"\"\"\n", + " worst_candidate = None\n", + " worst_score = current_score\n", + " for feature in selected:\n", + " trial_features = [f for f in selected if f != feature]\n", + " scores = cv_score(\n", + " hyperparams, trial_features, clean_df, extra_train_df, n_splits, seed\n", + " )\n", + " trial_score = float(scores[\"brier\"].mean())\n", + " if trial_score < worst_score - tolerance:\n", + " worst_score = trial_score\n", + " worst_candidate = feature\n", + " return worst_candidate, worst_score\n", + "\n", + "\n", + "def sffs_select_features(\n", + " candidate_features: list[str],\n", + " hyperparams: dict,\n", + " clean_df: pd.DataFrame,\n", + " extra_train_df: pd.DataFrame,\n", + " n_splits: int,\n", + " seed: int,\n", + " tolerance: float,\n", + " floating_every: int,\n", + " max_steps: int,\n", + ") -> tuple[list[str], list[dict]]:\n", + " \"\"\"Sequential Forward Floating Selection over candidate_features, by mean CV Brier.\n", + "\n", + " Returns (selected_features, trajectory) - trajectory has one dict\n", + " per accepted add/remove step, for the plateau plot below.\n", + " \"\"\"\n", + " min_features_for_removal = 2\n", + " selected: list[str] = []\n", + " remaining = list(candidate_features)\n", + " current_score = float(\"inf\")\n", + " trajectory: list[dict] = []\n", + " additions_since_floating = 0\n", + " start = time.monotonic()\n", + "\n", + " for step in range(1, max_steps + 1):\n", + " best_candidate, best_score = _sffs_best_addition(\n", + " selected,\n", + " remaining,\n", + " current_score,\n", + " hyperparams,\n", + " clean_df,\n", + " extra_train_df,\n", + " n_splits,\n", + " seed,\n", + " tolerance,\n", + " )\n", + " if best_candidate is None:\n", + " elapsed = time.monotonic() - start\n", + " print(\n", + " f\"[SFFS step {step:3d}] no addition improves beyond tolerance \"\n", + " f\"({len(selected)} features, brier={current_score:.4f}) - stopping. \"\n", + " f\"{elapsed:6.0f}s elapsed\"\n", + " )\n", + " break\n", + "\n", + " selected.append(best_candidate)\n", + " remaining.remove(best_candidate)\n", + " current_score = best_score\n", + " additions_since_floating += 1\n", + " elapsed = time.monotonic() - start\n", + " trajectory.append(\n", + " {\n", + " \"step\": step,\n", + " \"action\": \"add\",\n", + " \"feature\": best_candidate,\n", + " \"brier\": current_score,\n", + " \"n_features\": len(selected),\n", + " }\n", + " )\n", + " print(\n", + " f\"[SFFS step {step:3d}] + {best_candidate:<55s} brier={current_score:.4f} \"\n", + " f\"n_features={len(selected):2d} {elapsed:6.0f}s elapsed\"\n", + " )\n", + "\n", + " if (\n", + " additions_since_floating >= floating_every\n", + " and len(selected) >= min_features_for_removal\n", + " ):\n", + " additions_since_floating = 0\n", + " removed_any = True\n", + " while removed_any and len(selected) >= min_features_for_removal:\n", + " worst_candidate, worst_score = _sffs_best_removal(\n", + " selected,\n", + " current_score,\n", + " hyperparams,\n", + " clean_df,\n", + " extra_train_df,\n", + " n_splits,\n", + " seed,\n", + " tolerance,\n", + " )\n", + " removed_any = worst_candidate is not None\n", + " if removed_any:\n", + " selected.remove(worst_candidate)\n", + " remaining.append(worst_candidate)\n", + " current_score = worst_score\n", + " elapsed = time.monotonic() - start\n", + " trajectory.append(\n", + " {\n", + " \"step\": step,\n", + " \"action\": \"remove\",\n", + " \"feature\": worst_candidate,\n", + " \"brier\": current_score,\n", + " \"n_features\": len(selected),\n", + " }\n", + " )\n", + " print(\n", + " f\"[SFFS step {step:3d}] - {worst_candidate:<55s} \"\n", + " f\"brier={current_score:.4f} n_features={len(selected):2d} \"\n", + " f\"{elapsed:6.0f}s elapsed\"\n", + " )\n", + " else:\n", + " print(\n", + " f\"[SFFS] hit max_steps={max_steps} - stopping (not necessarily converged)\"\n", + " )\n", + "\n", + " total_elapsed = time.monotonic() - start\n", + " print(\n", + " f\"\\nStage 1 done: {len(selected)} features selected, \"\n", + " f\"final brier={current_score:.4f}, {total_elapsed:.0f}s total\"\n", + " )\n", + " return selected, trajectory\n", + "\n", + "\n", + "sffs_selected_features, sffs_trajectory = sffs_select_features(\n", + " features.ALL_FEATURES,\n", + " STAGE1_HYPERPARAMS,\n", + " clean_pool,\n", + " uncertain_pool,\n", + " STAGE1_N_SPLITS,\n", + " INNER_SEED,\n", + " SFFS_TOLERANCE,\n", + " SFFS_FLOATING_EVERY,\n", + " SFFS_MAX_STEPS,\n", + ")\n", + "print(\"\\nselected features:\", sffs_selected_features)" + ] + }, + { + "cell_type": "markdown", + "id": "sffs-plot-md", + "metadata": {}, + "source": [ + "### SFFS trajectory - where it plateaued" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "sffs-plot", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA94AAAHqCAYAAADyGZa5AAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjEsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvctoD+AAAAAlwSFlzAAAPYQAAD2EBqD+naQAAj7tJREFUeJzs3Xd0VNXexvFnJr0nhAQChN5771bAhlRRUYqKFKXYwKuo13JflGu9ooANG6DYQIr9UlSKIFUEpJdAaCmk95n9/oHkEghkEhJOyvez1iydc/Y+85s5DOTJ2WdvmzHGCAAAAAAAlAi71QUAAAAAAFCeEbwBAAAAAChBBG8AAAAAAEoQwRsAAAAAgBJE8AYAAAAAoAQRvAEAAAAAKEEEbwAAAAAAShDBGwAAAACAEkTwBgAAAACgBBG8AQDlVkpKimw2m/79739bXQoKUFLnKjg4WPfdd1+xHtNKaWlpuv/++1WtWjXZbDYNHjzY6pIAAC4geANAOZCTk6P3339fnTp1UuXKlRUUFKR27drp2WefVXR0dJ62/fv3l81my/fx+uuvF7pdbGysHn/8cTVt2lT+/v6KiIhQjx499OGHHyo9Pf2idcfGxspms+mVV14pzo/jsinr9ZeEi30mfF7Shg0bNGLECNWvX18+Pj6qW7eu7r33Xu3du9el/q+99ppmz56tn376ScYYffbZZ8VeI+cJAIqfu9UFAAAu3T333KN58+bp5Zdf1uDBgxUYGKhly5Zp0qRJWrVqlZYuXZqnvZubm3Jycgo8bkHtEhIS1KFDBzmdTs2cOVPdu3dXVlaWPv74Y40fP16JiYl66KGHLvXtFZm/v7+MMZa9PnCuf/zjHxo9erSeeeYZhYWFafPmzRo9erQ6dOigrVu3KjIy8qL9f/nlF7Vu3VrNmze/TBUDAIoDV7wBoIyLiorS3Llzdffdd+vhhx9WRESE/Pz81LdvX23atEnXXnttib32p59+qoMHD2ratGnq3bu3goKCFBYWpkmTJum3335T1apVS+y1gbJo+fLlGjx4sGrVqiVfX19169ZNr7/+uhISEvT5558X2D82NlY+Pj6XoVIAQHEieANAGRcfHy9Jql69+nn7/P399cQTT1jy2i1btrzo/acbNmxQWFiYJOnRRx/NHcY+cuRISdKRI0dks9k0ffp0ffXVV2rRooU8PDz01VdfSTr93s708fDwUO3atfXwww8rOTk59zUudN+ww+HQK6+8oubNm8vb21shISEaNGiQ9u3bl6ddTk6OXn31VbVq1Uq+vr6qVq2a7rrrLh0+fLjA+iUpOTlZjzzyiGrVqiVPT09Vr15d999/v+Li4nLbXOh9zps3T2FhYRo0aNB5n112drYiIiLUt2/fC36+hTVz5ky1atVK/v7+qlq1qvr06aP169cX6nO72GfiyueVH1fPVWxsrO666y6FhIQoKChId9xxh06dOuXy+y/suVq8eHFuTc2aNdOSJUtcfq1zeXh4SJL8/Pwu2Gbp0qWy2WzasmWLli1blvv5/fzzz5Jc/5wK+t4UdJ7Wrl0rm82mhQsXnlejv7+/xo8fn+/nld93OCoqSvfcc4+qVasmT09P1alTR88884yys7NzjxETE6MxY8aoZs2a8vHxUaNGjTRp0qTcv3sAoMwwAIAyLT093VSuXNnUrl3bbN++vcD2/fr1M25ubsXSbsWKFUaSGThwoDl16pSrJeeKiYkxkszLL7983r7Dhw8bSaZfv37m3nvvNQcPHjR//vmn+fXXX89rm5ycbH788UcTGRlpbr/99jzbJZmpU6fmbnM6nWbAgAEmJCTEfPLJJ+bUqVNm3759pm/fviY8PNxER0fntuvbt68JDAw07733njl+/Lg5fvy4mT17tnniiScKrD8rK8t06tTJhIeHm2+//dYkJiaan3/+2dSqVcs0adLEJCcnF/g+H3vsMePu7p5b0xmfffaZkWQWLlxY6M88Px999JFxc3Mzc+bMMYmJiSYuLs5899135rbbbiv053axz+Ri+y7lXGVmZpo2bdqYyMhIs2LFCpOYmGi++eYbc+utt5qgoCAzZsyYi77/wp6rQYMGmfvvv98cOnTIHD9+3AwaNMh4enqaw4cPF+pzz8jIMOvXrzft2rUzzZs3N0lJSQX2adWqlenRo0eeba5+Tue60PfmYufpt99+M5LM119/fd4+Pz8/M27cuNznF/uzvX//fhMWFma6detmNmzYYJKTk81///tfU61atTy19OzZ0zRr1sxs3rzZZGRkmH379pn//Oc/5rXXXivwswKA0oTgDQDlwLJly0z16tWNJNOiRQtz1113mffee88cPXr0vLb9+vUzks57VKlSpUjtpkyZYry9vY2np6e56qqrzAMPPGAWLlxo0tLS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9913n2666Sa1bdv2gu2uuuoqHT58WH/++aeio6N133336Y477tD27dsl/e87kd93hu9L0Tz88MPKzs7W/fff71L73bt3a9WqVef9crdy5cr65ZdfdPDgQe3atUvLly/XjBkz9Nprr5VE2eXW119/rccee0wTJ06Uj4+Pbr/99gu2jY+PV6VKlc6bGPfs74MrbUoTgnc55ebmJrvdroyMjDzb09PTZbfb5ebm5lIblKxZs2bpX//6lz755JPc2ZklqUqVKurfv3/u82bNmumBBx7QZ599ZkGVFcddd90lb29vSVJgYKBefvll7d69W5s2bZKk3BlrzwTxM9LT05nZ/DJ5//331b9/f4WFheXZfuONN6pmzZqSJJvNpkmTJqlOnTr66quvrCizTJs2bZpef/11ffHFF6pbt+4F23l4eOT774ek3O+DK23gmi1btqhfv34aO3ZsviM+CtvnnnvuyT0HISEhmjp1qrZu3aq//vqr2Gsvz1JTU3XzzTfLx8dHc+fOvWjbPn36KCIiQtLpGbOffvpphYaG5l71PvNvTH7fGb4vhffcc89p7ty5WrhwYe7nXpD3339f4eHh6tu3b57tjRs31pVXXpn7vFu3bho+fDg/lxXSpEmTtGbNGkVHR+uGG27QNddco5iYmHzb5vfvh5T3++BKm9KE4F1O2Ww2RUZGKjo6Os/26OjoPD+cFtQGJeeDDz7QuHHj9Mknn2jAgAEFtq9SpYpOnjzJ1YjLqEqVKpKU+x2pVatWnudnHD16lO/MZbBlyxZt2rTJ5dARHh5+3rnCxU2fPl2PPfaY5s+frxtuuOGibWvVqpXvvx+Scr8PrrRBwf744w/17NlTQ4YM0euvv14ifc79+w4FS01NVe/evZWUlKSlS5cWetkvm82msLCw3M+8atWq8vLy4ueyYjBlyhS9/PLL+vbbb9W9e3eX+uTk5Gj27Nm6++67C1waTjr9neH7UjR2u10TJ05UUlKSVq9enW+bWrVqKSkpScnJybnbsrKyFBMTk+ffmILalCYE73IkOjo6zx/eXr166ZtvvpExRpJkjNGSJUvUq1evQrXBpcnIyNCqVat06tSp3G0fffSR7r//fs2dO1e33nrreX1SU1PP2/bTTz+pUaNGcnd3L9F6K5LNmzfn3tOYlpaW+z0446effpKk3LXZO3XqpICAAC1evDi3zfr163X06FG+M8UoKipK69atO2/7rFmzVLt2bfXs2fO8fed+Z06cOKGtW7eqefPmJVZneTNjxgxNmjRJX331lXr37n3e/tTUVK1atUqJiYmSTv/7sWHDhjzr3y5atEh16tTJXavblTa4uK1bt6pHjx4aPHiw3nzzzXzbbNiwQQcOHHC5T37zhfz000+y2Wx5Rl/hwtLS0tS7d2+dOnVKy5YtO294uCQdOHBAGzZsyH1+7t9TUVFR2rVrV+7fU25ubrrmmmvy/BuTmZmpH374gX9jCuGFF17Q1KlT9c033+iqq646b39iYqJWrVp13vn45ptvdPz4cY0cOfK8Pue2NcZo6dKl/Bvjovx+rj3z89fZ351169YpKipKknTNNdfI3d1dS5Ysyd3/448/KisrK/fnAFfalCpWzuwG1yUlJZmVK1ealStXGh8fHzNp0iSzcuVKs3Pnztw2L7/8snFzc8t9vm/fPhMcHGyGDx9uFi9ebIYNG2aCg4PNvn37CtUGF7du3TqzcuVKc91115lOnTqZlStX5llve8+ePUaSWbJkiTHGmC+++MLY7Xbz4IMP5p7TlStX5ln+4umnnzZ33323+eyzz8ySJUvM3XffbTw8PMzChQsv+/srq/bu3WtWrlxp/u///s9IMsuWLTMrV67MM3tms2bNzL333muMMeb33383Xbp0Me+++6758ccfzdSpU01AQIAZNWpUnuO++uqrxtfX18yYMcN8/vnnpkGDBqZv376X862VaadOnTIrV640P//8s5Fknn32WbNy5Uqzd+/e3DZPPvmkCQ0NzdMvPT3dhISEmClTpuR73Hbt2pkXXnjBfPfdd+bjjz82zZo1Mw0bNmSZNxd99NFHxmazmccffzzP30ubN2/ObbN582YjyaxYscIYY0xOTo7p0KGD6dChg1mwYIGZOnWqcXd3zzOrsyttcGF79uwxYWFhpnv37nnOy8qVK/PMUF6rVi3z4IMPutxnxYoV5sorrzSzZs0yP/74o/nXv/5lfH19zcMPP2zF2yxzHA6H6dGjhwkJCTELFy7M8xmfvSLGgw8+aGrVqpX7vFmzZuall14y33//vfnggw9MgwYNTIsWLfLM8P/7778bLy8v89BDD5nFixeb3r17mxo1api4uLjL+RbLrGnTphlJZsqUKXnOy9atW3PbrFixwkjK8/ebMcbcfPPN5uqr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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "trajectory_df = pd.DataFrame(sffs_trajectory)\n", + "\n", + "fig, ax = plt.subplots(figsize=(10, 5))\n", + "ax.plot(range(1, len(trajectory_df) + 1), trajectory_df[\"brier\"], marker=\"o\", ms=3)\n", + "removals = trajectory_df[trajectory_df[\"action\"] == \"remove\"]\n", + "if len(removals):\n", + " ax.scatter(\n", + " removals.index + 1,\n", + " removals[\"brier\"],\n", + " color=\"red\",\n", + " marker=\"x\",\n", + " s=60,\n", + " label=\"removal\",\n", + " zorder=3,\n", + " )\n", + " ax.legend()\n", + "ax.set_xlabel(\"accepted SFFS step (add or remove)\")\n", + "ax.set_ylabel(\"mean CV Brier score\")\n", + "ax.set_title(f\"SFFS trajectory - settled on {len(sffs_selected_features)} features\")\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "sffs-fallback-md", + "metadata": {}, + "source": [ + "### Guard against trusting a too-narrow SFFS answer\n", + "\n", + "SFFS can converge on a handful of features that separate this\n", + "particular 500-label sample almost perfectly (a real, observed failure\n", + "mode here: 3 duration-ratio features alone hit ~99%+ separation on the\n", + "clean pool) - less a sign the model is excellent than a sign it found\n", + "one dominant proxy and stopped looking, which is exactly the kind of\n", + "narrow, brittle signal Stage 2's much larger rotation budget shouldn't\n", + "be spent tuning around. If SFFS selected fewer than `MIN_SFFS_FEATURES`\n", + "features, Stage 2 uses (almost) the full candidate list instead - every\n", + "feature except `company_id` (the categorical operator/company code,\n", + "excluded because a onehot/OneHotEncoder-free LightGBM categorical split\n", + "on a low-cardinality registry code is a much weaker, noisier signal\n", + "than the rest of the feature set and not worth the fallback's added\n", + "dimensionality). This never touches what SFFS itself found or reported\n", + "above - only what Stage 2 goes on to actually use." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "sffs-fallback", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "SFFS selected only 3 features (< MIN_SFFS_FEATURES=5) - falling back to all 80 features (excluding company_id) for Stage 2.\n" + ] + } + ], + "source": [ + "sffs_raw_selected_features = list(sffs_selected_features)\n", + "sffs_fallback_triggered = len(sffs_raw_selected_features) < MIN_SFFS_FEATURES\n", + "\n", + "if sffs_fallback_triggered:\n", + " sffs_selected_features = [f for f in features.ALL_FEATURES if f != \"company_id\"]\n", + " print(\n", + " f\"SFFS selected only {len(sffs_raw_selected_features)} features \"\n", + " f\"(< MIN_SFFS_FEATURES={MIN_SFFS_FEATURES}) - falling back to all \"\n", + " f\"{len(sffs_selected_features)} features (excluding company_id) for Stage 2.\"\n", + " )\n", + "else:\n", + " print(\n", + " f\"SFFS selected {len(sffs_raw_selected_features)} features \"\n", + " f\"(>= MIN_SFFS_FEATURES={MIN_SFFS_FEATURES}) - \"\n", + " \"using its answer as-is for Stage 2.\"\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "stage2-outer-md", + "metadata": {}, + "source": [ + "## Stage 2: the outer loop\n", + "\n", + "`RepeatedStratifiedKFold` runs `N_OUTER_REPEATS` independent 5-fold\n", + "partitions of `clean_pool` back to back - internally it's a fresh\n", + "`StratifiedKFold` per repeat with a derived random state, yielding all\n", + "of one repeat's folds before moving to the next. That ordering is what\n", + "makes the \"repeats completed so far\" convergence check below possible:\n", + "rotation `i`'s repeat number is `i // N_OUTER_SPLITS`, its fold number\n", + "is `i % N_OUTER_SPLITS`." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "stage2-outer-setup", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "50 outer rotations (5 folds x 10 repeats), 80 features fixed from Stage 1\n" + ] + } + ], + "source": [ + "outer_cv = RepeatedStratifiedKFold(\n", + " n_splits=N_OUTER_SPLITS, n_repeats=N_OUTER_REPEATS, random_state=OUTER_SEED\n", + ")\n", + "outer_rotations = list(\n", + " outer_cv.split(clean_pool[sffs_selected_features], clean_pool[\"label\"])\n", + ")\n", + "N_ROTATIONS = len(outer_rotations)\n", + "print(\n", + " f\"{N_ROTATIONS} outer rotations \"\n", + " f\"({N_OUTER_SPLITS} folds x {N_OUTER_REPEATS} repeats), \"\n", + " f\"{len(sffs_selected_features)} features fixed from Stage 1\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "stage2-search-md", + "metadata": {}, + "source": [ + "## Stage 2's per-rotation search: hyperparameters only\n", + "\n", + "`sffs_selected_features` is fixed for the rest of the notebook - no\n", + "feature-mask dimensions here, just the 8 LightGBM hyperparameters. That\n", + "smaller search space is what lets `N_INNER_TRIALS` drop to 50-100\n", + "(default 75) instead of the 150-200 the old combined search needed, and\n", + "is what keeps 50 outer rotations affordable." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "stage2-search", + "metadata": {}, + "outputs": [], + "source": [ + "def build_hyperparameter_objective(\n", + " outer_train_clean: pd.DataFrame, outer_train_uncertain: pd.DataFrame\n", + ") -> Callable[[optuna.Trial], float]:\n", + " \"\"\"Build an Optuna objective over LightGBM hyperparameters only.\"\"\"\n", + "\n", + " def objective(trial: optuna.Trial) -> float:\n", + " hyperparams = {\n", + " \"num_leaves\": trial.suggest_int(\"num_leaves\", 7, 63),\n", + " \"min_child_samples\": trial.suggest_int(\"min_child_samples\", 5, 50),\n", + " \"learning_rate\": trial.suggest_float(\"learning_rate\", 0.01, 0.3, log=True),\n", + " \"feature_fraction\": trial.suggest_float(\"feature_fraction\", 0.5, 1.0),\n", + " \"bagging_fraction\": trial.suggest_float(\"bagging_fraction\", 0.5, 1.0),\n", + " \"lambda_l1\": trial.suggest_float(\"lambda_l1\", 1e-8, 10.0, log=True),\n", + " \"lambda_l2\": trial.suggest_float(\"lambda_l2\", 1e-8, 10.0, log=True),\n", + " \"n_estimators\": trial.suggest_int(\"n_estimators\", 50, 400),\n", + " }\n", + " scores = cv_score(\n", + " hyperparams,\n", + " sffs_selected_features,\n", + " outer_train_clean,\n", + " outer_train_uncertain,\n", + " N_INNER_SPLITS,\n", + " )\n", + " return float(scores[\"brier\"].mean())\n", + "\n", + " return objective" + ] + }, + { + "cell_type": "markdown", + "id": "stage2-rotation-loop-md", + "metadata": {}, + "source": [ + "## The rotation loop itself\n", + "\n", + "For every outer rotation: run the hyperparameter-only Optuna search\n", + "against inner CV over this rotation's outer-train slice, refit the\n", + "winner on the *full* outer-train slice, evaluate once on outer-eval -\n", + "never touched above - and stash the raw outer-eval prediction per\n", + "`trip_id` for the out-of-fold calibration set built later. One line\n", + "printed per rotation as it completes." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "stage2-rotation-loop", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[rotation 1/50] repeat 1, fold 1: inner best brier=0.0000, outer-eval brier=0.0000, 20s elapsed\n", + "[rotation 2/50] repeat 1, fold 2: inner best brier=0.0003, outer-eval brier=0.0000, 35s elapsed\n", + "[rotation 3/50] repeat 1, fold 3: inner best brier=0.0000, outer-eval brier=0.0001, 50s elapsed\n", + "[rotation 4/50] repeat 1, fold 4: inner best brier=0.0000, outer-eval brier=0.0098, 63s elapsed\n", + "[rotation 5/50] repeat 1, fold 5: inner best brier=0.0001, outer-eval brier=0.0000, 80s elapsed\n", + "[rotation 6/50] repeat 2, fold 1: inner best brier=0.0000, outer-eval brier=0.0000, 95s elapsed\n", + "[rotation 7/50] repeat 2, fold 2: inner best brier=0.0000, outer-eval brier=0.0000, 112s elapsed\n", + "[rotation 8/50] repeat 2, fold 3: inner best brier=0.0000, outer-eval brier=0.0033, 124s elapsed\n", + "[rotation 9/50] repeat 2, fold 4: inner best brier=0.0000, outer-eval brier=0.0000, 142s elapsed\n", + "[rotation 10/50] repeat 2, fold 5: inner best brier=0.0000, outer-eval brier=0.0000, 159s elapsed\n", + "[rotation 11/50] repeat 3, fold 1: inner best brier=0.0007, outer-eval brier=0.0000, 174s elapsed\n", + "[rotation 12/50] repeat 3, fold 2: inner best brier=0.0001, outer-eval brier=0.0005, 191s elapsed\n", + "[rotation 13/50] repeat 3, fold 3: inner best brier=0.0001, outer-eval brier=0.0000, 205s elapsed\n", + "[rotation 14/50] repeat 3, fold 4: inner best brier=0.0000, outer-eval brier=0.0009, 224s elapsed\n", + "[rotation 15/50] repeat 3, fold 5: inner best brier=0.0000, outer-eval brier=0.0000, 245s elapsed\n", + "[rotation 16/50] repeat 4, fold 1: inner best brier=0.0000, outer-eval brier=0.0000, 266s elapsed\n", + "[rotation 17/50] repeat 4, fold 2: inner best brier=0.0000, outer-eval brier=0.0000, 281s elapsed\n", + "[rotation 18/50] repeat 4, fold 3: inner best brier=0.0000, outer-eval brier=0.0000, 295s elapsed\n", + "[rotation 19/50] repeat 4, fold 4: inner best brier=0.0001, outer-eval brier=0.0000, 307s elapsed\n", + "[rotation 20/50] repeat 4, fold 5: inner best brier=0.0000, outer-eval brier=0.0035, 320s elapsed\n", + "[rotation 21/50] repeat 5, fold 1: inner best brier=0.0000, outer-eval brier=0.0097, 336s elapsed\n", + "[rotation 22/50] repeat 5, fold 2: inner best brier=0.0000, outer-eval brier=0.0000, 351s elapsed\n", + "[rotation 23/50] repeat 5, fold 3: inner best brier=0.0000, outer-eval brier=0.0000, 363s elapsed\n", + "[rotation 24/50] repeat 5, fold 4: inner best brier=0.0028, outer-eval brier=0.0001, 376s elapsed\n", + "[rotation 25/50] repeat 5, fold 5: inner best brier=0.0001, outer-eval brier=0.0000, 395s elapsed\n", + "[rotation 26/50] repeat 6, fold 1: inner best brier=0.0023, outer-eval brier=0.0002, 406s elapsed\n", + "[rotation 27/50] repeat 6, fold 2: inner best brier=0.0000, outer-eval brier=0.0000, 417s elapsed\n", + "[rotation 28/50] repeat 6, fold 3: inner best brier=0.0000, outer-eval brier=0.0000, 441s elapsed\n", + "[rotation 29/50] repeat 6, fold 4: inner best brier=0.0000, outer-eval brier=0.0000, 455s elapsed\n", + "[rotation 30/50] repeat 6, fold 5: inner best brier=0.0002, outer-eval brier=0.0001, 475s elapsed\n", + "[rotation 31/50] repeat 7, fold 1: inner best brier=0.0007, outer-eval brier=0.0000, 489s elapsed\n", + "[rotation 32/50] repeat 7, fold 2: inner best brier=0.0004, outer-eval brier=0.0000, 506s elapsed\n", + "[rotation 33/50] repeat 7, fold 3: inner best brier=0.0000, outer-eval brier=0.0000, 519s elapsed\n", + "[rotation 34/50] repeat 7, fold 4: inner best brier=0.0002, outer-eval brier=0.0000, 533s elapsed\n", + "[rotation 35/50] repeat 7, fold 5: inner best brier=0.0000, outer-eval brier=0.0034, 550s elapsed\n", + "[rotation 36/50] repeat 8, fold 1: inner best brier=0.0004, outer-eval brier=0.0003, 565s elapsed\n", + "[rotation 37/50] repeat 8, fold 2: inner best brier=0.0000, outer-eval brier=0.0000, 577s elapsed\n", + "[rotation 38/50] repeat 8, fold 3: inner best brier=0.0000, outer-eval brier=0.0000, 592s elapsed\n", + "[rotation 39/50] repeat 8, fold 4: inner best brier=0.0000, outer-eval brier=0.0000, 610s elapsed\n", + "[rotation 40/50] repeat 8, fold 5: inner best brier=0.0000, outer-eval brier=0.0000, 621s elapsed\n", + "[rotation 41/50] repeat 9, fold 1: inner best brier=0.0000, outer-eval brier=0.0000, 644s elapsed\n", + "[rotation 42/50] repeat 9, fold 2: inner best brier=0.0000, outer-eval brier=0.0000, 659s elapsed\n", + "[rotation 43/50] repeat 9, fold 3: inner best brier=0.0004, outer-eval brier=0.0118, 674s elapsed\n", + "[rotation 44/50] repeat 9, fold 4: inner best brier=0.0001, outer-eval brier=0.0000, 684s elapsed\n", + "[rotation 45/50] repeat 9, fold 5: inner best brier=0.0000, outer-eval brier=0.0001, 699s elapsed\n", + "[rotation 46/50] repeat 10, fold 1: inner best brier=0.0000, outer-eval brier=0.0061, 714s elapsed\n", + "[rotation 47/50] repeat 10, fold 2: inner best brier=0.0001, outer-eval brier=0.0000, 727s elapsed\n", + "[rotation 48/50] repeat 10, fold 3: inner best brier=0.0000, outer-eval brier=0.0007, 740s elapsed\n", + "[rotation 49/50] repeat 10, fold 4: inner best brier=0.0000, outer-eval brier=0.0000, 755s elapsed\n", + "[rotation 50/50] repeat 10, fold 5: inner best brier=0.0005, outer-eval brier=0.0108, 765s elapsed\n", + "\n", + "Stage 2 done: 50 rotations in 765s\n" + ] + } + ], + "source": [ + "clean_pool_by_trip_id = clean_pool.set_index(\"trip_id\", drop=False)\n", + "\n", + "rotation_records = []\n", + "oof_raw_predictions: dict[int, list[float]] = defaultdict(list)\n", + "\n", + "stage2_start = time.monotonic()\n", + "for rotation_id, (train_idx, eval_idx) in enumerate(outer_rotations):\n", + " repeat_id, fold_id = divmod(rotation_id, N_OUTER_SPLITS)\n", + "\n", + " outer_train_clean = clean_pool.iloc[train_idx].reset_index(drop=True)\n", + " outer_eval = clean_pool.iloc[eval_idx].reset_index(drop=True)\n", + "\n", + " objective = build_hyperparameter_objective(outer_train_clean, uncertain_pool)\n", + " study = optuna.create_study(\n", + " direction=\"minimize\",\n", + " sampler=optuna.samplers.TPESampler(seed=OUTER_SEED + rotation_id),\n", + " )\n", + " study.optimize(objective, n_trials=N_INNER_TRIALS, timeout=INNER_TIMEOUT_SECONDS)\n", + "\n", + " winner = lightgbm_build_model(study.best_params)\n", + " winner.fit(\n", + " pd.concat(\n", + " [\n", + " outer_train_clean[sffs_selected_features],\n", + " uncertain_pool[sffs_selected_features],\n", + " ],\n", + " ignore_index=True,\n", + " ),\n", + " pd.concat(\n", + " [outer_train_clean[\"label\"], uncertain_pool[\"label\"]], ignore_index=True\n", + " ),\n", + " )\n", + "\n", + " raw_eval_proba = winner.predict_proba(outer_eval[sffs_selected_features])[:, 1]\n", + " eval_metrics = metrics.evaluate(outer_eval[\"label\"].to_numpy(), raw_eval_proba)\n", + "\n", + " for trip_id, proba in zip(outer_eval[\"trip_id\"], raw_eval_proba, strict=True):\n", + " oof_raw_predictions[trip_id].append(float(proba))\n", + "\n", + " rotation_records.append(\n", + " {\n", + " \"rotation_id\": rotation_id,\n", + " \"repeat_id\": repeat_id,\n", + " \"fold_id\": fold_id,\n", + " \"hyperparams\": study.best_params,\n", + " \"inner_cv_brier\": study.best_value,\n", + " **eval_metrics,\n", + " }\n", + " )\n", + "\n", + " elapsed = time.monotonic() - stage2_start\n", + " print(\n", + " f\"[rotation {rotation_id + 1:2d}/{N_ROTATIONS}] repeat {repeat_id + 1}, \"\n", + " f\"fold {fold_id + 1}: inner best brier={study.best_value:.4f}, \"\n", + " f\"outer-eval brier={eval_metrics['brier']:.4f}, {elapsed:6.0f}s elapsed\"\n", + " )\n", + "\n", + "print(\n", + " f\"\\nStage 2 done: {N_ROTATIONS} rotations in {time.monotonic() - stage2_start:.0f}s\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "aggregate-metrics-md", + "metadata": {}, + "source": [ + "## Aggregate outer-eval metrics across all rotations\n", + "\n", + "One row per rotation, four metrics each (AUC/Brier/log-loss/ECE), on\n", + "raw (not yet calibrated) predictions - calibration is built later from\n", + "these same rotations' predictions. Mean and std here, not just mean:\n", + "this is **the honest performance estimate**, replacing any single fixed\n", + "split's number." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "aggregate-metrics", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
meanstd
auc1.00000.0000
brier0.00120.0030
log_loss0.00390.0081
ece0.00230.0041
\n", + "
" + ], + "text/plain": [ + " mean std\n", + "auc 1.0000 0.0000\n", + "brier 0.0012 0.0030\n", + "log_loss 0.0039 0.0081\n", + "ece 0.0023 0.0041" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "rotation_metrics = pd.DataFrame(rotation_records)[\n", + " [\"rotation_id\", \"repeat_id\", \"fold_id\", \"auc\", \"brier\", \"log_loss\", \"ece\"]\n", + "]\n", + "\n", + "outer_eval_summary = (\n", + " rotation_metrics[[\"auc\", \"brier\", \"log_loss\", \"ece\"]].agg([\"mean\", \"std\"]).T\n", + ")\n", + "outer_eval_summary.columns = [\"mean\", \"std\"]\n", + "outer_eval_summary.round(4)" + ] + }, + { + "cell_type": "markdown", + "id": "hyperparam-consensus-md", + "metadata": {}, + "source": [ + "## Hyperparameter consensus across rotations\n", + "\n", + "The median of each rotation's winning value, not any single rotation's\n", + "exact config - a point estimate from one rotation is still just a point\n", + "estimate. Tight histograms below mean the search kept landing in the\n", + "same region regardless of which rows were in outer-train that rotation\n", + "(trustworthy consensus); wide spread means the \"winning\" value moved\n", + "around a lot and this median should be treated with more caution." + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "hyperparam-consensus", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "consensus hyperparameters (median across all rotations):\n", + " num_leaves: 30\n", + " min_child_samples: 22\n", + " learning_rate: 0.13990994418445057\n", + " feature_fraction: 0.733775259790791\n", + " bagging_fraction: 0.7797319207223575\n", + " lambda_l1: 2.711804476299991e-06\n", + " lambda_l2: 2.804643006967186e-06\n", + " n_estimators: 294\n" + ] + }, + { + "data": { + "image/png": "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7Ngxvfjii5o4caJeeuklSf8eit6+fXvNmjVLGRkZLs/z0UcfKTU1Vffff3+unvfFF1/Um2++6Rzn119/VVRUlKZOnZonyw+gYBk7dqwiIyO1e/duffXVV/rss8/0wgsvaMSIEapRo4Z27NihZcuW6bvvvnNpwF7K+PHjVaZMGW3fvl3r16/XgQMH1Ldv31znO3bsmJo2baqVK1fqgw8+UGxsrBYvXqy0tDQtW7bMI88p/Xse8bZt28put2vLli3aunWrgoKCNGXKlMua38CBA7Vw4ULNnTtXp0+f1ooVK1SvXj299957znFat27tUlf+/vtvPfDAAxo6dKjb85nnxXs3evRoValSRbt27dL69et1+vRp3XDDDTp69OhFl+fEiRNq1qyZ1q1bp88//1ynTp3S3Llz9fXXX6tHjx7Oxk9OlhsALmXSpEm64447FBUVpd27d+vgwYNq3bq1OnTooFWrVjnHW758uXMdeu7cOS1ZskRHjhxRt27dsjTBjx49qhdeeEHTpk3Tvn37dP311zsfW7hwobZu3aoff/xRe/fuValSpdSrV68cn2M9J9MnJCToxhtv1Pbt27V8+XL9/fff6tKlix5//PEreq2++OIL/fXXX1q5cqXWrl2r0NBQBQcHu9SX2NhYvfrqq3r//fc1fPhwSVKXLl20Y8cOSdKsWbOc406cOFHSvzvTunbtqnfffVcTJkxQbGys1qxZoyNHjqh169Y+c/55AN5v7ty52rBhgyTps88+kzHG+SOl3bt3q0mTJoqJidG3336rU6dOafr06Zo+fboeeOAB5zz69u3rXI9lZGRo9+7d6tq1q+688059//33kuRcn0nS6dOnneO3aNHisrM/8cQTuv7667Vr1y5NnjxZkvTGG2/olltuUfv27bVz504dOnRIHTt2VOfOnfXdd9/laL4BAQEyxujxxx+Xv7+/M+uFP56NjY3VyJEj9dprr2nfvn1q27atJGnx4sUutfH777/XyZMn1bVrVyUmJjqnf/XVV9WrVy81bdpUmzZt0qFDhzRixAjnvqbExERdd911ioqKcs7vUj9Ye+qppzRs2DA99NBDOnr0qNauXaukpCS1bNkyS1MjLi5Ozz33nKZOnapjx45p6NCheu655zRv3rwcvUbwcQbIRz/88IORZG6//XaX4T/++KORZObPn+8cNmvWLCPJ7Nixw2XcmJgYI8m89dZbWebbt29fl3FfeeUV4+fnZ+69916X4a+++qqRZA4fPpyr/Bs2bDCSzIIFC5zDJk2aZCSZDRs2uIy7b98+4+/vb955552LznP48OGmbNmyzvtz5841ksx3333nMl7t2rVN69atc/28LVq0MF27ds3VcgLAf/3vf/8zkszIkSNdhj/66KOmcOHCZsSIES7DH3vsMRMcHGzS0tKcw7788ksjyWzatCnLfP87/ezZs40k8/vvv+cq54MPPmjsdrvZv3//JZclJ8/pLrO7YXfeeacJDQ01Z86ccZln3759s9SNnChbtqwZNGhQrqbJ1Lt3b9OiRQvn/bx47zLn8fjjj7uMe+jQIRMYGGiGDh3qHObu9Rk0aJApVKiQOXTokMv0q1evNpLMsmXLrni5ARRM1113nenZs6fzfkxMjAkMDDSDBw/OMm7btm1N8+bNLzq/v/76y0gy3377rXNYhQoVTFBQkDl69KjLuJs2bTKSTLt27VyGb9u2zUgy7733nnPYgQMHjCTzv//977Kmnzx5stu6OHXqVCPJPPfccxddrv/KfO6WLVvmeJqJEycau93urA87duwwksysWbOyjPvBBx+4rN8znThxwhQuXNiMGzcuV3kB4GIy99V89tlnLsNvvvlmU7p06Szf0T/55BMjyfz5558XnW+LFi1M7969nfcnTJhgJJnTp09nGfeBBx4wYWFhWYZ/9tlnWb4bjx492kgykyZNchn3n3/+McHBweaBBx7IMp8uXbqY+vXrXzTvfz3++OPG398/y/Bq1aqZgIAAc/DgwRzNZ+/evUaSWbhwoTHGmKNHj5qgoCDTr1+/i0533XXXmaioKLePNW7c2HTu3Nl5/8CBA8bPz888+uijLuOdOXPGFCtWzGU+1apVM8HBwVnqcu3atU3Hjh1ztEzwbRyhAUtERUW53K9Tp44kaf/+/Vc0365du7rcr1WrljIyMpyd5kyZh0f/93Dyy7Fo0SJVrlzZeYhjpqpVq6pKlSouh7bPnTtXbdq0UbFixeTn5yebzaYpU6bo+PHjSkpKkiT16tVLYWFhmjlzpnO6devWaceOHS6/IMjp89avX1/Lly9XdHS082gYALhc7taz586dU+fOnV2G165dW8nJyTp27FiO5ptXdWHp0qVq2bKlqlSpkm/PKUkrVqzQjTfeqGLFirkM79Wr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v/yytm7dqjNnzmjGjBk6evSo1dFcPP7442rUqJF69uyphQsX6uTJkzpz5ox+/fVXDRo0SHPmzJH077kRN2/erHfffVeJiYn666+/dMstt2S5vkemBx54QFu2bNHo0aN13XXXqXnz5pf1vFFRUfrwww+1f/9+paSkaNeuXfriiy9UoUIF1ahRw6OvDQDkt+joaJUuXVpRUVFauXKlzp49q71792rMmDH66quvPPa8L7zwglJTU3XHHXdoz549OnXqlKZMmZLjL/AXSk9PV+vWrfXll18qJiZGDodDmzZt0pIlS9SoUSPnqbF69OihJUuWaMGCBUpKStL69et11113XbK5cCWOHj2q//3vfzpz5oy2bdum22+/XSEhIRoxYsRFpxs7dqzCwsLUtWtXrVixQvHx8YqNjdXq1at1991368cff8zxcgPAxVSqVEnjx4/Xa6+9pjFjxujAgQM6f/68du7cqWnTpql///6SpLZt2yokJESjRo3S4cOHdeLECb388svO69p5m4cfflhVqlTRvffeqw0bNighIUELFizI0uDICz169NDhw4c1YcIEnT17Vrt27VLv3r2zbI+UL19eJUqU0PLly7McVX/fffepXbt2uvPOO/XZZ5/pxIkTSkhI0MaNG/Xkk09q+vTpeZ4bAP7r9ddf17lz55w/ekpMTNSxY8f07bffqkePHtq9e7dsNpu6d++ujz/+WKtWrVJSUpJWrlypYcOG6frrr3eZX+Z1lhYvXpzlVFR9+/ZVSEiInnjiCR09elRHjx7V8OHD3Z5CKTvlypXTpEmTNH36dD333HPav3+/zp8/r127dundd9/N02tWuNOmTRsVL15czz//vA4dOqTY2FhNnjzZeTr0C3OOHz9eH3/8sUaMGKEDBw4oISFBK1as0B133OEcr06dOtqyZUuOjnSpXLmyhgwZovfee0/Tp0/XqVOnnPUnJSVF48ePz/Plhe+ioQGv9tlnn6lq1apq3ry5IiMjdeDAAY0ePdrqWC6KFCmin376Sf369dPzzz+vihUrqnr16ho+fLgaNmyo2267TZL06KOPaty4cZo4caLCw8PVv39/Pf3001k2DDLdeeedKlKkiGJiYrIcnZGb5504caJ++eUXderUSaGhoerYsaNq1Kihn376SYUKFfLcCwMAFihXrpx+++03tWnTRv3791epUqXUvXt3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7dqlVq1aKSwsTKdPn9aECRPUrVs3T+UDAPiw8PBwffXVV+rXr5/effddJSQkqG3btpo+fbrV0QAAXurdd99V3759VbZsWRUtWlTBwcFasGCBSpYsaXU0AIAX6tatm8aPH68ePXqoRIkSOnnypB5//HHdd999VkcDAHhArhoakjRp0iQ9//zzOn78uCpUqKDChQt7IpdH2e12jR49OsvhhVebgrCcBWEZJZbzalIQlvG/2rdvr0OHDungwYMqWrSoypQpY3Ukr1IQPxN5gdft8vHaXT5eu/xRsmRJff/99/rnn3+UmJioKlWqZHvKEF9XkD5TBWlZpYK1vCzr1cuXlnfEiBEaMmSIjhw5orJly2Z77QxP8KXXyVN4DXgNJF6Dgr78Uv69BjZjjPHoMwAAAAAAAAAAAFyhq/OnTgAAAAAAAAAA4KpCQwMAAAAAAAAAAHg9GhoAAAAAAAAAAMDrXdUNja1bt2rQoEGqXbu2pk2b5nacHTt2qG/fvqpfv76ioqK0cuXKfE55ZXbs2KHBgwerefPmat++vV555RWdP38+y3izZ89W27Zt1bBhQw0aNEixsbEWpL18v/76q/r166fGjRurU6dOmjZtmtLS0lzGOXPmjIYNG6ZGjRqpTZs2evfddy1Ke+UWLlyoWrVq6fHHH8/y2Jo1a9SjRw/Vr19fvXv31pYtWyxIePk6d+6sWrVqufxNmDDBZZz09HS9+uqruv7669WkSRM9++yzSkpKsijx5fvkk0/UpUsXNWnSRM8884wSExNdHo+JidF9992nBg0aqGPHjvrqq68sSgpPS0hI0PTp09W8eXO1bNnS7TipqakaP368mjdvrmbNmik6OloOhyOfk3qXlJQUzZgxQ927d1eTJk101113aePGjVnGO3LkiB544AE1aNBAHTp00JdffmlBWu/icDj01ltvqXPnzmratKnuuecebdiwIct4q1evVvfu3VW/fn3dcccd2rZtmwVpvdegQYNUq1YtLV261GW4MUbTpk1T69at1bhxYw0fPlwJCQkWpYS3OHnypB577DE1atRIN954oz744IMrnubIkSNZvjfVqlVLq1ev9tBS5Nz777+vG264QY0aNdLjjz+u06dPX3R8h8OhTz/9VO3atVOtWrV05syZPJlvfti0aZNuu+021a9fXz179tS6desuOc2GDRt0//33q3bt2vroo4+yPP7555+7fW/T09M9sQg5dvbsWY0YMUKNGzdWq1at9OabbyojIyPb8Y0xWrhwofr06aPGjRurV69eWrJkyRXPN79kfiYbNmyohx9+WMePH7/o+Lt379Zjjz2mFi1aqF27dnr55ZezbKcMGTIky/t6//33e3IxcmTXrl2688471aBBA3Xr1k3Lly+/6PjGGH3xxRfq1auXGjVqpF69emnRokVXPF9flJCQoOHDh6tx48Zq3bq1pk2bpotdjvbs2bNu/3+7e/18xd69e3XPPfeoQYMG6tKli5YtW3bJaTIyMjRjxgx16NBBzZo104svvqjk5OR8SOsZK1euVFRUlOrXr6++fftqx44dFx2/bdu2bj8HTz31VD4lznvvvvuu2rRpo0aNGmnYsGHZ1vJMcXFxeuaZZ9S6dWs1adJE99xzj/7444/8CesBSUlJevbZZ9WkSRNdf/31evXVVy9Zt7dv367+/furSZMmuummmzRr1qx8SusZu3bt0rBhw3TttdfqpZdeytE0Bw8eVL9+/dSgQQN16tRJ33zzzZUHMVepRYsWmbp165pp06aZiIgIM3r06CzjHD582JQsWdIMGDDArF271owZM8YEBASYn3/+Of8DX4aDBw+aOnXqmLffftusX7/efP3116ZatWomKirKZbx33nnHBAcHmw8++MD8+OOP5sYbbzR169Y1KSkpFiXPnfXr15sOHTqYTz/91Pzxxx/m008/NWXKlDGDBw92jpOenm5atmxpmjVrZlauXGnmzJljihYtaiZOnGhh8svz999/m4oVK5prr73W3HbbbS6Pbdy40djtdjNq1Cizdu1a8/DDD5vQ0FCzb98+i9LmXrVq1cyYMWPMjh07nH///POPyzhPPfWUCQ8PN/Pnzzfff/+9qVWrlunevbtFiS/P448/bsLDw83s2bPN5s2bzRtvvGGefvpp5+Nnz541lStXNjfffLP5+eefzRtvvGECAgLMl19+aWFqeErdunXNI488Yh555BETFhbmdpwHHnjAVKxY0SxatMgsXbrUVKpUydxzzz35nNS7PPLII+bhhx82ixYtMhs2bDBPPfWUCQwMNGvXrnWOk5iYaKpWrWq6detmfv75ZzNt2jQTEBBgPvvsMwuTW2/QoEHmmWeeMStXrjTr1683Q4YMMXa73WzatMk5ztq1a01gYKAZPXq0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41I16ccff5TNZlOnTp2cwzp06KCgoCCvOK2IVYKCgrIM+2+dW7VqlVq3bq2iRYs6h0VFRWn37t06evSoxzN6q//Wzp9//llnzpxRixYtJP17CoAff/zRZV3r5+enrl27+sy61lMcDof69u2rSZMmqXLlylkeX79+vc6fP+/y2tWtW1cREREF/rUryFatWqW6deuqQoUKzmFRUVE6efKktm7desXT3HXXXWrcuLH69u2bo9MdedKmTZsUHx/v8n+gSpUqql279kX/D1yqFl7ufD3tv9+/pX/fpytZ1kzHjx9X8+bNdf3112vIkCGW161Vq1apfPnyqlu3rnNYVFSUkpOT9euvv7qdJqe1Orfz9bT9+/fr0KFDLu9tyZIl1aJFi2zfW39//0tuf2datGiR87SykyZNsvw0otnt+/j555+znMo503+/Sxw4cEB//vlnlu3w3M7X16xatUrXXHONateu7RwWFRWlc+fOaf369RedduDAgWrYsKH69Onjc6c3v9CqVavUoUMHl+3ZqKgo/frrr25PeS79u01cv359NWvWzGX4hfPwFenp6frpp5+yfG/u0qVLjutTenq6Zs+erbvvvluFChXyVFSP+f3333X27FmX16BGjRqqUaNGtq9BsWLFVK9ePX333XfOfazLli1TYGCgc5vEl6xatUphYWFq2rSpc1hUVJRSU1P1yy+/uJ0mISFBJUuWlM1mcw4LCwtTQkKC29MBezt3Nf9SVq1apZtuusll2qioKP3xxx+Kj4+/7CxXbUMjJ2JiYlSuXDmXYWXLllVycrJOnjxpUarLl5aWprFjx6p58+aKiIiQ9O8ySnK7nIcPH873jFeib9++qlGjhkqXLq0SJUroww8/dD6W3XspyWeWc+XKlfrwww81Y8YMt48nJibqzJkzPv9e1q5dW88++6w++OADdevWTU899ZTLtUJiYmIUEBCgUqVKOYfZbDaVKVPGZ5Zz586datiwod555x01b95cbdq00XPPPedyfvWr4TOLvBMTE6MSJUq47AAJDAxUWFgYn4cLLFu2TOvWrVOfPn2cw/i/lL1t27apVq1aqlixorp27apPPvlEN9xwgyTp1KlTOnfunM/XFE946qmnVKtWLd17771uH7+avlsh71zOuiin03Tv3l2jR4/WtGnTFBERodatW+vrr7/Oy/i54qn/A976fyu7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tGlTpaSkuFzz588//1RiYqLPfO49Zfv27brpppvUo0cPvffee1mO3mratKl+++03l9M4rF27VsWLF1e1atXyO65XK168uPPUd3a7XfXq1bvqasqVmjx5sn755RdnLf7yyy8l/dvQyPzFdObrc+FrFxcXpz179hTo166ga9q0qbZu3epyLvG1a9cqMDAw219jXs400r/fnYoUKWLZecgbNmwof39/l/8DZ8+e1V9//XVF/wc8Nd8rld02Y+PGjfP8uQ4dOuT8TmyFpk2bav/+/YqNjXUOW7t2rSRd9D2Ii4tT+/btVapUKS1atCjLtvTlzteTateurSJFiri8t2lpadqwYcMlM40YMUIzZszQ999/n6PPwalTp5SUlGT5e+vuc1yrVi2X65BdyoXfJfJyvt6sadOm2rNnj06dOuUctnbtWtlstlytBw4dOqTAwMAraoRaJbv3uXLlytnukGzatKlCQ0OzNDel/9sm9hWFChXSddddd9nfm2fPni273a6+fft6KqLHNWrUSH5+fi6vQXx8vLZv357ta5D5Pl/4Gcn8AbmvfQakfz/Thw8fdjlNUk5rmb+/v6655hoVLVpUCxcuVPHixdW8eXOP5vUW2a0/ypUr53IduVy7okuK+4hq1aqZ0aNHZxn+008/GT8/P/PVV18ZY4zZtGmTCQkJMdOmTcvnhJfviSeeMGFhYWbTpk3ZjvPAAw+YmjVrmn/++cdkZGSYF1980RQtWtQcO3Ys/4JegdmzZ5s1a9aYjIwMY4wxR48eNa1btzb169d3jnP69GkTFhZmnnzySZOenm5OnTpl6tWrZ3r37m1R6isXFRVlbrvtNpdhs2bNMoULFzbr1683xhizdOlS4+/vb7799lsrIuba2rVrzezZs01KSooxxpiYmBjTpEkTU6dOHZOWluYc78YbbzQ33HCDSUpKMmlpaebee+81lSpVMsnJyVZFz5Xjx4+bkJAQ87///c8YY0xKSooZMGCAqVixojl//rwxxphdu3aZwMBA5/rmwIEDply5cmbkyJGW5Ybnvf766yYsLCzL8IyMDNOwYUPTrVs3k5ycbFJSUkzPnj3Ntddea9LT0y1I6h127txpypQpYx588EFnDfivvXv3mqCgIDN16lRjjDF///23qVChghk+fHh+RvU6I0aMMHFxcc77CxcuNEFBQc71kjHGvPPOOyYk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586d04cffqjSpUtr6dKlyeJP6zEFADsZY3T06FE988wzqlSpkk6ePGktS7r/xcqVK9WpUycdOHBACxcuVNWqVbVp0yZt2rRJ7dq10969e/Xjjz+qVq1aqU7LtGXLFrVv31779+/Xb7/9poYNG2rQoEFOdRITE9WzZ0+1bt1aO3bs0M6dO3Xffffd9N4ZN9oHAEDmS7q/ULly5fTtt99a5Un3v1ixYoU6duyopUuX6tChQ2rXrp1efPFFRUVFKTQ0VIsXL9aRI0fUq1cv9e3bN8XXiI2NVWhoqBYtWqRjx45p8ODBuvPOO3Xq1CmnenPmzFHFihX17bff6p9//tFzzz2n8ePHZ3gfMqpIkSIpXkw6f/68JMnb2/uWXwMAstKcOXM0fvx4TZkyRYMGDdI///yj6dOnq3Llyvr111/Tvb3o6Gh16tRJjRo10u+//66zZ8/qtddeU/Xq1Z3uS/vMM8+oUaNG2rhxoy5cuKAZM2aoVq1aya75pCale2gk7cuECRP08ssv659//tHbb7+tu+66S5cvX9b777+vF154QSdOnNCHH36omjVr3vAehHFxcdq6davuu+8+PfDAA2m+J+yZM2d09913q2fPnjpw4ID27t2rzp07q0mTJk73HPx3vAMHDtThw4e1bNmyTDk+uI0YIBUrV640kkz16tXN6dOnrfIVK1YYSea999674frx8fGmadOmplGjRlbZwoULjSQzceJEq+zq1aumffv2RpL58MMPM1R3y5YtRpJZsGBBsvhr165tzp49a5W/++67xuFwmF27dmXotdLKz8/PDBw40Kns38f01KlT1nG6fPlyittI6Rhu2LDBeHl5maefftokJCRY5VFRUSYsLMwYY8z69euNJLNkyZJk20zpWC1btsxIMqNGjbLK4uLiTLt27UyuXLnMP//84xR/Wo4pALja999/bySZP//884b1IiMjTcWKFU2fPn2ssi+++MJIMm3atDHR0dHGGGMSExNNgwYNTN26dU2XLl1MVFSUVd64cWNTvXp1p+0mbaNFixbmypUrVvnnn39uJJk5c+ZYZePHjzeSzOLFi62yc+fOmTp16iT7TE7rPqTFzY7RqVOnjKRk7RUAZFd+fn7m+eefv2m9oUOHmty5c5uIiAhjzPXvBpJM4cKFzR9//GHV++STT4yXl5fp3r27+e2336zySZMmGUlmx44dVlnSNgoWLGg2bNhglR8/ftzkz5/fdOjQwSo7ceKECQgIMD179nTq8z/99NMmMDDQdOnSJd37kFZpOUbXrl0zNWrUML6+vubo0aPp2j4A2K1Lly4mT5485qWXXrLKYmNjTaVKlcw999yT7u099thjJmfOnMk+8+vWrWsaNGhgjDHm6NGjRpL56quvnNY9deqUOXz4sPW8T58+plixYqm+TuHChVPcl2HDhjlt08/Pz3Tp0sUMGTLEKj979qzx9/c3L7744k33KSwszDgcDvPNN9+kKbb27dubwMBAc+jQIatsz549JmfOnKZnz55O8QYGBprBgwdbZefPn0/z8QGMMYYpp3BTXbt2VeHCha3nrVq1UuPGjfXll1/qxRdftMrj4+P1yy+/aOfOnbpy5YoSExPl5eWlP/74QwkJCfL29ta0adNUuHBhPfXUU9Z6/v7+6tevn5YsWeL0uumpeyPdunVzGk7Xo0cPvfzyy9q4caOqVq2aqa+VVp07d1aRIkUkXf9FU1BQkKS0HcPx48crICBAY8aMkZfX/x9kFRAQcMPpUG7kyy+/VFBQkNP/08fHRyNHjtTSpUv13XffOS1LyzEFALvExMRo1apV2rdvn6KiomSMUUBAQIqj7R5++GHlzJlT0vXpM9q3b69XXnlFgwcPtn6N6nA41KFDB7300ku6cuWKAgMDk20jd+7c1vPHHntMb775pr788kt169ZN0vUh2nfeeafat29v1StQoIB69eqlrVu33tI+AABu3bZt27R582ZduHBBCQkJOnXqlCIjI7V7926nqWfr1q2rOnXqWM8feOABDRo0SBEREapXr55V3rFjRw0YMEC//vqratSo4fRatWrVcprGo3jx4nriiSf07rvv6syZMypcuLBmzZql6Ohovf766059/ldffVUTJky4pX3IDM8995x27typkSNHZsq0VgCQ1a5cuaKhQ4daz3PkyKGOHTvqgw8+sK7FpMXly5c1bdo0Pfnkk06f9/7+/nr22WfVp08f7d+/3xrpcOLECaf1k64N3eq+vPTSS07bvPvuuzVv3jxrNJ0kFSxYUA0bNkxxFMqZM2e0atUqHTt2TLGxsZKk3Llza/PmzXrwwQdv+PpnzpzRkiVL9MwzzzjdU6ly5cp68MEHNWXKFH366afKmzevFe/LL79s1cufP7/OnTsnyTXHB9kPU07hplK6QF2tWjUdOHDAmnbq5MmTqlWrlvr06aOdO3daH35eXl6KjY3VlStXJEn79u1ThQoVkg1LrlKlSrLXSE/dG/lv/aQPw3/++SfTXyutUjqmaT2Gu3btUoUKFZwunt2qffv2qXz58snmZq9WrZq8vLy0d+9ep/K0HFMAsMPu3btVvnx5DRo0yKmd8vLycurMJ6lcubLT80KFCt2wPKXPuf9+pnt5ealKlSras2ePVbZv375U29Nb3QcAQMbFx8erc+fOaty4sdasWaPIyEhJsi5k/fdz1xXthnS9PTDGWFMR7tu3Tz4+PqpYsaJTvSJFiqhAgQK3tA+36s0339TEiRPVp08fvfbaa5m6bQDIKkWKFNEdd9yRrCwuLi7FacFTExYWpoSEBJ04cUJvvvmmRo8erdGjR2vUqFH6+eefJUkHDx5UpUqV1KNHD73xxhuqUqWKnn32WS1atEhRUVGZsi9JyYIkhQoVUsGCBZU/f/5k5f9tm6ZPn65SpUppwoQJOnPmjJV8Sev3j/3790tSsgR+UlliYqIOHDhgleXLl09FixZ1qufK44PshxEauKmUbmSX9OGWtGzEiBE6fvy49u7dq+DgYKvec889p9WrVydbLy3SU/dG/jvfa1LS4t/3AMms10qr/zaakmuOYVoZY1K9YWFK0nJMAcAOzz33nHx8fBQWFuaU+N2xY4cOHTqUrH5qn2fp+ZxLrZ38b/mN2tNb2QcAQMbNmDFDCxYs0I8//qj77rvPKp83b56mTp2arL4r241/L7tRn/+/y9K7D7di3Lhxev3119WjRw9Nnz49zb9gBgB3k9K9gTJybSMxMVHS9Vku/rte8eLFNXz4cJUpU0aSNGvWLA0aNEg//PCDNmzYoEmTJil//vxasGDBLY2kS21fUiv/d5yRkZEaMGCAunbtqm+++cap7tixY9P0+v9tw24mpWtikuuOD7IfEhq4qd27d6tz587JysqXL2992O/Zs0dVqlRxuhAvXb9R6r9VqlRJmzdvtqZPSvLfEQDprXursvK1UpPWY1ijRg0tW7ZMUVFRypUrV4rbSu8Xi0qVKmnt2rWKjY2Vn5+fVb57924lJiYm+8UZALirPXv26J577nFKBCQkJOjPP/902Wvu3r1btWvXtp4bY7R3715VqlTJKqtUqZJ2796d4rr/Zcc+AMDtas+ePXI4HLr33nudyv/bB89MqbUHDofDGpFRuXJlxcfH68CBA0598TNnzujChQtO62bVPkyaNEnPP/+8unXrpm+//ZabgQOAro+68/LyUvny5TVixIib1m/UqJE17eDx48dVq1YtjRgxQj/++KOk9F/PuVVHjhxRdHS0WrZs6VS+a9cuRUdHO5WlFltS2xUWFpZsWVhYmLy8vFShQoU0xXOz4wNITDmFNJg7d67TcLvVq1drw4YNeuyxx6yycuXK6cCBAwoPD7fK5s+f7zSkTJL69eunM2fO6IsvvrDKYmNjU/zlUHrq3qqsfK3UpPUYDho0SNHR0Ro2bJj1SwBJio6O1q5duyT9/ymg0jpM8rHHHtPly5f1wQcfWGXx8fEaMWKEAgIC1Lt37wzvFwBkpXLlymn79u1Ovzr6+OOPXTpU+euvv3ba/tSpU3Xs2DGndrJfv37atm2bli1bZpVduHBBs2bNSrY9O/YBAG5X5cqVkzFGf/zxh1V28OBBzZs3z2Wv+eeff2rTpk3W85MnT+rLL79U27ZtrXsX9ujRQzlz5tSbb77pNCJjzJgxye7llBX78NVXX+npp59W165dNWPGDPn48NtIAJCu3xfv4Ycf1qRJk6xrMv+2atUqSdcTBwcPHnRaVrRoUeueqkmKFCmiixcvZtkMGCVLlpSPj49TEjwuLk4jRoxINtV5arEVKVJEbdu21dSpU/X3339b5fv379c333yjrl27JpsS67/SenwAiREaSIOBAweqXbt2qlevniIiIjRnzhx16NBBQ4YMseq8+uqrWrZsmerVq6f27dvryJEjunjxogYMGKCRI0da9R544AENHjxYAwYM0MqVK1WiRAlt3LhRzz//vBYvXuyU7U1P3VuVla+VmrQew0aNGmnKlCkaOHCgfv31V91zzz2KiIjQr7/+qvfee0/VqlVT6dKlVb9+fY0YMUL79u1TQECAWrdurYYNG6b42u3atdMbb7yh119/XRs2bFClSpX0yy+/6ODBg5o5c6aKFSvm8v0HgMzw5ptv6v7771f9+vXVvHlz7dq1S0FBQercubNmz57tktfs16+fmjZtqsaNG+uff/7R/Pnz9b///U89evSw6gwcOFDr169Xp06d1K1bN+XPn18bN27UwIED9eijj2b5PkyaNElnz5615ln//fffrV+U9ezZ02l0CQBkZw8++KA+++wzhYaG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f37ToUMHExsba3dobuuLL74wJUuWNPXr1zdt2rQxuXLlMk888YRJTEy0OzSP8M4775gcOXKYYsWK2R2K29u/f7+pWrWqKV26tOnYsaOpWLFimvsKt6O4uDjTrVs3ExgYaNq1a2caNmxocuXKZb766iu7Q7stTZkyxZQvX96UK1fOSDKXLl1KVict/eFb6TNnhoSEBPPss8+aokWLmuLFi5s6deokq3PlyhUjybRs2dLpWs3atWud6mWkb5yZpkyZYkqWLGnuvvtuq/169NFHk12HS0v/Nr194MzWv39/ExwcbFq2bGkaN25scufObT7//HOnOgMHDjTFihVz+p8MGTLEqY479FsHDRpkQkJCTGhoqGnYsKEJCAgw48ePd6qzY8cOExwcbKpWrWoaNWpkcuXKZebMmZPuOq506tQpc//995uKFSuaDh06mIoVK5rg4GCn7zmnTp0yksz999/v9H/ZvHmz07amT59uAgICTJMmTUylSpVMyZIlzf79+7NsX/7t2WefNTly5Eh27icmJponnnjCBAYGWtflWrVqZaKjo9NVJz1sTWhs3LjR6QthZGSkqVq1qnnkkUdSXScpoXH16tWsCtPlxo4dawoUKGCOHz9ujLneSAUEBJgvv/wy1XW2bt1qfHx8nC7cHz9+3GzdutXl8bpKly5dTL169awLr99//71xOBzpStKMHj3aBAQEmPDwcFeF6VLXrl0zISEhZuDAgVbZ4MGDTZEiRVJ9zx8+fNhIcvpwXrBggZFk9uzZ4/KYXWHv3r3G29vbzJw50xhzPenXsGFD065du1TXGTp0qAkODjbnz583xlzvxN97772mX79+WRKzK2zevNksWrTIxMfHm8KFC6cpoTF16lSTK1cus2/fPmPM9c+F/Pnzm3feecfV4dpizZo1RpJZt26dMeZ6J6xChQrmf//7X6auY6dXX33VFC9e3Jw9e9YYY8y2bduMj4+PmTt3bqrrTJ482aljMGPGDONwOExYWJjL482IRo0amTZt2lhfID755BPj7+9vTpw4ccP1EhMTTWhoqBk7dqxbJzTOnj1rcufObZ3DiYmJplOnTqZWrVo3XK9JkyamQYMG5sqVK9Z6S5YscXm8GbFkyRLj7e1ttmzZYowx5vz586ZEiRLmhRdeSHWdV155xQQHB1v7Z4yxLrq4o0WLFplff/3V+vKRloTG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jDHWdZMk27dv1+HDh61rqe7Qb7148aKuXbvmVLZz507FxcVZn/X169dXvnz5nOJcuXKlLl68qNDQ0DTXcbWUzoOtW7cqR44cKlSokKTk17el6+dK5cqVre/voaGhOn78uNMI/dmzZys4ODhLbjdQsmTJZP2W1q1bq0yZMpo1a5aqVKlixbl8+XLrmlxiYqLmzp3r9N5JS510ydCtxDNJXFycadiwoalWrZqZMGGCefTRR42fn5/ZtGmTVWfNmjVGkvnzzz+NMcY8++yzplOnTubjjz82n3zyialXr54pXLhwineD9xTHjx83hQsXNqGhoWbSpEmmefPmpmTJkubcuXNWndGjR5tcuXJZzy9evGjKlStnOnbsaKZMmWJeeOEF4+fnZ7799ls7diFTrF692vj6+pr+/fub8ePHmwoVKpiWLVuaxMREq07btm3Nvffe67Tetm3bjCSzZs2aLI7YNd5++22TK1cuM3r0aPP222+bwMBAM3z4cKc6QUFBVllMTIypVauWqVSpkvnkk0/MhAkTTNWqVU3VqlVNdHR01u9AJmnbtq0pW7as+fjjj82gQYOMj4+P+eGHH6zlYWFhRpJZuXKlMcaYkydPmjp16pj333/fTJw40dSqVcvUrl3bXL582a5duGWXL182M2fONDNnzjRBQUGmT58+ZubMmWbdunVWnU8//dRIMlevXjXGGHPhwgVTunRp06xZMzNp0iTTrl07U7BgQXP06FG7dsOlYmNjTd26dU2tWrXMhAkTzEMPPWQCAgLMH3/8YdX54YcfjCSzd+/eNK/jTvbv32/y5s1runTpYiZNmmQaNGhgKlWqZCIiIqw6zz//vClWrJgxxpjExERTu3ZtkzdvXvPll19a76GZM2eaffv22bUbNzR79mzj4+NjXnzxRTNu3DhTrFgx06dPH6c6derUSVb2b5LM1KlTXRxpxj355JOmcOHC5v333zevvPKK8fX1NV999ZW1/NKlS8n2Yfjw4aZYsWLmgw8+MBMnTjSVKlUyTZo0MfHx8TbswY1FRUWZ6tWrm3r16plJkyaZHj16mMDAQKf+2ffff28kmePHjxtjjFm7dq3x8fExDz74oJkyZYoZOnSoyZkzp3nrrbfs2o0bOnLkiJk5c6aZPHmykWReffVVM3PmTLNz506rzlNPPWUqVqxoPd+5c6fJnTu36d27t5k4caKpU6eOqVmzpvWZDfdy7tw5U7JkSdOiRQszadIk06ZNG1O4cGHrPYvknnjiCePt7W3GjBnj1N5s3LjR7tA8xujRo602HKnr06ePqVKlipk4caJ5//33TXBwsOnbt6/dYbmtEydOmAIFCpimTZuazz//3Lz77rumSJEiplOnTnaHdlvatm2bmTlzpnnppZeMJDNlyhQzc+ZMc+LECatOWvrDaanjaj/88IOZOXOmadOmjSlTpoz1uR8XF2eMMearr74y99xzj3n33XfNZ599Zh544AETEBBgFi9e7LSdm/WNXa1///7Gy8vLvPXWW07t14YNG6w6aenfpqWOK8XGxpqqVauaAgUKmKlTpzrty6FDh4wxxiQkJJhKlSqZZ5991nz55ZdmxIgRJn/+/KZNmzbW/80Y+/utW7ZsMTVr1jTDhw83U6dONcOHDzeFCxc2bdu2NQkJCVa9r776yvj6+ppXX33VvP/++6ZQoULmqaeectpWWuq40ocffmhatmxpxo4da6ZMmWL69+9v/P39zdixY60648ePN82bNzdjx441kydPNm3atDGBgYHWNa4kvXv3NsWLFzcffvihefHFF42Pj4+ZPXt2lu3Lfw0cONDUqVPHqSw8PNyUL1/eNG7c2EyaNMl07NjR5MuXz3oPprVOejiM+ddP/G0QExOjzz77TH/++afy58+vxx57zMrwSNdHJowaNUrvvvuuNRXITz/9pB9//FExMTGqUqWKHnnkEQUGBtq1C5ni5MmTmjx5so4ePaqyZctqwIABTjcPWrBggRYtWqRp06ZZZZcvX9bkyZO1b98+FSlSRN27d1ft2rVtiD7zbN++XdOnT9elS5dUt25dPfHEE05D5d577z3FxcXp1VdftcrmzJmjVatW6bPPPpPD4bAj7Ey3dOlSLV68WMYYtW3bVh07dnRa/thjj6lNmzbq2rWrpOvn0fTp07V9+3YZY1SjRg3169fP7X5tnh5xcXH66quvtGnTJgUFBalv375ONzo/ceKEXnjhBb322mvWTaIOHz6szz77TBcuXFCDBg300EMPpWmopbtK2sf/ql27tl566SVJ0qpVq/Tll1/qm2++ka+vr6TrUw19+umnOnjwoEqUKKH//e9/KlasWJbGnpWSRuPs2LFDhQoV0uOPP+40zdiOHTs0ZswYjRs3TsHBwWlax90cPXpUn332mf755x9VqlRJAwYMsEYySten3du4caMmTpyoxMRE9e7dO8XtPPjgg2rXrl1WhZ0uGzdu1IwZMxQdHa0mTZqob9++8vL6/wNJX331VQUHB2vgwIEprt+zZ0/1799fTZs2zaqQ08UYoxkzZujnn3+Wn5+funfvrmbNmlnLo6Oj9eijjybbh6VLl2rJkiXy8vJS3bp11bdvX7ed+ubKlSv69NNPtXv3bhUtWlRPPvmk06/dfvvtN3344YeaNGmSNQLj0KFD+uabb3Ts2DEVKFBAbdu2dTou7mTDhg2aMGFCsvLOnTure/fukq6PGN2zZ48++OADa/mhQ4f0xRdf6PTp06pWrZr69+9/W93bydOcP39ekyZN0l9//aWSJUuqf//+WTplhKcZOnSoNU3IvzVs2FDPPPNM1gfkgRYsWKBVq1Zp4sSJdofi1hITEzVjxgytWbNGAQEBatasmdO0jEjuwoULmjJlig4cOKCAgAA1bNhQ3bt3d+pfIWt8/fXX1gjOf3vhhRd01113Wc9v1h9Oax1XevHFF51mh0gyffp06z4M27Zt09y5c3Xu3DmVKVNGffv2TfZ99GZ9Y1d79dVX9ddffyUrr1+/vp599lnr+c36t2mt4ypJ3yFS8vjjj6tly5ZWvenTp2vbtm3Kmzev7rnnHnXo0CHZOnb3W0+cOKGvv/5ahw4dUsGCBdW8eXPdf//9yeqtXbtWc+bMUWxsrFq0aKHevXsnux6YljqutHnzZi1cuFDnzp1TiRIl1K1bN6fr3dL170cLFizQhQsXVL58eT388MPWSKYkiYmJ+vrrr7Vu3ToFBASod+/eTvfeyGpTp07VoUOH9NZbbzmVX7p0SZ9++qn279+v4sWL66mnnko2DWla6qSV7QkNAAAAAAAAAACAmyE1DwAAAAAAAAAA3B4JDQAAAAAAAAAA4PZIaAAAAAAAAAAAALdHQgMAAAAAAAAAALg9EhoAAAAAAAAAAMDtkdAAAAAAAAAAAABuj4QGAAAAAAAAAABweyQ0AAAAAAAAAACA2yOhAQAAAAAAAAAA3B4JDQAAAAAAAAAA4PZIaAAAAAAAAAAAALdHQgMAAAAAAAAAALi9/wfNPwGJm6riJAAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "HYPERPARAM_NAMES = [\n", + " \"num_leaves\",\n", + " \"min_child_samples\",\n", + " \"learning_rate\",\n", + " \"feature_fraction\",\n", + " \"bagging_fraction\",\n", + " \"lambda_l1\",\n", + " \"lambda_l2\",\n", + " \"n_estimators\",\n", + "]\n", + "INT_HYPERPARAMS = {\"num_leaves\", \"min_child_samples\", \"n_estimators\"}\n", + "\n", + "hyperparam_history = pd.DataFrame(\n", + " [record[\"hyperparams\"] for record in rotation_records]\n", + ")\n", + "\n", + "consensus_hyperparams = {}\n", + "for name in HYPERPARAM_NAMES:\n", + " median_value = hyperparam_history[name].median()\n", + " consensus_hyperparams[name] = (\n", + " round(median_value) if name in INT_HYPERPARAMS else float(median_value)\n", + " )\n", + "\n", + "print(\"consensus hyperparameters (median across all rotations):\")\n", + "for hp_name, hp_value in consensus_hyperparams.items():\n", + " print(f\" {hp_name}: {hp_value}\")\n", + "\n", + "fig, axes = plt.subplots(2, 4, figsize=(16, 7))\n", + "for ax, name in zip(axes.flat, HYPERPARAM_NAMES, strict=True):\n", + " ax.hist(hyperparam_history[name], bins=15)\n", + " ax.axvline(consensus_hyperparams[name], color=\"black\", linestyle=\"--\")\n", + " ax.set_title(name)\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "convergence-md", + "metadata": {}, + "source": [ + "## Convergence check - do this before trusting the numbers above\n", + "\n", + "Running mean/std of outer-eval Brier score as more repeats are added.\n", + "Flat by the last couple of repeats means `N_OUTER_REPEATS` was enough;\n", + "still visibly drifting means raising `N_OUTER_REPEATS` and re-running\n", + "Stage 2 is worth it (Stage 1's SFFS result doesn't need to be redone). A\n", + "wide std that persists even once the curve is flat isn't a search\n", + "problem - it's the ~376-row clean pool's irreducible statistical noise,\n", + "and more rotations or trials won't shrink it; only more labels would." + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "convergence", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAxYAAAHqCAYAAACZcdjsAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjEsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvctoD+AAAAAlwSFlzAAAPYQAAD2EBqD+naQAAbUtJREFUeJzt3Xd8VfX9x/H3HcnNDoSEALIUkO1CXIDgAqTYOkBU1FqljjrrXtVatc7WUVv7s1hnW0QctA6cgIKoRdHKkL0CJCF7567v74+bXHLJ4Obem9zc5PV8PPLg5txzzv3cy01y3ve7LMYYIwAAAAAIgzXaBQAAAACIfQQLAAAAAGEjWAAAAAAIG8ECAAAAQNgIFgAAAADCRrAAAAAAEDaCBQAAAICwESwAAAAAhI1gAQAAACBsBAsAXd6iRYtksVi0bNmyTv2Yseaaa65Rt27dol0GgvTwww/LYrGopKQk2qUAiBKCBRDjcnNzdfPNN2vYsGFKTExUWlqajjvuOP3pT3+S0+kM+bxLliyRxWLRokWLIlhtbPvyyy9lsVgCvtLS0jR69Gg9+uijqq2tjXaJQCPl5eVyOBx6/vnngz5mzpw5yszMbMOq0BoJCQm64YYbol0GcEAECyCGrVy5UocddpgWL16sv/zlLyoqKtK2bdt05ZVX6ne/+50mTZqk0tLSaJfZ6Tz22GMyxsgYo+3bt+v666/XHXfcocsuuyzoc0ydOlXGGI0fP74NK41tzzzzDJ9+R8D7778vt9ut6dOnR7sUAJ0cwQKIUeXl5TrzzDOVmZmppUuX6uSTT1ZiYqIyMjJ0ySWX6MMPP9TKlSt1+eWXR7vUTq179+6aM2eOJk+erNdee00ejyfaJQEBFi5cqOOOO07Z2dnRLgVAJ0ewAGLU888/r127dul3v/udUlJSGt1/5JFH6qKLLtL8+fO1bt06SdLcuXNlsVi0adOmgH23bdsmi8Wiv/71r5KkF198USeddJIk6fTTT/d3+3nyySf9x5SXl+vWW2/VoEGDFB8fr169eumKK65QUVGRf5/6cQRLlizRE088oYMPPlg2m00//vhjs8+rqKhI119/vQYOHKj4+HgddNBBuu6661ReXi5J+uabb2SxWPTMM880OnbDhg2yWCx67LHHJEnfffddQLelxMREHXbYYXryySdljAnmZW6V+Ph4Wa37fq3OmzdPFotFq1at0oMPPqj+/fvLarUqNze32TEWB3r+Bzpv/XMOptvEhRdeqL59+6qgoEAXXHCBunfvrrFjx0qSJk2apOOOO67RMQ888IAsFosqKioanaekpESzZ89Wenq6MjIyNGfOHFVVVTX5mMHs29QYi9YcL0mvvPKKRowYoYSEBI0cOVJvv/120GMB6h9r7969mjFjhtLS0tS3b189++yzkqS8vDzNnDlT6enpysrK0r333tvkeVatWqUzzzxTPXr0kMPh0MiRI/V///d/Tb6u9V/p6ek65ZRT9Omnn4b1/N1ut9577z397Gc/829bv369Zs6cqd69eys5OVmHHXaYHnvsMdXU1EiSTj31VD3//PMqLCwMqKmgoMB/jn/+85+NXtdg3HfffbJardqyZUuj+/74xz/KYrFo7dq1QdXZGi291yXf78YxY8b4u5Oefvrp+u677wLOYbFYdPvtt+vDDz/UEUccoYSEBA0dOrTZLmbBnHPYsGH+19dut6tv376aM2eO8vPzJfm6ulosFtXW1uqpp57y7zt16lT/OebNm6djjjlG6enpyszM1KmnnqpPPvmk1a8REBEGQEyaMmWKsVqtpqysrNl9FixYYCSZJ5980hhjzN/+9jcjyWzcuDFgv61btxpJ5tlnn/VvW7x4sZFk3n///UbnraioMEcccYQ55JBDzAcffGDKysrMqlWrzFFHHWUOP/xwU1NTY4wx5v333zeSzNlnn23uv/9+k5eXZz744AOzefPmJustKioyhx56qBkxYoRZsmSJKS8vN1999ZUZMWKEGTdunHG73cYYY4488khzxBFHNDr+tttuM3a73eTm5jZ5/oKCAvPcc88Zh8Nh/vSnP/m319f5+eefN/taGmPMihUrjCTz2GOP+beVlJSYv//978Zms5mbb745YP9//etfRpKZOXOm+eMf/2j27t1r3njjDZOXl9fkYwb7/Fs676pVq4wkc/3117f4XIwxZvbs2aZ3797mrLPOMh988IEpKioyL774ojHGmIkTJ5pjjz220TH333+/kWTKy8sbneeCCy4wH374oSkrKzMLFy40iYmJ5qabbmryMYPZ9+qrrzbp6ekhH//CCy8YSebOO+80ubm5Ztu2beaSSy4xZ5xxhpFkiouLg3p9zjvvPLN06VJTWlpq/vSnPxlJZuHChWb69Onm008/NaWlpebZZ581ksy8efMCzrFkyRLjcDjMueeea9avX29KS0vNq6++apKTk83999/f5OO63W6zZcsWc/nll5uEhASzdu3akJ6/McZ8/PHHRpL58ccfjTHGuFwu069fPzN16lSzceNGU11dbdasWWNuv/128/bbb/uPu+yyy0yPHj2arO/ll182ksxtt91m9uzZY7Zu3Wpmz54d1Ou6Y8cOY7Vazd13393ovlGjRpnjjz++VXUGq6X3+jXXXGMSExPNn//8Z7N3716Tk5NjLrvsMpOcnGxWr17tP4ckM23aNDNjxgyzadMmk5+fb377298aSeYvf/lLwOMFe86GKisrzeeff25GjRplJkyYYDwej/8+h8PR5M/0Rx99ZCSZJ554whQVFZnS0lKzePFiM3Xq1Fa/RkAkECyAGDV8+HCTmZnZ4j7//e9/jSTz61//2hgTuWDx4IMPGknm22+/Ddi+YcMGY7Vazd/+9jdjzL4L9rPOOiuo53TLLbcYu91uNmzYELB95cqVRpJ5/fXXjTHGPPPMM0aS+eabb/z7uN1u07t3b3PmmWce8HEuueQSM2rUKP/3rQ0WTX1dfPHFpra2NmD/+gDwi1/8otG5mnrMYJ9/S+dtjdmzZxtJ5o033mh0X2uDhSTz3nvvBez7i1/8wqSlpTX5mMHs21ywCOZ4j8dj+vTpY0466aSA/dxutzn44IODDhaSzKJFiwK2jxgxwiQnJ5t33nknYPvo0aPNaaedFrBt+PDh5rDDDvOHwnq//e1vTUJCgikqKmqxhqysLHP77bc3qimY188YY6699lozdOhQ//c//vijkWRee+21Fh+3uWDh8XjMQQcdZCZOnBiw3el0mv79+wf1uk6dOtX07ds34ML5yy+/NJLM3LlzW1VnsJp7r9f/bD300EMB291utxk2bJg555xz/NskmaysLFNVVRWw7/Tp001GRob/A5XWnLMpixYtMpICAkhzweLuu+82DofDeL3eFs8JtBe6QgExygTRlad+H4vFEtHH/s9//qPBgwfryCOPDNg+ZMgQ9e/fX0uXLg3Y/tOf/tR/u6SkpNHMSvVdsP7zn//oiCOO0JAhQwKOHzNmjLp37+4/7+zZs5WQkKC///3v/n3ee+897dmzp9EA6rlz5+rYY49Vamqq//FefPHFRt3BWqPh4O3y8nJ98MEH+uSTTzR58mS53e5G+zd8/i0J9vm39rwtsVgsERnUa7fbNXny5IBto0aNUllZWUAXmtbuG+pjrV+/Xrt379YZZ5wRsJ/NZtPpp58e9POKi4vTqaeeGrBt2LBhqqmpaVTD8OHDA7r4bN68WevWrdPZZ58tm80WsO+pp56qmpoaff3115KksrIy3XLLLTr00EOVkJDgf6/u3bu30Xu1Na/fv//974BuUP369VNGRobuuece/etf/1JhYWHQr4Xke1137drV6L0XFxenadOmBXWOyy67TDk5Ofrwww/92/7+978rOTlZs2bNikidTWnqvf7OO+9IkmbOnBmw3WazadKkSY1+5k499VQlJiYGbDvzzDNVVFTk7+bUmnN+++23OvPMM5WdnS2bzRbQzSmY31GHH364amtrNXv2bC1btiysmQCBSCBYADFqwIABKioqCujrvr8dO3ZIkvr379/iuYIJKQ3l5uZq06ZNstvtstvtstlsslqtslgs2rZtW6OLgIMOOijo837zzTdNnre4uNh/3m7duumcc87RP/7xD39/6+eff169e/cOuGh88skndfnll2vmzJlat26dXC6XjDG65ppr5HK5WvWcm5OSkqLJkyfrvvvu09KlSzV//vxG+0T6+bf2vC3JyspSfHx80Ps3917p2bNno4vntLQ0SWo0lqE1+4b6WPWvVc+ePZs8PlhZWVmNHis1NVU9evRQXFxco+0N68/NzZUk/e53v/P/f9b/n9bPBlZf59lnn62XX35ZTz75pPbs2SOv1ytjjAYOHNjovRrs6/fdd99p+/btOvPMM/3bkpKS9OGHH2rgwIG65JJLlJmZqcMOO0yPPPJIUNMl19fb1EDwYAeH/+xnP1NWVpZ/bEJVVZXmzZunWbNm+ceLhVtnU5p6r9f/Hw0dOjTgZ85qteqvf/1ro5+5lp53fagL9pxbtmzRhAkT5Ha79fHHH6uiokLGGP+4q2B+R82YMUN//vOf9d1332nChAnq1q2bpk6dqo8//rg1Lw0QMQQLIEZNnjxZXq+3xXUm3nvvPf++kpSeni5JAQOBJWnXrl2teuzMzEwdccQRcrvdcrvd8ng8/gshY4z/ces1vADr1q2bf7/6ryuvvNJ/3okTJzZ73n/+85/+81x22WUqKSnRW2+9pby8PL377ru65JJLAi64Xn75ZY0fP14333yz+vbtK7vdLknaunVrq55vMA499FBJ0urVqxvdt/8FaHNa8/xbc96WNHeO9PT0Ru8Tqfn3SmtaxcJtQQvm+B49ekiSfxBsQ01ta+1jBVND/ToQjz76qP//c///0wsuuEC7d+/WJ598optuuknTpk1T9+7dZbFY5Ha7lZOTE9JjS77WiuzsbB177LEB28eMGaNFixappKRES5cu1cSJE3X77bfrtttuO+A561/XvLy8Rvc1ta0pcXFxuuiii/Tvf/9bBQUFWrBggcrKyhq1NoZTZ3OPu7/6/6Ndu3YF/MzV/x95vd4DPsf6bfWvTbDnXLBggaqqqvTcc89p9OjR/paQ1v5++tWvfqW1a9dqz549evHFF1VaWqopU6boq6++atV5gEggWAAxas6cOerdu7fuueeeJlstVq1apVdeeUUzZszQ8OHDJUmDBg2S1Pjit77pvqHk5GRJavLTwTPOOEM//PCDf7apSDnjjDP01Vdfafv27Qfcd9KkSRo0aJCef/55vfzyy3K73br00ksb7edwOAK+37NnT5t8mrdhwwZJUq9evUI+R2uef1sbNGiQtm3bFvDecrlcAd1XOrKhQ4eqT58+evfddwO2ezyedlv0cejQoTr00EO1YMGCRheoTdn/vfrPf/6zya51wVq4cKHOOOOMgJnKGkpMTNSJJ56oP/3pTxozZow+++wz/33JyclN/uzXv67/+c9/ArbXzz4VrDlz5sjpdOrVV1/V888/r2HDhumEE05odZ3hqu8a9dprrwW1/8cff9xoVqqFCxeqe/fu/q6hrT3n/v/vL7/8cqN9mvv/aKhXr14699xz9cILL8jr9Wr58uVBPT4QSQQLIEalpaXp7bffVn5+viZNmqTFixerurpaxcXFeumllzR58mQdeeSR+tvf/uY/5qijjtKYMWP0wAMPaPXq1SouLtZzzz3X5KdwgwcPVkJCgj744ANVV1cH3HfjjTfqsMMO0xlnnKF///vfKiwsVHFxsVasWKErrrgi6D+o+7v77rvVv39//eQnP/F/UllYWKjPP/9cl1xyScCFi8Vi0aWXXqpPP/1UTz31lCZOnKjBgwcHnO+nP/2pFi9erH/961+qrKzUt99+qxkzZjTqMx+OiooKffTRR7r33nvVp08fzZ49O+Rzteb5N6c108225LLLLlN1dbVuvvlmFRYWauvWrbrssssajavpqKxWqx588EF98skn+s1vfqP8/Hzt2LFDl19+uUaOHNludTz33HP67rvvdN555+n7779XVVWVduzYoQULFmjChAnyeDzq06ePxowZo6eeekqrVq1SWVmZFixYoBdeeMH/YUBr5eTk6Ntvvw0YXyFJS5cu1fnnn6/FixeroKBAVVVV+ve//61169b5p5iWfGM2KioqtHjx4oC1Wepf1yVLlujOO+9UXl6etm/frksvvVSHH3540PUNHz5cxx9/vP7whz/os88+a9RaEWydL774on/cVCiOPfZYXXvttbrjjjv0xBNPaOfOnaqqqtLq1av18MMP68YbbwzY/+ijj9bPf/5zbdmyRQUFBXrggQf0zjvv6He/+50/IAR7ztNPP112u1033nij9u7dq5ycHP3617/2tyw3NGrUKH3xxReNflffdtttuv/++/XDDz+ourpaeXl5evbZZ2W1WnXiiSeG9JoA4SBYADHsmGOO0Q8//KATTzxRV155pbp3767+/fvrL3/5i+666y599tlnjdYBmDdvngYMGKCxY8dq+PDhysnJ0W9+85tG5+7evbueffZZffjhh0pLSwtYxyIlJUXLli3T7Nmzdccdd+iggw7SkCFDdOutt2rs2LEBfbpbIyMjQ1999ZWmT5+u66+/XtnZ2RoxYoTuuecenXLKKTrttNMC9r/kkktktVq1a9euJle9vvPOO3XzzTfr1ltvVVZWlq6++mo98sgjGjZsWEj11bvlllv8g2uzsrJ0zTXX6JxzztHKlSv93SFC0drn35ZGjhypV199VZ9++qn69Omjs846SxdddFHMBAvJ9/546aWXtGDBAvXr10+nn366zjjjDI0ZM0ZS40+K28LEiRO1cuVK2e12TZ06Vd27d9fEiRO1YMECPfzww/6ue2+++aYOP/xwnXzyyRowYIBef/11vfbaa/7ue621cOFCJSUlNQrR48aN01lnnaUHH3xQw4YNU3Z2tn7zm9/od7/7nR555BH/fj//+c910UUXacaMGYqLiwtYx+KSSy7Ryy+/rLfeekv9+/fX1KlT9bOf/azZFofmzJkzRzk5OYqLi9PFF18cUp2R8PTTT+u5557TG2+8oZEjR6pnz56aPXu2KioqdOuttwbse9hhh+mSSy7RmWeeqYMOOkgvv/yy/vrXv+qaa65p9TlHjx6tN954Q99995369++vCRMmqE+fPk129XrqqaeUmJiogQMHBgzwvummm+T1enXhhReqR48eOuyww7RhwwZ9/PHHOvrooyP6OgHBsJjWjtoEACCG/fKXv9T8+fNVWloa7VLazOTJk5WSkqI333wz2qV0GhaLRbfddpsefvjhaJcCdFi0WAAAuoza2lq9++67mjhxYrRLaTNlZWVasmRJo25QANDWCBYAgE5px44duuKKK/xjG1avXq0ZM2aooKBAd999d7TLazNpaWlyOp36+c9/Hu1SAHQxBAsAQKfUr18/jR07VnPmzFF2drbGjRunmpoaLV68WMccc0y0ywOATocxFgAAAADCRosFAAAAgLARLAAAAACELbQJshESr9er3bt3KzU1VRaLJdrlAAAAAC0yxqi8vFx9+vSR1dpymwTBoh3t3r1b/fr1i3YZAAAAQKvs3LlTffv2bXEfgkU7Sk1NleT7j0lLS4tyNQAAAEDLysrK1K9fP/91bEsIFu2ovvtTWloawQIAAAAxI5hu/AzeBgAAABA2ggUAAACAsBEsAAAAAISNYAEAAAAgbAQLAAAAAGEjWAAAAAAIG8ECAAAAQNgIFgAAAADCRrAAAAAAEDaCBQAAAICwESwAAAAAhI1gAQAAACBsBAsAAAAAYSNYAAAAAAgbwQIAAABA2AgWXYQxJtolAAAAoBMjWHQRbq/R7pLqaJcBAACATopg0YUUVji1t7w22mUAAACgEyJYdDG5pTUqrXJFuwwAAAB0MgSLLmhncZUqa93RLgMAAACdCMGiCzJG2l5YpRqXJ9qlAAAAoJMgWHRRHq/R9sIquTzeaJcCAACAToBg0YU53V5tL6yU18tUtAAAAAgPwaKLq3Z6taOoinUuAAAAEBaCBVRe49bu0ppolwEAAIAYFpPBwul0qqCgoFWfsgdzTLDnraioUE1N57oQL6pwKr+8cz0nAAAAtJ+YChZer1c33XSTunXrpoEDB6pv37566623wj4m2POuWLFCxxxzjHr27Kn+/ftrzpw5qqioiOhzjKa80lqVVDmjXQYAAABiUEwFiz/84Q964YUX9MUXX6isrEy33XabZs2apXXr1oV1TDD7/PDDDzrllFM0depUlZSUKDc3VxMmTND69evb9Dm3t5zialWwxgUAAABayWJiaNTuwIEDde655+rRRx/1bxs8eLDOOOMMPfHEEyEfE8w+P/3pT7V3716tWLEi5PrLysqUnp6u0tJSpaWlhXyeULg8Xv24pzyofa1WaVBWihLibG1cFQAAADqy1ly/xkyLRX5+vrZv365x48YFbB8/fry+/vrrkI8JZh+3262PPvpIM2bMkDFGhYWF8no77/oPXq+0rbCSNS4AAAAQtJgJFnv37pUkZWZmBmzPzMz03xfKMcHuU1NTo6KiIg0aNEhDhgxRSkqKLr/8clVVVTVbc21trcrKygK+YoXLbbStoFIe1rgAAABAEGImWFitvlLd7sD+/y6XSzZb0112gjkmmH0sFosk6W9/+5veeecdFRUV6bvvvtM777yju+66q9maH3roIaWnp/u/+vXrF9Rz7ShqXKxxAQAAgODETLA46KCDJEm5ubkB2/Py8vz3hXJMMPtkZWXJ4XDo/PPP14gRIyRJhx56qC666CK99957zdZ8xx13qLS01P+1c+fOoJ5rR1JR41ZOcXW0ywAAAEAHFzPBIi0tTUcccYQ++ugj/za3261PPvlEJ554on9bcXGx8vLygj4mmH1sNpsmTJig8vLAwc/l5eVKSUlptmaHw6G0tLSAr1hUUuVSXhlrXAAAAKB59mgX0Bq/+c1vNGvWLI0dO1bHH3+8Hn/8cUnSVVdd5d/nlltu0ZdffqnVq1cHfUww+9xzzz2aOnWqJkyYoHHjxumrr77Siy++2OxsVJ1Nflmt4mxWZSTHR7sUAAAAdEAx02IhSWeffbZeffVVvfTSSzrrrLNUVlampUuXKisry79PRkaGevXq1apjgtlnwoQJevvtt/Xqq69q2rRpeuGFFzR37lxdccUV7fPkO4DdJdUqr3FFuwwAAAB0QDG1jkWsi5V1LFpisfjWuEiMZ40LAACAzq5TrmOBjsEY3xoXTjdrXAAAAGAfggVaze0x2lbIGhcAAADYh2CBkNS6vNpeWMkaFwAAAJBEsEAYKms9rHEBAAAASQQLhKmkyqXcUta4AAAA6OoIFgjb3vJaFVbURrsMAAAARBHBAhGxp7RGpdWscQEAANBVESwQEcZIO4uqVOV0R7sUAAAARAHBAhFjjLStoEq1bk+0SwEAAEA7I1ggojxeo20FVXJ7WEAPAACgKyFYIOKcbq+2FVbJywJ6AAAAXQbBAm2i2unRzuKqaJcBAACAdkKwQJspq3ZrdwkL6AEAAHQFBAu0qcIKp/aWs8YFAABAZ0ewQJvLLa1RaRVrXAAAAHRmBAu0i53FVaqsZY0LAACAzopggXZhjLS9sEo1Lta4AAAA6IwIFmg3Hq/RtsJKuVjjAgAAoNMhWKBdudxG2wsrWeMCAACgkyFYoN1VO73aUVQlYwgXAAAAnQXBAlFRXuPWLta4AAAA6DQIFoia4kqX8strol0GAAAAIoBggajKK61VSZUz2mUAAAAgTAQLRF1OcbUqWOMCAAAgphEsEHW+NS4qWeMCAAAghhEs0CF4vWKNCwAAgBhGsECH4XIbbSuolIc1LgAAAGIOwQIdSo2LNS4AAABiEcECHU5FjVs5xaxxAQAAEEsIFuiQSqpcyitjjQsAAIBYQbBAh5VfVquiSta4AAAAiAUEC3Rou0uqVV7jinYZAAAAOACCBTo03xoXVap2ssYFAABAR0awQIdnjG+NC6ebNS4AAAA6KoIFYoLbY7StkDUuAAAAOiqCBWJGrcurbYWVrHEBAADQAREsEFOqaj3aWcQaFwAAAB0NwQIxp7TapT2lhAsAAICOhGCBmFRQ7lRhRW20ywAAAEAdggVi1u6SGpVWs8YFAABAR0CwQEzbWVSlKqc72mUAAAB0eQQLxDRjpG0FVap1s4AeAABANBEsEPM8XqNtBVVye1hADwAAIFoIFugUnG6vthVWycsCegAAAFFBsECnUe30aGdxVbTLAAAA6JIIFuhUyqrd2l3CGhcAAADtjWCBTqewwqm95axxAQAA0J4IFuiUcktrVFrFGhcAAADthWCBTmtncZUqa1njAgAAoD0QLNBpGSNtK6xUjYs1LgAAANoawQKdmtfrCxcu1rgAAABoUwQLdHout9H2wkrWuAAAAGhDBAt0CdVOr3YUVckYwgUAAEBbIFigyyivcWsXa1wAAAC0CYIFupTiSpfyy2qiXQYAAECnQ7BAl5NXVqviSme0ywAAAOhUCBboknaVVKuCNS4AAAAixh7KQR6PRz/88INycnIkSf369dPo0aNltZJTEBuMkbYXVuqQzBQlxtuiXQ4AAEDMa1WwWLNmjZ566inNnz9fpaWlAfd169ZNs2bN0nXXXacRI0ZEtEigLXi90ua9FeqREq+sFIfsNoIxAABAqIK+krrqqqt0/PHHq6amRs8995y2bNmiqqoqVVZWasuWLfrrX/+qyspKHXfccfrVr37VljUDEWOMVFDu1Pq8cu0tr2U6WgAAgBAF3WKRlpambdu2KSMjo9F9Bx98sA4++GDNmjVLRUVFeuSRRyJaJNDWvF4pt7RGhZW16pWWoG5J8dEuCQAAIKZYDB/RtpuysjKlp6ertLRUaWlp7frYLo9XP+4pb9fHjGWJ8Vb1Sk9UiiOkYUgAAACdQmuuX+lUDjSh2unV1r2V2lZQqRqXJ9rlAAAAdHhBfxx75plnBn3St99+O4RSgI6nvMatitoKdUuKU3ZaguIY4A0AANCkoK+SBg4c6P9KS0vTwoULtXnzZnXv3l3du3fXpk2btHDhwnbv4gO0NWN8K3avzy1XXlmNvF56DwIAAOwv6BaLJ5980n/7ggsu0G9/+1vde++9Afvcd9992rBhQ8SKAzoSY6T8sloVVTrVM9WhjOR4WSyWaJcFAADQIYQ0eLtnz57asGGDunXrFrC9pKREQ4cOVV5eXqTq61QYvN25OOKsyk5LUHpiXLRLAQAAaBNtPnjb6XRq7dq1jbavWbNGTqczlFMCMafW5dWOwipt3luhKqc72uUAAIBOyBgTMxPJhDSX5s9//nPNmDFDd999t8aOHStjjFauXKkHHnhAl1xySYRLBDq2qlqPNudXKj0xTtnpDjnstmiXBAAAYpgxRhW1bpVWu1RW7VZSvE0DM5OjXdYBhRQs/vCHPygrK0u/+c1vVFRUJEnq0aOHfv3rX+u2226LaIFArCitdqmsxqWM5Hj1THXIzgxSAAAgSMYYVTo9KqlyqqzaLU8MThYT0hiLZcuWafz48ZLkH0+RnZ3d6D4EYoxF12G1SlmpDmUmO2S1MsAbAAA0rb5lorTK1WyYSE2wR63FojXXryG1WEyYMEH1eaQ+UDR1H9BVeb1SXqlvBqleaQnqlhQf7ZIAAEAHUVnrVkm1S2XVLrk9nee6OaRg0ZySkhKlpqZG8pRATHO5jXYWVaugola90hOV4ojojxwAAIgRlfUtE50sTDTUqk7gF154oS688MKA2/VfF1xwgU488UQdd9xxbVJovSeeeEIDBgxQQkKCxo4dq2XLlkXkmNac99Zbb5XFYtENN9wQzlNBF1Lt9Grr3kptK6iMmZkdAABAeKqcbu0uqdaPuWXasrdShRXOThsqpFYGC7vdLrvdHnC7/is5OVmzZs3Sq6++2iaFStLzzz+vu+66S3/+85+1a9cunXzyyZo6dap27NgR1jGtOe8HH3yg//znPxoyZEibPEd0buU1bm3Kr1BOcZVcHm+0ywEAABFW5XRrT6kvTGzO94UJl7vzhomGQhq8ffPNN+vxxx9vi3paNGzYME2ePFlPP/20JN/o+X79+umiiy7SQw89FPIxwZ43NzdXY8eO1dtvv63LLrtMkyZNCliR/EAYvI2GLJa6Ad4pDtkY4A0AQMyqdnpUUu1UabWrTUJErAzeDmk+zN///vdavHix//svv/xSl1xyie6//365XK5QTnlARUVFWr9+vSZNmuTfZrFYNGnSJH3xxRchHxPseY0xuuiii3T11VdrzJgxEX1u6JqMkfLLarU+t1yFFbVMegAAQAypdnqUW1qj9bnl2pRfoYLyrtMy0ZyQgsUDDzygFStWSJIqKio0ffp05eXl6cUXX9Tdd98d0QLr5ebmSpKysrICtvfs2dN/XyjHBHvehx9+WE6nU7feemvQNdfW1qqsrCzgC9ifx2u0u6RGG/MrVFrdNsEcAACEr8YVGCb2ltfK6aZrc72Qpqh55ZVX/IObP/zwQw0aNEjvv/++1q5dq6lTp+qRRx6JaJEtMcbIYmldN5Jgjmm4z8qVK/XHP/5R33zzjazW4LPYQw89pPvuu69VtaHrqnV5taOwSkkOm3qnJygpnhmkAACIthqXxz+bU62LENGSkFos8vPzlZ6eLklavHixpk2bJkkaOHCgCgsLI1ddA7169ZIk7d27N2D73r17G62l0Zpjgtnnyy+/VEFBgQYMGCCLxSKLxaLvv/9eTz31lCwWi9xud5OPf8cdd6i0tNT/tXPnztY8ZXRRVbUebc6v1I7CKtW6mUEKAID2VuPyKK+sRhvyyrUxr0L5ZbWEiiCEFCxGjRqlJ554Qt9++63mzZunqVOnSpLWrl2rkSNHRrTAehkZGRo6dGjA2A5jjJYsWaITTjgh5GOC2eeaa66RMSbg6/DDD9f1118vY4x/pqz9ORwOpaWlBXwBwSqtdmljXoV2l1TLzQxSAAC0qRqXR/llNdpImAhZSMHiscce01NPPaUxY8boJz/5iY499lhJvrUg2nJth5tvvll///vf9e6776qoqEi33367SkpKdOWVV/r3mTNnjkaNGtWqY4LZB4gGY6TCCqfW55Urv7xGXm/XHhQGAEAk1boDw0ReWa1qCBMhC6kT94knnqj8/HxVVFQEfAp/7733tun6DnPmzFFZWZmuuuoq5eXlafTo0Vq0aJEGDBgQ1jGhnBdoT16vlFdaq6JKp7JTE9Q9OT7aJQFye7xye40S4mzRLgUAglbr9o2ZKKt2qdpJiIikkNaxQGhYxwKRkhhvVXZaglIT4qJdCrqg8hqXiitdKqtxyRjJbrMoOd6uJIdNyfF2JcRZWz2pBgC0Jafbq5JqZ8yGiVhZx4JpZ4AYVO30altBlVIS7OqdnsAnxmhzTrdXJVVOFVU1nqfd7TH+GVMkyWqVkuLtSo63KdlhV2KcTVYWgQTQzpxur/93U7WTyVDaA8ECiGEVNW5trKlQ9+Q4ZaclKM4W0rApoEnGGJVVu1VU5VRFTdOz3zXF6/W9N33H1MpikRLjbQGtGqw2D6AtECaii2ABdALFlS6VVLmUlepQZoqDizaEpcblUVGlUyVVLnkiMGGAMb5plKtqPVJdj8yEOKuSHXZ/2CAUAwiVy7MvTFTVEiaiiWABdBLGSPlltSqscCo7zaGM5Hj6uSNoHq+vO1NRpbNdPuWrcXlV43KqUE5JUrzdqqS6rlNJ8Ta69wFokcvjVVm1SyWEiQ4l4sHizDPP1Ntvvx3p0wIIksdrtLukRoWVTmWnJSg9kQHeaF5lrVtFlU6VVvsGYkeL0+2tG8fhG6fBgHAA+3M3aJmoJEx0SBEPFgsXLoz0KQGEoNbl1Y7CKiU5bOqdnqCkeBoo4eP2eFVc5VJxlbPDLv7EgHAAku/3VVmNWyVVTlU5PVH9AAQH1qorjSeffLKNygDQVqpqPdqcX6n0xDhlpzvksNPFpCsyxqi81q3iSqfKa9wx98eZAeFA11EfJnwtE7H3+6ora1Ww+PWvf60ePXq0VS0A2lBptW/dgYzkePVMdcjOYNkuodbtUUmVb+yE29N5/jozIBzoXIwx/paJWPzwAz6tChbHHXecHn/8cY0bN67ZfegDC3RcxkiFFU4VVzl9M0glO+hO0gl5vUZlNb4w0ZX6Ibc0IDzZYaO1DuiAqp0eFVdFbhY6RFergsVVV12lv/71ry0GCwAdn9cr5ZXWqqjSqezUBHVPjo92SYiA+j/QxVVOeTvm0Il2xYBwoGNyeXw/lyVVTtV00HFeCE2rgsW5554rm63lT3xWrVoVVkEA2o/LbZRTXK3CylplpyUoNYEZpGKNx2tUUhcmqp38gW4JA8KB6KlfcLO4yqkKxk10WhZj+K9tL2VlZUpPT1dpaanS0tLa9bFdHq9+3FPero+J2JOSYFfv9ATWEIgBFXUDsaM9TWxnwoBwIPKqnG4V17VO0JIautQEuwZmJkflsVtz/cr8kwD8Kmrc2lhTocR4qxLibEqMsykx3qYEO5/kdgQuj9fX1anSJaebv9CRxoBwIDKcbq9Kqn3jJjrqlNZoGwQLAI1UO72qdnpVLF+XEYtFctjrwkZ8XeCg20i7qJ8ppbiS7gPRwIBwIDj1k0YUV7nqpoVGV0SwAHBAxtRfYO0bCCtJjjirEuNsAYGDriORUev2qLjSt4hdZ5omNtYdaEB4YjxBA11LZa1v3ERptYuuTiBYAAhdrctb18y9L2zE2+vCRrzV37LBmhnB8Xp9g4uLqpy+7jjo8JobEB5nsyjeZpXdZpW9/rbVws8COgVfuHaquIpumQgUkWDx+OOP6+abb47EqQDEuPpPdEur922Ls1v8ISOhrmWDvur7VDs9KqpyMrixE6hfIbw5FosUVxc24qxWxdktslutirNZArbTzRAdTf0HH8VVXWt9HLRORGaFslgsYnKpA2NWKGAfu82yb3B4XeiIt3edsOHxmrqB2MzjjsZsVovibL4WjvrQERBIbLR+oH0wA13HwKxQANACt8eo3ONWeYNPd21WS8Dg8IR4a6cbHMsfaQTD4zW+VYhbCJ0Wiy+gx9msirNa991uGEho/UAIat0elVT5Widcbn5RIXgECwAdhsdrVFHjDuhKYrVKCXE2JdUFjoS6r1hCf2S0BWN8i1y63B5JzXdNsVq1b7yH1aJ4u+/fOHtgIEHX5mnQ1YkxXghVRILF559/HonTAEAjXm+DtQXqWCyBYSMx3iaH3SqLpeN8Mttwldlypl5EFHm9Uo3XG1Trh91qrQshln2DzusDiY3Wj86ovMalkioXraiIiFYFi9mzZ2v69OmaOnWqunfv7t8+fvz4iBcGAM0xxjfgudq5f9iw1gUOe13rRvuHjRqXx7+IncfLX2nEBn/rhzyqPkDrh3+8hzWw61V9IKH1o+Orcfm6OpVU09UJkdWqYDFo0CA9+uijuvjii3XCCSdo+vTpmj59uoYPH95W9QFAUHxho25hv8rGC/slxbfdKuJer1FJtUtFlc6AsAN0Nl6vVOv1triassVSP/g8cLareJtvzFS83cp6N1Hg8Rp/l0x+T6GthDQr1K5du/TOO+/onXfe0aeffqpevXr5Q8bEiRMVHx/fFrXGPGaFAqLPYmmw1kaYC/tVOd0qqnSqpIouBEBr2G0WOexWxdv3hQ1H3VdH6tIY64wxKq91q6TSpbIafk/FsliZFSrs6WZramr0ySef6J133tG7776rkpISnXbaaZo+fbp+8YtfhHPqTodgAXRcwS7s5/Z4VVzlUkkV08QCkVa/zse+0GGVI86meJu1S01HHa76LpklVS65PaSJzqDLBIv9ff/99/7WjBUrVkTy1DGPYAHEloYL+8XbrSqrdvOpHxAl9V0bHXabHHG+MR2OON/3dK3yfehRUu370KPayYcenU2XDRZoHsECAIDIs1ktjcJGV+haZYxRWY1bJXWzz3FF13nFSrAIe7rZBQsWaMaMGeGeBgAAICQer/FNS93EjFZxdos/aOwby2GL6a5V1c59XZ2YfQ4dSdjBYubMmaLRAwAAdES+RQTdqthve33XqvoB5A2DR1Pjq6KN8V2IBay8DQAAuhxjpBqXt+4iPXARS1vdCuWOBi0c9V2t2nORwIYLbVbU0tUJHR/BAgAAoAGP1zRahLNenL1uTY64wFaOeFvkxnNUOd0qrnKplK5OiDGtDhZ33333Abc98MADoVcEAADQQfm6VnlUWRsYOurXyGmqe1Uwq5G7PF7/uImWFiAEOrJWB4ucnJygtgEAAHQVxki1rqZXJbdaG0yV2yB4xNksqqj1tU5U0tUJnUDY081aLBYGbweJ6WYBAADQWrEy3WzHm/YAAAAAQMwhWAAAAAAIW9jB4oknnohEHQAAAABiWNjB4oYbbohAGQAAAABiWdDBYu3atUGftDX7AgAAAIh9QQeL0047TRdddJGWL1/e5CxQXq9XS5Ys0ezZs3XqqadGtEgAAAAAHVvQ61isW7dODz74oKZNmya73a6jjjpK2dnZMsYoNzdX33zzjSTpiiuu0I8//thmBQMAAADoeFq9jkVFRYXee+89LV++XDt37pTFYlHfvn01fvx4TZs2TcnJ0ZljNxawjgUAAABaK1bWsQh7gTwEj2ABAACA1oqVYME6FgAAAADCRrAAAAAAEDaCBQAAAICwESwAAAAAhC2kYDF69OhI1wEAAAAghoUULHbu3KnS0tJI1wIAAAAgRoUULM4++2w999xzka4FAAAAQIwKeuXthioqKnTrrbfqtdde04gRIxQfHx9w/9y5cyNSHAAAAIDYEFKwsFqtmjVrliTJ6XTK6XRGtCgAAAAAsSWkYDFv3rxI1wEAAAAghjHdLAAAAICwhRQsvF6vnnnmGR155JFKT0/3b7/tttuUk5MTseIAAAAAxIaQgsWTTz6pxx9/XL/85S9VVlbm3z5s2DDdf//9ESsOAAAAQGwIKVj89a9/1fz58/WrX/0qYPtpp52mt956KyKFAQAAAIgdIQWL7du3+1fftlgs/u1JSUkBLRgAAAAAuoaQgsXAgQP1zTffSAoMFm+88YaGDRsWmcoAAAAAxIyQppu98cYbdfHFF+v3v/+9JGnp0qVatGiRnnzySf3f//1fRAsEAAAA0PGFFCyuuOIKOZ1O3XDDDfJ6vZo0aZIyMzP12GOP6eKLL450jQAAAAA6OIsxxoR6sDFGOTk58nq96tevn6xWlsVoSVlZmdLT01VaWqq0tLR2fWyXx6sf95S362MCAAAgfKkJdg3MTI7KY7fm+jWkFot6FotF/fr1C+cUAAAAADqBoIPFeeedJ0maN2+e/3Zz5s2bF15VAAAAAGJK0MEiJSWlydsAAAAAENYYC7QOYywAAADQWrEyxiKk0db1i+MBAAAAgBRisNi5c6dKS0sjXQsAAACAGBVSsDj77LP13HPPRboWAAAAADEqpOlmKyoqdOutt+q1117TiBEjFB8fH3D/3LlzI1IcAAAAgNgQUrCwWq2aNWuWJMnpdMrpdEa0KAAAAACxJaRgwToVAAAAABoKa+Vtt9utHTt2SJL69+8vuz2s0wEAAACIUSEN3q6trdXtt9+ubt26adCgQRo0aJC6deumO++8s827Rc2fP1/jxo3T4MGDddZZZ2ndunUROeZA+2zYsEFXXXWVDj/8cI0ZM0bXX3+98vPzI/a8AAAAgFgWUrC4/vrr9c9//lPPPPOMVq9erdWrV+uZZ57Rq6++qhtuuCHCJe7z1ltvafbs2brooov0xhtvKDU1VRMnTtTevXvDOuZA+3g8Hp111lk64ogj9Morr+jPf/6zvvnmG51yyimqqalps+cbKR6v0VdbCrV0w179kFMij5c1EQEAABBZIa28nZaWpk8++URjx44N2P7111/rtNNOa7M1Lo466igdddRR/lmn3G63evfurWuvvVb33HNPyMcEs4/X65XVui+Hbdy4UYceeqgWL16sSZMmBVV/NFbeXrR6j+77z1rtKd0XgHqkxOvyCYfohEGZ7VIDAAAAQtepV95OSkrS4MGDG20fMmSIkpKSQjnlAZWVlWnVqlU67bTT/NvsdrtOOeUUffbZZyEfE+x5G4YKSXK5XJKkuLi48J9cG1m0eo+uevXbgFAhSYUVTj30/o/6YnNBlCoDAABAZxNSsJg6daoeffRRNWzsMMbo0Ucf1dSpUyNWXEO7du2SJPXq1Stge3Z2tv++UI4J5bzGGN15550aPHhwo1abhmpra1VWVhbw1V48XqP7/rNWLTVH/e3zLXSLAgAAQEQEPY3TnDlz/Lerqqr08MMP6/XXX9eYMWNkjNG3336rzZs3a+bMmW1SqNfrlaRGM0/FxcXJ4/GEfEwo573zzjv16aefasmSJY0WB2zooYce0n333dfs/W3p661FjVoq9ldQ4dTa3aUa3bdb+xQFAACATivoYFFRUeG/3XCBPEmyWCw6+uijdfTRR0e2ugaysrIkSQUFgd13CgoK/PeFckxrz/vb3/5WzzzzjN5//30dddRRLdZ8xx136MYbb/R/X1ZWpn79+rV4TKTklwc3qDyvvFaj27gWAAAAdH5BB4toL4rXs2dPDRgwQMuXL9fPfvYz//Zly5bpjDPOCPmY1pz3vvvu0x/+8Ae99957Gj9+/AFrdjgccjgcrXqekdIzNSGo/f66dLM25ldoyohsHZKV0sZVAQAAoLMKaYxFtFx99dV6/vnn9f3338vr9erpp5/Wjh07dPnll/v3ufXWW3Xqqae26phg9nnwwQf1+OOP67333tOECRPa5wmH4ZiDM9Q7PUGWFvaxWqRat1fv/bBH17/2nW6c/50+WJOramfTXcAAAACA5sTUUtk33XST9uzZo+OOO042m01paWmaN2+ehg8f7t+nqKhIubm5rTrmQPsUFRXp7rvvVlJSks4///yAmh577LFG2zoCm9Wie88Yoate/VYWqclB3LdOGaaUBLs+WJOrFZsLtTG/QhvzN+n5ZVs18dAsTRnZS4N70ooBAACAAwtpHYtoczqdKi0tVWZmpiyWwM/ki4uL5XQ6lZ2dHfQxB9rH6/Vq9+7dTR6TkZER9BS7HWUdi8yUeP1yv3UsSqtd+mRdnj5Yk6vdDfYdlJWsKSN7aeKhWUqKj6kcCgAA0CnEyjoWMRksYlU0goXkm3p2xeYCfbezVBlJcRrRJ102a9Phyhij1btK9cHaPC3fVCB33XS0CXFWnTjE14oxpGdKs+EMAAAAkUWwQCPRChaS5PJ49eOe8lYdU1rt0uL1+fpgTa5yiqv92w/O9LViTDo0S8kOWjEAAADaUqcOFtXV1Xr66ae1fPlyFRUVNbp/2bJlrT1llxBrwaKeMUZr95TpgzW5WrapQC6P7y3jsFs1YUimpozspaHZqbRiAAAAtIFYCRYhfdx81VVX6aOPPtKMGTMOuJYDYp/FYtHIPuka2Sddv5xwiBav36sP1uRqR1GVPl6Xr4/X5WtARpKmjOylk4b2VEoCrRgAAABdTUgtFt27d9eyZcs0cuTItqip04rVFoumGGP0Y265Fq3J1bKNBXJ6fCuYx9usGj84U1NG9dLwXrRiAAAAhKtTt1gkJSW12wrS6JgsFouG907T8N5p+uWEQ7R0fb4WrcnVtsIqfbo+X5+uz1e/jCRNGZGtk4f1VGpCXLRLBgAAQBsKqcXiiiuu0KhRo3Tttde2RU2dVmdqsWiKMUYb8ir0wZpcfbZxr2rdvlaMOJtF4wb5xmKM7JNGKwYAAEArdOoWi+LiYl133XVasGCBBg8e3OhCce7cuaGcFjHOYrFoaK9UDe2VqjkTDtbSDXu1aHWuthRUasmGvVqyYa8O6paoqSN76aRhPZWeSCsGAABAZxFSsLBarZo1a5YkqbKyMqIFoXNIirfr9FG9NXVkL23K97ViLN24V7tKqvX88q16acU2nTCoh6aM7KXRB6XTigEAABDjWMeiHXX2rlAHUuV06/ONBVq0Jleb8iv82/ukJ2jKyF46eVhPdUuKj2KFAAAAHU+sdIUiWLSjrh4sGtqUX6EP1+Zqyfq9qnZ5JEl2q0XHHtJDU0f20mF902WlFQMAAKDzB4uqqip9+eWX2rFjh9xud8B9c+bMCeWUnR7BorFqp0efb9qrD9fkaX3evvp6pSVo8shsnTosW92TacUAAABdV6cOFt9//72mT5+uiooKlZSUKDs7W3l5eZKk/v37a/v27aFV3skRLFq2taBCH6zJ0+L1+apy+loxbFaLjhmYoakje+mI/t1oxQAAAF1Opw4Wp5xyio499lj9/ve/l8VikTFG27dv14UXXqjJkyfrN7/5TcjFd2YEi+DUuDxatqlAH6zJ1Y+5+2rumerQ5JG9dOqwnuqR4ohihQAAAO2nUweLbt26acuWLcrIyJDValVNTY3i4+O1YcMGnXrqqdqxY0fIxXdmBIvW215YqQ/W5OrT9fmqrPW1Ylgt0ti6Vowj+3eXzUorBgAA6Lw6dbCob6WQpOzsbH355Zc6+OCDVV5erqysLNXU1IRWeSdHsAhdrduj5ZsK9cGaXK3dU+bfnpni0OQR2TptRLYyacUAAACdUJcJFuecc44SExN1zTXX6KWXXtLXX3+tb775JrTKOzmCRWTsKKrSh2ty9emP+Sqv9U0cYLVIRw/I0JSR2RozIINWDAAA0GnESrAIaYG8u+66y3/7kUce0cyZM3X88cdrwIABmjdvXiinBILWPyNJcyYcoouPH6gvNvvGYqzeXaavtxXp621F6pEcr9PqWjF6piZEu1wAAIAuIWLrWNTU1CghgYu4ltBi0XZyiqv0wZo8ffpjnspqfK0YFkljBnTXlJG9NHZgYCuGx2u0dnepiqpcykiK04g+6bRyAACADilWWixYIK8dESzansvj1ZdbCrVoTa7+l1Pq356RFK9TR2Rr8ohsbd5boec+36LCCqf//h4p8bp8wiE6YVBmNMoGAABoVqcOFl6vV3/5y1/0/PPPa8uWLSot9V3A3Xbbbbr22mvVt2/f0Crv5AgW7Wt3SbU+XJunT9blqaTaFdQxd5w+jHABAAA6lFgJFtZQHuDJJ5/U448/rl/+8pcqK9s3Q8+wYcN0//33h3JKIOL6dEvUJScM1N8vGavbpw7T4X3TD3jM3z7fIo+XRjwAANAxeLxGq3YUa+F3u7Ric2GHvk4JqcXi0EMP1auvvqpjjjkmYIaonJwcHXXUUcrPz494oZ0BLRbR9UNOie58e/UB9zt9VLZOHNJTg7JSlBhva4fKAAAAGvtic0Gj7tu90xN07xkjNHVU73apoc1nhdq+fbtGjx4tyTf1bL2kpKSAFgygIymqCq471Pur8/T+6jxZJPXNSNKQnik6tGeKhmSnamCPZMXbQ2roAwAACNoXmwv00Ps/NtqeW1qjq179Vs9eeFS7hYtghRQsBg4cqG+++Ubjx48PCBZvvPGGhg0bFrHigEjKSIoLar8RvdOUX16rgopa7Syq0s6iKn36o68Vzm61aECPJA3pmaoh2Ska0jNV/TOSmFEKAABEjMdr9NznW5q8z8g38+V9/1mr00b06lDXICEFixtvvFEXX3yxfv/730uSli5dqkWLFunJJ5/U//3f/0W0QCBSRvRJV4+U+IDmxP1lpsTr92eNls1qUXGlUxvzK7Qxv9z3b165ymrc2ry3Upv3VmrRGt8x8XarBmUma0h2qob09IWN3t0SZLV0nB90AAAQG2pcHi3dkN/i9YqRtKe0Rl9vLdLxg3q0X3EHEFKwuOKKK+R0OnXDDTfI6/Vq0qRJyszM1GOPPaaLL7440jUCEWGzWnT5hEOabFas98sJh/iTf/fkeB1zcIaOOThDkmSM0d7y2n1hI69Cm/ZWqMrp0brccq3L3TeGJTnepsE9UzS4Z13YyE5RVoojoIUPiGWsBQMAoTPGqLDSqV3F1coprlJOSbVyiqu1q6Rae8trgz5PfnlNG1bZemGtY2GMUU5Ojrxer/r16yerlb7nLWHwdsfQ1ECozJR4/TKEdSy8xmhXSbU21bVobMyv0Ja9lXJ6vI327ZYYp8E9U+qChi9wdEuKD/v5AO2tqZ8h1oIBgMZqXB7tLvEFhpzi+vBQpV0l1apxNb5WqJcYZ1V1C/fX+9cvj2vzFgsWyOugCBYdR1t+2ur2eLWjqKquZcPXurG9sKrJ6eGyUh3+7lNDeqZocM8UJTtCakgE2kVzgwnrsRYMgK4m1NYHq0XqnZ6ovt0TdVC3un+7J+mgbolKcdh12cv/bbY7lEVSr/QELbvt5DZvLW7zWaEkadmyZVq+fLmKi4sb3ffwww+HelqgXdisFo3u261Nzm23WXVIVooOyUrRlJG+bbVuj7YWVNa1bPjCRk6x7xfO3vJafbG50H/8Qd0S/d2nhvRM1cGZyUqIY9pbRF9Lgwnr/e3zLTr24B50iwLQ6YTa+pDqsNeFhkT1rQsOfbsnqldaguy25nv7NNd9u/63671njOhwv2tDarG4//77dd999+mII45Qt27dGt3/8ccfR6K2TocWCzRU5XRrs79Vwxc28soaf7JhtUj9M5ICBocP6JGkuBZ+GQGR4DVGxZVO5ZXXKq+sRv/LKdHH6w68TtGpw3tqRO809UhxqEdyvHqkOJQcb2OMEYAOr771Iae4Wrsi1PqQnhjcrJRNibV1LEIKFj179tQ///lPnXrqqSEX2RURLHAgpdUuX6tG3eDwjfnlKm5i/Y04m0UHZyb7u1ANyU7VQd0SO9wnF+jYjDEqq3Erv6zGHx72fdUqv7xGLk9kessmxFnVI9kXNDJS4pWZ7FCPlHh/8OiRHK9uSfG8hwG0i/ZufQiHx2u0taBCVqtFPVMTdMzBGe36u7LNg0VaWpp2796tlJSUkIvsiggWCEVhRa02NBgcvim/QhW17kb7JcbZdEiWL2wcWteNKjutdTNRMdNP51PldCu/rFZ55fsCQ8PwUO3ytHi81SJlpjjUKy1BcTarvtnRuPvr/sb07y4jo8IKpwornU2+X5t7rO5J8cpMcSgjOb4ueDiU2SCAZCTH0zUQ6KQi/Teoo7U+hCM1wa6BmclReew2H2Pxk5/8RPPnz9ell14aUoEAgtcjxaHjUxw6/hDfrA/GGOWW1fhbNDbmV2jz3gpVuzxas7tMa3aX+Y9NTbDXDQqv70aVoh4pjiYfh5l+YpPT7dXe+taGuvCQ2yA8lNcc+KI+Iyle2WkOZaclqGdagv92dlqCMpPj/Z/CebymxcGEkm+Gtd9MD+z3W+PyqKjSqcKKWhVUOusCR23Av8VVTnmNVFjpCyMtSXHY97V01IeO+gCSEq+MZIfSEuxR6XpFOAdCE87foFhqfejsQmqx2LNnj0aNGqWxY8dq0KBBjX55P/PMMxErsDOhxQJtxeM1yiluMBNVXrm2FlTK3cRMVBnJ8f6QMaRnqgb3TNHq3aXM9NNBebxGhRW1/haG3P1aHooOcBEu+f54+kLDvsDgDxKpDjnswbcAtNWsUB6vUUmVL1QUVtSqsNKpgrrgUVTX8lFQUata94GnX5R83QV7NNHdqkeKQ5l13bEykuIjevFAOAdCE8zvleMP6dFpWh9CESstFiEFizlz5uiVV17R8ccf3+Tg7bfffru1p+wSCBZoTy6PV9sLqwLGa+woqlITWUNWi5rcXi8zJV5zLx7bZT95bctPoY0xKqly1bU0NB7rUFDhbHKq4oYS4qzKTt0/OCSoV5pDPVMTIj6FcSTXgmkNY4wqnR5f8Khv7ahrASmoqPW1ilQ6VVrdeFxSUyyS0pPi/OM9MpJ93bACg0i8kuIP/PoxDW/zaMVBS4JpCbVZLbJbpVp3878LO3vrQ6cOFqmpqXr//fc1fvz4kIvsiggWiLYal0eb91bUDRD3tWzsLg1u1c60BLtSE+KUEGdVQpxNDrvNd9tuk6NuW4K97r6A2759Am/77rNbLR1+pqBIfApdUeP2hYb6rwbhIb+stskFFRuyWy3qmeqo66bka23o1SBARKPbT0e+WHR5vP6Wj6K6lo768R71rSFFlc4mW/SakhhnU4+UBmM/6ls+6saAdE+M000Lvm+xC1dXDee04qCex2tUWu3y/wzW/7sxv1zf7SwN6hydpfUhFJ06WPTq1UsbN25UampqyEV2RQQLdEQfrMnVM4s3ReWxrRbVBZLAcOKIaxhamgop+4WbOJscdfvU33bYrWFfbAf7KXSNy9N4YHR53cxKZTWqdB54gHSPFId6pjrqWhoCxzlkJMfL2sEDWKzxGqOyapcKKpwqqmzQ9ar+oqfuwqfqAP93rTHx0Ez1SU+U3WaV3WqR3WaR3WqVzWpRXFO3bRbZrRbF2ax1n9gG3m7qPB0JrThdh9Pt3RfiG4SGworaui6NvnFUB2p9bcml4wbqjMP6dIrWh1DESrAIqX180qRJevnll3X11VeHVCCAjqNPekJQ+109aZD6dk9SjdujWpdXNS6Patx1/7o8qnF5Veve/7Z3v/19t+s/KfYaqcrpiejFWz2L5A8izbWaOJprZbFbFW+36s8HCFyPfbBeSfGbVBbEAOluSXHKTg0MDPWtD5kpDtYlaWdWi0XdknxT3ErNz3BY7fQ0Gmhe2OACqqjCqaKqA49zkaSlGwoiVH3TLJI/ZNQHDpvVWhdULL5AYrMqrsHthsHEXrdfs7frQk1cg9sBYcdmUVxdwLFapb8s2dxivSym2PEZY1RZu+9noKD+Z6BBAC+oqA1qkgjJ9yFKt6R4f2tfj+R4uTxefbA274DHDs5K6bKhIpaEFCycTqeuueYazZ8/X4MHD270qeDcuXMjUhyAtjeiT7p6pMQfcKaf00b0itgFgNvjVY3bq1rXvvBR46oLIO4WQkpdmKmtDzP1t+sCTq3L6+9WZKS6Y7xSdUTKbvw8vMYfKpLjbY0GRtd/9Ux1MEVqjEqMt6lvfJL6dk9qdp/vdhTrN/9ec8BzHX9IhrolxcvtNfJ4jNxer1weI4/Xd9vtMXLvf9vjrdvW+Pb+H/4aSS6PkcsT+aDeFgoqnLr2X9+qZ1qCkuPtSnbYlOKwK8VhV3KDf5PjbQHfd5YgEu3uhC1NmNAwPAQ7YUK8zeqfLCEzxeGfoc0fIlLi1b2JtWo8XqOVO4oP+DdoRJ/0sJ4v2kdIwSI+Pl6zZs2SJFVWVka0IADty2a16PIJh7TYZeGXEw6J6B88u82qFJtVKREeVCz5/kjVuvcLKfu1rtS6WwgpdcftLavVnrIDjz+56LgBmja6d5s8F8SG0X27BRXOb5s6PKI/R17jCyUujy+IeLxGLq/XF1Q8wQcUd8P9627Xhx1/8PF45fLuu93s8XWPV17rUln1gT/F3llcrZ3FrUv+iXH1QaNB4KgLJgGBpO52isNWd79difG2DtGtsK3HntS6PY3GFdV3S6rvslQ/xXMwUh12X2iom9Sg4Voz9ZMfpDhCG+sVjb9BaDshjbFAaBhjgY4sWjP9dFQ/5JTozrdXH3C/3585SqP7dmv7gtChMZ4gULA/P7OP7a/MZIcqnG5V1rpVUev7t7LWo0qnWxU1blU6fd8faDHHYFgtUlKD1pHkulCSklD3bzPhpL7VpD3HbjXFGKOKWneTLQv+8UIVTpW3YlHKjLp1YHyzogVOzVw/M1prpqQOFX+DWtapx1gA6HxOGJSpYw/u0WFn+mlvwXYRo3keku/n547Th3FhVCfYn5+ZY/oF/TvG7fGq0umpCx51IaTu+4pa937/1u3n9G2rqHHL7TXyGvm+r3UrT82vfdAcu9XSIHTsF06a2J7SILgk1bWWPPf5lhYf49klm+XxGhVXNZ5BqbDCecBZ5Oo57NZ9rQtNrGLfI9k3xqij/I7nb1DnQItFO6LFAogtfAqN1op2v/mOpKP9/Djd3gYtIm5/wNg/nOy7HdhqEsaERn5xVotcEThRWoK9yZaF+m5JPVIcSo63dfjpvBG8WGmxIFi0I4IFEHtongdC11l+fowxqnZ59gWO+taQBl21KpoKJ3WhpbUz3/VJT9DBmcmNwkP92IZ4O7MjdTWxEizoCgUALaB5HghdZ/n5sVgsSoq3+1ZhD2EJL4/XqMrp1rfbi/X4RxsOuP81Jw1m7BZiEsECAA7AZrXwRx4IET8/vtcgNSFO44dk6YUV2xi7hU4rpGBx5plnNnufw+HQIYccogsvvFAjR44MtS4AAIBOhalV0dmF1EkvLS1NCxcu1NatW9W9e3dlZGRoy5YtWrhwoYwxWrp0qY488kh99tlnka4XAAAgZtXPINYjJT5ge2ZKPBNCIOaF1GLhdrv14IMP6s477wzY/sADD2jdunX64osv9MADD+iOO+7Q8uXLI1IoAABAZ9BZxp4A+wtpVqiePXtq48aNSk8P7ANYUlKioUOHKi8vT/n5+RoyZIhKS0sjVmysY1YoAAAAtFaszAoVUlcop9OpdevWNdq+du1a1db6FpxxuVzKzKQ5DwAAAOgKQuoKdeGFF2rGjBm66667dPTRR8sYo2+++Ub333+/LrroIknSK6+8ogsuuCCixQIAAADomEIKFk888YQyMzN11113qbi4WJLUvXt3XXfddbrrrrskSaeddpoOP/zwyFUKAAAAoMMKe+Xt3NxcWSwWZWdnR6qmTosxFgAAAGitWBljEfYCeb169Qr3FAAAAABiXEjBorq6Wk8//bSWL1+uoqKiRvcvW7Ys7MIAAAAAxI6QgsVVV12ljz76SDNmzNBRRx0V6ZoAAAAAxJiQgsXChQu1bNkyjRw5MtL1AAAAAIhBIa1jkZSUpH79+kW6FgAAAAAxKqRgMX36dL300kuRrgUAAABAjAqpK1RxcbGuu+46LViwQIMHD5bFYgm4f+7cuREpDgAAAEBsCClYWK1WzZo1S5JUWVkZ0YIAAAAAxJ6QgsW8efMiXQcAAACAGBbSGAsAAAAAaCjoFovzzjtPkq+1ov52c2jRAAAAALqWoINFSkpKk7cBAAAAwGKMMdEuoqsoKytTenq6SktLlZaW1q6P7fJ49eOe8nZ9TAAAAIQvNcGugZnJUXns1ly/MsYCAAAAQNhCmhWqurpaTz/9tJYvX66ioqJG9y9btizswgAAAADEjpCCxVVXXaWPPvpIM2bM0FFHHRXpmgAAAADEmJCCxcKFC7Vs2TKNHDky0vUAAAAAiEEhjbFISkpSv379Il0LAAAAgBgVUrCYPn26XnrppUjXEpTPPvtMs2bN0qRJk3Tttddqz549ETkmUvsAAAAAXVFIwaK4uFjXXXedJk6cqMsuu0xz5swJ+Gorixcv1imnnKIhQ4bolltu0caNGzVu3DiVlzc/jWowx0RqHwAAAKCrCmkdi2itvD1u3Dj169fPf/6qqir17t1b99xzj2666aaQj4nUPgfCOhYAAABorVhZxyJmFsirqqpSamqqXn75Zc2ePdu//ZxzzlFVVZXef//9kI6J1D7BIFgAAACgtWIlWMTMAnk7d+6U1+tVnz59Arb36dNH27dvD/mYSO3TlNraWpWVlQV8AQAAAJ1RzCyQ53K5JEkOhyNge2Jiov++UI6J1D5Neeihh3Tfffc1/6QAAACATiJmFsjLyMiQpEZBprCwUD169Aj5mEjt05Q77rhDN954o//7srIypukFAABApxQzC+T16dNHvXr10n//+19Nnz7dv/2rr77ShAkTQj4mUvs0xeFwNGrlAAAAADqjmFog79JLL9XcuXO1a9cuSdIbb7yhtWvX6tJLL/Xv89BDDwUMsA7mmEjtAwAAAHRVIbVY1C+Qd+2110a6nhbdc8892rhxowYPHqx+/fopJydHzzzzjMaOHevfZ/Pmzfr+++9bdUyk9gEAAAC6qpCmmz333HP1+uuv68QTT9TgwYNlsVgC7p87d27ECmzK7t27lZeXp8GDBys1NTXgvi1btqiiokKHHXZY0MdEep/mMN0sAAAAWitWppsNqcXCarVq1qxZkqTKyspQThGWPn36NJr6td4hhxzS6mMivQ8AAADQ1YQULNpqZW0AAAAAsSlmFsgDAAAA0HGF1GJx5plntnj/22+/HcppAQAAAMSokIJF3759A773er3auHGjPv7444CpXgEAAAB0DSEFi2eeeabZ7atWrQqrIAAAAACxJ6JjLC6++GK9//77kTwlAAAAgBgQ0WCxZcsWOZ3OSJ4SAAAAQAwIqSvU7bff3mhbcXGx3nrrLc2YMSPsogAAAADElpCCxcqVKxtt6969u+6++25deeWVYRcFAAAAILaEFCw+/vjjSNcBAAAAIIaFNMZi9OjRka4DAAAAQAwLKVjs3LlTpaWlka4FAAAAQIwKKVicffbZeu655yJdCwAAAIAYFdIYi4qKCt1666167bXXNGLECMXHxwfcP3fu3IgUBwAAACA2hBQsrFarZs2aJUlyOp2sXQEAAAB0cSEFi3nz5kW6DgAAAAAxLKIrbwMAAADomggWAAAAAMJGsAAAAAAQNoIFAAAAgLARLAAAAACEjWABAAAAIGwECwAAAABhI1gAAAAACBvBAgAAAEDYCBYAAAAAwkawAAAAABA2ggUAAACAsBEsAAAAAISNYAEAAAAgbAQLAAAAAGEjWAAAAAAIG8ECAAAAQNgIFgAAAADCRrAAAAAAEDaCBQAAAICwESwAAAAAhI1gAQAAACBsBAsAAAAAYSNYAAAAAAgbwQIAAABA2AgWAAAAAMJGsAAAAAAQNoIFAAAAgLARLAAAAACEjWABAAAAIGwECwAAAABhI1gAAAAACBvBAgAAAEDYCBYAAAAAwkawAAAAABA2ggUAAACAsBEsAAAAAITNHu0CAKAjsFgku82iOJtV8Tar/7bvyyKb1SK3x8jl8crp9qrW7ZXT45XL45XLbaJdPgAAUUewANDpWSzyB4SGYcG+X4g4EEczvzGNMap1e/2hw1kXNpwej2rdXnm9EX5CADosi2Xfv1aLRRaLZFH9v/X37/ve5TFyuvklgc6BYAEgplksUrzdKrs1MDTE2a2Ks+4LEG1bg0UJcTYlxNmavN/j9V04ON1e1Xo8/gsJZ10YMTR4AE2yWBR4Yd7oIn2/22pi/6a2S1L9hX8Tx/rua/rY+rqaPLZ+h1byeo2cHq9qXV7VuD2qdXlV6/Z9MMHvB8QSggWADqs+NMTZfMHBf9tmUXxdiLBZQ/tD3p5sVosS421KjLdJimt0/75WDt+/zgb/uj1cVaBjslh8722b1SKrxfevzWKR1aoGty2yW33/Wpu78N8/KCi8i/RYZLValGD1fTiRvt/viPqA0TBs1Lg8tISiQyJYAIgKq1V13ZB8rQoNb8fFUGiIhHi7VfF2q+RofF/9J5n+wNGgyxWfZiIc+wcAW304aCEs1G+zWy1d6sI/mhx2mxx2m5QQuN3l8dYFjrrg4fYFD8Z8IZoIFgAizma1+ANCw9aFhoOjrV0kNISr4SeZTXF7Grdy1N92ewzBo5Oq74pjt7XcWmCzWGSz7QsN1gbhAbGt/gOYlP0Gf3m9xh8yahq0cjj5IALtgGABoFVsVovi7RbZrVbfOAabxTeWocFtQkP7sde19CTFN77PGFM3c9W+MR0NA4jHy1VGtITTWlB/DNAUa0DXy33qJ5moDx2+rlW+23SrQqQQLAD4Wa2Sw271fxJm36+LUrzNSveHGGKxWOq6UajJblYer/F3p2jYxao+eHSVTzcbDsi1yHchX9/v37rfDD6NZvlpMMB3333yh4X6MQa0FiDaAieZCBzH4XTva9lo2L2KMV5oLYIF0MXYbb5B0PE2qxx1ffvrv2/r2ZPQsfgucpvvZuXab1xHqGt3tMWF+/6zAVkbzOYTOGOP73bA4zc8Jxf5gP/vQOp+2z1e42/dqGnQysFsdmgOwQLoZOrXbGgYGOLtdSGCsQ1ohfqWq+QmWjvqu1V4jeHCHeikbFaLkuLtjbpa+rtVuRq2dPjGdBA4ujaCBRCDLBY1am1oeJvuSmhr9d0qAHQ9LXWramp63FoXY7q6CoIF0EHZ6tZtcDQRHoJZJRoAgPbW3PS47vrpcevW4WB63M6JYAFEUZzdEhAYHDab/zYDPAEAnUX9DHb7d61sOD1ufeuG0+OVMUZGktf4ps2u/5euVh0bwQJoQ/UrRwd0VWowcJouSwCArqy56XFbYhqGDQWGDiMjr/Ht4zWSmtyv+dDS6Hj/9w32EyGnOQQLIEz1U7TGN2htaBgmAABA5NTPCmdV9D+c83obhxTt972RZLyNQ4uRqQs+gfeZhkGm7lyOuNi4niBYAEFgilYAALC/+pnvbB0g5HQEBAtATNEKAAAQLoIFuiSb1aK0RLvSEuP84YHxDgAAAKEjWKDLsFqltIQ4pSfFKdVhJ0gAAABEUMwFi40bN2ru3LnKy8vT6NGjdeWVVyo5OTnsYw60j8vl0vz58/XFF1/Ibrdr/PjxmjFjBhenHZzFIqUnxiktMU5pCYQJAACAthJTo06/++47HXnkkdq9e7fGjh2rf/zjHzrxxBPldDrDOuZA+3i9Xo0YMUKLFi3SyJEj1a9fP11//fWaOXOmDHONdTgWi5SWaFf/jCSN6J2mfhlJSk+MI1QAAAC0IYuJoSvjqVOnymKx6P3335ckFRQUqH///nriiSd0xRVXhHzMgfYxxignJ0f9+vXzn3f58uUaP368Vq5cqTFjxgRVf1lZmdLT01VaWqq0tLSQX4dQuDxe/binvF0fsz1ZLFKKw+5vnWBxOQAAgPC15vo1Zlosamtr9cknn2jmzJn+bZmZmTr55JP17rvvhnxMMPtYLJaAUCFJAwYMkCQVFRVF5gmi1SwWKdlh00HdEzWsV6oGZiare3I8oQIAACAKYmaMxY4dO+R2u9W/f/+A7QMGDNDSpUtDPiaU80rSM888o/T0dB1zzDHN7lNbW6va2lr/92VlZc3ui+AlOWzqVtcyEccaEgAAAB1CVIPFs88+2+LFuyQ99dRTys7OVk1NjSQpJSUl4P6UlBT/ffsL5phQzvvGG2/oscce06uvvqr09PRma3/ooYd03333NXs/gpcYb1N6YpzSE+NYzRoAAKADimqwOOqoo9S9e/cW96mfman+An7/rkeFhYXq1q1bk8cGc0xrz/vuu+9q9uzZeuqpp3T++ee3WPsdd9yhG2+80f99WVlZoy5VaF5CnFXpSb4w4bDbol0OAAAAWhDVYHHsscfq2GOPDWrffv36qVu3blq9erWmTZvm3/7DDz9o9OjRIR/TmvO+9957Ouecc/TYY4/pmmuuOWDNDodDDocjqOcHH0ec1d8ykRBHmAAAAIgVMdOnxGKx6IILLtDzzz/vH6uwbNkyff3117rwwgv9+z333HO65ZZbgj4m2PMuWrTIHyquvfbadnnOXUW83aqsVIeGZKfo0OxUZaclECoAAABiTExNN1tSUqKpU6cqJydHI0aM0BdffKGrr75ajzzyiH+fOXPm6Msvv9Tq1auDPuZA+5SXl6tnz55KTU3VySefHFDTFVdcoZNOOimo+pludp84u8XfMpEUHzNzCAAAAHQprbl+jalgIfkWq/vyyy/9K2QPHjw44P7//ve/Kiws1NSpU4M+5kD7OJ1Ovfnmm03Wc8wxx+iQQw4JqvauHizsNovSEuPULTFOyQ7CBAAAQEfXqYNFLOuKwcJmtSgt0bdwXYrDzurXAAAAMaQ11698bIyIs1qltIQ4pSfFKZUwAQAA0CUQLBARFktgmLCy+jUAAECXQrBAyCwWKTXB180pLSGOMAEAANCFESzQKhaLlOywq1tinNIS42QjTAAAAEAECwQp2WFTt6R4pSXYZbfFzPInAAAAaCcECzQryWHzrzURR5gAAABACwgWCJAYb1V6YrzSE+MUbydMAAAAIDgECyghzuprmUiKk8Nui3Y5AAAAiEEEiy7KUR8mEuOUEEeYAAAAQHgIFl1InN2ibnXdnBLjCRMAAACIHIJFFxFns2pYr5aXYQcAAABCxehcAAAAAGEjWAAAAAAIG8ECAAAAQNgIFgAAAADCRrAAAAAAEDaCBQAAAICwESwAAAAAhI1gAQAAACBsBAsAAAAAYSNYAAAAAAgbwQIAAABA2AgWAAAAAMJGsAAAAAAQNoIFAAAAgLARLAAAAACEjWABAAAAIGz2aBfQlRhjJEllZWVRrgQAAAA4sPrr1vrr2JYQLNpReXm5JKlfv35RrgQAAAAIXnl5udLT01vcx2KCiR+ICK/Xq927dys1NVUWiyXa5XR5ZWVl6tevn3bu3Km0tLRol4MOjvcLgsV7BcHivYJgRfO9YoxReXm5+vTpI6u15VEUtFi0I6vVqr59+0a7DOwnLS2NX+gIGu8XBIv3CoLFewXBitZ75UAtFfUYvA0AAAAgbAQLAAAAAGEjWKDLcjgcuvfee+VwOKJdCmIA7xcEi/cKgsV7BcGKlfcKg7cBAAAAhI0WCwAAAABhI1gAAAAACBvBAgAAAEDYCBbokrxer9avX6/169fL5XJFuxzEgJqaGi1btkw//vhjtEtBB7dx40atWbNGXq832qWgA8vNzdXKlSu1bdu2aJeCDmbTpk1atmyZPB5Ps/usX79e3377rWpra9uxsgMjWKDLefzxx9WvXz/97Gc/07Rp09SvXz8tWLAg2mWhg7vuuus0ceJE3X333dEuBR3UypUrNXLkSJ144om6+OKLdeSRR2rt2rXRLgsdTFlZmU4//XQNGTJEV155pY4++mgdfvjh2rRpU7RLQ5S98847mjRpko455hhNmDBB5eXljfbZs2ePjj76aB1//PGaOXOmDjroIL3//vtRqLZpBAt0OaWlpVq1apV+/PFHbd68WTfffLNmz56trVu3Rrs0dFCvv/66Vq5cqVNOOSXapaCDysnJ0WmnnabJkydr165d+uabb/T6669r9+7d0S4NHcwjjzyi7777Tlu2bNHKlSuVk5OjpKQk3XDDDdEuDVG2Zs0a3XvvvXr55Zeb3efSSy+Vw+HQ7t27tXnzZl1zzTU677zzVFBQ0I6VNo9ggS7n/vvvV8+ePf3fX3XVVXI6nfrvf/8bxarQUW3dulXXX3+9/vGPfyg+Pj7a5aCD+uMf/6jk5GQ9+uijslp9f1oPPfRQnXrqqVGuDB3N3r17NXjwYGVlZUmSEhISNGbMGO3duzfKlSHabrvtNp100knN3r9792598MEHuvXWW5WQkCBJuuWWW+RyuTpMzwt7tAsAoq0+UAwePDjKlaCjcbvdOv/883X33Xdr+PDh0S4HHdgnn3yiqVOnSpK+/fZbZWRkqH///v6QAdS7/vrrNWXKFP3ud7/TuHHjtH79ei1YsEAvvPBCtEtDB/fdd9/JGKMxY8b4tyUnJ2v48OFatWpVFCvbh2CBLq20tFRXXnmlpk2bpqOOOira5aCDueuuu5SZmalf/epX0S4FHdzu3btVWlqq4cOHKyUlRbt371avXr30r3/9SyNHjox2eehAhg4dqksuuUR//OMf9fbbb2v79u2aNm2aTjjhhGiXhg6uqKhIktSjR4+A7T169PDfF218lIIuq7KyUtOnT1diYqJeffXVaJeDDmblypV66qmndOmll2rZsmVatmyZiouLVVhYqGXLlnW4mTgQXXFxcXr33Xf15ptv6rvvvtPOnTvVt29fXXjhhdEuDR3MLbfcon/+85/asGGDvv32W+Xk5Cg3N1fnnHNOtEtDBxcXFyfJN0thQ9XV1R2mqy4tFuiSKisr9ZOf/ERlZWX69NNP1b1792iXhA6murpaRx99tP74xz/6t61bt05Wq1W33367FixYoF69ekWxQnQkAwcOVGJiog477DBJksPh0C9+8Qude+65Ki8vV2pqapQrREfxzjvvaObMmf6xfomJibr00kt1wQUXqLKyUsnJyVGuEB3VgAEDJEm7du0KuG7ZtWuXxo8fH62yAhAs0OVUVVXpJz/5iYqLi/XJJ580alIEJGnChAlatmxZwLbp06crISGhwwySQ8cxZcoUzZs3T8YYWSwWSfLP9sOFIhrKyspSTk5OwLadO3cqOTlZSUlJUaoKsWDMmDHKyMjQv//9b40aNUqS9MMPP2jr1q067bTTolydD8ECXYrX69VPf/pT/e9//9MLL7wQsNjZIYccoj59+kSxOgCx6rrrrtMLL7ygSy+9VOedd562bNmi+++/X7fccgsDuBHg2muv1YUXXqhDDjlEJ554otatW6cHH3xQ11xzjT+UomvaunWrdu3a5V//5ssvv1RKSopGjBihjIwMxcXF6f7779dNN92klJQU9e7dW/fee68mT56sk08+OcrV+1iMMSbaRQDtxel0NvvDd/3112vmzJntXBFiyW233ab4+Hjdf//90S4FHdCePXv06KOP6ocfflDPnj119tlna8aMGdEuCx3QkiVL9NJLLyknJ0dZWVk644wzdN555xEsurinn35a8+fPb7T9oYce0oQJE/zfv/766/rHP/6hqqoqnXjiibrxxhs7TGsXwQIAAABA2GifBQAAABA2ggUAAACAsBEsAAAAAISNYAEAAAAgbAQLAAAAAGEjWAAAAAAIG8ECAAAAQNgIFgAAhMnr9WrevHkqKCiIWg3z589XXl5e1B4fAAgWAIA253a7NW/ePBUWFka7lDbhdDp1/vnn68cffwz6mEi/JhdccIF++OGHiJwLAEJBsAAAtLmamhqdf/752rhxY7RL6TB4TQB0NvZoFwAACM8nn3yi7Oxs9erVS1999ZVSUlI0ceJESVJFRYVWrFght9utww8/XH369PEf53Q69eabb2rq1KkqLCzUunXrdNBBB+nII49s9BgtnUeS3nzzTTmdTtlsNvXr109HHnmkHA6H//633npLkvTxxx9r27Zt6t69u6ZMmSKPx6OvvvpKRUVFGj16tAYMGNDic62qqtKXX34pp9Op4447Tt26dQu4Pz8/X1999ZXsdrtOOOEEpaenN/l8i4qKtHbtWvXu3VtjxoyRJK1Zs0abNm3SsGHDNHTo0JBep9a8bs29JsG83pK0ZcsW/fDDDzr44IM1evToA9YCAG3OAABi2rhx48z48ePNgAEDzPTp081DDz1kjDHm3XffNT169DDjx483p59+uklPTzePPvqo/7i9e/caSeb00083Bx98sJkyZYpJSUkxl112WcD5D3QeY4y59NJLzaxZs8yMGTPMsGHDzJAhQ8zGjRv9959//vlGkjn11FPNrFmzzG233Wby8vLMsGHDzLBhw8xPf/pTM3jwYHPrrbc2+zzfeecdk5GRYUaNGmWmTJliBg0aZJYsWeK///nnnzeJiYnmxBNPNGPHjjXp6enmvffea/R8J06caIYPH26mTZtmEhISzBVXXGEuu+wyM3LkSHP66acbh8Nh/vKXv7TqdaqurjaSzOeffx7069bUaxLs6/2nP/3JOBwOc9JJJ5nDDz/cTJkyxVitVvPRRx81+/oBQFsjWABAjBs3bpzJyMgwu3bt8m/bvXu3SUlJCbiwXr16tUlMTDTffvutMWbfBfO4ceNMdXW1McaY//3vfyYuLs5/XDDn2Z/X6zUXX3yxmTFjhn9beXm5kWRWrFjh3/b444+bI444wng8Hv9xb731VpPn3Llzp0lMTDQPPPCAf1thYaH/Qr7+/rlz5/rvv/32203v3r1NRUVFwPM9//zz/Y/5yiuvGEnmF7/4hfF6vcYYY5599lmTkZHhP08wr9P+wSKY162p1ySY43bu3GkcDof517/+5d9nzpw5RhLBAkBUMcYCADqBc845J6C7zOuvv66kpCRVVlbq9ddf1+uvv641a9aod+/eWrJkScCx1157rRISEiRJo0eP1umnn6758+e3+jzr16/Xu+++q9dee00ZGRn6+uuvW6w5MTFRxcXF2rFjhyTJYrHozDPPbHLf119/XcnJybr99tv92zIyMjR+/HhJ0sKFC5Wenq5LL73Uf/+dd96pvLw8LV26NOBcv/zlL2W1+v78HX/88f5tFovFv62oqKjRDE8tvU5N1Rvs69ba495++21lZWXpvPPO8x936623NntOAGgvjLEAgE6gd+/eAd9v27ZNXq9XCxYsCNg+duxY9erVK2DbwIEDA74/+OCD9b///S/o87hcLp199tlatmyZxo4dq27duik3N1f5+fkt1vyLX/xCX3/9tUaOHKlDDz1Up512mq6++uomx1ns2LFDhxxyiGw2W5Pn2r59uwYOHOgPB5KUmpqqrKwsbd++PWDf7t27+2/XjwNpaltNTU3AcS29Tvtrzevf2uN27NjRZC0AEG0ECwDoBBpeUEtSWlqaEhMTNW/evAMeW1xc3Oj7zMzMoM/z+uuv6+uvv9bWrVv9g6lffPHFoFosXnzxRT377LNasWKFnn32WR111FHatGlTwIW+JHXr1q3FaVkzMzNVVFQUsM0Yo5KSEv9zCVdLr9P+WvP6t/a4Hj16NFkLAEQbXaEAoBOaOnWqdu7cqXfffTdge1VVlUpKSgK2vf322wH3L1q0SOPGjQv6PLm5ucrOzg6YoemNN94I2D8xMVE2my2gFWDXrl3++04++WT95S9/UVFRUZPTr06ePFmbN2/W8uXLA7bv3btXkjR+/Hht3LgxYB2Hf//73/J6vTrmmGManS8ULb1O+wvmdWvqNQnmuPHjx2vdunXasGGD//4333wzjGcGAJFBiwUAdELHHXecbrzxRs2cOVPXXHONhg4dqk2bNumNN97QW2+9FRAC3nzzTVmtVh1++OF68cUXlZaWpssvvzzo85x++um64447dNVVV+noo4/WO++8o2XLlgXUY7PZdMQRR+gPf/iDdu3apczMTK1atUrvvfeepk+frqysLM2fP19Dhw7VYYcd1uj5HH/88frVr36ln/zkJ7ruuut00EEH6d1339X06dN1+eWX64QTTtB5552nadOm6aabblJ1dbUeeeQR3XLLLQecwjZYLb1Oobz+Tb0mU6ZMOeBx48aN0xlnnOHft7CwUHPnzm3UagUA7Y0WCwCIcaeeeqpGjRrVaPsf/vAHvfPOO3I6nVq+fLm6d++uzz77TCNHjgzY780339SgQYP09ddf67TTTtOKFSuUmJgY9HmGDx+uL774QvHx8fr888910kkn6T//+Y/OPffcgMd54403NGrUKL3//vtavHixbr/9dj300EMqKCjQ8uXLNXnyZK1YscI/QHp/f/7zn/Xqq6+qoKBA33//va644oqAC/tXXnlFDz74oH744Qdt27ZNL774oh588EH//Q6HQ7NmzQroZpWUlKRZs2YpLS3Nvy0tLU2zZs1SUlJS0K+TzWbTrFmzlJWV1arXf//XJNjjXnvtNV1//fX69ttvZbFYtGLFCp1//vktjt8AgLZmMcaYaBcBAGh/BQUFysrK0qpVq3TEEUdEu5wOi9cJAIJDiwUAAACAsBEsAKCLaqprEBrjdQKA4NAVCgAAAEDYaLEAAAAAEDaCBQAAAICwESwAAAAAhI1gAQAAACBsBAsAAAAAYSNYAAAAAAgbwQIAAABA2AgWAAAAAMJGsAAAAAAQtv8Hi55mHOZh/oAAAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "if this is flat by the last couple of repeats, 10 repeats was enough - if still visibly moving, increase N_OUTER_REPEATS in the config cell and re-run Stage 2.\n" + ] + } + ], + "source": [ + "running_rows = []\n", + "for completed_repeats in range(1, N_OUTER_REPEATS + 1):\n", + " so_far = rotation_metrics[rotation_metrics[\"repeat_id\"] < completed_repeats]\n", + " running_rows.append(\n", + " {\n", + " \"repeats_completed\": completed_repeats,\n", + " \"brier_running_mean\": so_far[\"brier\"].mean(),\n", + " \"brier_running_std\": so_far[\"brier\"].std(),\n", + " }\n", + " )\n", + "running_convergence = pd.DataFrame(running_rows)\n", + "\n", + "fig, ax = plt.subplots(figsize=(8, 5))\n", + "ax.plot(\n", + " running_convergence[\"repeats_completed\"],\n", + " running_convergence[\"brier_running_mean\"],\n", + " marker=\"o\",\n", + ")\n", + "ax.fill_between(\n", + " running_convergence[\"repeats_completed\"],\n", + " running_convergence[\"brier_running_mean\"]\n", + " - running_convergence[\"brier_running_std\"],\n", + " running_convergence[\"brier_running_mean\"]\n", + " + running_convergence[\"brier_running_std\"],\n", + " alpha=0.2,\n", + ")\n", + "ax.set_xlabel(\"repeats completed\")\n", + "ax.set_ylabel(\"running mean brier (+/- 1 std)\")\n", + "ax.set_title(\"Outer-eval Brier: running mean/std vs. repeats\")\n", + "plt.tight_layout()\n", + "plt.show()\n", + "\n", + "print(\n", + " \"if this is flat by the last couple of repeats, \"\n", + " f\"{N_OUTER_REPEATS} repeats was enough - if still visibly moving, \"\n", + " \"increase N_OUTER_REPEATS in the config cell and re-run Stage 2.\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "distribution-md", + "metadata": {}, + "source": [ + "## Metric distribution across rotations\n", + "\n", + "One point per outer rotation (50 points), not a single bar with error\n", + "whiskers - shows both typical performance and how much it swings\n", + "rotation to rotation." + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "distribution-plot", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_234627/1693650207.py:4: MatplotlibDeprecationWarning: vert: bool was deprecated in Matplotlib 3.11 and will be removed in 3.13. Use orientation: {'vertical', 'horizontal'} instead.\n", + " ax.boxplot(rotation_metrics[metric_name], vert=True, widths=0.4)\n", + "/tmp/ipykernel_234627/1693650207.py:4: MatplotlibDeprecationWarning: vert: bool was deprecated in Matplotlib 3.11 and will be removed in 3.13. Use orientation: {'vertical', 'horizontal'} instead.\n", + " ax.boxplot(rotation_metrics[metric_name], vert=True, widths=0.4)\n", + "/tmp/ipykernel_234627/1693650207.py:4: MatplotlibDeprecationWarning: vert: bool was deprecated in Matplotlib 3.11 and will be removed in 3.13. Use orientation: {'vertical', 'horizontal'} instead.\n", + " ax.boxplot(rotation_metrics[metric_name], vert=True, widths=0.4)\n", + "/tmp/ipykernel_234627/1693650207.py:4: MatplotlibDeprecationWarning: vert: bool was deprecated in Matplotlib 3.11 and will be removed in 3.13. Use orientation: {'vertical', 'horizontal'} instead.\n", + " ax.boxplot(rotation_metrics[metric_name], vert=True, widths=0.4)\n" + ] + }, + { + "data": { + "image/png": "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3KOFm7OUbSp20sgBAAAAAKh/FDYAANVSXl6ucePGqVOnTjp58qROnDgho9Gom266qUY59vY7bdo0zZkzR6tWrVJ2drZ++umnSxZVgIbIbDZr6tSpiouL0+LFizVo0CD5+Pho0KBBWrx4seLi4jRt2jSZzWZJ54oac9fu1/Idudp//LSW78jV3LX7KW4AAIAmyWQyacKECQoLC1PHjh31yCOPqLj44v/usSfHnphNmzZp7NixateunTp27KjExET99NNPtX6NAADHVauwYbFYZDKZdPbsWYfyLvXgsTcGAOB8q1at0u7du5WcnCx3d3c1a9ZMzz33nL799lv9+OOP1c6xJ+aXX37R7Nmz9fLLL6tv376SJD8/Pz355JP1c/FALUpPT9fBgwf15JNPysXF9p9mLi4uSkpK0oEDB5Seni5J2rAvT0dMxeoS7Kv2LZupS7CvjpiKtWFfnjOGDwAAUKcSExP1ww8/KC0tTZ988om++OILTZgwocY5l4o5duyYRo0apTZt2ig9PV0rVqyQxWLRlVdeqZKSkjq5VgCA/RwqbJw8eVKvvvqqOnfurObNm+s///mPXXlvvPGGWrVqJV9fX7Vt21YfffRRtWIAAA3Hd999p6CgIEVERFjbhg4dKoPBoO+//77aOfbE/Pe//1VFRYXi4+NVWFio3NzcurhEoF7k5ORIknr27Fnl++fbz8dlm0rk7W6Ui8EgSXIxGOTtblS2iT+wAQBA07Jp0yatXLlS77zzjnr16qUrrrhCf//73/XRRx/p0KFD1c6xJ+bHH39UYWGh/vKXv6h9+/bq1KmTnnjiCeXm5mrfvn319hkAAKrmUGFj4cKFys3N1VdffWV3zqJFizRt2jS99957Ki4u1vPPP6877rhD69evdygGANCw5OXlqWXLljZtrq6u8vf3V15e1b8ctyfHnphffvlFgYGBmj59usLCwtSjRw+1bt1aH3744QXHW1paqoKCApsDaAhCQkIkSZmZmVW+f779fFxogKeKysyqsFgkSRUWi4rKzAoN8KyH0QIAANSf9PR0+fv7q3///ta2q666SpIu+J2RPTn2xPTv318hISH65z//KbPZrNLSUs2fP1/du3dXp06davEqAQDV4epI8KWm+lVl1qxZio+P1/XXXy9Juuuuu/T+++/rjTfe0NChQ+2OAepC9q9Ld2SbShQa4KlB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zGazQ+cHANQdChsAAAAAAABokH766SdFRUWpW7du6tixowYPHqzDhw/XOMeeGLPZrCeffFKBgYEaPny42rVrp3nz5tX6NQIAHEdhAwAAAAAAAA3O2bNnFR8fr549e+rkyZM6ceKEvL29ddNNN9Uox95+p0yZon/+859at26dDh06pF27dunYsWN1dr0AAPu5OnsAAAAAAAAAwP+3atUq7dmzR8uWLZOrq6tcXV317LPPavjw4dq2bZt69epVrRx7YrKysvTWW29p3rx5+t3vfidJ8vHx0eOPP17fHwMAoArM2AAAAAAAAECD89133ykoKEjh4eHWtsGDB8tgMGjz5s3VzrEn5r///a8qKio0btw45efnKzs7WxaLpS4uEwBQDRQ2AAAAAAAA0OCcOHFCgYGBNm2urq7y9/dXXl5etXPsicnOzlZgYKCee+45dezYUf369VNQUNBF99goLS1VQUGBzQEAqBsUNgAAAAAAANDgGI1GlZWVVWovLS2Vq2vVq6vbk2NPjIuLi44fP66TJ08qNzdXR48e1UsvvaR77rlH33//fZXnTk5Olr+/v/UICwuz+1oBAI6hsAEAAAAAAIAGJywsTMeOHVNFRYW1raCgQMXFxWrbtm21c+yNkaRp06bJzc1NknTPPfeoRYsWWrVqVZXnTkpKUn5+vvXIysqqwdUDAC6GwgYAAAAAAAAanJiYGBUWFmr9+vXWti+//FIuLi6Kjo62tv388886deqU3Tn2xMTGxsrFxUUnT560xpSUlKioqEj+/v5VjtfDw0N+fn42BwCgblDYAAAAAAAAQIPTq1cvjR8/XnfffbeWL1+uzz//XFOmTNHEiRMVEhJijevWrZv+9a9/2Z1jT0xYWJjuv/9+TZ48WStWrNCGDRt02223yd/fX+PHj6//DwMAYKPqBQkBAAAAAAAAJ/vwww/14osv6qmnnpKrq6seeughTZs2zSamS5cuatGihUM59sS8/vrrmjVrlp599lmZzWb16dNHGzZsqLTxOACg/hksFovF2YOoDQUFBfL391d+fj5T/QDgErhn8hkAgKO4b/IZAICjLvf75uV+/Wi4tmzZon79+mnz5s3q27evs4cDWDly32QpKgAAAAAAAAAA0GhQ2AAAAAAAAAAAAI0GhQ0AAAAAAAAAANBosHk4AKDaLBaL5s+fr2XLlsnV1VXx8fEaP358jXMc6ddiseihhx7S/v379fbbb6tdu3a1dn0AAAAAAABoeJixAQCotoceekiPPvqohg0bpt69e+uOO+5QcnJyjXMc6XfmzJn66quv9OWXX6qgoKDWrg0AAAAAAAANEzM2AADVsm/fPr355pv6/PPPNXbsWEmSt7e3pkyZookTJyogIKBaOY70u3nzZs2cOVPvvvuurrvuujq/ZgAAAAAAADgfMzYAANWyYsUKeXp6asyYMda2xMRElZSUaPXq1dXOsbffwsJC3XzzzXrnnXfUqlWrWr02AAAAAAAANFwUNgAA1XLgwAG1bt1abm5u1ragoCB5enrqwIED1c6xt9/7779fV199td0zNUpLS1VQUGBzAAAAAAAAoPFhKSoAQLWUlZXJ29u7UruXl5fKysqqnWNPzLx58/TDDz9o8+bNdo83OTlZzz33nN3xAAAAAAAAaJgobAAAqiUgIEAnT560aauoqFB+fn6V+2vYm2NPzOzZs2U0GnXjjTdKkvLz8yVJkydP1g033KCHH3640rmTkpI0ZcoU6+uCggKFhYXZe7kAAAAAAABoIChsAACqpVevXsrNzdXRo0cVHBwsSdq2bZsqKirUq1evaufYEzN79mxrMUOS9u7dq3Xr1ummm27S0KFDqzy3h4eHPDw8aufiAQAAAAAA4DTssQEAqJbRo0crKChIr776qrXtlVdeUZcuXTRw4EBrW1xcnL744gu7c+yJiY6OVlxcnPUYNmyYJGn48OEXLKoAAAAAAACgaWDGBgCgWry9vfXJJ58oMTFRK1asUGlpqYqKirRkyRK5uPyvbv7ll19q1KhRdufY2y8AAAAAAAAuTxQ2AADVFhsbq6ysLG3atEmurq4aMGBApeWelixZoh49ejiUY0/Mb3Xq1ElLlixR+/bta/cCAQAAAAAA0OBQ2AAA1Ii3t7diYmIu+H5cXJzDOfbGnOfv71/leQAAAAAAAND0sKYHAAAAgHqza9cuTZo0Sdddd50efvhhZWVl1UrOvn379Nhjj2nUqFFauXJlrZ0bAAAAQMNDYQMAAABAvdi9e7cGDhyoM2fO6M4779SBAwc0cOBAHT16tEY577//vkaPHq0WLVpo5cqVysnJqZVzAwAAAGiYHF6K6tSpU/rwww916NAhderUSbfffru8vb0vmrNz504tWbJEx48fV69evXTzzTfL1dX21Lt379bixYt17NgxtWnTRomJiWrXrp2jwwMAAADQQD3//PPq2rWr/vWvf0mS4uPj1alTJ82YMUOvvvpqtXPGjRunu+66SwaDQUlJSbV2bgAAAAANk0MzNnJzc9W3b1999tlnCggI0Jw5czRo0CCdPn36gjnz589X3759tW/fPrVs2VKzZs3SqFGjdPbsWWvM4sWL1b17d+3cuVPBwcH69ttv1blzZ6Wnp1f/ygAAAAA0KCtWrNC4ceOsr93c3BQXF6cVK1bUKCcwMFAGg6HWzw0AAACgYXJoxsYLL7wgT09Pff311/Lw8NCDDz6oTp06afbs2XryyScrxVssFj300EN6/PHH9eyzz0qSHnzwQUVERGjevHm69957JUlvvPGGbrjhBs2bN8+aO2zYML377ruKjo6uweUBAAAAaAiKiop0/PhxtW3b1qa9bdu2OnjwYK3l1GY/paWlKi0ttb4uKCiw+5xAfTGbzUpPT1dOTo5CQkIUHR0to9Ho7GEBAADUKYdmbHz++ee68cYb5eHhIUny9/fX2LFj9fnnn1cZf+LECZlMJvXt29fa5u3tra5duyo1NdXaFhwcrPz8fOvriooKFRYWKiQkxKGLAQAAaMzMZrNWr16tBQsWaPXq1TKbzc4eElBrysrKJEleXl427d7e3tb3aiOnNvtJTk6Wv7+/9QgLC7P7nEB9SE1NVWRkpGJjY3XrrbcqNjZWkZGRNn9vAwAANEV2FzZKS0uVlZWljh072rSHh4drz549VeYEBgYqODjYZnr38ePHtXXrVu3evdvaNnPmTPn7+2vo0KGaMGGCBg4cqAEDBuivf/3rRcdTUFBgcwAAADRWfDmFps7Hx0eurq46efKkTfuJEyfUvHnzWsupzX6SkpKUn59vPbKysuw+J1DXUlNTlZiYqKioKGVkZKiwsFAZGRmKiopSYmJilc+PbFOxFm7O0uyVe7Rwc5ayTcVOGDkAAEDN2V3YKC4+9w8eX19fm3Y/Pz8VFRVdMG/u3Ln617/+pZiYGP3pT3/SFVdcoe7du1v7k6S9e/dq06ZNCg8PV6dOndSuXTutW7dOv/zyywX75ddTAADAWWr7i6HzX0717NlTb731lj744AO99dZb6tmz5wW/nAIaG1dXV/Xs2VNbtmyxad+yZYt69+5dazm12Y+Hh4f8/PxsDqAhMJvNmjp1quLi4rRw4UKVlJRoyZIlKikp0cKFCxUXF6dp06bZzPzLNhVr7tr9Wr4jV/uPn9byHbmau3Y/xQ0AANAo2V3YaNasmQwGg0wmk037qVOnLvoP/Li4OO3bt09//vOfFR0drf/+97+KiopSYGCgNeaOO+5QXFycPvzwQz322GNKTU1V9+7ddf/991+wX349BQAAnKG2vxg6/+VUv379lJmZqcmTJ+uuu+7S5MmTlZmZqX79+lX6cgporO6880599tln2rt3ryTp+++/14oVK3TnnXdaY+bPn6/4+HiHcmrr3EBjkZ6eroMHD2rIkCHq3LmzzWy/zp07a/DgwTpw4IDS09OtORv25emIqVhdgn3VvmUzdQn21RFTsTbsy3PilQAAAFSP3ZuHu7m5qVOnTtq5c6dN+86dO9W9e/eL5rZq1Urjx4+3vl69erViYmIknftjfv/+/erXr59NTr9+/fTGG29csE8PDw/rXh8AAAD15bdfDLkYDKqwWLTraKE27MvT+H7/m0Ga/euXRdmmEoUGeGpQRKBCA7wq9Xf+y6lDhw4pLi5OCxYsUM+ePZWZmanp06crLS1NFotF6enp1n8/AY3VAw88oC1btqhXr17q2rWrfvrpJ02ePFm///3vrTH79+/X6tWrHcr54Ycf9Oijj1pfv/TSS/rnP/+pa6+9VlOmTLG7H6CxyMnJkSQ9+eSTcnO3/bs4NzdXTz31lE2cJGWbSuTtbpSLwSBJcjEY5O1uVLappJ5GDQAAUHvsLmxI0k033aQPPvhATz31lFq0aKFDhw4pLS1Nr7zyijXmq6++0urVq61tP/zwg7p06SJvb29J0pw5c3TgwAEtWrRIkmQ0GtWtWzctW7ZMd911l6Rzm4evWLFCUVFRtXKRAAAAtcWeL4bOz+o4YiqWt7tRmdn52p5doAnDwysVN7KzsyVJY8aM0cKFC7V+/XotWbJEISEhWrhwocaNG6elS5da44DGzGg06l//+peef/55HT58WBEREWrTpo1NzO23367Y2FiHctq3b68nnnhCkqz/KUmhoaEO9QM0Fq1atZIkWSwWte7aX4PH3yPv1h1lzP9Fh1b+W18vXypJ+tlk0OyVexQa4Clvd6OKysyqsFishfmiMrNCAzydeSkAAADV4lBh4/HHH9fXX3+tfv36aciQIfrmm280YsQI3XPPPdaYTZs2ac6cOdbCRl5enm677Tb1799fR44c0aZNm/Txxx+rW7du1py3335bCQkJGjhwoKKiorRp0yadPHlSy5Ytq6XLBAAAqB2hAZ7KzM6/6BdD9s7qkKTjx49Lkjp06KDOnTvr4MGD1vc6dOigMWPG2MQBTUH79u3Vvn37Kt8LDw9XeHi4QzktWrTQqFGjanxuoLGoqKiQJHl4+2ny9Hfk7ub267PGW/c895a+29hP+aZT2nQgT12an1Zmdr78vNzk5+WmXUcLrUWONgFeGhwReImzAQAANDwOFTaaNWum9PR0ff311zp8+LAmTJigESNGyPDrLxYl6dprr7X5ZdRVV12lr7/+WitXrpSHh4c+/fRTNW/e3Kbf4cOHa//+/Vq7dq1yc3N14403asSIEfLyqrxcAwAAgDMNigjU9uyCi34x5MhyH0FBQZKkf/zjH5WWonrxxRf1zjvvSJKM3v6/blR+8aWtAABN39q1ayVJpUUF+vBvf9bIm+9TcIdOyj+YqRfe+VD5plOSpLO/7FD7q662FtgHdmyu5t7u1mfJ4IhAteFZAgAAGiGHChvSuSnco0ePvuD7AwcO1MCBA23aQkJCdNttt120Xz8/P8XFxTk6HAAAgHoVGuClCcPDbfbP+P9fDNkzq+O84OBgm9cWi8V6/NZ3uRXy3JF7yaWtAACXj77x9+pAxpea/fDN1rZWoWEa88fJWvbhW9YfIZ4vsBeVVeie6LALdQcAANBoOFzYAAAAuNyFBnhVWlLqt+yZ1fH/devWTdu3b9eQIUOsbR06dFDXrl31888/68TpMl1px9JWAICmLyYmRi+88ILydn2vG6Z/pvyDmTp5/JjatW2j5+4drzFXn1uaLfx35350yH4aAACgqaGwAQAAUMvsmdVx3rFjxyRJP//8s6677jo9+uij8vLyUnFxsZYtW6Yvv/xSklRRZLJraSsAQNMXExOjoKAgHd65Rdv++VdF3zhB8aOGya8kVw/c9Qf9vPU7efu3lLl1Nx06cYb9NAAAQJNDYQMAAKAOXGpWx3khISGSpOnTp+vdd99VWlqa9b2OHTvqxRdf1JNPPimjTwu7lrYCADR9RqNR77zzjsaPH6+fvl+vzelfW9/z9vaWJM164w216N6G/TQAAECTRGEDAADAiaKjo9WhQwd9++232r17t9avX6+cnByFhIRo6NChGj9+vNq176C+Awc7tLQVAKBpS0hI0MKFCzVlyhQdOnTI2t6qVSvNmDFDCQkJThwdAABA3aKwAQAA4ERGo1EzZsxQYmKixo8fr6SkJMXFxSkzM1Pjx49XWlqaUlJSdEVsJ7uWtgIAXD4SEhI0btw4paenW4vi0dHRMhqNzh4aAABAnaKwAQAA4GQJCQlKSUnR1KlTbTYP79ixo1JSUqy/umWjcADA/2c0GhUTE+PsYQB16ujRo1qzZo1cXV0VGxurFi1a1EqOI/2uWLFChw8f1vjx49W8efMaXQ8AoOZcnD0AAAAAnCtu7N27V998840+/vhjffPNN9qzZw9LiQAAgMtaamqqIiIi9N5772nWrFkKDw/X6tWra5zjSL/r16/X+PHjNWHCBGVnZ9fOhQEAaoTCBgAAQANx/le3t9xyi2JiYlhKBAAAXNYKCgp09913KykpSf/973+Vnp6um2++WXfccYfKy8urneNIv6dOndIf//hH/e1vf6vz6wUA2I/CBgAAAAAAABqcpUuXqrCwUBMnTrS2Pfjggzp8+LC+/fbbauc40u8999yjP/zhDxo2bFhtXhoAoIYobAAAAAAAAKDByczMVOvWrdWyZUtrW7du3WQ0GpWZmVntHHv7feutt/TLL7/omWeesWu8paWlKigosDkAAHWDwgYAAAAAAAAanIKCgkobdRsMBvn7+1+waGBPjj0xP/74o5599ll99NFHcnV1tWu8ycnJ8vf3tx5hYWF25QEAHEdhAwAAAAAAAA2Ol5eXCgsLK7WfPn1aXl5e1c6xJ+bBBx/UgAEDtHr1ar333nv6/PPPJZ3bdHzt2rVVnjspKUn5+fnWIysry74LBQA4zL6SMwAAAAAAAFCPIiMjlZubq5KSEnl6ekqSsrOzVVZWpsjIyGrn2BNz5ZVX6vDhw9qwYYMkKS8vT5K0bds2hYWFafjw4ZXO7eHhIQ8Pj1r8BAAAF0JhAwAAAACABirbVKwN+/KUbSpRaICnBkUEKjSg6l+qA03NmDFjZDablZqaqltvvVWS9PHHH8vX11cjRoywxr333nsaPHiwevToYVeOPTF//etfbcby/fff6/PPP9dzzz2nnj171vm1AwAujsIGAAAAAAANULapWHPX7tcRU7G83Y3KzM7X9uwCTRgeTnEDl4W2bdvq6aef1v3336+dO3eqpKREs2fP1uzZs+Xj42ONmzBhgmbOnKkePXrYlWNvvwCAhovCBgAAAAAA1VBUVKSff/65zvpfufOoftx3Uu1besvFYFAzi0U/HizSx4WHNLJbsDWuuLhYBw8eVIcOHS6470B96dq1q7y9vZ06BjQtzzzzjK644gotW7ZMrq6uWrlypYYNG2YTc/fdd9vMorAnx56Y3woKCtLdd9+tFi1a1O4FAgCqxWCxWCzOHkRtKCgokL+/v/Lz8+Xn5+fs4QBAg8Y9k88AABzFfZPPAJVt2bJF/fr1c/YwGpTNmzerb9++zh4GGojL/b55uV8/Go7/v6xhs9NZuiZmKPdsNDiO3DeZsQEAAAAAQDV07dpVmzdvrrP+V+48qozfzNiosFh06ESRBke0sJmxsXPnTt12223697//rW7dutXZeOzRtWtXp54fAGCrqmUNDSeybd5nLyc0RhQ2AAAAAACoBm9v7zr9pWvr8GKd+c2XUUVlZv0uzEt/GB6uNlV86dStWzd+eQsAsLFhX56OmIrVJdjXWiRff7BUknSssERfsJcTGikKGwAAAAAANEChAV6aMDzc5pe0gyMCqyxqAABQlWxTibzdjXIxGCRJLgaDPN1cJEnbfzHpiNndpuix62ihNuzL0/h+Yc4cNnBJFDYAAAAAAGigQgO8+HIJAFBtoQGeyszOV35xmY6cKlF+SZmyTxZJko4Vlsk7wLbo4e1uVLapxJlDBuxCYQMAAAAAAAAAmqBBEYHK2H9Sq3YeU5m5QpJBZaVmSZKnq4vyy8yqsFisMzaKyswKDfB07qABO1DYAAAAAAAAAIAmKDTAS12DffVzToGaeRjl7+Umg1dz/SBJsqhNgJd2HS207uXUJsBLgyMCnTxq4NIobAAAAAAAAABAE1VUZlZkKx+1b9lMkvTLaTdJUkm5RQ+zlxMaKQobAAAAAAAAANBEnd9n47dLTklSK1939nJCo0VhAwAAAAAAAACaqEERgdqeXWBdcurwiXObh/+ubYBzBwbUAIUNAAAAAAAAAGiiQgO8NOG3S06ZW2iRpCBfNglH40VhAwAAAAAAAACasN8uObVlS6GTRwPUHIUNAAAAAAAAAGhCsk3FNpuCD4oIVCibgqMJobABAAAAAAAAAE1EtqlYc9fu1xFTsbzdjcrMztf27AJNGB5OcQNNhouzBwAAAAAAAAAAqB0b9uXpiKlYXYJ91b5lM3UJ9tWRX2dwAE0FhQ0AAAAAAAAAaCKyTSXydjfKxWCQJLkYDPJ2NyrbVOLkkQG1h8IGAAAAAAAAADQRoQGeKiozq8JikSRVWCwqKjMrNMDTySMDag97bAAAAAAAAABAEzEoIlDbswu062ihvN2NKiozq02AlwZHBDp7aECtobABAAAAAAAAAE1EaICXJgwP14Z9eco2lSg0wFODIwLVho3D0YRQ2AAA1EhJSYk2b94sV1dX9e3bV25ubrWSY0/M0aNHtWfPHrVp00YRERG1cj0AAAAAADR2oQFeGt8vzNnDAOoMhQ0AQLWtXbtWiYmJatmypUpLS2U2m/XFF1+oV69eNcq5VMyuXbv00EMP6ccff1RERIR+/vlnde3aVZ9++qlCQkLq/LoBAAAAAI3Pnj17VFhY6OxhON3OnTtt/vNy5+vrq06dOjl7GHAQhQ0AQLUUFxfrpptu0q233qpZs2bJYrHopptu0i233KIdO3bIYDBUK8eemJycHD3yyCMaPXq0JOn06dMaMWKEJk2apEWLFtX3RwEAAAAAaOD27Nmjzp07O3sYDcptt93m7CE0GLt376a40chQ2AAAVMvy5cuVm5urxx57TJJkMBj0xBNPqF+/ftq0aZOuuOKKauXYExMTE2PTr4+PjxISEvTWW2/V7UUDAAAAABql8zM1/v3vf6tbt25OHo1zFRcX6+DBg+rQoYO8vC7vfTd27typ2267jZk8jRCFDQBAtWzdulWtW7dWmzZtrG29e/eWi4uLtm3bVmVhw56c6vQrSd9++626dOlywfGWlpaqtLTU+rqgoMCh6wUAAAAANH7dunVT3759nT0Mpxs6dKizhwDUCIUNAEC1mEwmtWjRwqbNxcVF/v7+OnXqVLVzqtPvBx98oGXLlmnVqlUXHG9ycrKee+65S14XAAAAAAAAGjYKGwCAanF3d1dxcXGl9uLiYrm7u1c7x9F+Fy9erPvvv1//+Mc/NGLEiAuONykpSVOmTLG+LigoUFhY2AXjAQB1Z/fu3Tp06JA6deqkDh061FrOxWL27t2rn3/+2abNzc3Nul8TAAAAgMaDwgYAoFo6dOig3NxcnT17Vm5ubpKkvLw8lZSUXPALJ3tyHOn3iy++0M0336zXX39d995770XH6+HhIQ8Pj+pfMACgxsrLy/WHP/xBS5cuVVRUlLZu3ao777xTb775pgwGQ7Vz7In55JNPNGPGDJtlF3x8fChsAAAAAI2Qi7MHAABonK6++moVFxdrxYoV1raFCxfKw8PDZnPvZcuW6dChQ3bn2NtvWlqafv/73+vvf/+7Jk6cWDcXCQCoVa+//rpWrlypH3/8UevXr9f69ev1/vvva8GCBTXKsbffLl26KC0tzXp88skndXatAAAAAOoOhQ0AQLVERkZq4sSJuvvuu/Xuu+/q9ddf17Rp0/TUU0+pefPm1rhrrrlGixYtsjvHnpi1a9cqMTFRcXFxCg8P17Jly6wHAKDhmj9/vv6vvbuPjqq+9z3+3jOZSSbJJJMQIA/yFMqTRBDEFrQB60PBp4q0eq3V3h4XaktPPbc+nF64p2ctPEugntOjLUdbi13HUz3SerVytVQ5aoPoKVQJVgETwEBCHkhIIJNM5jEzs+8fIZFAApMwZDLh81orLmbP77fnt0fYv+z93d/v7/bbb+/JwLv00ktZvHgxv/nNb86pT6z7DQQCvPvuu/zlL3/B4/HE9dhERERERGToqBSViIgM2vr165kzZw5vvvkmKSkpbNiwgTvuuKNXm8WLF/cqIRVLn7O1aWxs5KqrrqKjo4Mnn3yyV98lS5bE/ThFROTchcNh9u7dy4oVK3ptv/TSS/nVr3416D4D2e++fftYtWoVbrebw4cPs27dOr7//e/3O+ZgMEgwGOx53d7efvYDFRERERGR806BDRERGTSLxcLy5ctZvnx5v21OzaKIpc/Z2tx+++3cfvvtgxu0iIgkREdHB5FIhNzc3F7bR40ahdvtHnSfWPdbWlrK4cOHGT16NADPPfcc99xzDyUlJSxatKjPz1+7di2rV68ewFGKiIiIiMhQUCkqERERERE57+x2OwA+n6/Xdp/P1/PeYPrEut9Fixb1BDUAvvOd7zBz5syecol9WblyJW1tbT0/tbW1ZzxGEREREREZGsrYEBERERGR8y49PZ0xY8ZQV1fXa3ttbS2TJk0adJ/B7LdbTk4OjY2N/b6fmppKamrqGfchIiIiIiJDTxkbIiIiIiIyJJYsWcKmTZswTROAUCjE66+/3mt9pP3797Nly5YB9YmlzbFjx3qNpaGhgV27djF37tz4H6iIiIiIiJxXytgQEREREZEh8Y//+I/MmzePb37zm9x44428+OKLRKNRHnrooZ42L774Ik8++WTP+hix9ImlzQ033MCiRYuYM2cOLS0tPPnkk3zhC1/gu9/97pAdv4iIiMhwVO/2s6OqhXp3gCJXGvMn51HkciR6WCJnpIwNEREREREZEpMnT6a8vJyCggI2bdrE7Nmz+fDDD3utfTF16tRemRax9ImlTVlZGePGjWPz5s3s3r2blStX8uGHH5KVlTU0By8iIoO2ZcsWfvjDH/LII4/w/vvvx63P2docPXqUp556ir/7u7/jJz/5CTU1Ned0HCLDUb3bz4ZtB9myt4mDzR1s2dvEhm0HqXf7Ez00kTNSYENERERERIZMcXExTzzxBK+88grr1q2joKCg1/t33nknv/3tbwfUJ5Y26enp/OAHP+CFF17gV7/6FcuXL8dms8X/AEVEJK4effRRbrvtNjIzMwG45ppreOaZZ865z9navPHGG8yfP5/KykqKi4vZvXs306ZN46233orn4Ykk3I6qFhrcfqblO5kwKoNp+U4aTmRwiAxnKkUlIiIiIiIiIiLDTl1dHf/0T//Ef/zHf3DnnXcCMGbMGB555BHuuusuMjIyBtUnljYzZ85k7969OByfl+MxTZOVK1dy3XXXDcHRiwyNeneAdLsVi2EAYDEM0u1W6t2BBI9M5MyUsSEiIiIiIiIiIsPOm2++idVqZdmyZT3b7rzzTjweD1u3bh10n1jajB8/vldQA2DatGk0NTXF5+BEhokiVxq+UISoaQIQNU18oQhFrrQEj0zkzJSxISIiIiIiIpLEjnq6nqp9bPOn5O2NcvnEXK6/pEALv0rS++yzzxg7dixpaZ/fYC0qKsJut/PZZ58Nus9g9uv3+3n++ee5+uqr+x1vMBgkGAz2vG5vb4/tQEUSaP7kPHbXt7Ov0UO63YovFKHQ5WDB5LxED03kjBTYEBEREREREUlS9W4//7njMAD7Gz3UW9uoaPBQ2ejhh9dNVXBDkprf78fpdJ62PTMzE7+/74WNY+kz0P1Go1H+5m/+Bp/Px09+8pN+x7t27VpWr17d7/siw1GRy8G9C4vZUdVCvTtAkSuNBZPzKNT8IcOcAhsiIiIiIiIiSWpHVQuHWjoAyM9OIyfXQbMnSOWRdnZUtfD1y8YleIQig5eVlUVra2uvbaZp0tbWRlZW1qD7DGS/pmly3333UVZWRllZGfn5+f2Od+XKlTz44IM9r9vb2xk3Tv8GZfgrcjk0X0jSUWBDREREREREJIHq3f5eT8rOn5wXc6ZFvTtANNpVF90wDAzDwJ5iIRw1tfCrJL2SkhKampo4duwYo0aNAqCiooJIJEJJScmg+8S6X9M0+e53v8trr71GWVkZF1988RnHm5qaSmpq6jkft4iInJ0WDxcRERERERFJkHq3nw3bDrJlbxMHmzvYsreJDdsOUu/uu8zOqYpcaVgsBtB1E9Y0TULhKCkWQwu/StJbsmQJTqeTX/ziFz3bfv7znzN+/HiuuOKKnm3Lly/n7bffjrlPLG1M0+R73/semzZtoqysjJkzZ57XYxURkYEZVMZGNBqlra0Nl8uFYRgx9/F4PGRnZ5+xXTAYJBqN4nCojpuIiIiIiIiMbDuqWmhw+5mW78RiGERNk32NnpjKSNW7/bT6OjHNz197Mv3YrRZmFGRp4VdJetnZ2fz617/m7rvv5t133yUQCLB79242bdpESsrnt7R+/etfU1JSwrXXXhtTn1jaPPvsszzzzDOUlpbyxBNP9BrXs88+O3RfgoiI9GnAgY1//ud/Zs2aNQQCAZxOJ+vWreOee+7pt31TUxMPPPAAr7/+OhaLhby8PNavX8/NN9/cq11lZSUPPPAA27ZtIyMjg2uvvZann366JyVQREREREREZKSpdwdIt1uxnHho0GIYpNutZy0j1Z3p0eD2k5/dlZmRZrMwdWwmC6eO5oZLCrTwq4wIy5YtY8GCBbz77rukpKTwla985bR7RRs2bGDBggUD6nO2Nl/60pfYsGHD+T04EREZtAEFNl566SV+/OMf89prr3HdddexceNG7r77biZPnsyiRYv67POtb30Lj8fDoUOHGDt2LK+88gq33XYbO3fu7Klb2NDQQGlpKV/72tdobm4mMzOTTZs2sXPnThYvXnzuRykiIiIiIiIyDBW50thT30bUNHsyNnyhyFnLSJ2c6dHQkQHAxFGZ3HJpoRaAlRGnoKCAO+64o9/3ly9fPuA+Z2sza9YsZs2aNbCBiojIkBnQGhvr16/n1ltv5atf/SqGYXDnnXdyxRVX8NRTT/XZ3uPx8Kc//YmHH36YsWPHAvD1r3+duXPn9uqzZs0aMjMz+eUvf4nT6cQwDG699VYFNURERERERGREmz85j0KXg32NHmqOednX6KHQ5ThrGalTMz2gK2NDC4aLiIjIhSDmwEY0GmXnzp18+ctf7rV94cKFfPDBB332sdlsWCwWvF5vr+0+n4/t27f3vN68eTO33HILNpuNtrY2otHoQI5BREREREREJCkVuRzcu7CYxTPHUjw6k8Uzx3LfwuKzlpEqcqXhC0WIdi+wAQQ6o1owXERERC4IMZei6ujoIBAIkJfX+6mR0aNHc/To0T77pKWlcdttt7F69WouuugiJk2axPPPP8+nn37aaz+1tbUYhsGcOXOoqqoiHA5z6623sn79enJzc/vcdzAYJBgM9rxub2+P9VBERERERERkBDhw4AAejyfRw4iLSQZMygHw0HiwmcaztM/oCGAcq+e/q4P4jtYAYLTVk9lRy65dzed7uMOW0+lkypQpiR6GiMiwUu/288buI3xYfRwDmDcxh+svKaRIazFJEos5sGGcSG8Nh8O9tofDYaxWa7/9nn32WdasWcMjjzxCW1sbS5YsYcWKFbz66qu92v3yl79ky5YtLFy4kMOHD7N48WK+973v8bvf/a7P/a5du5bVq1fHOnwREREREREZQQ4cOMDUqVMTPYxh5ff/+r/5/b8mehSJt3//fgU3REROqHf7eeKt/Xxw8DihSBQw+bShncrGDn543VQFNyRpxRzYcDqdZGVl0dTU1Gt7U1MThYWF/fbLyMjgscce47HHHuvZdtNNN/X6BbSoqIh58+axcOFCAMaPH8+KFStYtWoVpmn2BFVOtnLlSh588MGe1+3t7YwbpwXSRERERERELgTdmRovvPACM2bMSPBoEsvv91NdXc3EiRNxOC7cG1QVFRXcddddIyaLR0QkHnZUtVB5pB2rBcZnd80RLR0hKo60s6Oqha9fpvupkpxiDmwAlJaW8tZbb/UKKHRnWXTr6OjA5/MxZswYgNMCE/X19bz99tusX7++Z9tXvvIVGht7J9p2dHTgcDj6DGoApKamkpqaOpDhi4iIiIiIyAgzY8YM5s6dm+hhJNyVV16Z6CGIiMgwVO8OEI6a2FMsPfdZ7SkWItEo9e5AgkcnMngxLx4OXVkSb7/9No8//jgVFRX86Ec/4rPPPuOhhx7qafMv//IvvbIxnn76aZ544gkOHDjAu+++y0033cSCBQu45557etr8/d//Pe+//z4///nPOXjwIK+//jpPPPEE9957bxwOUUREREREREREROTCU+RKI8ViEApHMU0T0zQJhaNYLRaKXGmJHp7IoA0osHHllVfy2muv8frrr7NkyRLKy8t56623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5hIOh5k1axYvvfRSr3b79u3j+PHjMfeJVxuRRLtsQg7P/s/LT9s+Z3wOd37p3Mp8iCSr73znOxw4cIBFixaRlZWF1+vl8ccf7xVYaGlpYf/+/QPqE682IudLkcvB1y87ezmaWNt1mzs+h+WlxWds880zzDmLS85toXCR803XHCJd+pof5sYQjF5Sks+SAZzrBzIHDXTOEok3ZWyIiFyA4n3OrKurIyUlhfz8039hqqysZOzYseTk5MTcJ95t+qJ5Q0RkYOJ53vR6vRw9epSCgoLT1tVpaWmhtbX1tDrHZ+oT7zb90dwhIjIw8Txv6ppDRGTkG8h5U4ENEZELkM6Z+g5ERAZK5019ByIiA3Whnzcv9OMXERmogZw3LUM0JhERERERERERERERkXOmwIaIiIiIiIiIiIiIiCQNBTZERERERERERERERCRpKLAhIiIiIiIiIiIiIiJJQ4ENERERERERERERERFJGgpsiIiIiIiIiIiIiIhI0lBgQ0REREREREREREREkoYCGyIiIiIiIiIiIiIikjQU2BARERERERERERERkaShwIaIiIiIiIiIiIiIiCSNlEQPIF5M0wSgvb09wSMRERn+us+V3efOC5HmDRGRgdHcoblDRGSgLvS5Q/OGiMjADGTeGDGBDY/HA8C4ceMSPBIRkeTh8XjIzs5O9DASQvOGiMjgaO7Q3CEiMlAX6tyheUNEZHBimTcMc4SEzaPRKA0NDTidTgzDSPRwRESGNdM08Xg8FBYWYrFcmFUJNW+IiAyM5g7NHSIiA3Whzx2aN0REBmYg88aICWyIiIiIiIiIiIiIiMjId+GFy0VEREREREREREREJGkpsCEiIiIiIiIiIiIiIklDgQ0REREREREREREREUkaCmyIiIiIiIiIiIiIiEjSUGBDRERERERERERERESShgIbIiIiIiIiIiIiIiKSNBTYEBERERERERERERGRpPH/AXxrXYD43qvdAAAAAElFTkSuQmCC", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "fig, axes = plt.subplots(1, 4, figsize=(16, 4.5))\n", + "rng = np.random.default_rng(0)\n", + "for ax, metric_name in zip(axes, [\"auc\", \"brier\", \"log_loss\", \"ece\"], strict=True):\n", + " ax.boxplot(rotation_metrics[metric_name], vert=True, widths=0.4)\n", + " jitter = rng.normal(1, 0.03, size=len(rotation_metrics))\n", + " ax.scatter(jitter, rotation_metrics[metric_name], alpha=0.5, s=15)\n", + " ax.set_title(metric_name)\n", + " ax.set_xticks([])\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "oof-calibration-md", + "metadata": {}, + "source": [ + "## Out-of-fold calibration across the full clean pool\n", + "\n", + "Every outer-eval prediction collected during Stage 2's rotation loop\n", + "came from a model that never trained on that row - by construction, not\n", + "extra bookkeeping. Each clean row landed in outer-eval exactly once per\n", + "repeat (`N_OUTER_REPEATS` times total), so it has that many raw\n", + "predictions from that many differently-trained models; averaging them\n", + "per row gives one calibration point per clean row, and Platt scaling\n", + "fits on that full averaged set. This uses essentially the entire\n", + "376-row clean pool for calibration, leak-free, instead of a fixed\n", + "60-76 row slice.\n", + "\n", + "The raw-vs-calibrated table below is a bonus sanity check, not the\n", + "headline result - the aggregated outer-eval metrics above remain the\n", + "honest performance estimate; this table is computed once over the\n", + "whole clean pool at once and is shown mainly to make calibration's\n", + "effect visible before the reliability diagram below." + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "oof-calibration", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "376 clean rows have out-of-fold predictions (10-10 each, expected 10 - one per repeat)\n", + "\n", + "aggregated OOF metrics, raw vs. calibrated (bonus, not the headline - see above):\n", + " raw calibrated\n", + "auc 1.0000 1.0000\n", + "brier 0.0006 0.0001\n", + "log_loss 0.0028 0.0013\n", + "ece 0.0024 0.0012\n" + ] + } + ], + "source": [ + "oof_trip_ids = sorted(oof_raw_predictions)\n", + "oof_raw_avg = np.array([np.mean(oof_raw_predictions[tid]) for tid in oof_trip_ids])\n", + "oof_true = clean_pool_by_trip_id.loc[oof_trip_ids, \"label\"].to_numpy()\n", + "\n", + "n_missing = len(clean_pool) - len(oof_trip_ids)\n", + "if n_missing:\n", + " msg = f\"{n_missing} clean rows never appeared in any outer-eval fold\"\n", + " raise AssertionError(msg)\n", + "\n", + "predictions_per_row = pd.Series(\n", + " [len(oof_raw_predictions[tid]) for tid in oof_trip_ids], index=oof_trip_ids\n", + ")\n", + "print(\n", + " f\"{len(oof_trip_ids)} clean rows have out-of-fold predictions \"\n", + " f\"({predictions_per_row.min()}-{predictions_per_row.max()} each, \"\n", + " f\"expected {N_OUTER_REPEATS} - one per repeat)\"\n", + ")\n", + "\n", + "oof_calibrator = PlattCalibrator().fit(oof_raw_avg, oof_true)\n", + "oof_calibrated = oof_calibrator.predict(oof_raw_avg)\n", + "\n", + "raw_oof_metrics = metrics.evaluate(oof_true, oof_raw_avg)\n", + "calibrated_oof_metrics = metrics.evaluate(oof_true, oof_calibrated)\n", + "print(\n", + " \"\\naggregated OOF metrics, raw vs. calibrated \"\n", + " \"(bonus, not the headline - see above):\"\n", + ")\n", + "print(\n", + " pd.DataFrame({\"raw\": raw_oof_metrics, \"calibrated\": calibrated_oof_metrics}).round(\n", + " 4\n", + " )\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "calibration-plot-md", + "metadata": {}, + "source": [ + "### Reliability diagram, raw vs. calibrated" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "calibration-plot", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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4X7C5Fy4cOG1Y71Jnjx5gLR9j98kLe85SP3vp9q1a7N3714iIyM5ffo0a9asYeTIkbx48YIZM2aYILn+ZyC5YsGUr4+xr3lq3xN3797l2LFjzJ07N9EJjqioqGS/J8b8//BvL168QFGURMcJCwsjJibG8F5P7XsiMjIyVdsZS9UHIMaMGUP+/PkZPXq04Wmq9u3bc+LEiddeJkur//baefToEQcPHkx2261btyb6+Pnz55w8eTJVl31SI+GSckxMTLKf9/LyonXr1vz000/MmzcPjUZD375933jcJk2acPz48SSn6Lds2ZLqbKl5nRo3bszx48eTPDG6bdu2VI+TlvEyC1O/NqZ4HWvWrEm+fPn4888/X7td9erVcXFxSfIUYGRkZIqX//779f7+++9JtnF2dk72fZyWXK6urkl+5l69epWq2xpq1KiBm5sbmzZtSrRep9OxefNmSpcubThD4O3tbTir+u9xkrttI6Wv63WaNm3K4cOHk/z8JbRdatKkSZqO96axLl++zN27d5Md63W/q5o0acK5c+d4+vRpovVp+T0BSX9PhoSEcPjw4VT9nqxYsSJFixZl3bp1ry1kK1asSJ48eZKMFRMTw+7du984ToUKFShatChr1qx57YNzafl+J2T673sOYOPGjRQuXJhSpUql6ljJcXJyolGjRixevJjy5ctz+PBho4/1X97e3ty+fTvRk5wxMTGpmiEktVL7mv9Xat8TCf77+2nFihVm6Z0aExOT5EnThAdKEt7rqX1PmPu9o2ox5+TkxPjx4/Hz8zP8ZzFx4kQKFixImzZt2LNnDyEhIYYnxN577700P8LboUMH9u7dy59//klERARnz56lR48ehlO3/xUYGMiCBQt4+fIlvr6+vP322zg4OPDFF1+k++sFKFeuHFZWVuzcuTPFXyBDhgzB39+fdevW0bhx4yT3DyZn3LhxWFlZ0b17d27cuEFwcDBz584lLCwsVblS+zqNHz8ejUbDO++8w82bN3n58iULFy5MczuQtH5f1GSO18YUr6OdnR1Llixhz5499OvXD19fX6Kiorh37x6rVq0y5HNycmLixImsW7eOmTNnEhQUxN27d/nwww+pWbNmomOWLVuWEiVK8O2333L9+nVCQkJYvnw5Bw4cSHJ/X4UKFXj06BHnz59P9Is7rbk2btzIt99+S2BgIHfu3OH9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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "fig, ax = plt.subplots(figsize=(6, 6))\n", + "CalibrationDisplay.from_predictions(oof_true, oof_raw_avg, n_bins=10, ax=ax, name=\"raw\")\n", + "CalibrationDisplay.from_predictions(\n", + " oof_true, oof_calibrated, n_bins=10, ax=ax, name=\"Platt-calibrated\"\n", + ")\n", + "ax.set_title(\n", + " \"Reliability diagram - aggregated out-of-fold predictions, full clean pool\"\n", + ")\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "roc-pr-md", + "metadata": {}, + "source": [ + "### ROC and Precision-Recall curves\n", + "\n", + "Computed on the raw out-of-fold predictions - Platt scaling is a\n", + "monotonic transform, so it can't change either curve's shape, only the\n", + "probability values a downstream threshold would be compared against." + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "roc-pr-plot", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAABFEAAAHqCAYAAADMLNG2AAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjEsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvctoD+AAAAAlwSFlzAAAPYQAAD2EBqD+naQAAw3ZJREFUeJzs3Xd0VNX6//HPpPcACSH0XkLvHUPvvUhVULpYEK8XuGBDFL02RAFFsFKlN6UKCkqRfiGA9B5CS0IIEEL27w9+mS9DEphMBkPw/Vora83ss/c5zzlTzs4z5+xtMcYYAQAAAAAA4L5cMjsAAAAAAACArIAkCgAAAAAAgB1IogAAAAAAANiBJAoAAAAAAIAdSKIAAAAAAADYgSQKAAAAAACAHUiiAAAAAAAA2IEkCgAAAAAAgB1IogAAAAAAANiBJAqAB6pZs6bq16+f2WE8FCtXrlSlSpXk7e0ti8Wi48ePp1l306ZNqlWrlnx9fWWxWLR582a7tzN27FhZLBbduHHjgXW//fZbWSwWHThwwO71Z6bGjRuratWqNmWlSpVS586dnbqdh7FOAFLFihXVunXrv63dP8XQoUPl5+f3wDIgs1WsWFHNmzfP7DAeimXLlqlChQry8vKSxWJRZGRkmnV/++03Va9eXT4+PrJYLNq1a5fd2xk9erQsFotddb/44osH9jkfJXXr1lXdunVtygoVKqRevXo5dTsPY50PC0mUe7z33nuyWCzWPzc3N+XNm1e9evXS0aNH02y3efNmPfnkk8qTJ488PDwUEhKitm3bas2aNWm22b17t5555hkVKVJEXl5eCg4OVpUqVTRixIj7bgtITXBwsPr165fZYTjdw9yvy5cvq0uXLqpXr56uXLkiY4wKFSqUat2EhAR17txZhQoVUlRUlIwxqlmz5kOJ65+sUKFC6tatW2aHATxUkZGRNn0Ni8WiwMBAhYeHa/HixZkdXpZRv359m2Po5eWlkiVLavTo0YqPj8/s8ACn8vPz0/PPP5/ZYTjdw9yvqKgode3aVc2aNVNMTIyMMQoNDU21bnx8vDp37qzSpUvr4sWLMsaoYsWKDyWuf7LQ0FD16dMns8PIMJIoafjzzz9ljFFcXJy+//57/fLLL6pTp46io6NT1B0/frxq166tgIAArV27VnFxcdq8ebNKlSqlpk2b6j//+U+KNhMnTlSVKlUUHx+v2bNn6/Llyzp8+LBef/11rVixQh07dvwb9hL4Z9u5c6euXr2qLl26yMvL6751Dx06pLNnz6pjx47y9fX9myLMug4cOKB58+Y98usEMtOQIUNkjNHt27e1bds2+fr6qn379po7d+7fGseuXbu0bNmyv62dMwUFBckYI2OMzp07p5deeknvvvuuevbsmalxAch8f/75p+Lj49WlSxd5enret+7+/ft14cIFderUST4+Pn9ThFnX8ePHNX369Ed+nQ8LSZQH8PLyUqNGjTR69GhFRkam6NisXbtWw4YN05AhQzR16lSFhYXJw8NDRYoU0X//+1+9/fbbGjdunGbNmmVt88svv+iFF17QoEGDNGfOHOtlY9myZVO7du30559/qkuXLn/3rgL/OBcvXpQkeXt7O7UuAKSHi4uLihcvrhkzZsjV1VXjx4/P7JCypOzZs+u5555T69attWjRIp06dSqzQwKQiejn4WEhiWKn4sWLS5JOnz5tU/7222/L29tbY8eOTbXdv//9b+XKlUtvvfWWtWzMmDHy8fHRu+++m2obd3d3jRo16oEx7d27V926dVNoaKh8fHxUuXJlfffddzLGSJJat26tsmXLpmg3fvz4FPcEDho0SNmyZVN0dLT69OmjHDlyKCwsTB999JEsFov27t2bYj1TpkyRxWLRn3/+aS3bt2+funTpopw5c8rT01NhYWGaMGHCA/clPZLjT/4LCAhQeHi4fv755xR1586dq3LlysnLy0ulSpXS7Nmz9fnnn8tisaR4Le2tm9axSvbDDz9YE2N+fn5q0qSJtm7darOt69ev6+WXX1ZoaKh8fX3VrFkzHT16NNWxR6pWrWpze1mePHn09NNP68yZM5KkuLg4WSwWXbp0SdOmTbPWvffeRXviunnzpl555ZUUcdkrMTFR7777rkqWLClPT0/lzJlT3bp1s1lHdHS0LBaL3nvvvRTtq1atqsaNG6drv9Ly888/q169evLz85Ovr6/q1q2r5cuX22wr+baRatWqyWKxpHk/cOvWra2vS5s2bWSxWGwu8dy4caOaNGmigIAA+fj4qGrVqpo5c6ZdcS5fvlyVKlWSl5eXihUrpm+//daudpL0r3/9S25ubrp69ar69OmjbNmyKVu2bOrZs6fOnz+fat24uDgNGDBAwcHByp8/v3X5jz/+qNq1a8vX11e+vr6qX7++NmzYYLOOW7du6T//+Y/y5s0rX19fNWzYMM1xW9Iav2Tu3LmqV6+eAgIClCNHDrVs2VLbt2+XJFksFp04cUJz5syxvt53H+fU1pmUlKSPPvpIpUuXlqenp4KCgtS5c+cUcSVfPrp7927Vq1dP3t7eKlCggD755JMUMf7000+qV6+ecuTIoezZs6tevXpatGhRqvsJOFP27NkVEhJi/ec/+R772NhYPffccwoJCVHOnDmt9RcuXKi6devKz89PPj4+qlevntatW5divQsXLlR4eLgCAwOVLVs2NWvWTFu2bLEuT21sk1WrVik8PFxBQUHKli2b6tSpk+JKsNTaGWM0YcIElS1bVl5eXsqRI4c6dOigffv22dRLvnVv3759ql+/vry9vZU/f369//77jh28u6TVZ9u9e7c6duyo4OBgeXp6qnTp0po8eXKK9rt371aXLl2UK1cu63f6jBkzrP2re2/9DgwMVIMGDe57C7cjHhRHauNR3R3f3VdP9+nTR6Ghobp8+bJ69eql7Nmzq1KlSnrnnXdksVh06NChFOuZMGGCLBaL9uzZYxOTPccwI9JzfGfOnKkyZcrIy8tLpUuX1oIFC/Thhx/KYrFY/ylOb920jlWyr7/+WlWrVpWPj4/8/f3VvHlz7dixw2Zb165d0wsvvKCQkBD5+fmpZcuWOnnyZKpjj5QtWzbFMALPPvustZ9+8eJFWSwWXbt2TRMnTrTWTe4vpSeu+Ph4vfTSS8qVK5dNXPa6deuWxowZoxIlSsjT01MhISHq1auXTpw4Ya2TfLtiasngsmXLWr8z7N2vtCxZskR16tSRr6+v/Pz89MQTT2jVqlU220q+baRcuXKyWCxq3759qutq3Lix9XVp0qSJLBaLzS3b69evV8OGDeXv7y8fHx9Vr17d7isGFy1aZB2TpWTJkpoxY4Zd7STp+eefl5+fn6Kjo9WzZ08FBgYqe/bs6t27d4r3d3Ld2NhYPfvsswoKCrJ+F0rSjBkzVKNGDevxatSoUYqx/W7evKlXX31VuXPnlq+vr5o2barDhw+nGlta45fMnDlTderUkb+/v4KCgtSmTRvt3r1bN27ckMVi0fnz5/Xdd99ZX++7j3Nq67x9+7bef/99lSpVSp6engoODlbXrl1TxJUtWzYNGjRI27ZtU506deTt7a1ChQpp4sSJKWJcvHix6tSpo+zZsytHjhwKDw9P/1WVBjbGjRtnJJk///zTpvyzzz4zkszUqVOtZXFxccbV1dU0adLkvuvs1auXkWSOHz9url69alxdXU3Tpk0zFOcff/xhvL29TdOmTc2OHTtMXFyc2bVrl+nTp485ePCgMcaYVq1amTJlyqRo+8knnxhJ5ty5c9aygQMHmsDAQNO1a1ezdOlSc/nyZfP111+bqKgo4+HhYYYOHZpiPTVq1DDlypWzPt+yZYvx8fEx7dq1MxERESY2NtbMnTvXBAQEmOHDh2dof9Ny+/Ztc/z4cfPSSy8Zd3d3s337duuyH3/80UgyQ4cONWfPnjUnT540gwYNMm3btjWSzKlTpxyqm9axMsaYESNGGA8PD/Pxxx+byMhIc/bsWfPCCy8YT09Pm/dUu3btTGBgoJk/f76JiYkxW7ZsMa1atTLly5c34eHhae5vfHy82bRpk6lSpYqpUqWKuXXrlnVZUFCQ6du3b6rt7I2rY8eOJiAgwMydO9fExMSYTZs2mWbNmj0wrmTdu3c33t7e5ttvvzVXrlwxO3fuNFWrVjXBwcHmxIkTxhhjrly5YiSZcePGpWhfpUoV06hRI5uy++1XWmbPnm0sFosZPHiwOXXqlDl9+rR54YUXjMViMdOnT7fWmzVrVqqf99SsW7fOSDJLly61KV+zZo1xc3MzTz75pDl69Kg5f/68eeONN4wk89FHH1nrvf3220aSuX79urVsxYoVxsXFxfTr18+cPHnSnD592gwZMsS0a9fOSDL79++/b0yvvPKKcXV1NU899ZRZsGCBiYmJMRs3bjSFCxc2ZcqUMfHx8SnqPv3002b+/Pnm8uXL1u+zsWPHGldXVzNu3Dhz9uxZExkZaf79738bd3d389tvv1nX8fTTTxsfHx8zffp0Ex0dbf7880/TtGlTU61aNVOlShWb2EqWLGk6depkU/bmm28aV1dXM3r0aHPs2DETHR1tfv75Z9OrVy9rnYIFC5quXbumur+prbN///7Gw8PDfPHFF+by5ctm7969pk6dOiYwMND89ddf1nq5cuUyTZo0MZ06dTL79u0zV65csb5OP//8s7Xerl27jJubmxk1apSJiooyV69eNX/88Yfp2LGjuXjx4n1fD8Be586dM5LMkCFDbMovX75sXF1dTe3atY0xxowaNcpIMr179zazZ882ly9fNl999ZUxxpgPP/zQuLi4mDFjxpjTp0+bqKgo89prrxlXV1ezevVq6zrfe+894+LiYoYPH26OHDlioqOjzapVq0y3bt2sdSpUqGBatWplfR4REWE8PDzMq6++aiIjI01cXJzZsmWLefLJJ83p06fTbGeMMS+88IJxd3c3n3/+ubl06ZKJiIgw9evXN35+fmbv3r3WegULFjQNGjQwHTt2NHv27DHR0dHm3XffNZLMwoUL7TqO4eHhJigoKEV5q1atUpy7N2zYYLy8vEznzp3NgQMHTGxsrJk5c6bx8/Mzb7zxhrXe+vXrjaenp2nVqpXZtWuXuXr1qtm+fbvp2bOn9Tx2t8TERHPs2DEzePBg4+npaf73v/9Zl7300kvG19fXpn5qZamxJ45GjRql+O415v/6sleuXLGW9e7d24SEhJhOnTqZn3/+2Vy+fNl888035syZM8bV1dWMGDEixXoqVKhgqlWrlu5j6Ez3O74//PCDkWSGDx9uIiMjzfHjx82zzz5r2rRpYySZCxcuOFQ3rWNljDFDhw41Xl5e5rPPPjNRUVHm9OnTZsCAAcbHx8fs3r3buo7mzZubHDlymMWLF5uYmBjz+++/m9atW5tSpUqZZs2apbm/8fHxZuPGjaZ8+fKmdu3aJjEx0brM19c3xXdGMnvjatWqlcmePbtZuHChiYmJMRs2bDAtWrR4YFzJOnToYHx9fc2MGTNMdHS02b59u6lQoYLJlSuXOXPmjDHm/77fPvnkkxTty5Qpk+I74377lZbvvvvOSDIvvfSSOXPmjDl58qQZMGCAsVgsZu7cudZ633zzjZFk875Jy88//2wk2Xx/GmPMTz/9ZFxdXU2vXr3MsWPHzLlz58zIkSONJDNx4kRrveTv67stXrzYWCwW89xzz1nj7N+/v7Wfd+zYsfvGNGTIEOPr62u6detmli5damJiYsz69etN/vz5TaVKlczNmzdT1O3evbtZvHixuXz5spk2bZoxxpjRo0cbd3d38+GHH5pz586Zc+fOmaFDhxoPDw+zefNm6zqefPJJ4+/vb+bMmWNiYmLM5s2bTbNmzUzFihVNnTp1bGIrWLCg6dmzp03ZiBEjjJubmxkzZow5fvy4uXz5slm2bJnp06ePtU6uXLlM7969U93f1Nb51FNPGS8vLzNt2jRz5coVs3v3blO9enWTI0cOc/ToUWu9wMBA07x5c9O5c2ezf/9+c/nyZevrtG7dOmu9LVu2GFdXVzNmzBhz4cIFExsbazZu3GjatGlj4uLi7vt63I0kyj3uTaJcv37drFq1yoSGhppChQqZ2NhYa92DBw8aSaZfv373XWfyh+rXX381Bw4csKvNg1SsWNEUKlTI3LhxI8066U2iSDLfffddivqdO3c2wcHBNh/Uffv2GUlm/Pjx1rJq1aqZ4sWL29Qz5k5Hz83NzZw9ezZd+5hehQoVMs8//7z1edGiRW1O/sYYk5SUZEqXLp2ic5Weumkdq/379xuLxWJGjRqVYj2VK1e2npw2bdpkJJlPP/3Upt62bduMJLuSFRs3bjSSbL740ko22BvXli1bUj3pbd682a64tm7daiSZMWPG2JSfOHHCuLu7W2N72EmUpKQkky9fPlO5cuUUy2rWrGly5cpl7ZQ4I4lSuXJlU7BgQZOQkGBT3q5dO+Pr62uio6ONMaknUSpVqmTCwsJMUlKSTdtKlSrZnUSRZCZPnmxTvn79eiPJTJo0KUXdu0/4xhhz/Phx4+bmZl566aUU669Xr571pLl3714jybz99ts2df73v/8Zi8XywCTKkSNHjKurq3nuuefuu0/pSaIkfw/9+9//tql3/vx54+PjY7OeXLlyGX9/f3Pp0iVrWVJSkilYsKDp0KGDtezzzz83kmzqAc52bxIlKSnJHDp0yPrP/6xZs4wx/9d/uDsha4wxZ8+eNR4eHmbgwIEp1t24cWPr5/HUqVPG3d3dPPvss/eN595kyNSpU40k6z9F9rY7fPiwsVgs5sUXX7Spd/nyZePv72/atWtnLStYsKDx9vY2kZGRNnVLlChhWrRocd/tJrs3iXL58mUzYcIEY7FYUnTEy5UrZ8qUKWPz44Mxd5LInp6e1iRpWFiYKVGiRIp69ggNDTX/+te/rM8zkkSxJ470JlEkmTlz5qSo37p1a5M7d26bf9iT+yRffPGFtczeY/iw3H18k5KSTP78+U29evVs6iQmJppixYrZJEbSU9eYtI/Vrl27Uj0P3r5925QpU8b6/k7uL3z55Zc29X7//Xcjya5kxZo1a4wks2vXLmtZWskGe+P69ddf79tneFBcv/32m5Fk/vvf/9qUHz582Li6ulpje9hJlFu3bpmQkBBrsvluFStWNPnz57f2q5yRRCldurQpXry4zefDGGOaNWtmAgICzLVr14wxqSdRwsLCTMWKFW3K7v7/wp4kiiRrEi/ZihUrjCTrj7h3101OnCT766+/rIn0e1WrVs3a796+fbuRZD744AObOsnfBQ9KoiT/vzFs2LD77lN6kig7duwwkszrr79uU+/06dPG09PTZj2BgYEmW7ZsJiYmxlqWmJhocufObbp3724t+/DDD40k6+vmKG7nSUPy5f3e3t5q2rSpgoKCtGnTJvn7+1vrmP9/OeWDJNezd9qrBzl37px27dqlzp07P3CQpPRq27ZtirK+ffvq4sWLWrJkibVs2rRp8vDwsF5ydf78ef35559q166dPDw8bNo3btxYiYmJ+uOPP1Ld5qJFi1LMUnC/qV2vXbumUaNGqVSpUtbpypKnCUu+tOvUqVM6cuSI2rRpY9PWYrGkuPQ4PXXvdu+xWr58uYwxKcazsVgsatiwoX799VdJsl5qfe/2qlSpojx58qTYzt69e9WlSxflzp1bbm5uNre0pHWJnSNxrV27NtX9qlGjRpojmd8tuf29gyIXKFBA1atXty53ls6dO9u8Z5IvaT548KBOnz6d6uDMnTp10vnz51O9PU2S1qxZk+K9uG3btjRjuHLlinbs2KE2bdrI3d09RXzXrl1LcxrkK1euaOfOnWrdunWK74Z27drdd9/vde9rFh4ermzZsumXX355YN0VK1YoMTEx1XGYki/1TEhIsK7r3vZly5ZV0aJFHxjjypUrdfv2bfXo0eOBde2VHNO9r3VISIjq1auX4j1Xs2ZN5ciRw/rcYrGodOnSNrebVahQQZL01FNPae3atXZNSQ04KvkSdhcXF1WuXFkxMTGaP39+ihmq7v3crVq1SgkJCal+bhs3bqwdO3YoLi5Oq1ev1q1bt9L9uStfvrwsFoueeeYZrV69WtevX7er3bp162SMSfGZzJ49uxo2bJjiM1m1alXlypXLpqxMmTI2n8lBgwbZfCffO4PapUuXrMty5MihF198UU8//bS++eYba50TJ07of//7nzp06CA3Nzeb9o0bN9bNmze1efNmHTt2TPv379eTTz6Zot7drl69quHDh6tkyZI2/ZDIyEi7zssPYm8cjri37yHd6eedO3fO5rbor7/+Wj4+Purevbsk+49hambPnp3i3Hq/qV3tOb6HDx/WqVOnUuyPq6urWrZsaVOWnrp3u7d+8uX+937uXFxc1KBBgwf282rXrm1zDkq2a9cudezYUaGhodZ+XvItLfa8n+yNK61+XnKf4UHS6ucVLVpUFStWdHo/L7l/dO/t3Hv37lVUVFSa/bxTp07pr7/+SnWdy5YtS/FeTKtPKN25NSkiIkLt2rWTq6urzbLOnTsrNjbWZliDu507d0779+9P9f+L1P7fup976zdr1kze3t529fN++uknJSUlpdnP27Bhg4wxab4/qlSponz58j0wxp9//lnGGKf289J6z+XNm1c1a9ZM8Z5Lvl08maurq0qVKpVqP69Hjx5at26dbt686VBsJFHSkDw7T2xsrKZOnaq//vpLr7/+uk2dvHnzytXV1eY+wNQk32tYoEABa5v03H94r6ioKOv2HZFW8id5cNt7NW3aVPnz59e0adMk3bkf8ocfflD79u0VFBQkSdb7Nj/++GO5ubnJ1dVVrq6ucnFxsY5pcOnSJYfivVfPnj01efJkvf/++zp79qxu374tY4zKli2rW7du2WwrJCQkRft7y9JTN1lqxyr5GFSpUsV6DFxcXOTi4qIPP/xQN27cUHx8fLq2d/bsWeusUD///LNiY2NljLHOW5+8v/eT3rju7dCmVXav5PapJVxCQ0NT3LuZGnsTkxmJQ5Jdsdjj8uXLDm8ro8f7bqm9l3LlypVi2xaLRblz57YpS35/hIeHp3h/vPnmm7p9+7aio6MzHG9Gv7dS86DX+t7vnHv3XZICAgJsxg2oW7euZsyYobNnz6px48bWe/EXLFjgtLiBZMmz8yT3NzZs2JDqPwb3fm6SP7fNmjVL8bkdMWKEjDG6fPmyw5+7atWqac6cObp06ZKaNWtmnX55zpw59233oM9kXFycTYfVns/kg9w9O8+ZM2c0aNAgTZ8+3WYco+Tj9e6776booyTfj3/p0iW7j1eXLl309ddf66OPPtK5c+es/ZBixYrZdV5+kIfVz8uRI0eqg2a2bt1aoaGh1n7ejRs3NHPmTHXu3Nn6D4m9x9AZ7Dm+D7ufl9qxSj4GZcqUSfG5+/zzzxUdHa2kpKR0be/EiROqW7eubty4oVWrVunq1asyxlgTUunp52UkLvp5zt+Ws/p5bm5uqSbfQkJCUmzbw8NDwcHBNmXJ748aNWqkeH+89957SkhI0NWrV7NkP+/e/bfnnNK4cWN98803OnbsmBo2bKjAwEA1atRIS5cuTVdsJFEewN/fX3379tVrr72mr776SgsXLrQu8/PzU+3atfX7778rNjY21fa3bt3SmjVrVKJECRUsWFB+fn6qW7eufv/9d129etWhmJIHlUseWDQtgYGBqW4jrXb3/pKezMXFRc8884xWrVql06dPa+nSpbpw4YL69u1rrZP8gX399deVmJio27dv6/bt20pKSrJ2bgYMGJDq+tu3b2+tk/xXqlSpVOvGxMRoyZIlev7559WuXTvlyJFDLi4uMsbYJLOSkzvJH+i73VuWnrrJUjtWycfg4MGD1mOQlJRkcwx8fHzStb1FixYpNjZWEydOVMWKFa1Trh07dizVuFKT3rjuHZA0rbJ7JX/Bp9U+OQ4/Pz+5urqm672Zmnnz5tm8Z5KvGHlQHJJSnGCSNW7cOMV7MbVB+5JlZFsZPd53S+29dP78ees2krm4uKT4JSU5vu3bt6f5/ggJCclwvPZ+b6XHg47/vftv79WAPXr00M6dO3Xx4kX9+OOPcnNzU6dOnTJ9Klf8c917zkn+3P7+++9pfm4LFCiQoc9dly5dtG3bNl28eFHz58+Xr6+vunXrdt9pxh/0mfTz87O5gtaez+QXX3xh8518vysY8uTJo0mTJqlmzZrq37+/9Z+H5OP17rvvptlHefrpp+06XlFRUVq5cqVefvlltW7dWtmzZ5eLi4tu377ttNmA/u5+npubm55++mktX75cUVFRWrBggaKjo1Pt5z3oGKamW7duKc6t915RlMze45uZ/bwTJ06k+blzcXFJ1/bmz5+va9eu6YsvvlD58uWtiRtH+nkZicuZ/bzAwEBJSvW9efbsWTv36s5VI3e/ZzZu3GhXHFLafa/WrVuneC+mNglHMnv6Pg+7n5eYmGj90e5uUVFRKfo593vf7tu3L833R0BAQJbs59177O3t5yVPNBAVFaWZM2cqKSlJbdu21erVq+2OjSSKnV599VXlzZtXw4cPV2JiorX8tdde0/Xr19OcTee///2vIiMjba5iee211xQfH6/Ro0en2ubWrVt655130owlT548qlSpkubPn6+EhIQ06xUtWlRnz561+eAlJSWlOovNgzz77LOSpG+//VbTpk1TgQIFbEbPzps3rypVqqQFCxbYHB9ns1gsMsakuI1p4cKFNl/W+fPnV9GiRW1mY5HuZMDvLUtP3ftp1aqVLBbLA3+pa9CggSSl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za+//mo6deqU4jV80H6l5euvvzaSzLBhw8yZM2fM6dOnzeDBg43FYrEZENgZA8suW7bMuLi4mN69e5vjx4+byMhI63fzhAkTrPVSG1h2wYIFxmKxmOeff94a58CBA9M9O0/37t3N8uXLTWxsrPn1119NwYIFTfny5W0+c8l1U/Paa68Zd3d38+6775rjx49b/w/4+OOPzYABA6z1OnfubAICAsyPP/5onT20efPmds/OM3z4cOvsPCdOnDBXrlwxy5cvt+lLtm3b1hQtWtTm/XK/dfbo0cNm5s89e/aYmjVrmmzZslm/3425M7BsaoOtt2vXzpQsWdL6fMyYMWbUqFHW/5+joqKsr93atWtTPX6pIYlyj/slURISEkyRIkVMvnz5bP4RMubOiNudO3c2oaGhxt3d3QQHB5tWrVqZFStWpLmtnTt3mj59+phChQoZT09PkyNHDlOpUiUzfPhwmymb0rJnzx7TqVMnExQUZLy9vU3lypXNt99+azPTx/z5802pUqWMh4eHKVOmjFm6dOl9pzi+nzlz5hhJxmKxmOPHj6da5+DBg+bpp582efLkMe7u7qZgwYKmU6dONlNLZdSZM2fMk08+aXLkyGECAgJMhw4dzOnTp1Od2WXOnDmmTJkyxsPDw5QoUcLMmjXLOipzVFSUQ3UfdKzmzp1rwsPDTUBAgPHx8TFly5Y1//73v20SMfHx8eall14yISEhxsfHxzRp0sQcOXLEVKxYMcX01ytWrDCVK1c23t7eJn/+/GbMmDHWWUnuTqJERESYOnXqGG9v71RH0bYnruvXr5uhQ4eakJAQ4+3tbRo1amQOHTpkatSoYVcS5datW+btt982xYsXN+7u7iYoKMh06dLFHDp0yKZeQkKCefHFF01wcLA18XbmzJlUX8MH7Vdali5damrXrm18fHyMt7e3qVWrllm8eLFNHWckUYy5M+J9w4YNjZ+fn/Hy8jKVK1c233//vU2d1JIoxhizZMkSU6FCBePh4WGKFClipk6daj3p25tEiYmJMT179jQBAQEmICDAdOvWLUVn5H5JlOQ4GjVqZAIDA423t7cpXbq0efnll21O8Ddv3jTDhw83oaGhxtvb24SHh5t9+/alOkNEagkPY+4c81q1ahkfHx8TFBRkWrVqZbZt22ZdfvjwYRMeHm58fHyMJJuEcmrrTExMNP/973+t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SpUqmj59uiQpOjpab731looWLZqiXnR0tC5duvR3hwcAAJwkXUmUTZs2Paw4AAAAHgvr16/Xt99+q0WLFqVrGQAAePSlK4lSs2bNhxUHAADAYy82Nlb+/v6ZHQYAAHCQ3UmUd955Ry+//LJ8fHzuW+/atWsaP368Ro0aleHgAAAAsopdu3bp888/14kTJ3To0CH169fPZvnNmze1Zs0ajRw5MpMiBAAAGeVib8UDBw6oYMGCevnll/XLL78oJibGuuzKlStatWqVnn/+eRUsWFD79+9/KMECAAA8qhITExUXF6cbN25YH9/95+Hhoddee02DBw/O7FABAICD7L4S5YcfftDWrVv14YcfqkWLFkpISJCnp6eMMdbH7du3108//aTq1as/zJgBAAAeOVWrVtXs2bO1Z88ebdmyRf3798/skAAAgJOla0yU6tWr68cff9T169e1bds2nTp1ShaLRfny5VO1atXk5eX1sOIEAADIEsqXL6/y5ctndhgAAOAhSPcUx5Lk7e2tevXqOTsWAACALGnHjh2aMGGCKleurLp162rChAlp1q1cubJefPHFvzE6AADgLA4lUQAAAPB/jDFKTExUUlKS9XFakpKS/sbIAACAM5FEAQAAyKAqVapo+vTp1ud3PwYAAI8Pu2fnAQAAgH3OnTunv/76y/r8+++/14ABA7R8+fJMjAoAAGQUSRQAAAAnSkpKUseOHRUXFydJWr16tQYOHKjIyEh16dJFW7ZsyeQIAQCAozKURDly5IhWr17trFgAAACyvL179yohIUGVK1eWJM2YMUOjRo3SkiVLNHLkSM2aNSuTIwQAAI5yKIly4cIFNWjQQMWKFVPTpk2t5S1atNC6deucFhwAAEBWc/78eeXIkcP6fP369WrVqpUkqVixYoqKisqs0AAAQAY5lER55ZVXFBQUpPPnz9uU//vf/9bYsWOdEhgAAEBWVLJkSW3evFmbNm3S9OnTdePGDVWoUEGStH//fpUuXTqTIwQAAI5yaHaelStXateuXQoJCbEpr1y5sjZt2uSUwAAAALKiAgUK6IUXXlCdOnXk5uamqVOnysXFRfHx8Zo1a5Z+++23zA4RAAA4yKErUa5evSofHx9JksVisZZfuXJF7u7uzokMAAAgi3r33XcVHR2tS5cu6emnn5Z0Z8DZFStWKHfu3JkcHQAAcJRDSZSa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5s3T1KlT1bZtW5vy5A76woULlTdvXkmZc9wvX76sV155RStXrtStW7fUrFkz1axZ84Ht/vvf/+rNN99UrVq1dOTIEfn7+2v16tUKDQ21u07p0qVVp04dffjhh3rnnXecvm8AHg2JiYl67bXXNGHCBJUpU0Z58+bVyZMndeXKFY0ZM0a9evX622Pq2rWrzffV1KlT1b9/f3300UeS7iR8unbtquzZsz+U7Z8+fVqTJ0/W4cOHUyx77bXXNGPGDA0aNEiTJ09OsfzZZ59VkSJFVKJECV29elXr169X48aNNW/ePLm7uzs91qlTp+r999+XMUZHjhxR69at5efnd982+/fvV7NmzeTn56fg4GBt375dU6ZMUc+ePdNVZ+TIkerdu7f69OkjDw8Pp+8bgKyFJAqAR1JkZKTCw8NVp04drVy50qajtG7dOl29ejXVdmfOnNGGDRsk3RnorVSpUipRokSKehcuXNCOHTvk6empqlWr2qw/rWWFCxdWkyZNJElHjx7V3LlzJUnr16/X9u3bre3Dw8NTbO/YsWPas2ePgoODVblyZeto/pJ08OBBHT9+XA0aNNCmTZt0/vx5de7cWS4uthcLJiYm6ssvv9SkSZNSrP+HH35QsWLFVLBgQU2dOlWfffZZqsfnQRw97hl17do1PfHEE5o0aZI6dOhgV5sdO3ZoxIgRWrJkiVq3bq3r16+rTp06evnllzVr1iy760jSU089pYEDB+rNN998KJ1/AJnv+eef1/z58/Xrr7+qatWq1vLz58/r119/TbPd4sWLdf36dbm4uCh//vyqVKmSvLy8bOokJSVpy5YtunjxosqWLavChQvbtax9+/bKmTOnJOnHH3/U/v375e3trdmzZ9vUCQwMtNlefHy8/vjjDyUkJKh8+fLKly+fdVliYqLmzZunJk2a6NKlS9q3b5/KlCmT6rnwiy++UHh4uE17SYqOjtb8+fP16quvasqUKfroo49SHUz46aef1tChQyXdueKxcuXKmjJlioYMGZLm8XSUxWLRzz//rL1799p9nnjmmWdUsWJFLVq0SC4uLho/frz69++vhg0bWqcjtqdO48aNZbFYtGDBAnXr1s3p+wYgayGJAuCR9OGHH+rGjRuaMmVKil+aGjRokGa7c+fOadGiRZKkuLg4/f777+rQoYO+/vpra50ff/xR/fr1U9WqVeXm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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "fig, axes = plt.subplots(1, 2, figsize=(12, 5))\n", + "RocCurveDisplay.from_predictions(oof_true, oof_raw_avg, ax=axes[0])\n", + "axes[0].set_title(\"ROC curve - aggregated out-of-fold predictions\")\n", + "PrecisionRecallDisplay.from_predictions(oof_true, oof_raw_avg, ax=axes[1])\n", + "axes[1].set_title(\"Precision-Recall curve - aggregated out-of-fold predictions\")\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "confusion-md", + "metadata": {}, + "source": [ + "### Confusion matrix at a PR-curve-chosen threshold\n", + "\n", + "Threshold picked to maximize F1 on the *calibrated* OOF predictions\n", + "(not a default 0.5) - calibrated, because that's what a real deployment\n", + "would actually threshold against." + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "confusion-plot", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "decision threshold chosen from the calibrated PR curve (max F1): 0.845 (F1=1.000)\n" + ] + }, + { + "data": { + "image/png": "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jsY///xTIJ9cLse6devEx9u3b0dOTg58fX1Vri+9XywsLHD//n2kpKRoZHv8+HGBtClTphQ4rrm5OQCI/xf50tPTld7r9D9siSEyABMmTMC///4LLy8v9OrVCzY2NuJ04f379wMAPv30U6xfvx5NmzZFx44dERoairS0tCLL/eyzzxASEgJPT0/06NEDcrkc586dw6FDhwAATZs2hampKfr06QN3d3e0adNGa3V504gRIzBgwACkpaWhbNmy2L59e4EuKiDvV/C8efNw9uxZmJiYYP369Zg5c6bYEqNKfen9Y2Fh8c4DB2tra1SoUAGPHj1SSn/48CFq1KjxTuuiL2QC520R6ZRNmzbBxMSkwOBdIG/65fz58zFq1CjxS/h1586dQ2hoKExMTODt7Q0fHx+l5zMyMrB161YkJSWhYcOG+OCDD7BlyxZMmzYNQN6A4r179xZY8O748eO4ePEiKlasiI8++gj29vbic/fv38fff/+NxMREtG7dWlwnRt26vEmqbpcvX8bx48dhamqK9u3b4969e8jIyBCvXXh4OI4fP44hQ4YorRMjNROqqPoWdl2ING3IkCGIiIjAxYsXYWRkhIyMDNSoUQNjx47FzJkzS7t6OodBDBERkY64f/8+vL290bJlSwQEBGDz5s14+vQpwsLCYGdnV9rV0zkcE0NERKQjXFxccOXKFdSvXx///vsvAgICcOHCBQYwhWBLDBEREekltsQQERGRXmIQQ0RERHqJQQwRERHpJQYxREREpJcYxBAREZFeYhBDREREeolBDBEREeklBjFERESklxjEEBERkV5iEENERER6iUEMERER6SUGMURERKSX/h8rpuln82vbHgAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "precisions, recalls, thresholds = precision_recall_curve(oof_true, oof_calibrated)\n", + "f1_scores = 2 * precisions * recalls / np.clip(precisions + recalls, 1e-9, None)\n", + "best_threshold_idx = np.argmax(\n", + " f1_scores[:-1]\n", + ") # last precision/recall pair has no threshold\n", + "DECISION_THRESHOLD = thresholds[best_threshold_idx]\n", + "print(\n", + " f\"decision threshold chosen from the calibrated PR curve (max F1): \"\n", + " f\"{DECISION_THRESHOLD:.3f} (F1={f1_scores[best_threshold_idx]:.3f})\"\n", + ")\n", + "\n", + "predicted_label = oof_calibrated >= DECISION_THRESHOLD\n", + "fig, ax = plt.subplots(figsize=(5, 5))\n", + "ConfusionMatrixDisplay.from_predictions(\n", + " oof_true, predicted_label, display_labels=[\"Invalid\", \"Valid\"], ax=ax\n", + ")\n", + "ax.set_title(\n", + " f\"Confusion matrix - calibrated OOF predictions, threshold={DECISION_THRESHOLD:.3f}\"\n", + ")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "final-model-md", + "metadata": {}, + "source": [ + "## The final deployable model\n", + "\n", + "`sffs_selected_features` (Stage 1, fixed) and the consensus\n", + "hyperparameters (Stage 2), refit once on **all** 500 labeled rows\n", + "(`clean_pool + uncertain_pool`) to use every available label in the\n", + "model that actually gets kept.\n", + "\n", + "**Calibration reuses the Stage 2.5 `oof_calibrator` as-is, not a fresh\n", + "fit on this model's own predictions.** Fitting a new calibrator against\n", + "this final model's predictions on the same clean rows it was just\n", + "trained on would be exactly the \"optimistic, on its own training data\"\n", + "trap - this model has seen every one of those rows. Reusing the\n", + "calibrator already fit on Stage 2's rotation models (each of which\n", + "never saw its own outer-eval row) keeps calibration leak-free, at the\n", + "cost of a small transferability assumption: that this consensus-config\n", + "model's raw probability distribution is close enough to the rotation\n", + "models' for the same Platt transform to still apply. Given it's the\n", + "same family, the same fixed features, and hyperparameters that are\n", + "themselves the median of those same rotations, that assumption is\n", + "reasonable here." + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "id": "final-model", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "final model: LightGBM, 80 features, trained on all 500 labeled rows\n" + ] + } + ], + "source": [ + "final_model = lightgbm_build_model(consensus_hyperparams)\n", + "final_model.fit(\n", + " pd.concat(\n", + " [\n", + " clean_pool[sffs_selected_features],\n", + " uncertain_pool[sffs_selected_features],\n", + " ],\n", + " ignore_index=True,\n", + " ),\n", + " pd.concat([clean_pool[\"label\"], uncertain_pool[\"label\"]], ignore_index=True),\n", + ")\n", + "final_calibrator = oof_calibrator\n", + "\n", + "print(\n", + " f\"final model: LightGBM, {len(sffs_selected_features)} features, \"\n", + " f\"trained on all {len(labeled)} labeled rows\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "unlabeled-apply-md", + "metadata": {}, + "source": [ + "## Apply the final model to the full unlabeled pool\n", + "\n", + "The same `confident_90_count`-style coverage report the labeling app\n", + "itself produces, generalized to a few thresholds." + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "id": "unlabeled-apply", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "predictions over the full remaining unlabeled pool (940,488 trips):\n", + " confident at 0.70: 939,590 / 940,488 (99.9%)\n", + " confident at 0.80: 938,893 / 940,488 (99.8%)\n", + " confident at 0.90: 937,549 / 940,488 (99.7%)\n", + " confident at 0.95: 936,525 / 940,488 (99.6%)\n" + ] + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAxYAAAHqCAYAAACZcdjsAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjEsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvctoD+AAAAAlwSFlzAAAPYQAAD2EBqD+naQAATOBJREFUeJzt3Xd0VGX+x/HPhFRCEiCB0AIoVToEaVFAERABBaQoIlhorrKIghQLCxZQWUGxsFIsKwuraABBwKUqKEWKoqJShAQIoYQUIIUkz+8PD/NjyARmcgMzIe/XOXMO89zvc+9z752E+eQ2mzHGCAAAAAAs8PH0AAAAAAAUfQQLAAAAAJYRLAAAAABYRrAAAAAAYBnBAgAAAIBlBAsAAAAAlhEsAAAAAFhGsAAAAABgGcECAAAAgGUEC8ANpUuX1vDhw+3vz5w5I5vNpqlTp3pwVHldOk5vMHXqVNlsNiUnJ3tknrNmzZLNZtPhw4fdbrvWli1bJpvNpo0bN3psXM6WdzX2YUHGgWurQYMG6tat2zVZ1r/+9S/VqlVLfn5+Kl26tNv9b7nlFrVv3/6KbdeSleVfjW1/NbbHfffdp+rVqxfqPAubpz8HxQXBAkXGokWLZLPZ7K8SJUooMjJSvXr10o8//ujp4bnl5MmTstlsmjZtmqeHAhfMmDFDNptNx44d8/RQ3FYUxl4Uxni9a968ue644w6PjmHbtm0aPny4xowZo/T09GsaYAEUDoIFipwFCxbIGKP09HTFxsbql19+0S233KK9e/de87GUKlVKxhiNGzfumi8b7hk+fLiMMapSpUqh1F1r13pc3rIdvGUcuPq++eYbSdL9998vX19fD48GQEEQLFBk+fv7q02bNpo+fbrOnDmjOXPmeHpIAIACOnnypCQpKCjIwyMBUFAECxR5devWlSTFx8dLktq3b69WrVrp4MGD6t69u0JDQ9WjRw9JUk5OjqZNm6YGDRooMDBQZcqUUe/evbV//36HeZ48eVKDBg1SmTJlFBYWpvvvv1+nT5/Os+z8rrHIzs7WP//5TzVu3FglS5ZUpUqVNGjQIMXHx+uHH35QuXLlJEljxoyxn9o1ePBge//CHqczhw8fls1m09tvv62FCxeqbt26CgwMVOPGjbV48eJ8axctWqSGDRvKz89PixYtkiTt2LFD3bp1U+nSpe3zeP/9950uNyMjQ48//rjCw8MVEhKiXr166dChQw41ixcvdjjtLTg4WC1atNAnn3xS4Hm6eq7+pXVPPvmkRo0aJUmqWLGifUzr16/XkiVLZLPZ8mwvSVqxYoVsNps+/fTTyy5vw4YNatWqlQIDA1WtWjW99dZbLo1Lkk6cOKFhw4apatWqCgoKUp06dTR69GglJSVdcezS/18zkZiYqKeeekqRkZH2L3WX216ubO8nnnhCpUqVytP3wr7dvHmzS2PMbxyufOYurN/Jkyc1ZswYlStXTsHBwbr77rt19OhRp9vZ2Vhd+RzGxcVp8ODBioqKUmBgoOrXr6833nhD58+fv+K2lv66tqBx48YKDAxU6dKl1a1bN+3cudNhGRs2bFCHDh0UERGhsLAwtWrVSvPnz5cxxq2aS0VERGj79u1as2aNfV2dHSHau3evOnbsaP+dNmnSJKfz/eqrr9S+fXuFhoYqKChIrVu31sqVKy+7rX19fe2/R/38/GSz2exHgvO7Zqx3796qWbPmZefrDneWc+HaB1e3ibN5Xnxab7ly5dSrVy/99ttvTut3796tdu3aqWTJkoqKitI//vEP5eTk5KkryLZ3p29ubq5eeeUVVatWTUFBQYqJidH27dtdmr/0/9vN1fVx5efCnTpcfQQLFHm///67JCkqKsrelp6err/97W8aP368/vzzT/Xt21fGGPXp00evvPKKJkyYoGPHjmn79u06f/682rRpY/+ikZWVpU6dOmndunWKjY1VfHy8BgwYoGHDhrk0HmOM7r33Xk2ePFkjRozQn3/+qZ07d+qOO+7QrFmz1Lx5c504cUKS9Prrr8sYI2OM/YjLtRrnBatXr9batWv1v//9TwcOHNCtt96qXr16acmSJU5rV65cqWXLlmnnzp2KjIzU9u3bdcsttyg7O1tbt27VkSNHNGjQIP3tb3/ThAkT8sxj9OjRatOmjQ4cOKD169dr7969ateuncP51D169LBvl9zcXO3fv189e/bUwIED9eWXXxZongU1Y8YMTZ8+XZKUkJBgH1f79u3VrVs3Va1aVe+++26efu+++67Cw8PtodaZLVu2qFOnToqKitKePXu0detWJSYmau7cuS6NrX///tq0aZOWLl2q5ORkrVixQlWqVNFHH310xbFfbPTo0WratKl+++23fIPNpfWFtb1dHePF3P3MjR8/Xk2aNNG+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tas7aFhSkH3u5+en1atXa+XKlerUqZNyc3M1fPhw9enTR3Fxcfx+92IF/X+5f//+On36tOLi4lSqVCmdOnVKt956q9LS0jRr1qyrOmZce978/Y1Toa4TF74cXHr70CNHjqhq1aqF1gfeo1q1asrKytLJkyftbTk5OUpMTLzi/tuxY4c6duyohx9+WNOmTbvaQ0UhqVatmtOfV0n57vOKFStqz549GjdunMaNG6ePPvpIkvTiiy9qxYoVV3fAsCy/fe7j46MqVao47VO9enXdcMMN6tSpk6S/TpEYMmSIEhMT9ccff1z1MaPggoODFR4e7tb/yzk5OVqxYoUGDRpkP00yPDxc999/v5YuXXrVx4xrz5u/vxEsrhNVqlRR3bp1HX6JHD9+XN9//706duxob9uzZ492797tVh94p7Zt28rf399h/61du1ZpaWkO+2/btm06ePCg/f2uXbvUsWNHPfjgg5o+ffq1HDIs6tixo/25BBcsWbJEpUuX1s033yxJOn/+vDZu3GgPnEuWLNHGjRvtr1dffVWStGzZMg0fPvzarwTc0rFjR61evVrnzp2zty1ZskQxMTEKCgqSJJ0+fVobN260//Wyc+fOOnXqlMNfMw8fPixJKleu3DUcPQqiY8eO+vLLL+3vc3JytHz5coff63Fxcdq8ebMkqUSJEipbtqx9H18QHx/P/r6O/Prrr/r5558l/fXHg5o1azr8/5+QkKCtW7d6/vubRy8dR6GKjY01JUqUMC+++KKJjY01rVu3No0bNzZZWVn2mnvuuce0a9fOrT7wXs8//7wpXbq0mT17tpk/f76pUqWKGThwoENN5cqVzdNPP22M+eu5F+Hh4aZt27bm22+/dXhlZmZ6YhXgpi5dupi6deuaTz/91MycOdMEBgaat956yz49ISHBSDILFixw2p+7QhUtqamp5sYbbzSdOnUyS5YsMWPHjjW+vr5mw4YN9poL+3Tv3r3GGGOysrJMdHS06dq1q1m+fLn56KOPTFRUlBk0aJCH1gLu+OWXX0xwcLAZOnSoWbp0qenTp48pV66cw/NqJk6caMLCwuzvX3zxRRMcHGymTZtmvv76azN58mTj6+trZs+e7YE1gLvi4+PNt99+a+bMmWMkmfnz55tvv/3WJCQk2Gu6du1qOnToYH+/aNEi4+vra15++WXzxRdfmBYtWphmzZp5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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "unlabeled_raw_proba = final_model.predict_proba(unlabeled[sffs_selected_features])[:, 1]\n", + "unlabeled_calibrated_proba = final_calibrator.predict(unlabeled_raw_proba)\n", + "\n", + "print(f\"predictions over the full remaining unlabeled pool ({len(unlabeled):,} trips):\")\n", + "confidence_rows = []\n", + "for threshold in [0.7, 0.8, 0.9, 0.95]:\n", + " confident = (unlabeled_calibrated_proba >= threshold) | (\n", + " unlabeled_calibrated_proba <= 1 - threshold\n", + " )\n", + " confidence_rows.append(\n", + " {\n", + " \"threshold\": threshold,\n", + " \"n_confident\": int(confident.sum()),\n", + " \"pct_confident\": float(confident.mean()),\n", + " }\n", + " )\n", + " print(\n", + " f\" confident at {threshold:.2f}: {confident.sum():,} / {len(unlabeled):,} \"\n", + " f\"({confident.mean():.1%})\"\n", + " )\n", + "confidence_table = pd.DataFrame(confidence_rows)\n", + "\n", + "fig, ax = plt.subplots(figsize=(8, 5))\n", + "ax.hist(unlabeled_calibrated_proba, bins=50)\n", + "ax.set_xlabel(\"calibrated P(valid)\")\n", + "ax.set_ylabel(\"trip count\")\n", + "ax.set_title(\"Predicted probability distribution across the full unlabeled pool\")\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "summary-md", + "metadata": {}, + "source": [ + "## Summary" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "id": "summary", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "======================================================================\n", + "FINAL SUMMARY\n", + "======================================================================\n", + "\n", + "Stage 1 (SFFS): 80 features selected in 3 accepted steps\n", + "['gtfs_route_has_both_directions', 'weekday_number', 'is_weekend', 'trip_duration_seconds', 'route_avg_trip_duration_seconds_loo', 'route_avg_trip_duration_n_trips_loo', 'trip_duration_ratio_to_route_avg', 'route_direction_avg_trip_duration_seconds_loo', 'route_direction_avg_trip_duration_n_trips_loo', 'trip_duration_ratio_to_route_direction_avg', 'route_hour_avg_trip_duration_seconds_loo', 'route_hour_avg_trip_duration_n_trips_loo', 'trip_duration_ratio_to_route_hour_avg', 'route_direction_hour_avg_trip_duration_seconds_loo', 'route_direction_hour_avg_trip_duration_n_trips_loo', 'trip_duration_ratio_to_route_direction_hour_avg', 'route_reverse_direction_avg_trip_duration_seconds', 'route_reverse_direction_n_trips', 'trip_duration_ratio_to_reverse_direction_avg', 'route_reverse_direction_hour_avg_trip_duration_seconds', 'route_reverse_direction_hour_n_trips', 'trip_duration_ratio_to_reverse_direction_hour_avg', 'route_i_scheduled_duration_avg_seconds', 'route_i_scheduled_duration_n_trips', 'trip_duration_ratio_to_scheduled_i', 'route_v_scheduled_duration_avg_seconds', 'route_v_scheduled_duration_n_trips', 'trip_duration_ratio_to_scheduled_v', 'route_i_scheduled_duration_avg_seconds_at_hour', 'route_i_scheduled_duration_n_trips_at_hour', 'trip_duration_ratio_to_scheduled_i_at_hour', 'route_v_scheduled_duration_avg_seconds_at_hour', 'route_v_scheduled_duration_n_trips_at_hour', 'trip_duration_ratio_to_scheduled_v_at_hour', 'trip_fare_count', 'fare_gap_avg_seconds', 'fare_gap_stddev_seconds', 'fare_gap_coefficient_of_variation', 'fare_gap_avg_ratio_to_duration', 'fare_span_seconds', 'fare_span_ratio_to_duration', 'trip_distance_meters', 'route_i_length_meters', 'trip_distance_ratio_to_route_i', 'route_v_length_meters', 'trip_distance_ratio_to_route_v', 'trip_points_standard_distance_meters', 'trip_cohesion_ratio_to_route_i', 'trip_cohesion_ratio_to_route_v', 'trip_start_distance_to_i_start_meters', 'trip_start_offset_ratio_to_i', 'trip_start_distance_to_v_start_meters', 'trip_start_offset_ratio_to_v', 'trip_end_distance_to_i_end_meters', 'trip_end_offset_ratio_to_i', 'trip_end_distance_to_v_end_meters', 'trip_end_offset_ratio_to_v', 'path_frechet_distance_to_i_meters', 'path_match_score_frechet_i', 'path_frechet_distance_to_v_meters', 'path_match_score_frechet_v', 'path_hausdorff_distance_to_i_meters', 'path_match_score_hausdorff_i', 'path_hausdorff_distance_to_v_meters', 'path_match_score_hausdorff_v', 'trip_progress_correlation_to_i', 'trip_progress_correlation_to_v', 'trip_progress_correlation_n_points', 'trip_start_distance_to_nearest_garage_meters', 'trip_end_distance_to_nearest_garage_meters', 'route_i_straight_line_meters', 'route_i_straight_line_ratio', 'route_v_straight_line_meters', 'route_v_straight_line_ratio', 'trip_start_distance_to_nearest_terminal_meters', 'trip_start_distance_to_nearest_open_terminal_meters', 'trip_start_distance_to_nearest_closed_terminal_meters', 'trip_end_distance_to_nearest_terminal_meters', 'trip_end_distance_to_nearest_open_terminal_meters', 'trip_end_distance_to_nearest_closed_terminal_meters']\n", + "\n", + "Stage 2: 50 outer rotations (5 folds x 10 repeats)\n", + "\n", + "Outer-eval metrics (mean +/- std across all rotations) - the headline numbers:\n", + " mean std\n", + "auc 1.0000 0.0000\n", + "brier 0.0012 0.0030\n", + "log_loss 0.0039 0.0081\n", + "ece 0.0023 0.0041\n", + "\n", + "Consensus hyperparameters (median across rotations):\n", + " num_leaves: 30\n", + " min_child_samples: 22\n", + " learning_rate: 0.13990994418445057\n", + " feature_fraction: 0.733775259790791\n", + " bagging_fraction: 0.7797319207223575\n", + " lambda_l1: 2.711804476299991e-06\n", + " lambda_l2: 2.804643006967186e-06\n", + " n_estimators: 294\n", + "\n", + "Final unlabeled-pool confidence coverage:\n", + " threshold n_confident pct_confident\n", + "0 0.70 939590 0.9990\n", + "1 0.80 938893 0.9983\n", + "2 0.90 937549 0.9969\n", + "3 0.95 936525 0.9958\n" + ] + } + ], + "source": [ + "print(\"=\" * 70)\n", + "print(\"FINAL SUMMARY\")\n", + "print(\"=\" * 70)\n", + "\n", + "print(\n", + " f\"\\nStage 1 (SFFS): {len(sffs_selected_features)} features selected \"\n", + " f\"in {len(trajectory_df)} accepted steps\"\n", + ")\n", + "print(sffs_selected_features)\n", + "\n", + "print(\n", + " f\"\\nStage 2: {N_ROTATIONS} outer rotations \"\n", + " f\"({N_OUTER_SPLITS} folds x {N_OUTER_REPEATS} repeats)\"\n", + ")\n", + "print(\n", + " \"\\nOuter-eval metrics (mean +/- std across all rotations) - the headline numbers:\"\n", + ")\n", + "print(outer_eval_summary.round(4))\n", + "\n", + "print(\"\\nConsensus hyperparameters (median across rotations):\")\n", + "for hp_name, hp_value in consensus_hyperparams.items():\n", + " print(f\" {hp_name}: {hp_value}\")\n", + "\n", + "print(\"\\nFinal unlabeled-pool confidence coverage:\")\n", + "print(confidence_table.round(4))" + ] + }, + { + "cell_type": "markdown", + "id": "save-md", + "metadata": {}, + "source": [ + "## Save the final model to the repository\n", + "\n", + "Written to `ml/trip_validity_model/artifacts/`, saved unconditionally,\n", + "alongside the live labeling app's own per-run history from\n", + "`training.py` (`run_NNNN_*.joblib`) - both are now git-tracked model\n", + "artifacts in the same place, this one just named distinctly\n", + "(`final_lightgbm_model.joblib` / `final_model_summary.json`) so it's\n", + "never mistaken for one of the app's own numbered runs." + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "id": "save", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "saved to /home/victor/repos/opa-database/ml/trip_validity_model/notebooks/06_artifacts/\n", + " final_lightgbm_model.joblib - model + calibrator + config, for deployment\n", + " final_model_summary.json - the same metadata, human-readable\n" + ] + } + ], + "source": [ + "ARTIFACT_DIR = _root / \"ml/trip_validity_model/artifacts\"\n", + "ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)\n", + "\n", + "joblib.dump(\n", + " {\n", + " \"model\": final_model,\n", + " \"calibrator_params\": final_calibrator.to_params(),\n", + " \"selected_features\": sffs_selected_features,\n", + " \"hyperparameters\": consensus_hyperparams,\n", + " },\n", + " ARTIFACT_DIR / \"final_lightgbm_model.joblib\",\n", + ")\n", + "\n", + "summary = {\n", + " \"sffs\": {\n", + " \"raw_selected_features\": sffs_raw_selected_features,\n", + " \"n_features_selected\": len(sffs_raw_selected_features),\n", + " \"n_accepted_steps\": len(trajectory_df),\n", + " \"final_cv_brier\": float(trajectory_df[\"brier\"].iloc[-1]),\n", + " \"fallback_triggered\": sffs_fallback_triggered,\n", + " },\n", + " \"n_outer_rotations\": N_ROTATIONS,\n", + " \"n_outer_splits\": N_OUTER_SPLITS,\n", + " \"n_outer_repeats\": N_OUTER_REPEATS,\n", + " \"outer_eval_metrics\": {\n", + " metric_name: {\"mean\": float(row[\"mean\"]), \"std\": float(row[\"std\"])}\n", + " for metric_name, row in outer_eval_summary.iterrows()\n", + " },\n", + " \"selected_features\": sffs_selected_features,\n", + " \"consensus_hyperparameters\": consensus_hyperparams,\n", + " \"decision_threshold\": float(DECISION_THRESHOLD),\n", + " \"unlabeled_pool_confidence\": confidence_table.to_dict(orient=\"records\"),\n", + "}\n", + "with (ARTIFACT_DIR / \"final_model_summary.json\").open(\"w\") as f:\n", + " json.dump(summary, f, indent=2)\n", + "\n", + "print(f\"saved to {ARTIFACT_DIR}/\")\n", + "print(\" final_lightgbm_model.joblib - model + calibrator + config, for deployment\")\n", + "print(\" final_model_summary.json - the same metadata, human-readable\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "opa-database (3.12.13)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/ml/trip_validity_model/notebooks/07_final_trip_table.ipynb b/ml/trip_validity_model/notebooks/07_final_trip_table.ipynb new file mode 100644 index 0000000..0cce6c5 --- /dev/null +++ b/ml/trip_validity_model/notebooks/07_final_trip_table.ipynb @@ -0,0 +1,712 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "title", + "metadata": {}, + "source": [ + "# 07 - `ml.trip_validity_final`: the final trip-validity table\n", + "\n", + "The deliverable this whole model came from: one row per trip (all\n", + "940,988), with a single validity flag, sourced either from a human\n", + "label (the 500 collected via the labeling app) or from the notebook-06\n", + "final model - never both, never neither.\n", + "\n", + "**Columns**: `trip_id`, `bus_id` (5-digit), `route_id` (already the\n", + "correct zero-padded internal format - see the note below), `trip_date`,\n", + "`trip_start_timestamp`/`trip_end_timestamp`, `gtfs_feed_version_date` +\n", + "`gtfs_shape_id_i`/`gtfs_shape_id_v` (a single join away from\n", + "`ml.trip_validity_route_shapes`/`ml.trip_validity_route_stops` for\n", + "route geometry and ordered stops - no need to go through\n", + "`ml.trip_validity_route_gtfs_match` first), `is_valid`, `label_source`\n", + "(`'human'` or `'model'`), and `model_name`/`model_confidence` (both\n", + "`NULL` for human-labeled rows).\n", + "\n", + "**On `route_id` format**: checked directly - 935,004 of 940,988 trips\n", + "(99.36%) already have a clean 3-digit zero-padded code. The remaining\n", + "5,984 are genuinely 4-digit: `1074` and `1815` are real routes with\n", + "real GTFS matches (the agency itself uses 4 digits for them, confirmed\n", + "against `gtfs_route_short_name`); `1075` and `9999` have no GTFS match\n", + "in any feed at all (`9999` in particular looks like a sentinel/\n", + "placeholder in the source AFC data). `route_id` is carried over as-is -\n", + "forcing everything to exactly 3 digits would corrupt the two genuine\n", + "4-digit routes for no benefit.\n", + "\n", + "**On the ~1.7% of trips with no GTFS match** (30 of 352 routes never\n", + "matched any feed, `trip_validity_route_gtfs_match.gtfs_feed_version_date\n", + "IS NULL`): included anyway, with `gtfs_feed_version_date`/\n", + "`gtfs_shape_id_i`/`gtfs_shape_id_v` left `NULL` - this is a table of\n", + "*trips*, not of trips-with-known-routes, so every trip gets a row\n", + "regardless of whether its route geometry happens to be resolvable.\n", + "\n", + "**Model scoring**: every trip without a human label gets scored by the\n", + "notebook-06 final model (`ml/trip_validity_model/artifacts/\n", + "final_lightgbm_model.joblib` + its saved decision threshold), tagged\n", + "`model_name = 'trip_validity_lightgbm_v1'` - a stable version string,\n", + "not a file path, so it survives that artifact being retrained or moved\n", + "later without silently going stale." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "imports", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T15:34:32.411285Z", + "iopub.status.busy": "2026-08-22T15:34:32.411214Z", + "iopub.status.idle": "2026-08-22T15:34:32.578728Z", + "shell.execute_reply": "2026-08-22T15:34:32.578321Z" + } + }, + "outputs": [], + "source": [ + "import json\n", + "import os\n", + "import sys\n", + "from pathlib import Path\n", + "\n", + "import joblib\n", + "import pandas as pd\n", + "import psycopg\n", + "from psycopg import sql" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "root", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T15:34:32.579949Z", + "iopub.status.busy": "2026-08-22T15:34:32.579830Z", + "iopub.status.idle": "2026-08-22T15:34:32.581717Z", + "shell.execute_reply": "2026-08-22T15:34:32.581466Z" + } + }, + "outputs": [], + "source": [ + "_root = Path.cwd()\n", + "while not (_root / \"pyproject.toml\").exists():\n", + " _root = _root.parent\n", + "os.chdir(_root)\n", + "os.environ.setdefault(\"RAW_DATA_ROOT\", str(_root))\n", + "sys.path.insert(0, str(_root / \"ml/trip_validity_model/app\"))" + ] + }, + { + "cell_type": "markdown", + "id": "connect-md", + "metadata": {}, + "source": [ + "## Connect and import the app's own feature list and calibrator" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "connect", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T15:34:32.582439Z", + "iopub.status.busy": "2026-08-22T15:34:32.582360Z", + "iopub.status.idle": "2026-08-22T15:34:33.054142Z", + "shell.execute_reply": "2026-08-22T15:34:33.053767Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "connected\n" + ] + } + ], + "source": [ + "import features\n", + "from calibration import PlattCalibrator\n", + "\n", + "from opa_database.config import settings\n", + "\n", + "conn = psycopg.connect(settings.db_dsn)\n", + "print(\"connected\")" + ] + }, + { + "cell_type": "markdown", + "id": "table-md", + "metadata": {}, + "source": [ + "## Create the table\n", + "\n", + "`is_valid`/`label_source`/`model_name`/`model_confidence` are\n", + "constrained together: a human row must have both model columns `NULL`;\n", + "a model row must have both `NOT NULL` - the schema itself enforces\n", + "\"never both, never neither\" rather than trusting every future INSERT\n", + "to get it right." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "table", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T15:34:33.054941Z", + "iopub.status.busy": "2026-08-22T15:34:33.054829Z", + "iopub.status.idle": "2026-08-22T15:34:33.062407Z", + "shell.execute_reply": "2026-08-22T15:34:33.062154Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "table created\n" + ] + } + ], + "source": [ + "conn.execute(\"\"\"\n", + " DROP TABLE IF EXISTS ml.trip_validity_final CASCADE;\n", + "\n", + " CREATE TABLE ml.trip_validity_final (\n", + " trip_id bigint NOT NULL\n", + " REFERENCES ml.trip_validity_dataset (trip_id),\n", + " bus_id text NOT NULL,\n", + " route_id text NOT NULL,\n", + " trip_date date NOT NULL,\n", + " trip_start_timestamp timestamptz NOT NULL,\n", + " trip_end_timestamp timestamptz NOT NULL,\n", + " gtfs_feed_version_date date,\n", + " gtfs_shape_id_i text,\n", + " gtfs_shape_id_v text,\n", + " is_valid boolean NOT NULL,\n", + " label_source text NOT NULL\n", + " CHECK (label_source IN ('human', 'model')),\n", + " model_name text,\n", + " model_confidence double precision,\n", + " PRIMARY KEY (trip_id),\n", + " CHECK (\n", + " (label_source = 'human' AND model_name IS NULL AND model_confidence IS NULL)\n", + " OR\n", + " (label_source = 'model' AND model_name IS NOT NULL\n", + " AND model_confidence IS NOT NULL)\n", + " )\n", + " );\n", + "\"\"\")\n", + "conn.commit()\n", + "print(\"table created\")" + ] + }, + { + "cell_type": "markdown", + "id": "human-labels-md", + "metadata": {}, + "source": [ + "## Insert the 500 human-labeled trips\n", + "\n", + "Direct copy of the label - no model involved, so `model_name`/\n", + "`model_confidence` stay `NULL`." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "human-labels", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T15:34:33.063128Z", + "iopub.status.busy": "2026-08-22T15:34:33.063052Z", + "iopub.status.idle": "2026-08-22T15:34:33.075881Z", + "shell.execute_reply": "2026-08-22T15:34:33.075628Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "500 human-labeled rows inserted\n" + ] + } + ], + "source": [ + "conn.execute(\"\"\"\n", + " INSERT INTO ml.trip_validity_final (\n", + " trip_id, bus_id, route_id, trip_date,\n", + " trip_start_timestamp, trip_end_timestamp,\n", + " gtfs_feed_version_date, gtfs_shape_id_i, gtfs_shape_id_v,\n", + " is_valid, label_source, model_name, model_confidence\n", + " )\n", + " SELECT\n", + " d.trip_id, d.bus_id, d.route_id, d.trip_date,\n", + " d.trip_opening_timestamp, d.trip_closing_timestamp,\n", + " d.gtfs_feed_version_date, d.gtfs_shape_id_i, d.gtfs_shape_id_v,\n", + " l.label, 'human', NULL, NULL\n", + " FROM ml.trip_validity_labels l\n", + " JOIN ml.trip_validity_dataset d ON d.trip_id = l.trip_id;\n", + "\"\"\")\n", + "conn.commit()\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\"SELECT count(*) FROM ml.trip_validity_final;\")\n", + " print(f\"{cur.fetchone()[0]} human-labeled rows inserted\")" + ] + }, + { + "cell_type": "markdown", + "id": "model-load-md", + "metadata": {}, + "source": [ + "## Score every remaining trip with the final model\n", + "\n", + "Loads the notebook-06 final artifact (model, calibrator, selected\n", + "features) and its saved decision threshold, then predicts on every\n", + "trip *not* already in `ml.trip_validity_labels`." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "model-load", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T15:34:33.076624Z", + "iopub.status.busy": "2026-08-22T15:34:33.076546Z", + "iopub.status.idle": "2026-08-22T15:34:33.091741Z", + "shell.execute_reply": "2026-08-22T15:34:33.091423Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "model: trip_validity_lightgbm_v1, 80 features\n", + "decision threshold: 0.8454\n" + ] + } + ], + "source": [ + "MODEL_NAME = \"trip_validity_lightgbm_v1\"\n", + "\n", + "ARTIFACT_DIR = _root / \"ml/trip_validity_model/artifacts\"\n", + "artifact = joblib.load(ARTIFACT_DIR / \"final_lightgbm_model.joblib\")\n", + "final_model = artifact[\"model\"]\n", + "selected_features = artifact[\"selected_features\"]\n", + "calibrator = PlattCalibrator.from_params(**artifact[\"calibrator_params\"])\n", + "\n", + "with (ARTIFACT_DIR / \"final_model_summary.json\").open() as f:\n", + " model_summary = json.load(f)\n", + "DECISION_THRESHOLD = model_summary[\"decision_threshold\"]\n", + "\n", + "print(f\"model: {MODEL_NAME}, {len(selected_features)} features\")\n", + "print(f\"decision threshold: {DECISION_THRESHOLD:.4f}\")" + ] + }, + { + "cell_type": "markdown", + "id": "fetch-unlabeled-md", + "metadata": {}, + "source": [ + "### Fetch every trip without a human label\n", + "\n", + "Pulls both the identifier/timestamp columns this table needs and every\n", + "feature the model was trained on, in one query." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "fetch-unlabeled", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T15:34:33.092682Z", + "iopub.status.busy": "2026-08-22T15:34:33.092597Z", + "iopub.status.idle": "2026-08-22T15:34:54.473734Z", + "shell.execute_reply": "2026-08-22T15:34:54.473333Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "940488 trips to score\n" + ] + } + ], + "source": [ + "feature_columns_sql = sql.SQL(\", \").join(\n", + " sql.SQL(\"d.{}\").format(sql.Identifier(c)) for c in features.ALL_FEATURES\n", + ")\n", + "\n", + "unlabeled_query = sql.SQL(\"\"\"\n", + " SELECT\n", + " d.trip_id, d.bus_id, d.route_id, d.trip_date,\n", + " d.trip_opening_timestamp, d.trip_closing_timestamp,\n", + " d.gtfs_feed_version_date, d.gtfs_shape_id_i, d.gtfs_shape_id_v,\n", + " {feature_columns}\n", + " FROM ml.trip_validity_dataset d\n", + " WHERE NOT EXISTS (\n", + " SELECT 1 FROM ml.trip_validity_labels l WHERE l.trip_id = d.trip_id\n", + " )\n", + "\"\"\").format(feature_columns=feature_columns_sql)\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(unlabeled_query)\n", + " cols = [c.name for c in cur.description]\n", + " unlabeled = pd.DataFrame(cur.fetchall(), columns=cols)\n", + "\n", + "unlabeled = features.cast_feature_dtypes(unlabeled)\n", + "print(f\"{len(unlabeled)} trips to score\")" + ] + }, + { + "cell_type": "markdown", + "id": "score-insert-md", + "metadata": {}, + "source": [ + "### Score, threshold, and bulk-insert\n", + "\n", + "`model_confidence` is the calibrated P(valid) itself, not a\n", + "\"confidence in whichever direction\" transform - so a value near 0\n", + "means confidently invalid, near 1 confidently valid, near the\n", + "decision threshold means genuinely unsure. `is_valid` is just that\n", + "same value thresholded at `DECISION_THRESHOLD` (chosen in notebook 06\n", + "by maximizing F1 on the calibrated out-of-fold predictions, not a\n", + "default 0.5)." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "score-insert", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T15:34:54.474654Z", + "iopub.status.busy": "2026-08-22T15:34:54.474566Z", + "iopub.status.idle": "2026-08-22T15:35:07.089556Z", + "shell.execute_reply": "2026-08-22T15:35:07.089217Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "940988 total rows after model scoring\n" + ] + } + ], + "source": [ + "raw_proba = final_model.predict_proba(unlabeled[selected_features])[:, 1]\n", + "calibrated_proba = calibrator.predict(raw_proba)\n", + "\n", + "model_rows = unlabeled[\n", + " [\n", + " \"trip_id\",\n", + " \"bus_id\",\n", + " \"route_id\",\n", + " \"trip_date\",\n", + " \"trip_opening_timestamp\",\n", + " \"trip_closing_timestamp\",\n", + " \"gtfs_feed_version_date\",\n", + " \"gtfs_shape_id_i\",\n", + " \"gtfs_shape_id_v\",\n", + " ]\n", + "].copy()\n", + "model_rows[\"is_valid\"] = [bool(v) for v in (calibrated_proba >= DECISION_THRESHOLD)]\n", + "model_rows[\"label_source\"] = \"model\"\n", + "model_rows[\"model_name\"] = MODEL_NAME\n", + "model_rows[\"model_confidence\"] = [float(v) for v in calibrated_proba]\n", + "model_rows = model_rows.where(pd.notna(model_rows), None)\n", + "\n", + "insert_columns = [\n", + " \"trip_id\",\n", + " \"bus_id\",\n", + " \"route_id\",\n", + " \"trip_date\",\n", + " \"trip_opening_timestamp\",\n", + " \"trip_closing_timestamp\",\n", + " \"gtfs_feed_version_date\",\n", + " \"gtfs_shape_id_i\",\n", + " \"gtfs_shape_id_v\",\n", + " \"is_valid\",\n", + " \"label_source\",\n", + " \"model_name\",\n", + " \"model_confidence\",\n", + "]\n", + "table_columns = [\n", + " \"trip_id\",\n", + " \"bus_id\",\n", + " \"route_id\",\n", + " \"trip_date\",\n", + " \"trip_start_timestamp\",\n", + " \"trip_end_timestamp\",\n", + " \"gtfs_feed_version_date\",\n", + " \"gtfs_shape_id_i\",\n", + " \"gtfs_shape_id_v\",\n", + " \"is_valid\",\n", + " \"label_source\",\n", + " \"model_name\",\n", + " \"model_confidence\",\n", + "]\n", + "\n", + "copy_query = sql.SQL(\"COPY ml.trip_validity_final ({}) FROM STDIN\").format(\n", + " sql.SQL(\", \").join(sql.Identifier(c) for c in table_columns)\n", + ")\n", + "with conn.cursor() as cur, cur.copy(copy_query) as copy:\n", + " for row in model_rows[insert_columns].itertuples(index=False, name=None):\n", + " copy.write_row(row)\n", + "conn.commit()\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\"SELECT count(*) FROM ml.trip_validity_final;\")\n", + " print(f\"{cur.fetchone()[0]} total rows after model scoring\")" + ] + }, + { + "cell_type": "markdown", + "id": "indexes-md", + "metadata": {}, + "source": [ + "## Indexes and comments\n", + "\n", + "`(route_id, trip_date)` for resolving back through\n", + "`ml.trip_validity_route_gtfs_match`; `(gtfs_feed_version_date,\n", + "gtfs_shape_id_i/v)` for going straight to\n", + "`ml.trip_validity_route_shapes`/`ml.trip_validity_route_stops`;\n", + "`label_source` since \"give me every model-scored trip\" is an obvious\n", + "query." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "indexes", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T15:35:07.090467Z", + "iopub.status.busy": "2026-08-22T15:35:07.090376Z", + "iopub.status.idle": "2026-08-22T15:35:08.903088Z", + "shell.execute_reply": "2026-08-22T15:35:08.902747Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "indexes and comments applied\n" + ] + } + ], + "source": [ + "conn.execute(\"\"\"\n", + " CREATE INDEX trip_validity_final_route_date_idx\n", + " ON ml.trip_validity_final (route_id, trip_date);\n", + " CREATE INDEX trip_validity_final_shape_i_idx\n", + " ON ml.trip_validity_final (gtfs_feed_version_date, gtfs_shape_id_i);\n", + " CREATE INDEX trip_validity_final_shape_v_idx\n", + " ON ml.trip_validity_final (gtfs_feed_version_date, gtfs_shape_id_v);\n", + " CREATE INDEX trip_validity_final_label_source_idx\n", + " ON ml.trip_validity_final (label_source);\n", + "\"\"\")\n", + "conn.execute(\"ANALYZE ml.trip_validity_final;\")\n", + "conn.commit()\n", + "\n", + "\n", + "def comment_on_column(cur: psycopg.Cursor, col: str, text: str) -> None:\n", + " \"\"\"Apply a COMMENT ON COLUMN for one trip_validity_final column.\"\"\"\n", + " cur.execute(\n", + " sql.SQL(\"COMMENT ON COLUMN ml.trip_validity_final.{} IS {};\").format(\n", + " sql.Identifier(col), sql.Literal(text)\n", + " )\n", + " )\n", + "\n", + "\n", + "COLUMN_COMMENTS = {\n", + " \"trip_id\": \"ml.trip_validity_dataset.trip_id, as-is.\",\n", + " \"bus_id\": \"ml.trip_validity_dataset.bus_id (5-digit AFC vehicle number), as-is.\",\n", + " \"route_id\": (\n", + " \"ml.trip_validity_dataset.route_id, as-is - already zero-padded \"\n", + " \"(usually 3 digits; a small number of routes are genuinely \"\n", + " \"4 digits, confirmed against gtfs_route_short_name, not a \"\n", + " \"formatting artifact).\"\n", + " ),\n", + " \"trip_date\": \"ml.trip_validity_dataset.trip_date, as-is.\",\n", + " \"trip_start_timestamp\": \"ml.trip_validity_dataset.trip_opening_timestamp, as-is.\",\n", + " \"trip_end_timestamp\": \"ml.trip_validity_dataset.trip_closing_timestamp, as-is.\",\n", + " \"gtfs_feed_version_date\": (\n", + " \"ml.trip_validity_dataset.gtfs_feed_version_date, as-is. NULL if \"\n", + " \"this trip's route never matched any GTFS feed.\"\n", + " ),\n", + " \"gtfs_shape_id_i\": (\n", + " \"ml.trip_validity_dataset.gtfs_shape_id_i, as-is - join to \"\n", + " \"ml.trip_validity_route_shapes/route_stops on \"\n", + " \"(gtfs_feed_version_date, gtfs_shape_id_i) for the I-direction \"\n", + " \"route geometry/stops.\"\n", + " ),\n", + " \"gtfs_shape_id_v\": (\"Same as gtfs_shape_id_i, for the V direction.\"),\n", + " \"is_valid\": (\n", + " \"The final validity determination - a human label if one \"\n", + " \"exists, otherwise the notebook-06 model's prediction \"\n", + " \"thresholded at its own saved decision threshold.\"\n", + " ),\n", + " \"label_source\": (\n", + " \"'human' (500 rows, from the labeling app) or 'model' (everything else).\"\n", + " ),\n", + " \"model_name\": (\n", + " \"Version string identifying which model scored this trip - \"\n", + " \"NULL for human-labeled rows. See \"\n", + " \"ml/trip_validity_model/notebooks/06_model_sweep.ipynb.\"\n", + " ),\n", + " \"model_confidence\": (\n", + " \"Calibrated P(valid) from that model - NOT a \"\n", + " \"confidence-in-whichever-direction transform. Near 0 = \"\n", + " \"confidently invalid, near 1 = confidently valid, near the \"\n", + " \"decision threshold = genuinely unsure. NULL for human-labeled \"\n", + " \"rows.\"\n", + " ),\n", + "}\n", + "with conn.cursor() as cur:\n", + " for col, text in COLUMN_COMMENTS.items():\n", + " comment_on_column(cur, col, text)\n", + " cur.execute(\n", + " sql.SQL(\"COMMENT ON TABLE ml.trip_validity_final IS {};\").format(\n", + " sql.Literal(\n", + " \"The final Trip Validity deliverable: one row per trip, \"\n", + " \"human-labeled where available, model-scored otherwise. \"\n", + " \"See ml/trip_validity_model/notebooks/07_final_trip_table.ipynb.\"\n", + " )\n", + " )\n", + " )\n", + "conn.commit()\n", + "print(\"indexes and comments applied\")" + ] + }, + { + "cell_type": "markdown", + "id": "verification-md", + "metadata": {}, + "source": [ + "## Verification" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "verification", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T15:35:08.904024Z", + "iopub.status.busy": "2026-08-22T15:35:08.903950Z", + "iopub.status.idle": "2026-08-22T15:35:08.969858Z", + "shell.execute_reply": "2026-08-22T15:35:08.969541Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'total': 940988, 'human': 500, 'model': 940488, 'valid_count': 720080, 'no_feed_match': 6833, 'model_rows_missing_model_info': 0, 'human_rows_with_model_info': 0}\n", + "OK: 940988 total rows (500 human, 940488 model), 720080 valid (76.5%), 6833 rows across 30 routes with no GTFS feed match\n" + ] + } + ], + "source": [ + "EXPECTED_TOTAL = 940988\n", + "EXPECTED_HUMAN = 500\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\"\"\"\n", + " SELECT\n", + " count(*) AS total,\n", + " count(*) FILTER (WHERE label_source = 'human') AS human,\n", + " count(*) FILTER (WHERE label_source = 'model') AS model,\n", + " count(*) FILTER (WHERE is_valid) AS valid_count,\n", + " count(*) FILTER (WHERE gtfs_feed_version_date IS NULL) AS no_feed_match,\n", + " count(*) FILTER (\n", + " WHERE label_source = 'model'\n", + " AND (model_name IS NULL OR model_confidence IS NULL)\n", + " ) AS model_rows_missing_model_info,\n", + " count(*) FILTER (\n", + " WHERE label_source = 'human'\n", + " AND (model_name IS NOT NULL OR model_confidence IS NOT NULL)\n", + " ) AS human_rows_with_model_info\n", + " FROM ml.trip_validity_final;\n", + " \"\"\")\n", + " cols = [c.name for c in cur.description]\n", + " summary = dict(zip(cols, cur.fetchone(), strict=True))\n", + " print(summary)\n", + "\n", + " cur.execute(\"\"\"\n", + " SELECT count(*)\n", + " FROM ml.trip_validity_final f\n", + " JOIN ml.trip_validity_labels l ON l.trip_id = f.trip_id\n", + " WHERE f.label_source = 'human' AND f.is_valid != l.label;\n", + " \"\"\")\n", + " mismatched_human_labels = cur.fetchone()[0]\n", + "\n", + " cur.execute(\"\"\"\n", + " SELECT count(DISTINCT route_id)\n", + " FROM ml.trip_validity_final WHERE gtfs_feed_version_date IS NULL;\n", + " \"\"\")\n", + " orphan_route_count = cur.fetchone()[0]\n", + "\n", + "if summary[\"total\"] != EXPECTED_TOTAL:\n", + " msg = f\"expected {EXPECTED_TOTAL} total rows, got {summary['total']}\"\n", + " raise AssertionError(msg)\n", + "if summary[\"human\"] != EXPECTED_HUMAN:\n", + " msg = f\"expected {EXPECTED_HUMAN} human-labeled rows, got {summary['human']}\"\n", + " raise AssertionError(msg)\n", + "if summary[\"model\"] != EXPECTED_TOTAL - EXPECTED_HUMAN:\n", + " msg = \"human + model row counts don't add up to the total\"\n", + " raise AssertionError(msg)\n", + "if summary[\"model_rows_missing_model_info\"] != 0:\n", + " msg = \"found model-sourced rows missing model_name/model_confidence\"\n", + " raise AssertionError(msg)\n", + "if summary[\"human_rows_with_model_info\"] != 0:\n", + " msg = \"found human-sourced rows with non-NULL model_name/model_confidence\"\n", + " raise AssertionError(msg)\n", + "if mismatched_human_labels != 0:\n", + " msg = f\"{mismatched_human_labels} rows disagree with their own source label\"\n", + " raise AssertionError(msg)\n", + "\n", + "print(\n", + " f\"OK: {summary['total']} total rows ({summary['human']} human, \"\n", + " f\"{summary['model']} model), {summary['valid_count']} valid \"\n", + " f\"({summary['valid_count'] / summary['total']:.1%}), \"\n", + " f\"{summary['no_feed_match']} rows across {orphan_route_count} routes \"\n", + " \"with no GTFS feed match\"\n", + ")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (opa-database)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/ml/trip_validity_model/notebooks/08_final_fares_table.ipynb b/ml/trip_validity_model/notebooks/08_final_fares_table.ipynb new file mode 100644 index 0000000..73437d2 --- /dev/null +++ b/ml/trip_validity_model/notebooks/08_final_fares_table.ipynb @@ -0,0 +1,401 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "title", + "metadata": {}, + "source": [ + "# 08 - `ml.trip_validity_fares_final`: the final fare-collection table\n", + "\n", + "The second and last deliverable table: one row per fare tap (AFC\n", + "\"boarding\" event) for every trip already in `ml.trip_validity_final`,\n", + "regardless of whether that trip was ultimately judged valid or invalid.\n", + "\n", + "**Columns**: `fare_event_id` (surrogate PK), `trip_id` (FK to\n", + "`ml.trip_validity_final`), `event_id` (the raw AFC id, kept for\n", + "traceability), `card_id`, `integration_type`, `passenger_type`,\n", + "`fare_paid`, `subsidy`, `fare_tapped_at`.\n", + "\n", + "**On the PK**: `event_id` can't be the primary key - `'0'` is a\n", + "sentinel meaning \"no id assigned\" in the source data and is **not\n", + "unique** (223,399 of 15,381,356 November fare taps have it). Rather\n", + "than dropping those rows or picking an arbitrary one, `fare_event_id`\n", + "is a fresh `GENERATED ALWAYS AS IDENTITY` surrogate key - rebuildable\n", + "exactly like `trip_id`/`fare_id` already are elsewhere in this project\n", + "- and the raw `event_id` (duplicates and all) is kept as an ordinary,\n", + "non-unique column instead.\n", + "\n", + "**On scope**: built directly from `silver.afc_boardings` joined to\n", + "`ml.trip_validity_trips` by the same natural key\n", + "(`service_date`/`vehicle_number`/`trip_opened_at`/`trip_closed_at`)\n", + "notebook 01 already used to build `ml.trip_validity_trip_fares` - not\n", + "by rejoining through `event_id`, which would be ambiguous for every\n", + "`'0'`-sentinel row. Checked directly: for November 2023, `silver.\n", + "afc_boardings` and `ml.trip_validity_trip_fares` have the exact same\n", + "row count (15,381,356) - every fare tap in scope already matches a\n", + "trip, so this notebook only ever builds rows that have one (no `NULL`\n", + "`trip_id` case to handle in practice, though the join is still a plain\n", + "`JOIN`, not something that silently drops rows some other way -\n", + "verified below)." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "imports", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T15:58:10.905891Z", + "iopub.status.busy": "2026-08-22T15:58:10.905818Z", + "iopub.status.idle": "2026-08-22T15:58:10.933091Z", + "shell.execute_reply": "2026-08-22T15:58:10.932734Z" + } + }, + "outputs": [], + "source": [ + "import os\n", + "from pathlib import Path\n", + "\n", + "import psycopg\n", + "from psycopg import sql" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "root", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T15:58:10.934104Z", + "iopub.status.busy": "2026-08-22T15:58:10.934035Z", + "iopub.status.idle": "2026-08-22T15:58:10.937287Z", + "shell.execute_reply": "2026-08-22T15:58:10.936992Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "'/home/victor/repos/opa-database'" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "_root = Path.cwd()\n", + "while not (_root / \"pyproject.toml\").exists():\n", + " _root = _root.parent\n", + "os.chdir(_root)\n", + "os.environ.setdefault(\"RAW_DATA_ROOT\", str(_root))" + ] + }, + { + "cell_type": "markdown", + "id": "connect-md", + "metadata": {}, + "source": [ + "## Connect" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "connect", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T15:58:10.938200Z", + "iopub.status.busy": "2026-08-22T15:58:10.938124Z", + "iopub.status.idle": "2026-08-22T15:58:10.982737Z", + "shell.execute_reply": "2026-08-22T15:58:10.982446Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "connected\n" + ] + } + ], + "source": [ + "from opa_database.config import settings\n", + "\n", + "conn = psycopg.connect(settings.db_dsn)\n", + "print(\"connected\")\n", + "\n", + "TRIP_DATE_START = \"2023-11-01\"\n", + "TRIP_DATE_END = \"2023-12-01\"" + ] + }, + { + "cell_type": "markdown", + "id": "table-md", + "metadata": {}, + "source": [ + "## Create the table and load it" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "table", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T15:58:10.983639Z", + "iopub.status.busy": "2026-08-22T15:58:10.983574Z", + "iopub.status.idle": "2026-08-22T15:59:16.075192Z", + "shell.execute_reply": "2026-08-22T15:59:16.074892Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "rows inserted: 15381356\n" + ] + } + ], + "source": [ + "conn.execute(\"\"\"\n", + " DROP TABLE IF EXISTS ml.trip_validity_fares_final CASCADE;\n", + "\n", + " CREATE TABLE ml.trip_validity_fares_final (\n", + " fare_event_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n", + " trip_id bigint NOT NULL REFERENCES ml.trip_validity_final (trip_id),\n", + " event_id text NOT NULL,\n", + " card_id text,\n", + " integration_type integer,\n", + " passenger_type integer,\n", + " fare_paid double precision,\n", + " subsidy double precision,\n", + " fare_tapped_at timestamptz NOT NULL\n", + " );\n", + "\"\"\")\n", + "\n", + "conn.execute(\n", + " \"\"\"\n", + " INSERT INTO ml.trip_validity_fares_final (\n", + " trip_id, event_id, card_id, integration_type, passenger_type,\n", + " fare_paid, subsidy, fare_tapped_at\n", + " )\n", + " SELECT\n", + " t.trip_id,\n", + " b.event_id,\n", + " b.card_id,\n", + " b.integration_type,\n", + " b.passenger_type,\n", + " b.fare_paid,\n", + " b.subsidy_value,\n", + " b.boarding_at\n", + " FROM silver.afc_boardings b\n", + " JOIN ml.trip_validity_trips t\n", + " ON t.trip_date = b.service_date\n", + " AND t.bus_id = CASE WHEN length(b.vehicle_number) < 5\n", + " THEN lpad(b.vehicle_number, 5, '0') ELSE b.vehicle_number END\n", + " AND t.trip_opening_timestamp = b.trip_opened_at\n", + " AND t.trip_closing_timestamp = b.trip_closed_at\n", + " WHERE b.service_date >= %(start)s AND b.service_date < %(end)s;\n", + " \"\"\",\n", + " {\"start\": TRIP_DATE_START, \"end\": TRIP_DATE_END},\n", + ")\n", + "conn.commit()\n", + "\n", + "with conn.cursor() as cur:\n", + " cur.execute(\"SELECT count(*) FROM ml.trip_validity_fares_final;\")\n", + " print(\"rows inserted:\", cur.fetchone()[0])" + ] + }, + { + "cell_type": "markdown", + "id": "indexes-md", + "metadata": {}, + "source": [ + "## Indexes and comments" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "indexes", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T15:59:16.076244Z", + "iopub.status.busy": "2026-08-22T15:59:16.076160Z", + "iopub.status.idle": "2026-08-22T15:59:36.289920Z", + "shell.execute_reply": "2026-08-22T15:59:36.289665Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "indexes and comments applied\n" + ] + } + ], + "source": [ + "conn.execute(\"\"\"\n", + " CREATE INDEX trip_validity_fares_final_trip_id_idx\n", + " ON ml.trip_validity_fares_final (trip_id);\n", + " CREATE INDEX trip_validity_fares_final_event_id_idx\n", + " ON ml.trip_validity_fares_final (event_id);\n", + " CREATE INDEX trip_validity_fares_final_tapped_at_idx\n", + " ON ml.trip_validity_fares_final (fare_tapped_at);\n", + "\"\"\")\n", + "conn.execute(\"ANALYZE ml.trip_validity_fares_final;\")\n", + "conn.commit()\n", + "\n", + "\n", + "def comment_on_column(cur: psycopg.Cursor, col: str, text: str) -> None:\n", + " \"\"\"Apply a COMMENT ON COLUMN for one trip_validity_fares_final column.\"\"\"\n", + " cur.execute(\n", + " sql.SQL(\"COMMENT ON COLUMN ml.trip_validity_fares_final.{} IS {};\").format(\n", + " sql.Identifier(col), sql.Literal(text)\n", + " )\n", + " )\n", + "\n", + "\n", + "COLUMN_COMMENTS = {\n", + " \"fare_event_id\": (\n", + " \"Surrogate primary key, GENERATED ALWAYS AS IDENTITY - not the raw \"\n", + " \"AFC event_id, which is unusable as a key (see the event_id \"\n", + " \"comment below).\"\n", + " ),\n", + " \"trip_id\": \"ml.trip_validity_final.trip_id, as-is.\",\n", + " \"event_id\": (\n", + " \"silver.afc_boardings.event_id, as-is. NOT unique: '0' is a \"\n", + " \"sentinel for 'no id assigned' in the source data (223,399 of \"\n", + " \"15,381,356 November rows) and is shared across many rows - kept \"\n", + " \"here for traceability only, never as a key.\"\n", + " ),\n", + " \"card_id\": \"silver.afc_boardings.card_id, as-is.\",\n", + " \"integration_type\": (\n", + " \"silver.afc_boardings.integration_type, as-is (4 distinct codes).\"\n", + " ),\n", + " \"passenger_type\": \"silver.afc_boardings.passenger_type, as-is (26 distinct codes).\",\n", + " \"fare_paid\": \"silver.afc_boardings.fare_paid, as-is.\",\n", + " \"subsidy\": \"silver.afc_boardings.subsidy_value, as-is.\",\n", + " \"fare_tapped_at\": (\n", + " \"silver.afc_boardings.boarding_at, as-is (UTC) - when this fare was tapped.\"\n", + " ),\n", + "}\n", + "with conn.cursor() as cur:\n", + " for col, text in COLUMN_COMMENTS.items():\n", + " comment_on_column(cur, col, text)\n", + " cur.execute(\n", + " sql.SQL(\"COMMENT ON TABLE ml.trip_validity_fares_final IS {};\").format(\n", + " sql.Literal(\n", + " \"The final Trip Validity fare-collection deliverable: one \"\n", + " \"row per fare tap, matched to its trip in \"\n", + " \"ml.trip_validity_final. See \"\n", + " \"ml/trip_validity_model/notebooks/08_final_fares_table.ipynb.\"\n", + " )\n", + " )\n", + " )\n", + "conn.commit()\n", + "print(\"indexes and comments applied\")" + ] + }, + { + "cell_type": "markdown", + "id": "verification-md", + "metadata": {}, + "source": [ + "## Verification" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "verification", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-22T15:59:36.290973Z", + "iopub.status.busy": "2026-08-22T15:59:36.290902Z", + "iopub.status.idle": "2026-08-22T15:59:39.271095Z", + "shell.execute_reply": "2026-08-22T15:59:39.270763Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'total': 15381356, 'sentinel_event_ids': 223399, 'distinct_trips': 940988, 'null_trip_id': 0, 'negative_fares': 0}\n", + "OK: 15381356 fare taps across 940988 trips, 223399 with a '0' sentinel event_id (1.5%)\n" + ] + } + ], + "source": [ + "with conn.cursor() as cur:\n", + " cur.execute(\n", + " \"\"\"\n", + " SELECT count(*) FROM silver.afc_boardings\n", + " WHERE service_date >= %(start)s AND service_date < %(end)s;\n", + " \"\"\",\n", + " {\"start\": TRIP_DATE_START, \"end\": TRIP_DATE_END},\n", + " )\n", + " EXPECTED_ROWS = cur.fetchone()[0]\n", + "\n", + " cur.execute(\"\"\"\n", + " SELECT\n", + " count(*) AS total,\n", + " count(*) FILTER (WHERE event_id = '0') AS sentinel_event_ids,\n", + " count(DISTINCT trip_id) AS distinct_trips,\n", + " count(*) FILTER (WHERE trip_id IS NULL) AS null_trip_id,\n", + " count(*) FILTER (WHERE fare_paid < 0) AS negative_fares\n", + " FROM ml.trip_validity_fares_final;\n", + " \"\"\")\n", + " cols = [c.name for c in cur.description]\n", + " summary = dict(zip(cols, cur.fetchone(), strict=True))\n", + " print(summary)\n", + "\n", + "if summary[\"total\"] != EXPECTED_ROWS:\n", + " msg = (\n", + " f\"expected {EXPECTED_ROWS} rows (every November AFC boarding), \"\n", + " f\"got {summary['total']}\"\n", + " )\n", + " raise AssertionError(msg)\n", + "if summary[\"null_trip_id\"] != 0:\n", + " msg = \"found rows with a NULL trip_id despite the NOT NULL constraint\"\n", + " raise AssertionError(msg)\n", + "if summary[\"negative_fares\"] != 0:\n", + " msg = \"found negative fare_paid values\"\n", + " raise AssertionError(msg)\n", + "\n", + "print(\n", + " f\"OK: {summary['total']} fare taps across {summary['distinct_trips']} trips, \"\n", + " f\"{summary['sentinel_event_ids']} with a '0' sentinel event_id \"\n", + " f\"({summary['sentinel_event_ids'] / summary['total']:.1%})\"\n", + ")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (opa-database)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/pyproject.toml b/pyproject.toml index 2ac0dd5..e384190 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,8 +9,6 @@ requires-python = ">=3.12" license = { text = "MIT" } dependencies = [ "click>=8.4.1", - "dbt-core>=1.11.12", - "dbt-postgres>=1.10.2", "pandera>=0.32.0", "polars>=1.41.2", "psycopg[binary]>=3.3", @@ -23,11 +21,26 @@ opa-database = "opa_database.cli:cli" [dependency-groups] dev = [ + "ipykernel>=7.3.0", + "jupyter>=1.1.1", "prek>=0.4.5", "pytest>=9.1.1", "ruff>=0.15.18", "ty>=0.0.51", ] +ml = [ + "catboost>=1.2.10", + "folium>=0.20.0", + "joblib>=1.5.3", + "lightgbm>=4.7.0", + "matplotlib>=3.11.1", + "optuna>=4.9.0", + "pandas>=3.0.5", + "scikit-learn>=1.9.0", + "streamlit>=1.61.1", + "streamlit-folium>=0.27.4", + "xgboost>=3.4.1", +] [build-system] requires = ["hatchling"] @@ -37,8 +50,26 @@ build-backend = "hatchling.build" lint.extend-select = ["ALL"] lint.ignore = ["PLR0913", "D203", "D213", "COM812"] +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["S101", "SLF001", "ANN201", "D103"] +"*.ipynb" = ["T201"] +# Progress-reporting CLI scripts: stdout is the interface, and they are +# run directly (uv run path/to/script.py) rather than imported, so they +# are deliberately not packages. +"ml/*/scripts/*.py" = ["T201", "INP001"] + [tool.ty] -src.include = ["src", "tests"] +src.include = ["src", "tests", "ml/trip_validity_model/app"] +# trip_validity_model/app listed first: it and bus_matching_model/app have +# same-named sibling modules (features.py, db.py, ...), so with both paths +# present, `from features import X` resolves against whichever is listed +# first for any file ty checks -- trip_validity's own files must resolve +# to themselves. bus_matching_model/app is here only so the notebooks +# under ml/bus_matching_model/notebooks/ can resolve `gtfs_cache` (a name +# that doesn't collide); bus_matching_model/app's own files are excluded +# from the ty pre-commit hook entirely (see .pre-commit-config.yaml) so +# they never hit the collision themselves. +environment.extra-paths = ["ml/trip_validity_model/app", "ml/bus_matching_model/app"] rules.division-by-zero = "warn" rules.possibly-unresolved-reference = "warn" rules.unused-ignore-comment = "warn" diff --git a/requirements-dev.txt b/requirements-dev.txt index c3fb4d5..96f0868 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,17 +1,616 @@ # This file was autogenerated by uv via the following command: # uv export --only-group dev --output-file requirements-dev.txt +anyio==4.14.2 \ + --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 \ + --hash=sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f + # via + # httpx + # jupyter-server +appnope==0.1.4 ; sys_platform == 'darwin' \ + --hash=sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee \ + --hash=sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c + # via ipykernel +argon2-cffi==25.1.0 \ + --hash=sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1 \ + --hash=sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741 + # via jupyter-server +argon2-cffi-bindings==25.1.0 \ + --hash=sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99 \ + --hash=sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6 \ + --hash=sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44 \ + --hash=sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a \ + --hash=sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f \ + --hash=sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2 \ + --hash=sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0 \ + --hash=sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f \ + --hash=sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623 \ + --hash=sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b \ + --hash=sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44 \ + --hash=sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98 \ + --hash=sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500 \ + --hash=sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94 \ + --hash=sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6 \ + --hash=sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d \ + --hash=sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85 \ + --hash=sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92 \ + --hash=sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d \ + --hash=sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a \ + --hash=sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb + # via argon2-cffi +arrow==1.4.0 \ + --hash=sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205 \ + --hash=sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7 + # via isoduration +asttokens==3.0.2 \ + --hash=sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2 \ + --hash=sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933 + # via stack-data +async-lru==2.3.0 \ + --hash=sha256:89bdb258a0140d7313cf8f4031d816a042202faa61d0ab310a0a538baa1c24b6 \ + --hash=sha256:eea27b01841909316f2cc739807acea1c623df2be8c5cfad7583286397bb8315 + # via jupyterlab +attrs==26.1.0 \ + --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ + --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32 + # via + # jsonschema + # referencing +babel==2.18.0 \ + --hash=sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d \ + --hash=sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35 + # via jupyterlab-server +beautifulsoup4==4.15.0 \ + --hash=sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7 \ + --hash=sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9 + # via nbconvert +bleach==6.4.0 \ + --hash=sha256:4202482733d85cedd04e59fcb2f89f4e4c7c385a78d3c3c23c30446843a37452 \ + --hash=sha256:4b6b6a54fff2e69a3dde9d21cc6301220bee3c3cb792187d11403fd795031081 + # via nbconvert +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ + --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 + # via + # httpcore + # httpx + # requests +cffi==2.1.1 \ + --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \ + --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \ + --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \ + --hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \ + --hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \ + --hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \ + --hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \ + --hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \ + --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \ + --hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \ + --hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \ + --hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \ + --hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \ + --hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \ + --hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \ + --hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \ + --hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \ + --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \ + --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \ + --hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \ + --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \ + --hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \ + --hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \ + --hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \ + --hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \ + --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \ + --hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \ + --hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \ + --hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \ + --hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \ + --hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \ + --hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \ + --hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \ + --hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \ + --hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \ + --hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \ + --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \ + --hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \ + --hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \ + --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \ + --hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \ + --hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \ + --hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \ + --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \ + --hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \ + --hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \ + --hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \ + --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \ + --hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \ + --hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \ + --hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \ + --hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \ + --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \ + --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \ + --hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \ + --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \ + --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \ + --hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \ + --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \ + --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \ + --hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \ + --hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \ + --hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \ + --hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \ + --hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \ + --hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \ + --hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \ + --hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \ + --hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \ + --hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \ + --hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \ + --hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \ + --hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \ + --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \ + --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264 + # via + # argon2-cffi-bindings + # pyzmq +charset-normalizer==3.5.0 \ + --hash=sha256:054420b5db984971d886e5e4e2c37c760ae6682aedbd066687ff0949d9ed5f08 \ + --hash=sha256:06f4fb62a9139bef056b8b2da6773c94c2f259f90e4b8e53b166f3d0372d7cf6 \ + --hash=sha256:076cf9d3f3c7e410295c09d96355cf3b1bcae74990034d80e4371e20fe1ba4c6 \ + --hash=sha256:07f6f42b5a6325df35b458004fb5f9f29bf502d89287a33c7cdef3590e31de0f \ + --hash=sha256:0b2e44e6d42d1a4ff78ccc219a93c5449105d10b16198d1aea581080df8073f9 \ + --hash=sha256:0b373bab0b867b68b8eb249da9478cab9181a42993437cd2f5dba5fb0b4fbd1b \ + --hash=sha256:0c8953d9d1617794cfc40d81179571c9ba3805dd029623a15c93f1fb70e60a74 \ + --hash=sha256:0cce46dd29d73e135e8087b96eb62a4aca6d69391b7f97808c6588ebed3178f3 \ + --hash=sha256:0dfe83c1b4d00abbf433998117a14f56a5c2bc68226c0d331709eed0d1ce539b \ + --hash=sha256:0f211c21aa316cb6e2662e54a1194633a79d98a50a876addacfce7ba5b34b09f \ + --hash=sha256:0f76dc0a47f94cb9b69d86f01e477f4b0371ca70208b9ccea7e063c41eed9046 \ + --hash=sha256:125ee619611019471b177c70bc3e9d4cda9fad7e01d93523501d3b188df0193a \ + --hash=sha256:1328cc57dd4372be1265f68232cee890e087416e3e6e93e6ffb32c2bad4d36a4 \ + --hash=sha256:14f6904a3cf870abf044df3a8c4924ac6c8ef77e9896586fd37e73ae96cff2af \ + --hash=sha256:168a0cb536b5123a77bc42ecf5e0bf6f923d0d9ae43c42a14eb0677c19ac6c19 \ + --hash=sha256:196e270c4e80827b5072eed7d6aa661d133afada94fe366669f9609e718d305e \ + --hash=sha256:19e52bda45086df8a4be4bb5910af6f5d9d3b538c78712c8ae09ef10b85bf458 \ + --hash=sha256:1c010dd86d3f4c4433c9634d33ce8147393b270dfa54f217f965540b8ae8e075 \ + --hash=sha256:1d366548d2ee28a8cfdcc4296363978cc644a728333be9824d2de4652e83df0a \ + --hash=sha256:1f56ce84b317ef2a59d7d3461891c7597c79247d2192bb8114c68a1a1debfcc0 \ + --hash=sha256:1f99a8c3a1da5d955edbad18208b3d627bdd54c48a6e739fa877bdca98c686d6 \ + --hash=sha256:2080aa129a28267984cdc902898993d788c995c384e285d0d19199f56760d52e \ + --hash=sha256:22a1889f1c9b752c63c36758a0c2145458e3cadb20fced7a0790002e9dd12b26 \ + --hash=sha256:2401f7671242e921e604f609d429f6b282ea4ca787a6ffd22ed7372011ddb9d1 \ + --hash=sha256:3288a560dc3114d5d2ebe309b1ef43f8af355eafe25856832415c2a8196c9db3 \ + --hash=sha256:3418edd0ecb72a0a3861cf72f31be0ad9b7fe338ce2b58fb5cc80b9aeb792700 \ + --hash=sha256:368eb2fc9482158b3a3386e8f01fa61f479c968e9a19ceab8f0188b86b312991 \ + --hash=sha256:3b08ebf9488c7ff5eff038e48e6ea938178dfd9dcc8598b5ca941e4ae27b20be \ + --hash=sha256:3cfdab178a4add5483e26a9bb1c16d8018ccf39b4be7a3aea6c3979e6828f2ee \ + --hash=sha256:3d00e18e7bbf47e332ab63903d18bae31efc701b1d8cca0382b97784a621fc44 \ + --hash=sha256:401ea6e7af9e7852ed818f64714b579c1935482049670847ca3bd7ba45dc63fb \ + --hash=sha256:4253da1b4456b633651a8d59eb1dc7a8a8fa38241014dd7c217b353e547ae394 \ + --hash=sha256:478650a70a750d75d5add401606c77f77069c32e4ba2c9131dc6cee566962ca0 \ + --hash=sha256:48920bf6fe83eb2226756ac623fa54940487154eb18f80889d5735cf234965c0 \ + --hash=sha256:49bd5feb59b0bf3cbf6ebcf4352e371c95b9da9bacd4449f8b64d0ad2c10a26e \ + --hash=sha256:4c440122e1ea68b1f8b44a631ebf49c39180f6869b1da22d76e8a724208ec6e9 \ + --hash=sha256:4ebebb410bc517e1d284c52a123e82704b21e4e7e26a21ebecf7439d0647b8a3 \ + --hash=sha256:527e28a5e751d9e11369b9c5f9ab35c748eb9c109101920c7deb40d6eadf8d03 \ + --hash=sha256:54c963ce6404e52255b737e8a06d356fc762d59096ae566203a67cf2b7d050f2 \ + --hash=sha256:562d24ca7797c1af8852994950c2e623a907b201fc4b0ed29e92af173d3828ca \ + --hash=sha256:5780a29823e1d2bec69b7a104ead4195a43f3e97782efaedbf1f79a0157af715 \ + --hash=sha256:58ca5dc0a0ef99f2801ec0574214c978e9574055bc783830bbb6e7433218609f \ + --hash=sha256:5a4ee37248dfac25107c758bda99d545ce73e60b44d2dd39e4a2bb9f2831e9f5 \ + --hash=sha256:5b81980668800dd1c69faad8aea6e85a8cee0e13bcd3bba7671695ff16260293 \ + --hash=sha256:5c23fa4f6eccdd601949cb00f3988c01d64e671d8faba356397971077022e144 \ + --hash=sha256:5e68229977b2dea28e7061c0c0630a23f2f9f6e9c6fb38d77d3d6dbfe3768b74 \ + --hash=sha256:606a86c1c3196f3738de39a67a7490bbd61cb31c0e0436070bd0c6a48170b38e \ + --hash=sha256:6083d10a846218502d664375b9448508d9fa580bd834567423156c6abfbe899d \ + --hash=sha256:608553f476fca509537e804c4a71f5eb166ce63b75141f89c2c686ce1aa36956 \ + --hash=sha256:63ea0cc840c66670183578c2630d138c0e944aeadfc33f25173ee240f5db780d \ + --hash=sha256:6753de11eef42f1c321b26d682957d92c7f7bbce6530f34bbe0f9291dd37cc6f \ + --hash=sha256:68b7e84ae8239a94f8d2c8f3f3a3a81bcde54805ec8f42a34de927d155688ec6 \ + --hash=sha256:6abb1f356fb865baeb6ebc3fadd843e9a96fbf49b9adcca55037f3cceccb7438 \ + --hash=sha256:6c06875a1d4a7537bef70f659b55c6b55b9a47ec3ba8f2db610350c2d9915e6e \ + --hash=sha256:6c57af4084c10cb3286688d65e4c654190ff5edcbc2411d08cdca0a8a44c59a1 \ + --hash=sha256:6c95450fce59f00c6d08eff6572ec2e736e5054c9450253afd5748f8416f2eb9 \ + --hash=sha256:70ff1c16eb0eb5ee6bb12739292347f981a5ba764cc4df1bc2e69b0405d4ac3b \ + --hash=sha256:72982d9958a42f8132bf2d6b90214ed66477295ef1188731f98ae3511c6eeb5a \ + --hash=sha256:75e243abbb528c1a774390ed71e3f868a9f37b1373442e4bbadd401cfc505ff4 \ + --hash=sha256:826a295a039178479a325be1ae60eded1f0b10f7dda749df59e2440de8f61d64 \ + --hash=sha256:82cc5835997ec78afe293a192e385099355770a7db94b2fb1239d36b32796f1c \ + --hash=sha256:83b62410bd36bb1178a7d563e2ee0cf21eb1c980c912ab99c2c78f06227f1731 \ + --hash=sha256:84b736e3b391601bc47b86da381c749c0f894e9191aaca9f31f30c2632206df3 \ + --hash=sha256:8b3e9e29b8b07cc461b9ce7768db7693a93979d0dadf22046f6f3555ded2f516 \ + --hash=sha256:8efc3f1563ed431882dd0dc0411b5f8ace1b1b89074981deaf6bd8af77dbe1bc \ + --hash=sha256:8f006866047c6ec4b627ec144b1e0bbc7427cb31fd7c08d19897d0ac9032af3d \ + --hash=sha256:91f9f7c151e772acebe489eaec96e96a2877202d7dd144e3f96b8676881715a0 \ + --hash=sha256:96720f2aeed3434bc48f4d52fbad64ecc820cfed88915d664780ed9ba09ede78 \ + --hash=sha256:96ae7ab5d8155fde927aa0864fbc8ba3cc4fde6d41ab0c7cea9d6012b4978603 \ + --hash=sha256:98820e1ceb25c6df7a80c4fd8efa59cb121f99bc7c4c1693ad94a2caff5b311d \ + --hash=sha256:993dfcbe75a85a3784abb5084f2c41b915767c90546fcc92803cffa28611baea \ + --hash=sha256:9bb3e0d1345b9c0fe73673ea656375f38a78ec679c2edeae0c24800f04798a85 \ + --hash=sha256:9ce0f885239357379d92fd9a5fddbe20f0e30e0527c29ba69f8e99eeb1304a76 \ + --hash=sha256:9e726478d7a213847860219d74665a6892a643ac93b8f76580f6cf9ed39996b7 \ + --hash=sha256:a17864853f7c518ae7d4b368af98f427f9396805476af40af8698560f09d7d97 \ + --hash=sha256:a284c36b9c6616bf0a8aa4aabba668a0c75ba65ccf40a79868aeaa69ad996897 \ + --hash=sha256:a3ad0e3da22852533858663848608f3f24c0d35e5cde415a4903476f2b4c88ec \ + --hash=sha256:a5613a3a82c974227bde18f03409e30c467f8065cb56d822e3eb83708a5f223d \ + --hash=sha256:a60773eb5fda796e6e6f76b9c152d270fe59f9788a51a6ff8ba44082d8548ae4 \ + --hash=sha256:a864bdcacd8bff58bb4845304e031f821a3ec64b2b7259f2d409cd49c9e59ca3 \ + --hash=sha256:ac5a9cc079c67d75f4ddf343276031879eadbb333d1bb231cce297b8d7b9aae8 \ + --hash=sha256:b476cdb63df22da2b91837593380be3ddbe406f36c506c1c91d80e7196b66288 \ + --hash=sha256:b7eb3eab5c646d3de7dcb14a7c9caebace5249c5767da39e1761cb1576e521a3 \ + --hash=sha256:b8ea208b304587d47931b36481342d20336e0d338ab052f8b4305926482598d6 \ + --hash=sha256:bf1e75dc07a3850b53d1e5f75e04d3ae12afe56284be7821771eaa2466350c73 \ + --hash=sha256:bf91921009025e96ce57a03ced6d14604fc3baf0530351638e9504a55da6fa3b \ + --hash=sha256:c387c6bf91b4774e359a48a179e2872b8e8bf741e4fde06ba8d1665eb9a4760a \ + --hash=sha256:c38d1e9bc2073b0984d2099ea647fd7f6c0d8f83a1e14e0cd32926f16e4c44ce \ + --hash=sha256:c41b067eddcfa5ee6b1169c287605be7fb6b0ea22bba6474c5bb978a668def4f \ + --hash=sha256:c455829625df983f716cbaecbba77f2d1dc2e0e0ed1638c059cece15a279344b \ + --hash=sha256:c54036a518748b6c02e666f6d46c3817561998fb904c3be25b56fb4fe3dc5706 \ + --hash=sha256:c5c6d47a865147e0ae3322ce92e7fb52ba3169d94b447deda56897ea2aa6fac9 \ + --hash=sha256:c75191e3c8052045179646cb40e280800a4e0bdfda34d9c949c2f268d44e80e4 \ + --hash=sha256:c9bde7a960720c8b8e1b5ef7afaa0c9a2f3b55c44abd635b2b29dd066b298e3a \ + --hash=sha256:c9f45186390aee4d1f26f723c615b67df346766c3b16df000d84d6e374f06757 \ + --hash=sha256:d016dc857136c726958102c3b8a3986acdc65ace6fbf12cfdc09cc4bfa2935b2 \ + --hash=sha256:d54625cbf4e6b60bf0639728cb8b4cb541e340f6d7cafae5806051a40ddf4c45 \ + --hash=sha256:d6100f877d2ed95f0856a3fde25334153add94bf2224c43f45f88e7039262aaa \ + --hash=sha256:d672f329ae504ee240eb39b6effb3318aa8e7e8924c0ce8eee5760b3fad98539 \ + --hash=sha256:d7229a99120c6c2792d96f4857c2648ce5530e93667a2c2388c5ef69a6b84775 \ + --hash=sha256:d788e2ded0c4c47efa4d73cfe59eaf975ee32f425219873d2cb3e3fbaa00f636 \ + --hash=sha256:d8a9316f4da85e937242642b537c6d55d7e9287dd38e5634732f8233932aff45 \ + --hash=sha256:d90254c8f609338c53ec180fcd4c4f9c16502e238e3fc88ca7fd4c2f38d445b8 \ + --hash=sha256:d9419f44e568f7fafcdc0b3b5c766a2364e705a9b34fb8a56b431e0d1f3f4258 \ + --hash=sha256:d95244906ed69d0f79f190893c65e336c15959003e21449256dc05c001b52ea2 \ + --hash=sha256:dc28949de1bb5f7f30a46f15d74ce7ac5aaa63e03c5de04d68f571c7423af834 \ + --hash=sha256:deb99535e9bf0bea8e274c6413eb939a21be35a3f492678dba4d5b1f4d70f142 \ + --hash=sha256:e31786a947b136329bfdc458c82c06d4ec539b4a4436b7da4df4aafc9902ee80 \ + --hash=sha256:e3b9eaa99a6d8c9ace4cd303915947ef55088d4cd87c6676874f98c5c03aa040 \ + --hash=sha256:e4e8fa586df2208ef040684751345f10f503834a757c9a74ecd19c1a2f9b1ccd \ + --hash=sha256:e54dd1a66fa4bce0ccaf0db9dde336e49b3eec646dc4c1c0991279369d373a14 \ + --hash=sha256:e5f834965c2fe589837bac1002e07e25734ff70381903ccd95b3d649e22bfa40 \ + --hash=sha256:ec6c464cf45867f66a2273e2214d9199a8fbad5cb95ca0fd45f6a2fe1d9d2cf4 \ + --hash=sha256:f044cb1cf44012184715f46584658993b5fee9344d71c4b0c455a17a299730c0 \ + --hash=sha256:f0fde5e5100c735b2274ab898f0742a5dcde492796296cfbe7e0ad6a4cd1a396 \ + --hash=sha256:f1619a3cc174a7e3963dd34348e6fceb6e50db0ddeb0031bd7c73a58286454fa \ + --hash=sha256:f278e131afa96a3622cef9211c406ea2ad1b68eb06f8837cd443684a40e0ae50 \ + --hash=sha256:f2ce3d39fb4a9d674e6639dd5d3146b2e273475d2260f10163228d66fc04433d \ + --hash=sha256:f7496aed56b06325a1ad419c5bf23c6dd042558e874f71dd1b958f3e255f3053 \ + --hash=sha256:f8cd1283a9fe6c2065c807e9d5da81afe5e1e004caef39adc0d8ae86dd883698 \ + --hash=sha256:f9f91d3e8382900f3a68fa0ce94294479de9cd2de6bc0c70acd0f0dfd511836b \ + --hash=sha256:fd68c825548a611158230e2f9222e210ceb2e3391995c0aa5865cbdf3ab4bd49 \ + --hash=sha256:fded2e82ff082e5d8e017e2ddcc1411bd8cb83b8585097fc401ef574f756b888 \ + --hash=sha256:fec352b793cdc183cc9e7e0b6c10fd7bff38ec54ba44cc43599b9b56f7f3db2e + # via requests colorama==0.4.6 ; sys_platform == 'win32' \ --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 - # via pytest + # via + # ipython + # pytest +comm==0.2.3 \ + --hash=sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971 \ + --hash=sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417 + # via + # ipykernel + # ipywidgets +debugpy==1.8.21 \ + --hash=sha256:13678151fc401e2d68c9880b91e28714f797d40422994572b24560ef80910a88 \ + --hash=sha256:15d4963bd5ffa48f0da0947fd06757fa7621945048a14ad7705431566d3c0e7c \ + --hash=sha256:2c2ae706dec41d99a9ca1f7ebc987a83e65578363be6f6b3ac9067504917fae1 \ + --hash=sha256:3d6922439bf33fd38a3e2c447869ebc7b97da5cd3d329ff1ef9bc06c4903437e \ + --hash=sha256:4743373c1cac7f9e74a1b9915bf1dbe0e900eca657ffb170ae07ac8363205ae9 \ + --hash=sha256:9bb2a685287a2ac9b181cde89edcec64845cb51de7faaa75badb9a698bc24782 \ + --hash=sha256:9f96713896f39c3dff0ee841f47320c3f2983d33c341e009361bb0ebc79adc4e \ + --hash=sha256:a3c53278e84c94e11bd87c53970ec391d1a67396c8b22609fcac576520e611a6 \ + --hash=sha256:aa648733047443eb1d07682c4ef287d36a54507b643ffdf38b09a3ef002c72a0 \ + --hash=sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92 \ + --hash=sha256:bd7ba9dd3daa7c2f942c6ca8d4695a16bf9ac16b63615261c7982bc74f7ed20c \ + --hash=sha256:c193d474f0a211191f2b4449d2d06157c689013035bd952f3b617e0ef422b176 \ + --hash=sha256:ecbd158386c31ffe71d46f72d44d56e66331ab9b16cad649156d514368f23ab2 \ + --hash=sha256:fe0744a12353406de0ae8ccff0d0a4a666f00801a3db8fd04e7a5f761cd520e8 + # via ipykernel +defusedxml==0.7.1 \ + --hash=sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69 \ + --hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61 + # via nbconvert +executing==2.2.1 \ + --hash=sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4 \ + --hash=sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017 + # via stack-data +fastjsonschema==2.22.1 \ + --hash=sha256:0b83d1ce8d7845b959dcb20e1a5c3c8883b6541d9c52ab02cce5166b75ec805f \ + --hash=sha256:cf377ff5c9a6f4f3125fb35f75a2c5767bd824ffbcf62c209a93cd48d1453999 + # via nbformat +fqdn==1.5.1 \ + --hash=sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f \ + --hash=sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014 + # via jsonschema +h11==0.16.0 \ + --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 + # via httpcore +httpcore==1.0.9 \ + --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ + --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 + # via httpx +httpx==0.28.1 \ + --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ + --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad + # via jupyterlab +idna==3.18 \ + --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ + --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 + # via + # anyio + # httpx + # jsonschema + # requests iniconfig==2.3.0 \ --hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \ --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 # via pytest +ipykernel==7.3.0 \ + --hash=sha256:897eb64da762549ef610698fca5e9675195ec6ac8ec7f19d81ce1ca20c876057 \ + --hash=sha256:9acaaaf97d16355166e4085afe9d225bfbdf2b7ef520f9df3be8f2b248275e09 + # via + # jupyter + # jupyter-console + # jupyterlab +ipython==9.16.1 \ + --hash=sha256:4acae635506f6d352d94c4899a19d5f85f8bc4d230932342dca556fdab1c69b4 \ + --hash=sha256:5a3d1f9a47ff216d6cf9cf863124f6a2c1a198d1354c546a4d24a370a283b64c + # via + # ipykernel + # ipywidgets + # jupyter-console +ipython-pygments-lexers==1.1.1 \ + --hash=sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81 \ + --hash=sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c + # via ipython +ipywidgets==8.1.8 \ + --hash=sha256:61f969306b95f85fba6b6986b7fe45d73124d1d9e3023a8068710d47a22ea668 \ + --hash=sha256:ecaca67aed704a338f88f67b1181b58f821ab5dc89c1f0f5ef99db43c1c2921e + # via jupyter +isoduration==20.11.0 \ + --hash=sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9 \ + --hash=sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042 + # via jsonschema +jedi==0.20.0 \ + --hash=sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67 \ + --hash=sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011 + # via ipython +jinja2==3.1.6 \ + --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \ + --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 + # via + # jupyter-server + # jupyterlab + # jupyterlab-server + # nbconvert +json5==0.15.0 \ + --hash=sha256:56636a30c0e8a4665fe2179c0212f32eae3796dea89ea6f649b9436ecdb39618 \ + --hash=sha256:7424d1f1eb1d56da6e3d70643f53619862b4ce81440bdb8ecfd6f875e5ba4a71 + # via jupyterlab-server +jsonpointer==3.1.1 \ + --hash=sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900 \ + --hash=sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca + # via jsonschema +jsonschema==4.26.0 \ + --hash=sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326 \ + --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce + # via + # jupyter-events + # jupyterlab-server + # nbformat +jsonschema-specifications==2025.9.1 \ + --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \ + --hash=sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d + # via jsonschema +jupyter==1.1.1 \ + --hash=sha256:7a59533c22af65439b24bbe60373a4e95af8f16ac65a6c00820ad378e3f7cc83 \ + --hash=sha256:d55467bceabdea49d7e3624af7e33d59c37fff53ed3a350e1ac957bed731de7a +jupyter-builder==1.2.2 \ + --hash=sha256:6ebcd4c49daf5df6a18068a74a48010406700ed90a76c189fac43eaf85c60c63 \ + --hash=sha256:b6cea88f58e44b2c5eba96f28d2e0d16fd453d3ca6dc9c4492ff8a1f2e97f601 + # via + # jupyterlab + # notebook +jupyter-client==8.9.1 \ + --hash=sha256:0b7a295bc46e8751e9adae84781f726c851c1d911bd793edc4a3bde942e3da81 \ + --hash=sha256:a58f730dd9e728ba16ba1d62ebccf7ffe1ebbdbce4e95cfae941b7321ae1f4fa + # via + # ipykernel + # jupyter-console + # jupyter-server + # nbclient +jupyter-console==6.6.3 \ + --hash=sha256:309d33409fcc92ffdad25f0bcdf9a4a9daa61b6f341177570fdac03de5352485 \ + --hash=sha256:566a4bf31c87adbfadf22cdf846e3069b59a71ed5da71d6ba4d8aaad14a53539 + # via jupyter +jupyter-core==5.9.1 \ + --hash=sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508 \ + --hash=sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407 + # via + # ipykernel + # jupyter-builder + # jupyter-client + # jupyter-console + # jupyter-server + # jupyterlab + # nbclient + # nbconvert + # nbformat +jupyter-events==0.12.1 \ + --hash=sha256:c366585253f537a627da52fa7ca7410c5b5301fe893f511e7b077c2d93ec8bcf \ + --hash=sha256:faff25f77218335752f35f23c5fe6e4a392a7bd99a5939ccb9b8fbf594636cf3 + # via jupyter-server +jupyter-lsp==2.3.1 \ + --hash=sha256:71b954d834e85ff3096400554f2eefaf7fe37053036f9a782b0f7c5e42dadb81 \ + --hash=sha256:fdf8a4aa7d85813976d6e29e95e6a2c8f752701f926f2715305249a3829805a6 + # via jupyterlab +jupyter-server==2.20.0 \ + --hash=sha256:b5778ba337d8015a3dc2b80803ecdd5ac18d3797fddf61a50ea5fb472b4ebe14 \ + --hash=sha256:c3b67c93c471e947c18b5026f04f21614218adb706df8f48227d3ee8e0a7cdcc + # via + # jupyter-lsp + # jupyterlab + # jupyterlab-server + # notebook + # notebook-shim +jupyter-server-terminals==0.5.4 \ + --hash=sha256:55be353fc74a80bc7f3b20e6be50a55a61cd525626f578dcb66a5708e2007d14 \ + --hash=sha256:bbda128ed41d0be9020349f9f1f2a4ab9952a73ed5f5ac9f1419794761fb87f5 + # via jupyter-server +jupyterlab==4.6.3 \ + --hash=sha256:0a1ebc6567186f1eabd99536e94df7ed9e96d1e7c5ddf3e4406ae16e88abacb7 \ + --hash=sha256:2e3db6e3a12495ebd188276e985bf5ac502fbde3d1e8628819920210008de498 + # via + # jupyter + # notebook +jupyterlab-pygments==0.3.0 \ + --hash=sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d \ + --hash=sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780 + # via nbconvert +jupyterlab-server==2.28.0 \ + --hash=sha256:35baa81898b15f93573e2deca50d11ac0ae407ebb688299d3a5213265033712c \ + --hash=sha256:e4355b148fdcf34d312bbbc80f22467d6d20460e8b8736bf235577dd18506968 + # via + # jupyterlab + # notebook +jupyterlab-widgets==3.0.16 \ + --hash=sha256:423da05071d55cf27a9e602216d35a3a65a3e41cdf9c5d3b643b814ce38c19e0 \ + --hash=sha256:45fa36d9c6422cf2559198e4db481aa243c7a32d9926b500781c830c80f7ecf8 + # via ipywidgets +lark==1.3.1 \ + --hash=sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905 \ + --hash=sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12 + # via rfc3987-syntax +markupsafe==3.0.3 \ + --hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \ + --hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \ + --hash=sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 \ + --hash=sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb \ + --hash=sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6 \ + --hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \ + --hash=sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce \ + --hash=sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218 \ + --hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \ + --hash=sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73 \ + --hash=sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c \ + --hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \ + --hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \ + --hash=sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37 \ + --hash=sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f \ + --hash=sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d \ + --hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \ + --hash=sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19 \ + --hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \ + --hash=sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9 \ + --hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \ + --hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \ + --hash=sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354 \ + --hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \ + --hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \ + --hash=sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b \ + --hash=sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc \ + --hash=sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485 \ + --hash=sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f \ + --hash=sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12 \ + --hash=sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025 \ + --hash=sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009 \ + --hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \ + --hash=sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a \ + --hash=sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5 \ + --hash=sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f \ + --hash=sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1 \ + --hash=sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287 \ + --hash=sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6 \ + --hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \ + --hash=sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed \ + --hash=sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b \ + --hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \ + --hash=sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 \ + --hash=sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e \ + --hash=sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d \ + --hash=sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d \ + --hash=sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795 \ + --hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \ + --hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \ + --hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \ + --hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \ + --hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \ + --hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \ + --hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \ + --hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50 + # via + # jinja2 + # nbconvert +matplotlib-inline==0.2.2 \ + --hash=sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6 \ + --hash=sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79 + # via + # ipykernel + # ipython +mistune==3.3.4 \ + --hash=sha256:58b5c96d6fcb61190dfe5fae498d2b2065f99cf61e9649418fd54cf1ada86dfe \ + --hash=sha256:ee015381e955e370962968befe1d729ab60fafb6a715ac6751763fbce38c8d4a + # via nbconvert +nbclient==0.11.0 \ + --hash=sha256:04a134a5b087f2c5887f228aca155db50169b8cd9334dee6942c8e927e56081a \ + --hash=sha256:ef7fa0d59d6e1d41103933d8a445a18d5de860ca6b613b87b8574accdb3c2895 + # via nbconvert +nbconvert==7.17.1 \ + --hash=sha256:34d0d0a7e73ce3cbab6c5aae8f4f468797280b01fd8bd2ca746da8569eddd7d2 \ + --hash=sha256:aa85c087b435e7bf1ffd03319f658e285f2b89eccab33bc1ba7025495ab3e7c8 + # via + # jupyter + # jupyter-server +nbformat==5.11.0 \ + --hash=sha256:7dbaed4a69cae28c2b4d44ab7430a6af4544fb89455023f6f21550be757b60c8 \ + --hash=sha256:f70a17f591a9ccd1c601d5e61a4b20972703926df0ba42458ce14bf575766bb6 + # via + # jupyter-server + # nbclient + # nbconvert +nest-asyncio2==1.7.2 \ + --hash=sha256:1921d70b92cc4612c374928d081552efb59b83d91b2b789d935c665fa01729a8 \ + --hash=sha256:f5dfa702f3f81f6a03857e9a19e2ba578c0946a4ad417b4c50a24d7ba641fe01 + # via ipykernel +notebook==7.6.2 \ + --hash=sha256:5fe9e09c335cb4b7de21627b860f77210e70e54b1fb1276ad942a4a7e1d858d3 \ + --hash=sha256:cc02b5f0bb972160cccfe44ad8a1a202036206ba3439469c514f03aefa9ae807 + # via jupyter +notebook-shim==0.2.4 \ + --hash=sha256:411a5be4e9dc882a074ccbcae671eda64cceb068767e9a3419096986560e1cef \ + --hash=sha256:b4b2cfa1b65d98307ca24361f5b30fe785b53c3fd07b7a47e89acb5e6ac638cb + # via + # jupyterlab + # notebook packaging==26.2 \ --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 - # via pytest + # via + # ipykernel + # jupyter-events + # jupyter-server + # jupyterlab + # jupyterlab-server + # nbconvert + # pytest +pandocfilters==1.5.1 \ + --hash=sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e \ + --hash=sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc + # via nbconvert +parso==0.8.7 \ + --hash=sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c \ + --hash=sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1 + # via jedi +pexpect==4.9.0 ; sys_platform != 'emscripten' and sys_platform != 'win32' \ + --hash=sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523 \ + --hash=sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f + # via ipython +platformdirs==4.11.3 \ + --hash=sha256:5ed065d443751de711da036041a7a214122efc4a4de393b3f4137ba5576540e7 \ + --hash=sha256:66a73d38a849810252df809a3d8bcbda8e26f6c189920e7535ad608a48dbb5ab + # via jupyter-core pluggy==1.6.0 \ --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 @@ -34,13 +633,293 @@ prek==0.4.5 \ --hash=sha256:e491a1a4641d91d8b03dcce5588397e76d2a5b432c9b0a6c70475972b4512ab4 \ --hash=sha256:f7517774c72b001573520dc7111156779fd3e5b4452c11f09ff53c71a067e835 \ --hash=sha256:fccd11613ae92619d1ecda0ab3359ceebeb38898909ec84a8d383733d12158cc +prometheus-client==0.26.0 \ + --hash=sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b \ + --hash=sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6 + # via jupyter-server +prompt-toolkit==3.0.53 \ + --hash=sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2 \ + --hash=sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6 + # via + # ipython + # jupyter-console +psutil==7.2.2 \ + --hash=sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372 \ + --hash=sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9 \ + --hash=sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841 \ + --hash=sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63 \ + --hash=sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979 \ + --hash=sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a \ + --hash=sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b \ + --hash=sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9 \ + --hash=sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee \ + --hash=sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312 \ + --hash=sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b \ + --hash=sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9 \ + --hash=sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e \ + --hash=sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc \ + --hash=sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1 \ + --hash=sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf \ + --hash=sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea \ + --hash=sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988 \ + --hash=sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486 \ + --hash=sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00 \ + --hash=sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8 + # via + # ipykernel + # ipython +ptyprocess==0.7.0 ; os_name != 'nt' or (sys_platform != 'emscripten' and sys_platform != 'win32') \ + --hash=sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35 \ + --hash=sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220 + # via + # pexpect + # terminado +pure-eval==0.2.3 \ + --hash=sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0 \ + --hash=sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42 + # via stack-data +pycparser==3.0 ; implementation_name != 'PyPy' \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 + # via cffi pygments==2.20.0 \ --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \ --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 - # via pytest + # via + # ipython + # ipython-pygments-lexers + # jupyter-console + # nbconvert + # pytest pytest==9.1.1 \ --hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 \ --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c +python-dateutil==2.9.0.post0 \ + --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ + --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 + # via + # arrow + # jupyter-client +python-json-logger==4.1.0 \ + --hash=sha256:132994765cf75bf44554be9aa49b06ef2345d23661a96720262716438141b6b2 \ + --hash=sha256:b396b9e3ed782b09ff9d6e4f1683d46c83ad0d35d2e407c09a9ebbf038f88195 + # via jupyter-events +pywinpty==3.0.5 ; os_name == 'nt' \ + --hash=sha256:03bb3c16d691d9242267201830bcd0e64a9b663170e9042bc84b210da9de15ac \ + --hash=sha256:22ce1b780d89821cc52daf6eac0708af22d93d000ce9c7c07e37489db8594598 \ + --hash=sha256:24366280a8aa677323da87bec729cb3ea3b35367386cece0978bdc6e4695c690 \ + --hash=sha256:2c6008fb2d3774b48693b2fcb7f2cc317ade9dc581289a964ffeeaf81307c9b5 \ + --hash=sha256:48db1b0ad9d0a1b81dcaaa7163a99a7808deaceb0c1b2344716dc1fc090c3c4c \ + --hash=sha256:61db0db063de9865adbea66db294628f8577f608d9764a4c7d3384eeacc4e81b \ + --hash=sha256:7b566165e0c5fdd6abe167a5ac8b954be6a843eb55a85946576d6bc1dea03d6d \ + --hash=sha256:89c5c6ef08997a3b4b277b214a35fe15cab4dd6d119f0140aa71df5b1168fdbc \ + --hash=sha256:9c2919a81bc5cfb09b86fc5a002112b2de95ca4304a07413cbeeb746a1307a5c \ + --hash=sha256:d62946adf14b15b54c0b8d785f93fe18b04da23f4ad59e2e8c4612646e9abd23 \ + --hash=sha256:e9391c05fbfa7a992a97e831fc6849887b4014a614192e3d984a7ca59592b376 + # via + # jupyter-server + # jupyter-server-terminals + # terminado +pyyaml==6.0.3 \ + --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ + --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ + --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ + --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ + --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ + --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ + --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ + --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ + --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ + --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ + --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ + --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ + --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ + --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ + --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ + --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ + --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ + --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ + --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ + --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ + --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ + --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ + --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ + --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ + --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ + --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ + --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ + --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ + --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ + --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ + --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ + --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ + --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ + --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ + --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ + --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ + --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ + --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ + --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 + # via jupyter-events +pyzmq==27.1.0 \ + --hash=sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d \ + --hash=sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05 \ + --hash=sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28 \ + --hash=sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea \ + --hash=sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0 \ + --hash=sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113 \ + --hash=sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92 \ + --hash=sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd \ + --hash=sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233 \ + --hash=sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31 \ + --hash=sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc \ + --hash=sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd \ + --hash=sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e \ + --hash=sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128 \ + --hash=sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96 \ + --hash=sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f \ + --hash=sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c \ + --hash=sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2 \ + --hash=sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146 \ + --hash=sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97 \ + --hash=sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5 \ + --hash=sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf \ + --hash=sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540 \ + --hash=sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db \ + --hash=sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39 \ + --hash=sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a \ + --hash=sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a \ + --hash=sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856 \ + --hash=sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9 \ + --hash=sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7 \ + --hash=sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7 \ + --hash=sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496 \ + --hash=sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6 + # via + # ipykernel + # jupyter-client + # jupyter-console + # jupyter-server +referencing==0.37.0 \ + --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \ + --hash=sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8 + # via + # jsonschema + # jsonschema-specifications + # jupyter-events +requests==2.34.2 \ + --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ + --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed + # via jupyterlab-server +rfc3339-validator==0.1.4 \ + --hash=sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b \ + --hash=sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa + # via + # jsonschema + # jupyter-events +rfc3986-validator==0.1.1 \ + --hash=sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9 \ + --hash=sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055 + # via + # jsonschema + # jupyter-events +rfc3987-syntax==1.1.0 \ + --hash=sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f \ + --hash=sha256:717a62cbf33cffdd16dfa3a497d81ce48a660ea691b1ddd7be710c22f00b4a0d + # via jsonschema +rpds-py==2026.6.3 \ + --hash=sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5 \ + --hash=sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680 \ + --hash=sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538 \ + --hash=sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf \ + --hash=sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4 \ + --hash=sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97 \ + --hash=sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96 \ + --hash=sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a \ + --hash=sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187 \ + --hash=sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975 \ + --hash=sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f \ + --hash=sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703 \ + --hash=sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f \ + --hash=sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa \ + --hash=sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05 \ + --hash=sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba \ + --hash=sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223 \ + --hash=sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885 \ + --hash=sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698 \ + --hash=sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7 \ + --hash=sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed \ + --hash=sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f \ + --hash=sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf \ + --hash=sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e \ + --hash=sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f \ + --hash=sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24 \ + --hash=sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a \ + --hash=sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc \ + --hash=sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d \ + --hash=sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146 \ + --hash=sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e \ + --hash=sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e \ + --hash=sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4 \ + --hash=sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12 \ + --hash=sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6 \ + --hash=sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5 \ + --hash=sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7 \ + --hash=sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda \ + --hash=sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8 \ + --hash=sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342 \ + --hash=sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb \ + --hash=sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0 \ + --hash=sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77 \ + --hash=sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885 \ + --hash=sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617 \ + --hash=sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb \ + --hash=sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577 \ + --hash=sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80 \ + --hash=sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e \ + --hash=sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945 \ + --hash=sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0 \ + --hash=sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140 \ + --hash=sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822 \ + --hash=sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba \ + --hash=sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4 \ + --hash=sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a \ + --hash=sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8 \ + --hash=sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf \ + --hash=sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324 \ + --hash=sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53 \ + --hash=sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b \ + --hash=sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41 \ + --hash=sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9 \ + --hash=sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1 \ + --hash=sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d \ + --hash=sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690 \ + --hash=sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107 \ + --hash=sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2 \ + --hash=sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d \ + --hash=sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af \ + --hash=sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6 \ + --hash=sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db \ + --hash=sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369 \ + --hash=sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd \ + --hash=sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504 \ + --hash=sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13 \ + --hash=sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc \ + --hash=sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868 \ + --hash=sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2 \ + --hash=sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd \ + --hash=sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4 \ + --hash=sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6 \ + --hash=sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9 \ + --hash=sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00 \ + --hash=sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f \ + --hash=sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e \ + --hash=sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442 \ + --hash=sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90 \ + --hash=sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef + # via + # jsonschema + # referencing ruff==0.15.18 \ --hash=sha256:01a754cd6a1b630d3f97e33eb452cf7a98040482318e870f8bc52a5a30e62657 \ --hash=sha256:02299e6e9fa5b297a3f6d5d10d7bcd655c925b028bb8b9d4588214549c6b9ec4 \ @@ -60,6 +939,70 @@ ruff==0.15.18 \ --hash=sha256:a81beadbbff2c9c245561ae3f77b16709d87f35eec650d0501679239d3449b22 \ --hash=sha256:b2c9257fcbd4a3e5b977a1904e6facca016bafe2edc17df24db67cfaee03b4e4 \ --hash=sha256:dac80dc8d26b2257dbefabed62f5d255c3937b4ccb122da1fc634794fa3578b3 +send2trash==2.1.0 \ + --hash=sha256:0da2f112e6d6bb22de6aa6daa7e144831a4febf2a87261451c4ad849fe9a873c \ + --hash=sha256:1c72b39f09457db3c05ce1d19158c2cbef4c32b8bedd02c155e49282b7ea7459 + # via jupyter-server +six==1.17.0 \ + --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ + --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 + # via + # python-dateutil + # rfc3339-validator +soupsieve==2.9.2 \ + --hash=sha256:4a55d8cf158a9c2e587fa4922f1bbb91d68ac829e2d6f25403a85747c71daf74 \ + --hash=sha256:8089a26fd974ca7a1f30276d3d8492ab266ab15af581642dfe8aa162e0c1c823 + # via beautifulsoup4 +stack-data==0.6.3 \ + --hash=sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9 \ + --hash=sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695 + # via ipython +terminado==0.18.1 \ + --hash=sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0 \ + --hash=sha256:de09f2c4b85de4765f7714688fff57d3e75bad1f909b589fde880460c753fd2e + # via + # jupyter-server + # jupyter-server-terminals +tinycss2==1.5.1 \ + --hash=sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661 \ + --hash=sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957 + # via bleach +tornado==6.5.8 \ + --hash=sha256:11881db6b7c168494be2c2d12e65931451bdf7ee718535418ae1d8855dd5a0ee \ + --hash=sha256:547d63f450d570c14fe0e8db2cfb14c9bbd1c2503b4a6612586267955aa47b58 \ + --hash=sha256:5d242290bdf7ab3151bc1065fdd75c0dcc21cbc7b49f22a4c56329c2d6566d22 \ + --hash=sha256:67832909c4779c64942380cb5f044a5c6163d00831472d80e25e115de9917836 \ + --hash=sha256:68a7468c7e289f8514d7d664101753903217eff1bb6822c6b5994a0b5f5bcb26 \ + --hash=sha256:7b94ff0e128fe0542f3bd331fb44d06260fc4ac16881545159f34ef08aad4195 \ + --hash=sha256:7e2360a0ffbe145eca8af0b19cb7203d79b1a98dd4cccdd6b368f6f49c2e3808 \ + --hash=sha256:9452e1b208a8bd771e2cb1f2ff564985b9b214bdebbe622793e1799e0a6bd23f \ + --hash=sha256:9715b5eb79735b2bcd454ce216a9275b7c0470e64ea1bf5742f78b2f72b26eeb \ + --hash=sha256:cc6aa787d7cfab7c3d35189dc7a56fbd2399a569624c730c6b55b3d6531d0403 + # via + # ipykernel + # jupyter-client + # jupyter-server + # jupyterlab + # notebook + # terminado +traitlets==5.16.1 \ + --hash=sha256:ed900c2b631aa3a112811139fa97b8d2c3bad5e989656bba4b7e52c7852c18c1 \ + --hash=sha256:f775618166caa0396c8e337099240f2bd3e5e917d203b2e6fbe21a58d3cb1f6b + # via + # ipykernel + # ipython + # ipywidgets + # jupyter-builder + # jupyter-client + # jupyter-console + # jupyter-core + # jupyter-events + # jupyter-server + # jupyterlab + # matplotlib-inline + # nbclient + # nbconvert + # nbformat ty==0.0.51 \ --hash=sha256:08adbe53fb8bc9e7f00e89bf1d3c875a02cda76d83f109d2e6ab1ff35a7bfa8c \ --hash=sha256:25a5b31e6f23fd5dc63ad29087ded09932409e4154e2fe07bbaed015035990bb \ @@ -79,3 +1022,45 @@ ty==0.0.51 \ --hash=sha256:cc233a6235fb23e2a44b14731a10043e37ba2f30f2c361cf49ad3633c5b9da9c \ --hash=sha256:dc5e93695ab5dcbf1eef663aee60ec23a413547cc9cb06adcb0d842e9166bd0f \ --hash=sha256:f8f52952cff665bc52a36147e610c10f5699d30007d7a14ab7f345cff93476ff +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 + # via + # anyio + # beautifulsoup4 + # jupyter-client + # referencing +tzdata==2026.2 \ + --hash=sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10 \ + --hash=sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7 + # via arrow +uri-template==1.3.0 \ + --hash=sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7 \ + --hash=sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363 + # via jsonschema +urllib3==2.7.0 \ + --hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \ + --hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897 + # via requests +wcwidth==0.8.2 \ + --hash=sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda \ + --hash=sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85 + # via prompt-toolkit +webcolors==25.10.0 \ + --hash=sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d \ + --hash=sha256:62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf + # via jsonschema +webencodings==0.5.1 \ + --hash=sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78 \ + --hash=sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923 + # via + # bleach + # tinycss2 +websocket-client==1.9.0 \ + --hash=sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98 \ + --hash=sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef + # via jupyter-server +widgetsnbextension==4.0.15 \ + --hash=sha256:8156704e4346a571d9ce73b84bee86a29906c9abfd7223b7228a28899ccf3366 \ + --hash=sha256:de8610639996f1567952d763a5a41af8af37f2575a41f9852a38f947eb82a3b9 + # via ipywidgets diff --git a/requirements.txt b/requirements.txt index 56d6b5f..1714ddc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,345 +1,30 @@ # This file was autogenerated by uv via the following command: # uv export --no-dev --output-file requirements.txt -e . -agate==1.9.1 \ - --hash=sha256:1cf329510b3dde07c4ad1740b7587c9c679abc3dcd92bb1107eabc10c2e03c50 \ - --hash=sha256:bc60880c2ee59636a2a80cd8603d63f995be64526abf3cbba12f00767bcd5b3d - # via - # dbt-adapters - # dbt-common - # dbt-core - # dbt-postgres annotated-types==0.7.0 \ --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \ --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89 # via pydantic -attrs==26.1.0 \ - --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ - --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32 - # via - # jsonschema - # referencing -babel==2.18.0 \ - --hash=sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d \ - --hash=sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35 - # via agate -certifi==2026.6.17 \ - --hash=sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432 \ - --hash=sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db - # via requests -charset-normalizer==3.4.9 \ - --hash=sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380 \ - --hash=sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62 \ - --hash=sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226 \ - --hash=sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b \ - --hash=sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501 \ - --hash=sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698 \ - --hash=sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e \ - --hash=sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4 \ - --hash=sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519 \ - --hash=sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe \ - --hash=sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614 \ - --hash=sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0 \ - --hash=sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0 \ - --hash=sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917 \ - --hash=sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9 \ - --hash=sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32 \ - --hash=sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63 \ - --hash=sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd \ - --hash=sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198 \ - --hash=sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde \ - --hash=sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012 \ - --hash=sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b \ - --hash=sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5 \ - --hash=sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8 \ - --hash=sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35 \ - --hash=sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642 \ - --hash=sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2 \ - --hash=sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9 \ - --hash=sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c \ - --hash=sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33 \ - --hash=sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf \ - --hash=sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9 \ - --hash=sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84 \ - --hash=sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44 \ - --hash=sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9 \ - --hash=sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177 \ - --hash=sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b \ - --hash=sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39 \ - --hash=sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41 \ - --hash=sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0 \ - --hash=sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9 \ - --hash=sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48 \ - --hash=sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046 \ - --hash=sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632 \ - --hash=sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2 \ - --hash=sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1 \ - --hash=sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990 \ - --hash=sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b \ - --hash=sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534 \ - --hash=sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81 \ - --hash=sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a \ - --hash=sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d \ - --hash=sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf \ - --hash=sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115 - # via requests click==8.4.1 \ --hash=sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2 \ --hash=sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96 - # via - # dbt-core - # dbt-semantic-interfaces - # opa-database -colorama==0.4.6 \ + # via opa-database +colorama==0.4.6 ; sys_platform == 'win32' \ --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 - # via - # click - # dbt-common -daff==1.4.2 \ - --hash=sha256:47f0391eda7e2b5011f7ccac006b9178accb465bcb94a2c9f284257fff5d2686 \ - --hash=sha256:88981a21d065e4378b5c4bd40b975dbfdea9b7ff540071f3bb5e20cc8b3590b5 - # via dbt-core -dbt-adapters==1.24.4 \ - --hash=sha256:a8a4bb380168dd632012e33e883433c65def9fadc4f30e365db313907b270b99 \ - --hash=sha256:e31df9e27ae8e1d4fa0c5961767953cd3f122730c436815da4585b373376d31c - # via - # dbt-core - # dbt-postgres -dbt-common==1.38.0 \ - --hash=sha256:05f9c682c1c09b6a0ce79fe18dd7244a4549c3740329692bc19be9e7498f516f \ - --hash=sha256:1646e5384a8e317fa63890e60e41ca50dd4c5a89e02ad9a141c184d4b672b696 - # via - # dbt-adapters - # dbt-core - # dbt-postgres -dbt-core==1.11.12 \ - --hash=sha256:108ced0a65d1f5330e86920df5c7182f79cd6ddcedf8f2d73c7928a310751ae8 \ - --hash=sha256:3b7760a3760a6db8a14a6ef38fb86532b2c2b150d49beaa1feb0f50170baa86e - # via - # dbt-postgres - # opa-database -dbt-extractor==0.6.0 \ - --hash=sha256:05bcfab7ebd70296ceb31742e8333ba66a2c939de44e61a7088bebafa939aaf6 \ - --hash=sha256:080fd1edf123926ed97929c65a75874d0fea687ccd5d3ebbc9e81b339f099604 \ - --hash=sha256:1b9ed7b15df983a735f87773f6765db8458680c02fcebbf89df4e238503c0e08 \ - --hash=sha256:311f0d3a4994751c541a4fa303d205727ba90e90c85286c03d3d9284e2bf0bd4 \ - --hash=sha256:369dcc3499f160256756585783f1308868076d5a65d0a051348d22da8b90e67d \ - --hash=sha256:4b6b1e70dde78cb904ca7a8958c2c803e77779b6ce108f4ea7ac479f5700db89 \ - --hash=sha256:71b3f8897138cc6698d313b9a3d0450fd021937ff5463269ee18ed415541781b \ - --hash=sha256:868af715a6328d7317ce6e4db238f850f660fef13fb36b7ab4cf9163ed5f54ff \ - --hash=sha256:a5cb810edc60c0486f78cc29739ebda70c81b10a1686861e78addc9f91fcd7de \ - --hash=sha256:a79a570fdcb672505ac2bdc12360a2a7aec622ef604d8c607225854ff862518c \ - --hash=sha256:aecfa43f7e6f139e76d47e4e1d7b189655ae19a8cf697686230bacb89a94ae74 \ - --hash=sha256:af451633390ac19669d3bde6c79822e657d32f5d903b3388bb00d56333fd52d5 \ - --hash=sha256:c1fd2b083a75e80b13e9874dc9699bfdfddf3baa9b6a8dea48de06d51a082733 \ - --hash=sha256:caeaba8d8c813f8e32d586c12615c0c7d6b99bee4f1be845312e80ef731de164 \ - --hash=sha256:d6cf08ec793b8bc2bd6e260ef818230ae68a4f71436fa489f08d7db1a52e2ffe \ - --hash=sha256:dcf14ed245de8df269815ff4c4f555fa72d2621f4fff37c023b8c99d0e421b4f - # via dbt-core -dbt-postgres==1.10.2 \ - --hash=sha256:1367386caa99ca59f8228eae6d0e03f9537f9615f55a5ba5cc80a5b06aa6d32e \ - --hash=sha256:83b6b03572452d74bf3b60ef2578c7172d6e5ff4c285c876cd51aa9ed09be4c4 - # via opa-database -dbt-protos==1.0.541 \ - --hash=sha256:4e03f88e6b5c13a8cac68c6aa78267d8eb4396e6341a807112d619ab938318d5 \ - --hash=sha256:c1900f543c1b170bc8e065209537d9b5a8fa5577bc12e06ea26c41f1a4ec7ecc - # via - # dbt-adapters - # dbt-common - # dbt-core -dbt-semantic-interfaces==0.9.0 \ - --hash=sha256:1b54c06ba89190a47a7f0563360930a0cce869e55b484ca09d261ade0e319155 \ - --hash=sha256:5c921257dce8bb51c9ffb5479f2bdd959e16ebfb98ee833de6daa70788c47271 - # via dbt-core -deepdiff==8.6.2 \ - --hash=sha256:186dcbd181e4d76cef11ab05f802d0056c5d6083c5a6748c1473e9d7481e183e \ - --hash=sha256:4d22034a866c3928303a9332c279362f714192d9305bac17c498720d095fd1b4 - # via dbt-common -idna==3.18 \ - --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ - --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 - # via requests -importlib-metadata==8.9.0 \ - --hash=sha256:58850626cef4bd2df100378b0f2aea9724a7b92f10770d547725b047078f99ee \ - --hash=sha256:e0f761b6ea91ced3b0844c14c9d955224d538105921f8e6754c00f6ca79fba7f - # via dbt-semantic-interfaces -isodate==0.7.2 \ - --hash=sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15 \ - --hash=sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6 - # via - # agate - # dbt-common -jinja2==3.1.6 \ - --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \ - --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 - # via - # dbt-common - # dbt-core - # dbt-semantic-interfaces -jsonschema==4.26.0 \ - --hash=sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326 \ - --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce - # via - # dbt-common - # dbt-core - # dbt-semantic-interfaces -jsonschema-specifications==2025.9.1 \ - --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \ - --hash=sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d - # via jsonschema -leather==0.4.1 \ - --hash=sha256:67119c2aee93be821f077193bd8534e296c05b38bd174d9c5a80c4aa31d1a4d3 \ - --hash=sha256:ec61cba1ca3ccb96ed90e38b116fc58757d97d352171006b3288c47ce3fbd183 - # via agate -markupsafe==3.0.3 \ - --hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \ - --hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \ - --hash=sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 \ - --hash=sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb \ - --hash=sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6 \ - --hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \ - --hash=sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce \ - --hash=sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218 \ - --hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \ - --hash=sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73 \ - --hash=sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c \ - --hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \ - --hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \ - --hash=sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37 \ - --hash=sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f \ - --hash=sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d \ - --hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \ - --hash=sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19 \ - --hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \ - --hash=sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9 \ - --hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \ - --hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \ - --hash=sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354 \ - --hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \ - --hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \ - --hash=sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b \ - --hash=sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc \ - --hash=sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485 \ - --hash=sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f \ - --hash=sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12 \ - --hash=sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025 \ - --hash=sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009 \ - --hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \ - --hash=sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a \ - --hash=sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5 \ - --hash=sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f \ - --hash=sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1 \ - --hash=sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287 \ - --hash=sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6 \ - --hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \ - --hash=sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed \ - --hash=sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b \ - --hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \ - --hash=sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 \ - --hash=sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e \ - --hash=sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d \ - --hash=sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d \ - --hash=sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795 \ - --hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \ - --hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \ - --hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \ - --hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \ - --hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \ - --hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \ - --hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \ - --hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50 - # via jinja2 -mashumaro==3.9 \ - --hash=sha256:545099fa7f35d7da516a627d31b8f58249c4326c0e14dc8ea5da004a06061b17 \ - --hash=sha256:c179f3f29f7b88acc9472427ce9fc673072a04b3888ce4bd1cac94c266c8e587 - # via - # dbt-adapters - # dbt-common - # dbt-core -more-itertools==10.8.0 \ - --hash=sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b \ - --hash=sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd - # via dbt-semantic-interfaces -msgpack==1.2.1 \ - --hash=sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a \ - --hash=sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647 \ - --hash=sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d \ - --hash=sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064 \ - --hash=sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107 \ - --hash=sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac \ - --hash=sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720 \ - --hash=sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c \ - --hash=sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde \ - --hash=sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc \ - --hash=sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35 \ - --hash=sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9 \ - --hash=sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6 \ - --hash=sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07 \ - --hash=sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056 \ - --hash=sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4 \ - --hash=sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7 \ - --hash=sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1 \ - --hash=sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24 \ - --hash=sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355 \ - --hash=sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0 \ - --hash=sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707 \ - --hash=sha256:74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b \ - --hash=sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d \ - --hash=sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7 \ - --hash=sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402 \ - --hash=sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a \ - --hash=sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d \ - --hash=sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273 \ - --hash=sha256:98b58bdb89c46190e4609bb36abe17c6d4105ad13f9c5f8f6f64d320f8ced3fb \ - --hash=sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190 \ - --hash=sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a \ - --hash=sha256:b50b727bd652bdc37d950336c848ef20ec54a4cafc38dce19b1cd86ad625d0f7 \ - --hash=sha256:c1c79a604a2969a868a78b6ebd27a887e00c624f14f66b3038e0590cb23332d1 \ - --hash=sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889 \ - --hash=sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c \ - --hash=sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155 \ - --hash=sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24 \ - --hash=sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6 \ - --hash=sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1 \ - --hash=sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d \ - --hash=sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64 \ - --hash=sha256:f12038a35fabd52e56a3547bab42401af49a45caa6dd00b34c44de235bc93ee2 \ - --hash=sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc \ - --hash=sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c - # via mashumaro + # via click mypy-extensions==1.1.0 \ --hash=sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505 \ --hash=sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558 # via typing-inspect -networkx==3.6.1 \ - --hash=sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509 \ - --hash=sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762 - # via dbt-core -orderly-set==5.5.0 \ - --hash=sha256:46f0b801948e98f427b412fcabb831677194c05c3b699b80de260374baa0b1e7 \ - --hash=sha256:e87185c8e4d8afa64e7f8160ee2c542a475b738bc891dc3f58102e654125e6ce - # via deepdiff packaging==26.2 \ --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 - # via - # dbt-core - # pandera + # via pandera pandera==0.32.0 \ --hash=sha256:070c705eafbcfb4dd2ce494154d4bc0ce6d2b8e02a95ae15234040683bbab1f5 \ --hash=sha256:b8488704d80ed7fc0a812b0a8123e2028903e90e8ec5c31f880894c8e94786b3 # via opa-database -parsedatetime==2.6 \ - --hash=sha256:4cb368fbb18a0b7231f4d76119165451c8d2e35951455dfee97c62a87b04d455 \ - --hash=sha256:cb96edd7016872f58479e35879294258c71437195760746faffedb692aef000b - # via agate -pathspec==0.12.1 \ - --hash=sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08 \ - --hash=sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712 - # via - # dbt-common - # dbt-core polars==1.41.2 \ --hash=sha256:23ce9a2910b6e3e8d4258770bf44aa17170958df7af6e85feedf4458a04d8d29 \ --hash=sha256:256d6731162371b77f3f29a55eacb8c0fc740ddb1a293a01d2ef5b5393c5c708 @@ -355,20 +40,6 @@ polars-runtime-32==1.41.2 \ --hash=sha256:dedfaeec2c7f995298da7319dd9431d662e5dd1d0ec51b1459df4a0234ceff52 \ --hash=sha256:e7016a3deb641b64a31447abbbee0f34bd020a6a9ae34ee6b743837def15e2a4 # via polars -protobuf==6.33.6 \ - --hash=sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326 \ - --hash=sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901 \ - --hash=sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3 \ - --hash=sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a \ - --hash=sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135 \ - --hash=sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3 \ - --hash=sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2 \ - --hash=sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593 - # via - # dbt-adapters - # dbt-common - # dbt-core - # dbt-protos psycopg==3.3.4 \ --hash=sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a \ --hash=sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc @@ -408,48 +79,10 @@ psycopg-binary==3.3.4 ; implementation_name != 'pypy' \ --hash=sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260 \ --hash=sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7 # via psycopg -psycopg2-binary==2.9.12 \ - --hash=sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f \ - --hash=sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2 \ - --hash=sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c \ - --hash=sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6 \ - --hash=sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c \ - --hash=sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d \ - --hash=sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019 \ - --hash=sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7 \ - --hash=sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777 \ - --hash=sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd \ - --hash=sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5 \ - --hash=sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c \ - --hash=sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433 \ - --hash=sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d \ - --hash=sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290 \ - --hash=sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2 \ - --hash=sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d \ - --hash=sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b \ - --hash=sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965 \ - --hash=sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9 \ - --hash=sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f \ - --hash=sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354 \ - --hash=sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9 \ - --hash=sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5 \ - --hash=sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be \ - --hash=sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580 \ - --hash=sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f \ - --hash=sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033 \ - --hash=sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0 \ - --hash=sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e \ - --hash=sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5 \ - --hash=sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6 \ - --hash=sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980 \ - --hash=sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e - # via dbt-postgres pydantic==2.13.4 \ --hash=sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba \ --hash=sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6 # via - # dbt-core - # dbt-semantic-interfaces # opa-database # pandera # pydantic-settings @@ -524,195 +157,10 @@ pydantic-settings==2.14.1 \ --hash=sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de \ --hash=sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa # via opa-database -python-dateutil==2.9.0.post0 \ - --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ - --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 - # via - # dbt-common - # dbt-semantic-interfaces python-dotenv==1.2.2 \ --hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \ --hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3 # via pydantic-settings -python-slugify==8.0.4 \ - --hash=sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8 \ - --hash=sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856 - # via agate -pytimeparse==1.1.8 \ - --hash=sha256:04b7be6cc8bd9f5647a6325444926c3ac34ee6bc7e69da4367ba282f076036bd \ - --hash=sha256:e86136477be924d7e670646a98561957e8ca7308d44841e21f5ddea757556a0a - # via agate -pytz==2026.2 \ - --hash=sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126 \ - --hash=sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a - # via - # dbt-adapters - # dbt-core -pyyaml==6.0.3 \ - --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ - --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ - --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ - --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ - --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ - --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ - --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ - --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ - --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ - --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ - --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ - --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ - --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ - --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ - --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ - --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ - --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ - --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ - --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ - --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ - --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ - --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ - --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ - --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ - --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ - --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ - --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ - --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ - --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ - --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ - --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ - --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ - --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ - --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ - --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ - --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ - --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ - --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ - --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 - # via - # dbt-core - # dbt-semantic-interfaces -referencing==0.37.0 \ - --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \ - --hash=sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8 - # via - # jsonschema - # jsonschema-specifications -requests==2.34.2 \ - --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ - --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed - # via - # dbt-common - # dbt-core - # snowplow-tracker -rpds-py==2026.6.3 \ - --hash=sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5 \ - --hash=sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680 \ - --hash=sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538 \ - --hash=sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf \ - --hash=sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4 \ - --hash=sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97 \ - --hash=sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96 \ - --hash=sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a \ - --hash=sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187 \ - --hash=sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975 \ - --hash=sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f \ - --hash=sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703 \ - --hash=sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f \ - --hash=sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa \ - --hash=sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05 \ - --hash=sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba \ - --hash=sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223 \ - --hash=sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885 \ - --hash=sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698 \ - --hash=sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7 \ - --hash=sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed \ - --hash=sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f \ - --hash=sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf \ - --hash=sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e \ - --hash=sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f \ - --hash=sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24 \ - --hash=sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a \ - --hash=sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc \ - --hash=sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d \ - --hash=sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146 \ - --hash=sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e \ - --hash=sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e \ - --hash=sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4 \ - --hash=sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12 \ - --hash=sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6 \ - --hash=sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5 \ - --hash=sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7 \ - --hash=sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda \ - --hash=sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8 \ - --hash=sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342 \ - --hash=sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb \ - --hash=sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0 \ - --hash=sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77 \ - --hash=sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885 \ - --hash=sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617 \ - --hash=sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb \ - --hash=sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577 \ - --hash=sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80 \ - --hash=sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e \ - --hash=sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945 \ - --hash=sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0 \ - --hash=sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140 \ - --hash=sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822 \ - --hash=sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba \ - --hash=sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4 \ - --hash=sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a \ - --hash=sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8 \ - --hash=sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf \ - --hash=sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324 \ - --hash=sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53 \ - --hash=sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b \ - --hash=sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41 \ - --hash=sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9 \ - --hash=sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1 \ - --hash=sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d \ - --hash=sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690 \ - --hash=sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107 \ - --hash=sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2 \ - --hash=sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d \ - --hash=sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af \ - --hash=sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6 \ - --hash=sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db \ - --hash=sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369 \ - --hash=sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd \ - --hash=sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504 \ - --hash=sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13 \ - --hash=sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc \ - --hash=sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868 \ - --hash=sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2 \ - --hash=sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd \ - --hash=sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4 \ - --hash=sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6 \ - --hash=sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9 \ - --hash=sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00 \ - --hash=sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f \ - --hash=sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e \ - --hash=sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442 \ - --hash=sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90 \ - --hash=sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef - # via - # jsonschema - # referencing -six==1.17.0 \ - --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ - --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 - # via python-dateutil -snowplow-tracker==1.1.0 \ - --hash=sha256:24ea32ddac9cca547421bf9ab162f5f33c00711c6ef118ad5f78093cee962224 \ - --hash=sha256:95d8fdc8bd542fd12a0b9a076852239cbaf0599eda8721deaf5f93f7138fe755 - # via dbt-core -sqlparse==0.5.5 \ - --hash=sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba \ - --hash=sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e - # via dbt-core -text-unidecode==1.3 \ - --hash=sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8 \ - --hash=sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93 - # via python-slugify typeguard==4.5.2 \ --hash=sha256:5a16dcac23502039299c97c8941651bc33d7ea8cc4b2f7d6bbb1b528f6eea423 \ --hash=sha256:fcf9de18bd945cdb4c7b996e12b4c51ce83f92f191314a6d7cf1739586ec98cf @@ -721,17 +169,10 @@ typing-extensions==4.16.0 \ --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 # via - # dbt-adapters - # dbt-common - # dbt-core - # dbt-semantic-interfaces - # mashumaro # pandera # psycopg # pydantic # pydantic-core - # referencing - # snowplow-tracker # typeguard # typing-inspect # typing-inspection @@ -748,14 +189,4 @@ typing-inspection==0.4.2 \ tzdata==2026.2 ; sys_platform == 'win32' \ --hash=sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10 \ --hash=sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7 - # via - # agate - # psycopg -urllib3==2.7.0 \ - --hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \ - --hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897 - # via requests -zipp==4.1.0 \ - --hash=sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f \ - --hash=sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602 - # via importlib-metadata + # via psycopg diff --git a/src/opa_database/adapters/afc.py b/src/opa_database/adapters/afc.py index 6f900c4..d0726ee 100644 --- a/src/opa_database/adapters/afc.py +++ b/src/opa_database/adapters/afc.py @@ -19,7 +19,7 @@ from pathlib import Path from typing import IO -_DUMP_FILE_NAME = re.compile(r"V(\d{4})(\d{2})(\d{2})\.zip$") +_DUMP_FILE_NAME = re.compile(r"V(\d{4})(\d{2})(\d{2})t?\.zip$") # One column per (context tag, XML attribute), flattened onto every # Passageiro row. Order matches the nesting depth the value is read at. @@ -69,9 +69,12 @@ def _find_dump_zips(year: int, month: int) -> list[Path]: """Locate raw AFC daily dump zips for a given year/month. - Only matches the "V{YYYYMMDD}.zip" naming convention used since 2020. - 2014-2018 raw data uses a different format entirely (per-month folders - of "Viagenssigom{date}.csv" files) and isn't supported yet. + Only matches the "V{YYYYMMDD}.zip" naming convention used since 2020 + (a trailing "t" before ".zip" is also tolerated: 16 dumps in May 2022 + are named that way, e.g. "V20220513t.zip", the only month/year this + has been observed). 2014-2018 raw data uses a different format + entirely (per-month folders of "Viagenssigom{date}.csv" files) and + isn't supported yet. """ year_dir = settings.raw_data_root / "DADOS_BILHETAGEM" / str(year) matches = [ diff --git a/src/opa_database/adapters/avl.py b/src/opa_database/adapters/avl.py index 5a13f74..7e13d70 100644 --- a/src/opa_database/adapters/avl.py +++ b/src/opa_database/adapters/avl.py @@ -15,6 +15,11 @@ import datetime from pathlib import Path +# 2018 is the one legacy year with a fundamentally different raw layout +# (see `_find_2018_file`/`ingest`): a whole-month file with a header, +# instead of one headerless file per day under a Portuguese-month folder. +_LEGACY_WHOLE_MONTH_YEAR = 2018 + _PORTUGUESE_MONTHS = { 1: "JANEIRO", 2: "FEVEREIRO", @@ -43,21 +48,90 @@ def _find_month_dir(year: int, month: int) -> Path: Folder names are inconsistent across years and even within 2023 (e.g. "NOVEMBRO-2023", "ABRIL - 2023", "JULHO - 2023 -"), so this matches on the normalized Portuguese month name rather than the literal string. + + Raises on more than one match rather than silently picking one: 2022's + raw data has both "MAIO 2022" (31 files, the real month) and a stray + "MAIO - 2022" (a single, duplicate day-1 file left over from an + abandoned copy) both normalizing to MAIO. Picking whichever directory + iteration happens to see first would be non-deterministic and could + silently ingest the wrong (incomplete) one, exactly what happened + here before this check existed. """ year_dir = settings.raw_data_root / "DADOS_GPS" / str(year) target = _PORTUGUESE_MONTHS[month] - for entry in year_dir.iterdir(): - if entry.is_dir() and _normalize(entry.name) == target: - return entry - msg = f"No AVL folder found for {year}-{month:02d} under {year_dir}" - raise FileNotFoundError(msg) - - -def _read_raw_csv(path: Path) -> pl.DataFrame: - df = pl.read_csv(path, has_header=False, new_columns=RAW_COLUMNS) - timestamp = pl.col("metric_timestamp").cast(pl.Utf8) - timestamp = timestamp.str.strptime(pl.Datetime, "%Y%m%d%H%M%S") - return df.with_columns(timestamp) + matches = [ + entry + for entry in year_dir.iterdir() + if entry.is_dir() and _normalize(entry.name) == target + ] + if not matches: + msg = f"No AVL folder found for {year}-{month:02d} under {year_dir}" + raise FileNotFoundError(msg) + if len(matches) > 1: + names = ", ".join(sorted(m.name for m in matches)) + msg = ( + f"Multiple AVL folders found for {year}-{month:02d} under " + f"{year_dir}: {names}. Resolve the collision manually before " + "ingesting (e.g. confirm which is authoritative and remove or " + "rename the other)." + ) + raise ValueError(msg) + return matches[0] + + +def _find_2018_file(month: int) -> Path: + """Locate the single raw AVL file for 2018's one-file-per-month layout. + + Unlike every other year, 2018 has no month subfolders: all 12 months + sit directly under DADOS_GPS/2018/ as `Paint{MM}2018.csv`, one whole + month per file instead of one file per day. + """ + path = settings.raw_data_root / "DADOS_GPS" / "2018" / f"Paint{month:02d}2018.csv" + if not path.exists(): + msg = f"No AVL file found for 2018-{month:02d} at {path}" + raise FileNotFoundError(msg) + return path + + +# Every raw column except latitude/longitude is required (AvlSchema has no +# other nullable fields). A handful of rows across the raw history are +# individually truncated/corrupted (e.g. a write cut short mid-line), +# landing a null in one of these -- vanishingly rare (1-2 rows out of +# millions per occurrence) but enough to hit occasionally across years of +# continuous logging. +_REQUIRED_COLUMNS = [c for c in RAW_COLUMNS if c not in ("latitude", "longitude")] + + +def _read_raw_csv(path: Path, *, has_header: bool = False) -> pl.DataFrame: + # infer_schema_length=0 reads every column as a raw string first (same + # reasoning as adapters/gtfs.py): letting Polars guess types from a + # sample of early rows is fragile for a headerless file -- a malformed + # row anywhere earlier in the file can shift its column-type guess, + # then crash later on a perfectly normal value in a column it + # mis-inferred as numeric. Pandera's coerce=True (AvlSchema) casts each + # column from the raw string into its declared dtype afterward. + # has_header=True (2018 only) still uses new_columns to rename, which + # simply discards whatever header row is present -- 2018's own header + # names differ (no underscores) but its column order already matches + # RAW_COLUMNS, so no explicit mapping is needed. + df = pl.read_csv( + path, has_header=has_header, new_columns=RAW_COLUMNS, infer_schema_length=0 + ) + timestamp = pl.col("metric_timestamp").str.strptime( + pl.Datetime, "%Y%m%d%H%M%S", strict=False + ) + df = df.with_columns(timestamp) + # Drop individually-corrupted rows (a null or empty value in a + # required field -- reading everything as string first means a + # missing value can show up as "" rather than a true null) rather + # than letting the whole day's/month's ingest fail on a handful of + # unusable rows out of millions. + required_ok = pl.all_horizontal( + pl.col(c).is_not_null() & (pl.col(c) != "") + for c in _REQUIRED_COLUMNS + if c != "metric_timestamp" + ) + return df.filter(required_ok & pl.col("metric_timestamp").is_not_null()) def _write_day(df: pl.DataFrame, date: datetime.date) -> Path: @@ -74,6 +148,19 @@ def ingest(year: int, month: int) -> list[Path]: (which would overwrite one another), the last date seen in each file is held back and merged with the next file before being written. + A raw file that exists but is empty (e.g. 2022-01-13) is skipped and + treated the same as a missing day, rather than raising. Individual + rows with a null in a required field (a handful of truncated/corrupted + lines scattered across the raw history, e.g. one row in + 2022-11-04's file) are dropped rather than failing the whole load. + + 2018 is the one exception to "one raw file per day": its whole year + lives as 12 already-monthly files (`Paint{MM}2018.csv`) directly under + DADOS_GPS/2018/, each with a header row unlike every other year. The + per-date write loop below already handles a file spanning many + calendar dates (it doesn't assume one date per file), so no other + ingest logic changes for this case. + Args: year (int): Calendar year to ingest. month (int): Calendar month to ingest. @@ -82,12 +169,24 @@ def ingest(year: int, month: int) -> list[Path]: list[Path]: Paths of the bronze parquet files written. """ - month_dir = _find_month_dir(year, month) + if year == _LEGACY_WHOLE_MONTH_YEAR: + csv_paths = [_find_2018_file(month)] + has_header = True + else: + csv_paths = sorted(_find_month_dir(year, month).glob("*.csv")) + has_header = False + written: list[Path] = [] carry: pl.DataFrame | None = None - for csv_path in sorted(month_dir.glob("*.csv")): - df = AvlSchema.validate(_read_raw_csv(csv_path)) + for csv_path in csv_paths: + if csv_path.stat().st_size == 0: + # A handful of raw files are present but genuinely empty (e.g. + # 2022-01-13) rather than absent. Treat that identically to a + # missing day -- a real, if unusual, gap -- instead of letting + # Polars raise on the empty read. + continue + df = AvlSchema.validate(_read_raw_csv(csv_path, has_header=has_header)) if carry is not None: df = pl.concat([carry, df]) diff --git a/src/opa_database/adapters/gtfs.py b/src/opa_database/adapters/gtfs.py index 007fd50..8ab57d1 100644 --- a/src/opa_database/adapters/gtfs.py +++ b/src/opa_database/adapters/gtfs.py @@ -15,34 +15,98 @@ from opa_database.loaders.bronze import write_bronze if TYPE_CHECKING: + from collections.abc import Iterable from pathlib import Path -_EXPORT_NAME = re.compile(r"exportacao_(\d{4})-(\d{2})-(\d{2})\.zip$") +# GTFS export filenames use several distinct historical naming schemes. +# Each entry pairs a pattern with the (year, month, day) index into that +# pattern's match groups, since schemes differ in both separator and +# day/month/year ordering. +_EXPORT_NAME_PATTERNS: tuple[tuple[re.Pattern[str], tuple[int, int, int]], ...] = ( + # 2020+: exportacao_YYYY-MM-DD.zip + (re.compile(r"exportacao_(\d{4})-(\d{2})-(\d{2})\.zip$"), (0, 1, 2)), + # 2015-2018 (most legacy exports): exportacaoDDMMYYYY.zip, sometimes + # with a stray space before the date (e.g. "exportacao 25052018.zip") + (re.compile(r"exportacao ?(\d{2})(\d{2})(\d{4})\.zip$"), (2, 1, 0)), + # 2019 (roughly a third of that year's exports): exportacao_DD-MM-YYYY.zip + (re.compile(r"exportacao_(\d{2})-(\d{2})-(\d{4})\.zip$"), (2, 1, 0)), +) + +# One raw filename has a data-entry typo in the year ("2818" instead of +# "2018" -- the file lives in the 2018/ folder and its zip content is +# otherwise unremarkable), corrected explicitly rather than trusted as-is. +_FILENAME_YEAR_CORRECTIONS: dict[str, int] = {"exportacao 05072818.zip": 2018} + + +def _match_export_name(name: str) -> tuple[int, int, int] | None: + """Parse (year, month, day) from a GTFS export filename. + + Tries every entry in `_EXPORT_NAME_PATTERNS` in turn, since export + filenames aren't consistent across the raw archive's history. + + Args: + name (str): Filename to parse (e.g. "exportacao_2022-01-15.zip"). + + Returns: + tuple[int, int, int] | None: `(year, month, day)` if `name` + matches a known pattern, else `None`. + + """ + for pattern, (year_idx, month_idx, day_idx) in _EXPORT_NAME_PATTERNS: + match = pattern.match(name) + if match is None: + continue + groups = match.groups() + year = _FILENAME_YEAR_CORRECTIONS.get(name, int(groups[year_idx])) + return year, int(groups[month_idx]), int(groups[day_idx]) + return None + + +# Tables where a missing raw file is tolerated: `ingest` substitutes the +# nearest other export's data instead of failing (see `_read_table_for_export` +# and `docs/architecture.md`). Every other table still fails loudly on a +# missing file, since that's an untested, unvetted code path for them. +_SUBSTITUTABLE_TABLES = frozenset({"calendar_dates", "stop_times"}) + + +def _all_export_zips() -> list[Path]: + """List every GTFS export zip across all years, any known naming scheme. + + Unlike `_find_export_zips` (scoped to one year/month), this scans + every year directory, so it can be used to search for a substitute + export when a table is missing from a given export's own zip. + """ + root = settings.raw_data_root / "GTFS" + return sorted( + path + for year_dir in root.iterdir() + if year_dir.is_dir() + for path in year_dir.glob("exportacao*.zip") + if _match_export_name(path.name) is not None + ) def _find_export_zips(year: int, month: int) -> list[Path]: """Locate GTFS export zips for a given year/month. - Only matches the "exportacao_YYYY-MM-DD.zip" naming convention used - since 2020. Earlier exports use several inconsistent naming schemes - (e.g. "exportacao02102015.zip", "exportacao_02-08-2019.zip") and aren't - supported yet. + Matches any of `_EXPORT_NAME_PATTERNS` (the 2020+ convention plus the + 2015-2019 legacy naming schemes). """ - year_dir = settings.raw_data_root / "GTFS" / str(year) - matches = [ + return [ path - for path in year_dir.glob("exportacao_*.zip") - if (match := _EXPORT_NAME.match(path.name)) and int(match.group(2)) == month + for path in _all_export_zips() + if (parsed := _match_export_name(path.name)) + and parsed[0] == year + and parsed[1] == month ] - return sorted(matches) def _export_date(path: Path) -> datetime.date: - match = _EXPORT_NAME.match(path.name) - if match is None: + parsed = _match_export_name(path.name) + if parsed is None: msg = f"Unrecognized GTFS export filename: {path.name}" raise ValueError(msg) - year, month, day = (int(part) for part in match.groups()) + year, month, day = parsed return datetime.date(year, month, day) @@ -73,6 +137,104 @@ def _read_table(archive: zipfile.ZipFile, table: str) -> pl.DataFrame: return df +def _select_nearest[T]( + target_date: datetime.date, + candidates: Iterable[tuple[datetime.date, T]], +) -> T: + """Pick the candidate date nearest `target_date`. + + Ties (two candidates equally far from `target_date`) prefer the + earlier one. Generic over the second tuple element (a `Path` in real + use) and kept free of I/O so it's directly unit-testable. + + Args: + target_date (datetime.date): Date to measure distance from. + candidates (Iterable[tuple[datetime.date, T]]): `(date, value)` + pairs to choose among. + + Returns: + T: The value of the nearest candidate. + + """ + + def sort_key(item: tuple[datetime.date, T]) -> tuple[int, int]: + candidate_date, _ = item + distance = abs((candidate_date - target_date).days) + tie_break = 0 if candidate_date <= target_date else 1 + return (distance, tie_break) + + return min(candidates, key=sort_key)[1] + + +def _find_substitute_export(target: Path, table: str) -> Path: + """Find the nearest other export whose zip actually contains `table`. + + Args: + target (Path): The export zip missing `table`. + table (str): Bronze table name (e.g. "calendar_dates"). + + Returns: + Path: The nearest other export zip (see `_select_nearest`) whose + archive contains `f"{table}.txt"`. + + Raises: + FileNotFoundError: If no other export contains `table`. + + """ + target_date = _export_date(target) + candidates: list[tuple[datetime.date, Path]] = [] + for path in _all_export_zips(): + if path == target: + continue + with zipfile.ZipFile(path) as archive: + if f"{table}.txt" in archive.namelist(): + candidates.append((_export_date(path), path)) + if not candidates: + msg = f"No GTFS export contains {table}.txt to substitute for {target.name}." + raise FileNotFoundError(msg) + return _select_nearest(target_date, candidates) + + +def _read_table_for_export( + archive: zipfile.ZipFile, zip_path: Path, table: str +) -> pl.DataFrame: + """Read one table for one export, substituting for a missing raw file. + + Only `_SUBSTITUTABLE_TABLES` get this treatment; every other table + still raises on a missing file, since that's an untested, unvetted + code path for them. When a substitution happens, every row of the + result is stamped with `copied_from_feed_version_date` set to the + substitute export's date; a normally-sourced table gets that column + stamped `null` instead, so the column always exists with consistent + semantics whether or not this export needed a substitution. + + Args: + archive (zipfile.ZipFile): This export's own open archive. + zip_path (Path): This export's own zip path (used to locate a + substitute and to know which export we're reading for). + table (str): Bronze table name. + + Returns: + pl.DataFrame: The table's data, with `copied_from_feed_version_date` + added for tables in `_SUBSTITUTABLE_TABLES`. + + """ + if table not in _SUBSTITUTABLE_TABLES: + return _read_table(archive, table) + + if f"{table}.txt" in archive.namelist(): + return _read_table(archive, table).with_columns( + pl.lit(None, dtype=pl.Date).alias("copied_from_feed_version_date") + ) + + substitute_path = _find_substitute_export(zip_path, table) + with zipfile.ZipFile(substitute_path) as substitute_archive: + df = _read_table(substitute_archive, table) + return df.with_columns( + pl.lit(_export_date(substitute_path)).alias("copied_from_feed_version_date") + ) + + def ingest(year: int, month: int) -> list[Path]: """Ingest all GTFS export snapshots for a year/month into the bronze layer. @@ -80,6 +242,13 @@ def ingest(year: int, month: int) -> list[Path]: every table is partitioned by the export date rather than a calendar day of service. + A few raw exports are missing an entire table file (e.g. no + `calendar_dates.txt`). For tables in `_SUBSTITUTABLE_TABLES`, this is + tolerated: the nearest other export's data is substituted, and every + row it writes is tagged via `copied_from_feed_version_date` (see + `_read_table_for_export` and `docs/architecture.md`). Any other table + missing its file still raises. + Args: year (int): Calendar year to ingest. month (int): Calendar month to ingest. @@ -94,7 +263,7 @@ def ingest(year: int, month: int) -> list[Path]: partitions = {"year": date.year, "month": date.month, "day": date.day} with zipfile.ZipFile(zip_path) as archive: for table, schema in TABLES.items(): - df = schema.validate(_read_table(archive, table)) + df = schema.validate(_read_table_for_export(archive, zip_path, table)) path = write_bronze(df, source=f"gtfs/{table}", partitions=partitions) written.append(path) return written diff --git a/src/opa_database/adapters/vehicle_dictionary.py b/src/opa_database/adapters/vehicle_dictionary.py index 8baa2c9..ce48041 100644 --- a/src/opa_database/adapters/vehicle_dictionary.py +++ b/src/opa_database/adapters/vehicle_dictionary.py @@ -1,32 +1,76 @@ -"""Adapter for the vehicle dictionary source: a reference CSV snapshot. - -Unlike AVL/GTFS/AFC, this isn't a time series of raw files to pick a -year/month from — it's a single, currently-live reference file on disk -that gets updated in place over time. So instead of partitioning by a date -parsed out of the raw data, each ingestion run snapshots the file as of -today (or an explicitly given date), preserving the mapping as it existed -at that point without overwriting previous snapshots. +"""Adapters for the vehicle dictionary family of bronze sources. + +`DICIONÁRIO_VEÍCULOS/` on the raw data root held several independently +extracted vehicle-identity crosswalks. All of them are ingested from this +one module, same as how `adapters/avl.py` is one script handling AVL's +several raw layouts across years -- except here, unlike AVL, the raw +files don't all funnel into a single shared target schema, so each +`ingest_*` function below pairs with its own model in +`contracts/vehicle_dictionary.py`. See that module's docstring for what +each source actually maps and why they're kept separate rather than +merged. + +None of these are a time series of raw files to pick a year/month from -- +each is a single, already-final CSV, snapshotted as of whenever it gets +ingested (or an explicitly given date) rather than a date parsed out of +the raw data itself, preserving the mapping as it existed at that point +without overwriting previous snapshots. `veiculos_atuais.csv` (the source +behind `ingest_vehicle`) used to be the one exception -- a file that got +updated in place over time -- but all five raw files were fully captured +into bronze and then deleted from raw_data_root (kept only as an external +backup) once nothing was left un-ingested, so every source here is now +equally a one-time, static capture with no live file left behind it to +re-run against. """ from __future__ import annotations import datetime +import io from typing import TYPE_CHECKING import polars as pl from opa_database.config import settings -from opa_database.contracts.vehicle_dictionary import VehicleDictionarySchema +from opa_database.contracts.vehicle_dictionary import ( + DeviceDictionarySchema, + VehicleDictionaryLegacy2Schema, + VehicleDictionaryLegacySchema, + VehicleDictionarySchema, +) from opa_database.loaders.bronze import write_bronze if TYPE_CHECKING: from pathlib import Path -_RAW_RELATIVE_PATH = "DICIONÁRIO_VEÍCULOS/veiculos_atuais.csv" +_VEHICLE_RAW_RELATIVE_PATH = "DICIONÁRIO_VEÍCULOS/veiculos_atuais.csv" +_LEGACY_RAW_RELATIVE_PATH = "DICIONÁRIO_VEÍCULOS/dicionario_veiculos.csv" +_LEGACY2_RAW_RELATIVE_PATH = "DICIONÁRIO_VEÍCULOS/dicionario_veiculos2.csv" +_2018_RAW_RELATIVE_PATH = "DICIONÁRIO_VEÍCULOS/veiculos2018.csv" +_ANTIGO_RAW_RELATIVE_PATH = "DICIONÁRIO_VEÍCULOS/veiculos_antigo.csv" + +# Raw header, in order: Código, ID, Tipo de Veículo, Empresa, Placa, +# Número de Ordem, Situação, Ação. +_DEVICE_COLUMN_RENAME = { + "Código": "codigo", + "ID": "device_id", + "Tipo de Veículo": "vehicle_type", + "Empresa": "company", + "Placa": "plate", + "Número de Ordem": "vehicle_number", + "Situação": "status", + "Ação": "action", +} + + +def _today() -> datetime.date: + return datetime.datetime.now(tz=datetime.UTC).date() -def ingest(snapshot_date: datetime.date | None = None) -> Path: - """Snapshot the current vehicle dictionary into the bronze layer. +def ingest_vehicle(snapshot_date: datetime.date | None = None) -> Path: + """Snapshot the vehicle dictionary into the bronze layer. + + Source: `veiculos_atuais.csv` (`cod_veiculo`/`id_veiculo`). Args: snapshot_date (datetime.date | None): Date to record the snapshot @@ -36,9 +80,162 @@ def ingest(snapshot_date: datetime.date | None = None) -> Path: Path: The path of the bronze parquet file written. """ - date = snapshot_date or datetime.datetime.now(tz=datetime.UTC).date() - path = settings.raw_data_root / _RAW_RELATIVE_PATH + date = snapshot_date or _today() + path = settings.raw_data_root / _VEHICLE_RAW_RELATIVE_PATH df = pl.read_csv(path, separator=";", infer_schema_length=0) validated = VehicleDictionarySchema.validate(df) partitions = {"year": date.year, "month": date.month, "day": date.day} return write_bronze(validated, source="vehicle_dictionary", partitions=partitions) + + +def ingest_legacy(snapshot_date: datetime.date | None = None) -> Path: + """Snapshot the first legacy vehicle dictionary into the bronze layer. + + Source: `dicionario_veiculos.csv` (`vehicleid`/`numbus`). + + Args: + snapshot_date (datetime.date | None): Date to record the snapshot + as. Defaults to today (UTC) if not given. + + Returns: + Path: The path of the bronze parquet file written. + + """ + date = snapshot_date or _today() + path = settings.raw_data_root / _LEGACY_RAW_RELATIVE_PATH + df = pl.read_csv(path, separator=";", infer_schema_length=0) + validated = VehicleDictionaryLegacySchema.validate(df) + partitions = {"year": date.year, "month": date.month, "day": date.day} + return write_bronze( + validated, source="vehicle_dictionary_legacy", partitions=partitions + ) + + +def ingest_legacy2(snapshot_date: datetime.date | None = None) -> Path: + """Snapshot the second legacy vehicle dictionary into the bronze layer. + + Source: `dicionario_veiculos2.csv` (`id`/`carro`/`obs1`/`obs2`). + + Args: + snapshot_date (datetime.date | None): Date to record the snapshot + as. Defaults to today (UTC) if not given. + + Returns: + Path: The path of the bronze parquet file written. + + """ + date = snapshot_date or _today() + path = settings.raw_data_root / _LEGACY2_RAW_RELATIVE_PATH + df = pl.read_csv(path, separator=";", infer_schema_length=0) + validated = VehicleDictionaryLegacy2Schema.validate(df) + partitions = {"year": date.year, "month": date.month, "day": date.day} + return write_bronze( + validated, source="vehicle_dictionary_legacy2", partitions=partitions + ) + + +def ingest_2018(snapshot_date: datetime.date | None = None) -> Path: + """Snapshot the 2018 vehicle dictionary into the bronze layer. + + Source: `veiculos2018.csv` (`id_veiculo`/`cod_veiculo`, same shape as + `veiculos_atuais.csv`, reusing `VehicleDictionarySchema`). Kept as + its own bronze source rather than merged into `vehicle_dictionary`: + nothing confirms this file's `id_veiculo` values share an id space + with `veiculos_atuais.csv`'s. + + Args: + snapshot_date (datetime.date | None): Date to record the snapshot + as. Defaults to today (UTC) if not given. + + Returns: + Path: The path of the bronze parquet file written. + + """ + date = snapshot_date or _today() + path = settings.raw_data_root / _2018_RAW_RELATIVE_PATH + df = pl.read_csv(path, separator=";", infer_schema_length=0) + validated = VehicleDictionarySchema.validate(df) + partitions = {"year": date.year, "month": date.month, "day": date.day} + return write_bronze( + validated, source="vehicle_dictionary_2018", partitions=partitions + ) + + +def ingest_antigo(snapshot_date: datetime.date | None = None) -> Path: + """Snapshot the "antigo" vehicle dictionary into the bronze layer. + + Source: `veiculos_antigo.csv` (`cod_veiculo`/`id_veiculo`, same shape + as `veiculos_atuais.csv`, reusing `VehicleDictionarySchema`). Its + `id_veiculo` values (single/double digits) are far smaller than + either `veiculos_atuais.csv`'s or AVL's, so it is presumably an even + earlier snapshot than `veiculos2018.csv` -- kept as its own bronze + source for the same reason. Unlike the other raw dictionary files, + this one is ISO-8859-1 encoded, not UTF-8, so it's read as bytes and + transcoded before handing off to Polars. + + Args: + snapshot_date (datetime.date | None): Date to record the snapshot + as. Defaults to today (UTC) if not given. + + Returns: + Path: The path of the bronze parquet file written. + + """ + date = snapshot_date or _today() + path = settings.raw_data_root / _ANTIGO_RAW_RELATIVE_PATH + text = path.read_bytes().decode("iso-8859-1") + df = pl.read_csv(io.StringIO(text), separator=";", infer_schema_length=0) + validated = VehicleDictionarySchema.validate(df) + partitions = {"year": date.year, "month": date.month, "day": date.day} + return write_bronze( + validated, source="vehicle_dictionary_antigo", partitions=partitions + ) + + +def ingest_device_dictionary(snapshot_date: datetime.date | None = None) -> Path: + """Snapshot a dated device dictionary export into the bronze layer. + + Source: `device_dictionary_{date}.csv`. Unlike the other sources in + this module, this one has no single current path -- each extraction + is its own dated CSV export that never gets updated after the fact, + so `snapshot_date` is required rather than defaulting to today (kept + as an `| None` type, same as every other function here, so the CLI + can hold one uniform source-name -> ingest-function mapping; the + "actually required" part is enforced at runtime instead). + + Args: + snapshot_date (datetime.date | None): Date the export was + extracted, matching the `{date}` in + `device_dictionary_{date}.csv`. Required -- there is no + "current" file to default to. + + Returns: + Path: The path of the bronze parquet file written. + + Raises: + ValueError: If `snapshot_date` is not given. + FileNotFoundError: If no export exists for `snapshot_date`. + + """ + if snapshot_date is None: + msg = ( + "device_dictionary has no single current file -- pass the " + "snapshot_date of the specific dated export to ingest." + ) + raise ValueError(msg) + path = ( + settings.raw_data_root + / "DICIONÁRIO_VEÍCULOS" + / f"device_dictionary_{snapshot_date.isoformat()}.csv" + ) + if not path.exists(): + msg = f"No device dictionary export found for {snapshot_date} at {path}" + raise FileNotFoundError(msg) + df = pl.read_csv(path, infer_schema_length=0).rename(_DEVICE_COLUMN_RENAME) + validated = DeviceDictionarySchema.validate(df) + partitions = { + "year": snapshot_date.year, + "month": snapshot_date.month, + "day": snapshot_date.day, + } + return write_bronze(validated, source="device_dictionary", partitions=partitions) diff --git a/src/opa_database/cli.py b/src/opa_database/cli.py index 46ceefc..6377410 100644 --- a/src/opa_database/cli.py +++ b/src/opa_database/cli.py @@ -1,8 +1,12 @@ """Click entrypoint for running bronze-layer ingestion and silver-layer loads.""" +import datetime +import logging + import click from opa_database.adapters import afc, avl, gtfs, vehicle_dictionary +from opa_database.gold import build as gold_build from opa_database.silver import afc as silver_afc from opa_database.silver import avl as silver_avl from opa_database.silver import gtfs as silver_gtfs @@ -14,8 +18,16 @@ "gtfs": gtfs, } +# All of these live in the single adapters/vehicle_dictionary.py module +# (see its docstring for why); the CLI just needs a source-name -> +# ingest-function mapping, not a source-name -> module mapping. _REFERENCE_ADAPTERS = { - "vehicle_dictionary": vehicle_dictionary, + "vehicle_dictionary": vehicle_dictionary.ingest_vehicle, + "device_dictionary": vehicle_dictionary.ingest_device_dictionary, + "vehicle_dictionary_legacy": vehicle_dictionary.ingest_legacy, + "vehicle_dictionary_legacy2": vehicle_dictionary.ingest_legacy2, + "vehicle_dictionary_2018": vehicle_dictionary.ingest_2018, + "vehicle_dictionary_antigo": vehicle_dictionary.ingest_antigo, } _SILVER_LOADERS = { @@ -24,8 +36,16 @@ "gtfs": silver_gtfs, } +# All of these live in the single silver/vehicle_dictionary.py module +# (see its docstring for why); the CLI just needs a source-name -> +# load-function mapping, not a source-name -> module mapping. _SILVER_REFERENCE_LOADERS = { - "vehicle_dictionary": silver_vehicle_dictionary, + "vehicle_dictionary": silver_vehicle_dictionary.load_vehicle, + "device_dictionary": silver_vehicle_dictionary.load_device_dictionary, + "vehicle_dictionary_legacy": silver_vehicle_dictionary.load_legacy, + "vehicle_dictionary_legacy2": silver_vehicle_dictionary.load_legacy2, + "vehicle_dictionary_2018": silver_vehicle_dictionary.load_2018, + "vehicle_dictionary_antigo": silver_vehicle_dictionary.load_antigo, } @@ -55,14 +75,32 @@ def ingest(source: str, year: int, month: int) -> None: @cli.command("ingest-reference") @click.argument("source", type=click.Choice(sorted(_REFERENCE_ADAPTERS))) -def ingest_reference(source: str) -> None: +@click.option( + "--snapshot-date", + "snapshot_date", + type=click.DateTime(formats=["%Y-%m-%d"]), + default=None, + help=( + "Date to record the snapshot as (YYYY-MM-DD). Required for " + "device_dictionary, since it has no single current file to " + "default to; optional for every other reference source, which " + "default to today." + ), +) +def ingest_reference(source: str, snapshot_date: datetime.datetime | None) -> None: """Snapshot a reference SOURCE into the bronze layer. Args: - source (str): Reference source to snapshot (`vehicle_dictionary`). + source (str): Reference source to snapshot (`vehicle_dictionary`, + `device_dictionary`, `vehicle_dictionary_legacy`, + `vehicle_dictionary_legacy2`, `vehicle_dictionary_2018`, or + `vehicle_dictionary_antigo`). + snapshot_date (datetime.datetime | None): Date to record the + snapshot as. """ - path = _REFERENCE_ADAPTERS[source].ingest() + date = snapshot_date.date() if snapshot_date else None + path = _REFERENCE_ADAPTERS[source](date) click.echo(path) @@ -85,12 +123,44 @@ def load_silver(source: str, year: int, month: int) -> None: @cli.command("load-silver-reference") @click.argument("source", type=click.Choice(sorted(_SILVER_REFERENCE_LOADERS))) -def load_silver_reference(source: str) -> None: - """Load the latest reference SOURCE snapshot into the silver layer. +@click.option( + "--snapshot-date", + "snapshot_date", + type=click.DateTime(formats=["%Y-%m-%d"]), + default=None, + help=( + "Bronze snapshot date to load (YYYY-MM-DD). Defaults to the " + "most recent snapshot found for SOURCE under bronze_root." + ), +) +def load_silver_reference(source: str, snapshot_date: datetime.datetime | None) -> None: + """Load a reference SOURCE snapshot into the silver layer. + + Args: + source (str): Reference source to load (`vehicle_dictionary`, + `device_dictionary`, `vehicle_dictionary_legacy`, + `vehicle_dictionary_legacy2`, `vehicle_dictionary_2018`, or + `vehicle_dictionary_antigo`). + snapshot_date (datetime.datetime | None): Bronze snapshot date to + load. Defaults to the most recent snapshot found. + + """ + date = snapshot_date.date() if snapshot_date else None + table = _SILVER_REFERENCE_LOADERS[source](date) + click.echo(f"Loaded {table}.") + + +@cli.command("build-gold") +@click.option("--year", type=int, required=True) +@click.option("--month", type=int, required=True) +def build_gold(year: int, month: int) -> None: + """Build the gold star schema for a given year/month from scratch. Args: - source (str): Reference source to load (`vehicle_dictionary`). + year (int): Calendar year to build gold for. + month (int): Calendar month to build gold for. """ - _SILVER_REFERENCE_LOADERS[source].load() - click.echo(f"Loaded silver.{source}.") + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") + gold_build.build(year, month) + click.echo(f"Built gold schema for {year}-{month:02d}.") diff --git a/src/opa_database/config.py b/src/opa_database/config.py index f5764ab..09752df 100644 --- a/src/opa_database/config.py +++ b/src/opa_database/config.py @@ -2,7 +2,6 @@ from pathlib import Path -from pydantic import field_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -12,33 +11,29 @@ class Settings(BaseSettings): Settings are configured to be automatically read from environment variables, then a `.env` file, if they are not passed here. + `raw_data_root` is not checked for existence here: only the + bronze-ingest code path actually reads from disk, so a missing/wrong + path surfaces as a `FileNotFoundError` there instead, rather than + blocking every other use of `settings` (e.g. `db_dsn`-only scripts) + up front. + Attributes: raw_data_root (Path): Path where the full raw data directory is located at. bronze_root (Path, optional): Path where the bronze layer parquets will be written to. Defaults to a local `./data/bronze`. - silver_dsn (str, optional): Connection string for the PostgreSQL+PostGIS - silver layer, provisioned via `docker compose up -d`. - - Raises: - ValueError: If the `raw_data_root` passed or detected does not actually exist. + db_dsn (str, optional): Connection string for the PostgreSQL+PostGIS + database, provisioned via `docker compose up -d`. Shared by every + schema (`silver`, `ml`, ...), not just `silver`. """ raw_data_root: Path bronze_root: Path = Path("./data/bronze") - silver_dsn: str = "postgresql://opa:opa@localhost:5432/opa" - - @field_validator("raw_data_root") - @classmethod - def _must_exist(cls, v: Path) -> Path: - if not v.exists(): - msg = f"raw_data_root does not exist: {v}" - raise ValueError(msg) - return v + db_dsn: str = "postgresql://opa:opa@localhost:5432/opa" - # extra="ignore": `.env` also carries SILVER_DB_USER/PASSWORD/NAME, read + # extra="ignore": `.env` also carries DB_USER/PASSWORD/NAME, read # directly by `docker compose` for variable substitution rather than by - # this class (which only needs the composed `silver_dsn`). + # this class (which only needs the composed `db_dsn`). model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", extra="ignore" ) diff --git a/src/opa_database/contracts/afc.py b/src/opa_database/contracts/afc.py index 0837773..7796d05 100644 --- a/src/opa_database/contracts/afc.py +++ b/src/opa_database/contracts/afc.py @@ -38,7 +38,10 @@ class AfcSchema(pa.DataFrameModel): category_type: int vehicle_number: str - validator_id: str + # Nullable: a couple of dumps (e.g. 2021-01-10/11) have this attribute + # blank for most rows, a real gap in the raw feed rather than a + # parsing bug -- the rest of the row is still usable. + validator_id: str = pa.Field(nullable=True) line_number: str line_shift: int diff --git a/src/opa_database/contracts/gtfs.py b/src/opa_database/contracts/gtfs.py index 1a23f9d..1c614c0 100644 --- a/src/opa_database/contracts/gtfs.py +++ b/src/opa_database/contracts/gtfs.py @@ -1,9 +1,12 @@ """Pandera schemas for the raw GTFS bronze source. -One `DataFrameModel` per GTFS table (standard GTFS reference, no custom -fields observed in this feed). `stops_unicode.txt` is deliberately excluded: -it is a UTF-16 duplicate of `stops.txt` kept for legacy consumers, not a -distinct table. +One `DataFrameModel` per GTFS table, following the standard GTFS reference +columns, plus one adapter-added field: `CalendarDatesSchema` and +`StopTimesSchema` each carry a `copied_from_feed_version_date` column +recording when that table's data was substituted from a different export +(see those classes' docstrings and `docs/architecture.md`). +`stops_unicode.txt` is deliberately excluded: it is a UTF-16 duplicate of +`stops.txt` kept for legacy consumers, not a distinct table. All ID-like fields (route_id, stop_id, trip_id, shape_id, service_id, fare_id, block_id) are kept as strings since some carry meaningful leading @@ -54,11 +57,18 @@ class Config: class CalendarDatesSchema(pa.DataFrameModel): - """`calendar_dates.txt`: exceptions to the base weekly service patterns.""" + """`calendar_dates.txt`: exceptions to the base weekly service patterns. + + A handful of raw exports are missing this file entirely; for those, + `adapters/gtfs.py` substitutes the nearest other export's data and + stamps `copied_from_feed_version_date` with that export's date (see + `docs/architecture.md`). Null for every normally-sourced row. + """ service_id: str date: pl.Date exception_type: int + copied_from_feed_version_date: pl.Date = pa.Field(nullable=True) class Config: """Coerce raw CSV columns into their target dtypes.""" @@ -154,7 +164,13 @@ class Config: class StopTimesSchema(pa.DataFrameModel): - """`stop_times.txt`: per-trip, per-stop arrival/departure times.""" + """`stop_times.txt`: per-trip, per-stop arrival/departure times. + + One raw export is missing this file entirely; for that one, + `adapters/gtfs.py` substitutes the nearest other export's data and + stamps `copied_from_feed_version_date` with that export's date (see + `docs/architecture.md`). Null for every normally-sourced row. + """ trip_id: str arrival_time: str = pa.Field(nullable=True) @@ -165,6 +181,7 @@ class StopTimesSchema(pa.DataFrameModel): pickup_type: int = pa.Field(nullable=True) drop_off_type: int = pa.Field(nullable=True) shape_dist_traveled: float = pa.Field(nullable=True) + copied_from_feed_version_date: pl.Date = pa.Field(nullable=True) class Config: """Coerce raw CSV columns into their target dtypes.""" diff --git a/src/opa_database/contracts/vehicle_dictionary.py b/src/opa_database/contracts/vehicle_dictionary.py index 54bec67..95b9674 100644 --- a/src/opa_database/contracts/vehicle_dictionary.py +++ b/src/opa_database/contracts/vehicle_dictionary.py @@ -1,11 +1,35 @@ -"""Pandera schema for the raw vehicle dictionary bronze source. - -Maps the AFC-side vehicle code (`cod_veiculo`, e.g. "35251", or "DES 02002" -for decommissioned buses) to the GPS-side vehicle id (`id_veiculo`, matching -AVL's `vehicle_id`). `cod_veiculo` is not a unique key: buses get -reassigned, so ~2% of codes map to more than one `id_veiculo` over time. -Bronze keeps that as-is; reconciling which mapping is current is left for -a later layer. +"""Pandera schemas for the vehicle dictionary family of bronze sources. + +`DICIONÁRIO_VEÍCULOS/` on the raw data root held several independently +extracted vehicle-identity crosswalks, all now fully captured in bronze +(see `adapters/vehicle_dictionary.py` for why the raw files themselves +are gone). All four schemas below live in this one module, same as how +`contracts/avl.py` is one schema shared across AVL's several raw layouts +-- except here the raw *shapes* differ too, so each gets its own model +rather than a shared one: + +- `VehicleDictionarySchema` (`cod_veiculo`/`id_veiculo`): the original + mapping (`veiculos_atuais.csv`), plus two older snapshots that happen + to share this exact shape (`veiculos2018.csv`, `veiculos_antigo.csv`). + `id_veiculo` matches AVL's `vehicle_id` int. +- `VehicleDictionaryLegacySchema` (`vehicleid`/`numbus`, + `dicionario_veiculos.csv`) and `VehicleDictionaryLegacy2Schema` + (`id`/`carro`/`obs1`/`obs2`, `dicionario_veiculos2.csv`): two older, + never-ingested exports with their own shapes and their own, + much-smaller id ranges -- presumably an earlier GPS hardware + generation. Nothing confirms either id space lines up with modern + `vehicle_id`/`device_id` values, so reconciling them is deferred to a + later layer, same as this module's own `cod_veiculo` ambiguity below. +- `DeviceDictionarySchema` (`codigo`/`ID`/.../`Número de Ordem`/...): a + distinct bridge from the other three. It maps the GPS/AVL-side + *device* id (matching `avl_pings.device_id`, e.g. "ep1-428113843") to + the vehicle's real fleet number (matching AFC's `vehicle_number`), + rather than AVL's opaque internal `vehicle_id` int. `device_id` rides + along on every AVL ping but isn't used for any matching today. + +`cod_veiculo` is not a unique key: buses get reassigned, so ~2% of codes +map to more than one `id_veiculo` over time. Bronze keeps that as-is; +reconciling which mapping is current is left for a later layer. """ import pandera.polars as pa @@ -21,3 +45,61 @@ class Config: """Coerce raw string columns into their target dtypes.""" coerce = True + + +class VehicleDictionaryLegacySchema(pa.DataFrameModel): + """Loose validation for a raw legacy vehicleid-to-numbus mapping row.""" + + vehicleid: str + numbus: str + + class Config: + """Coerce raw string columns into their target dtypes.""" + + coerce = True + + +class VehicleDictionaryLegacy2Schema(pa.DataFrameModel): + """Loose validation for a raw legacy id-to-carro mapping row, with notes.""" + + id: str + carro: str + # Nullable: empty on all but one observed row ("DESATIVADO"). + obs1: str = pa.Field(nullable=True) + # Nullable: empty on most rows; populated rows look like dates + # (e.g. "15/10/2014"), kept as text since no format is confirmed. + obs2: str = pa.Field(nullable=True) + + class Config: + """Coerce raw string columns into their target dtypes.""" + + coerce = True + + +class DeviceDictionarySchema(pa.DataFrameModel): + """Loose validation for a raw device-id-to-vehicle-number mapping row. + + `codigo` and `device_id` are each unique within a snapshot when + present (verified against the first extraction); `codigo` and + `vehicle_number` are usually but not always identical (2 divergent + rows out of 2219 in that same extraction, e.g. a stray leading zero + or trailing letter on one side). + """ + + codigo: str + # Nullable: support/utility vehicles (trailers, spare motos) often have + # no GPS device fitted at all. + device_id: str = pa.Field(nullable=True) + vehicle_type: str + company: str + # Nullable: plate is missing for a large share of rows in the source. + plate: str = pa.Field(nullable=True) + vehicle_number: str + status: str + # Nullable: present in the raw header but empty on every observed row. + action: str = pa.Field(nullable=True) + + class Config: + """Coerce raw string columns into their target dtypes.""" + + coerce = True diff --git a/src/opa_database/gold/__init__.py b/src/opa_database/gold/__init__.py new file mode 100644 index 0000000..9e19be7 --- /dev/null +++ b/src/opa_database/gold/__init__.py @@ -0,0 +1 @@ +"""Gold layer: the normalized, trip-centric warehouse built from silver + ML outputs.""" diff --git a/src/opa_database/gold/build.py b/src/opa_database/gold/build.py new file mode 100644 index 0000000..87d1ec8 --- /dev/null +++ b/src/opa_database/gold/build.py @@ -0,0 +1,950 @@ +"""Builds the gold-layer star schema for one month from silver plus two ML tables. + +Gold is deliberately not a general reloadable pipeline yet (unlike bronze +and silver's ``replace_period``): it's a one-shot build scoped to whatever +month is requested, dropping and recreating the whole ``gold`` schema each +run. Every table is derived only from ``silver.*`` plus +``ml.trip_validity_final`` (which valid trips exist, and their matched +GTFS route/shape) and ``ml.bus_matching_final_pairs`` (which AVL device a +bus was actually carrying) -- no other ML table is a gold input, and +nothing here re-derives trip validity or bus/device identity itself. +""" + +from __future__ import annotations + +import datetime +import logging +import time +from typing import TYPE_CHECKING, LiteralString + +import psycopg + +from opa_database.config import settings + +if TYPE_CHECKING: + from collections.abc import Callable + +_logger = logging.getLogger(__name__) + +_DECEMBER = 12 + +# Route 614's AFC direction (0/1) <-> GTFS Ida/Volta (-I/-V shape suffix) +# mapping is reversed relative to every other route in this dataset. +# Verified against GPS ground truth (bus-matching model work, 2026-09-11): +# for 1,415 independently-confirmed bus/device pairs, the GTFS direction a +# device's actual GPS path best correlates with agreed with AFC's stated +# direction (under direction=0 -> I, direction=1 -> V) at >=0.9 for 99.3% +# of bus-device-days -- except route 614, which was consistently the +# opposite (mean agreement 0.03 across 10 buses, 32 days). That fix was +# written, verified, then reverted from the codebase at Victor's request +# before ever being pushed, so this fact lives nowhere else -- it must +# stay hardcoded here. +_REVERSED_DIRECTION_ROUTES = ("614",) + +# The company code -> name mapping has no queryable source anywhere in the +# repo: it only exists as inline comments next to the GARAGES coordinate +# list in ml/trip_validity_model/notebooks/05_final_dataset.ipynb. Hand +# transcribed here (11 distinct codes; company_modality confirmed stable +# per code against silver.afc_boardings for November 2023). Two-digit +# convention -- a bus's own first two digits, e.g. "02" -- not silver +# afc_boardings.company_code's separate 3-digit convention ("002"). +_BUS_COMPANIES = ( + ("02", 1, "Auto Viação Fortaleza"), + ("12", 1, "Auto Viação São José"), + ("14", 1, "Siará Grande"), + ("20", 1, "Santa Maria"), + ("21", 1, "Transportes Urbanos Aliança"), + ("26", 1, "Maraponga Transportes"), + ("30", 1, "Viação Urbana"), + ("35", 1, "Vega"), + ("36", 1, "Santa Cecília"), + ("42", 1, "Auto Viação Dragão do Mar"), + ("67", 2, "COOTRAPS"), +) + +# A stop-sequence variant is trusted as the shape's real stop list only if +# its stop order correlates this strongly with the stops' own position +# along the shape's geometry (via ST_LineLocatePoint). Chosen from real +# data: of 34 ambiguous (feed, shape) groups in November 2023, every +# legitimate alternate stopping pattern (e.g. short-turn vs full route) +# scored >=0.95, while data-association errors (stop_times rows that +# don't belong with their shape_id) scored far below this, in one checked +# case with stops sitting a median 1.1km from the shape's actual corridor. +_COHERENT_STOP_PATTERN_THRESHOLD = 0.9 + +_DDL = """ +CREATE SCHEMA gold; + +CREATE TABLE gold.bus_companies ( + company_code text PRIMARY KEY, + company_modality integer NOT NULL, + company_name text NOT NULL +); + +CREATE TABLE gold.buses ( + bus_id text PRIMARY KEY, + company_code text NOT NULL REFERENCES gold.bus_companies (company_code) +); + +CREATE TABLE gold.bus_device_intervals ( + bus_id text NOT NULL REFERENCES gold.buses (bus_id), + device_id text, + start_date date NOT NULL, + end_date date NOT NULL, + confidence double precision, + method text NOT NULL, + PRIMARY KEY (bus_id, start_date) +); + +CREATE TABLE gold.routes ( + route_id text PRIMARY KEY, + route_name text +); + +CREATE TABLE gold.route_directions ( + route_direction_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + route_id text NOT NULL REFERENCES gold.routes (route_id), + direction text NOT NULL CHECK (direction IN ('I', 'V')), + UNIQUE (route_id, direction) +); + +CREATE TABLE gold.route_direction_feed ( + route_direction_feed_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + route_direction_id bigint NOT NULL + REFERENCES gold.route_directions (route_direction_id), + feed_version_date date NOT NULL, + shape_id text NOT NULL, + has_alternate_stop_pattern boolean NOT NULL DEFAULT false, + UNIQUE (route_direction_id, feed_version_date, shape_id) +); + +CREATE TABLE gold.route_direction_shapes ( + route_direction_feed_id bigint NOT NULL + REFERENCES gold.route_direction_feed (route_direction_feed_id), + shape_sequence integer NOT NULL, + latitude double precision NOT NULL, + longitude double precision NOT NULL, + geom geometry(Point, 4326) + GENERATED ALWAYS AS (ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)) + STORED, + PRIMARY KEY (route_direction_feed_id, shape_sequence) +); + +CREATE TABLE gold.stops ( + stop_id text PRIMARY KEY, + stop_name text NOT NULL, + latitude double precision NOT NULL, + longitude double precision NOT NULL, + geom geometry(Point, 4326) + GENERATED ALWAYS AS (ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)) STORED +); + +CREATE TABLE gold.route_direction_stops ( + route_direction_feed_id bigint NOT NULL + REFERENCES gold.route_direction_feed (route_direction_feed_id), + stop_sequence integer NOT NULL, + stop_id text NOT NULL REFERENCES gold.stops (stop_id), + PRIMARY KEY (route_direction_feed_id, stop_sequence) +); + +CREATE TABLE gold.journeys ( + journey_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + service_date date NOT NULL, + bus_id text NOT NULL REFERENCES gold.buses (bus_id), + line_number text NOT NULL, + line_shift integer NOT NULL, + line_opened_at timestamptz NOT NULL, + line_closed_at timestamptz NOT NULL, + UNIQUE ( + service_date, bus_id, line_number, line_shift, + line_opened_at, line_closed_at + ) +); + +CREATE TABLE gold.trips ( + trip_id bigint PRIMARY KEY, + bus_id text NOT NULL REFERENCES gold.buses (bus_id), + journey_id bigint NOT NULL REFERENCES gold.journeys (journey_id), + route_direction_feed_id bigint + REFERENCES gold.route_direction_feed (route_direction_feed_id), + trip_date date NOT NULL, + trip_start_timestamp timestamptz NOT NULL, + trip_end_timestamp timestamptz NOT NULL +); + +CREATE TABLE gold.cards ( + card_id text PRIMARY KEY +); + +CREATE TABLE gold.passenger_types ( + passenger_type_id integer PRIMARY KEY +); + +CREATE TABLE gold.fares ( + -- Not GENERATED ALWAYS AS IDENTITY: this value is set explicitly on + -- insert (see _load_fares) to the same surrogate row number assigned + -- to its source row in gold._staging_trip_boardings, so + -- trip_positions' fare-origin rows can reference the right fare_id + -- without needing lat/lon to ever exist on this table -- fares is + -- fare/transaction info only, location lives solely in + -- trip_positions. + fare_id bigint PRIMARY KEY, + trip_id bigint NOT NULL REFERENCES gold.trips (trip_id), + card_id text NOT NULL REFERENCES gold.cards (card_id), + passenger_type_id integer NOT NULL + REFERENCES gold.passenger_types (passenger_type_id), + event_id text NOT NULL, + boarding_at timestamptz NOT NULL, + fare_paid double precision NOT NULL, + integration_type integer NOT NULL, + integration_bum integer NOT NULL, + subsidy_value double precision NOT NULL, + metro_transfer_value double precision NOT NULL +); + +CREATE TABLE gold.trip_positions ( + position_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + trip_id bigint NOT NULL REFERENCES gold.trips (trip_id), + origin text NOT NULL CHECK (origin IN ('avl', 'fare')), + fare_id bigint REFERENCES gold.fares (fare_id), + ping_at timestamptz NOT NULL, + latitude double precision NOT NULL, + longitude double precision NOT NULL, + geom geometry(Point, 4326) + GENERATED ALWAYS AS (ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)) + STORED, + speed integer, + direction integer, + CHECK ((origin = 'fare') = (fare_id IS NOT NULL)) +); +""" + +# Built after every table is loaded, matching silver's bulk-then-index +# pattern (loaders/silver.py::replace_period): incremental index +# maintenance during a multi-million-row insert is far slower than one +# batch build afterward, especially for GiST. +# +# Split in two: _INDEXES_BASE runs at the end of phase 1 (build_base), +# _INDEXES_TRIP_POSITIONS at the end of phase 2 (build_trip_positions). +# Building gold.trips(bus_id) as part of the *base* phase (not deferred +# to the end of everything) is deliberate: phase 2's trip_positions join +# reads gold.trips by bus_id, so it should already have this index (and +# real statistics from the phase 1 ANALYZE step) by the time that join +# runs, not just at the very end when it's too late to help. +_INDEXES_BASE: tuple[LiteralString, ...] = ( + "CREATE INDEX ON gold.buses (company_code)", + "CREATE INDEX ON gold.bus_device_intervals (device_id)", + "CREATE INDEX ON gold.route_direction_feed (route_direction_id)", + "CREATE INDEX ON gold.route_direction_feed (feed_version_date)", + "CREATE INDEX ON gold.route_direction_shapes USING GIST (geom)", + "CREATE INDEX ON gold.stops USING GIST (geom)", + "CREATE INDEX ON gold.route_direction_stops (stop_id)", + "CREATE INDEX ON gold.journeys (bus_id)", + "CREATE INDEX ON gold.journeys (service_date)", + "CREATE INDEX ON gold.trips (bus_id)", + "CREATE INDEX ON gold.trips (route_direction_feed_id)", + "CREATE INDEX ON gold.trips (trip_date)", + "CREATE INDEX ON gold.fares (trip_id)", + "CREATE INDEX ON gold.fares (card_id)", +) +_INDEXES_TRIP_POSITIONS: tuple[LiteralString, ...] = ( + "CREATE INDEX ON gold.trip_positions (trip_id, ping_at)", + "CREATE INDEX ON gold.trip_positions (fare_id) WHERE fare_id IS NOT NULL", + "CREATE INDEX ON gold.trip_positions USING GIST (geom)", +) + + +def get_connection() -> psycopg.Connection: + """Open a connection to the gold database. + + Returns: + psycopg.Connection: An open connection to the gold database. + + """ + return psycopg.connect(settings.db_dsn) + + +def _period_bounds(year: int, month: int) -> tuple[datetime.date, datetime.date]: + start = datetime.date(year, month, 1) + end = ( + datetime.date(year + 1, 1, 1) + if month == _DECEMBER + else datetime.date(year, month + 1, 1) + ) + return start, end + + +def _feed_dates( + conn: psycopg.Connection, start: datetime.date, end: datetime.date +) -> list[datetime.date]: + """Every GTFS feed a valid trip in [start, end) actually matched to. + + Deliberately not the calendar-month's own feed exports: a feed stays + active until superseded, so a trip's ``gtfs_feed_version_date`` can + predate its own ``trip_date`` by weeks (e.g. a 2023-09-15 export still + covering early-November trips). Scoping every GTFS dimension query to + this exact set (rather than a service_date-shaped range) is both + correct and cheap -- it's usually a handful of dates. + """ + rows = conn.execute( + """ + SELECT DISTINCT gtfs_feed_version_date + FROM ml.trip_validity_final + WHERE is_valid + AND trip_date >= %(start)s AND trip_date < %(end)s + AND gtfs_feed_version_date IS NOT NULL + """, + {"start": start, "end": end}, + ).fetchall() + return [r[0] for r in rows] + + +def _load_bus_companies(conn: psycopg.Connection) -> None: + with conn.cursor().copy( + "COPY gold.bus_companies (company_code, company_modality, company_name) " + "FROM STDIN" + ) as copy: + for row in _BUS_COMPANIES: + copy.write_row(row) + + +def _load_buses( + conn: psycopg.Connection, start: datetime.date, end: datetime.date +) -> None: + # company_code is always the bus_id's own first two digits -- verified + # exact for every company appearing in November 2023 (11/11). bus_id + # is defensively re-padded to 5 digits even though + # ml.trip_validity_final.bus_id is already documented as always 5. + conn.execute( + """ + INSERT INTO gold.buses (bus_id, company_code) + SELECT DISTINCT lpad(bus_id, 5, '0'), left(lpad(bus_id, 5, '0'), 2) + FROM ml.trip_validity_final + WHERE is_valid AND trip_date >= %(start)s AND trip_date < %(end)s + """, + {"start": start, "end": end}, + ) + + +def _load_bus_device_intervals(conn: psycopg.Connection) -> None: + conn.execute(""" + INSERT INTO gold.bus_device_intervals + (bus_id, device_id, start_date, end_date, confidence, method) + SELECT p.bus_id, p.device_id, p.start_date, p.end_date, p.confidence, p.method + FROM ml.bus_matching_final_pairs p + JOIN gold.buses b ON b.bus_id = p.bus_id + """) + + +def _load_routes(conn: psycopg.Connection, feed_dates: list[datetime.date]) -> None: + # route_id is re-padded from silver's 4-digit GTFS convention + # ("0042") to gold's 3-digit-minimum convention ("042", but "1074" + # stays "1074" -- lpad only pads, never truncates), matching + # ml.trip_validity_final's own route_id format so a trip's route_id + # needs no further translation anywhere downstream. + # Latest-feed-wins: route_long_name was verified stable for every + # route across November's own 2 feeds, but the full feed set a + # November trip can reference goes back further (see _feed_dates) and + # wasn't individually re-checked -- this rule is a safe default either + # way, not just a fallback for a known drift case. + conn.execute( + """ + INSERT INTO gold.routes (route_id, route_name) + SELECT DISTINCT ON (route_id) route_id, route_long_name + FROM ( + SELECT lpad((route_id::integer)::text, 3, '0') AS route_id, + route_long_name, feed_version_date + FROM silver.gtfs_routes + WHERE feed_version_date = ANY(%(feed_dates)s) + ) r + ORDER BY route_id, feed_version_date DESC + """, + {"feed_dates": feed_dates}, + ) + + +def _load_route_directions( + conn: psycopg.Connection, feed_dates: list[datetime.date] +) -> None: + # direction_id is 100% NULL in this feed; direction instead lives as a + # literal -I (Ida) / -V (Volta) suffix on shape_id. route_id re-padded + # to gold's 3-digit-minimum convention, same as _load_routes. + conn.execute( + """ + INSERT INTO gold.route_directions (route_id, direction) + SELECT DISTINCT lpad((route_id::integer)::text, 3, '0'), + CASE WHEN shape_id LIKE '%%-I' THEN 'I' ELSE 'V' END + FROM silver.gtfs_trips + WHERE feed_version_date = ANY(%(feed_dates)s) AND shape_id IS NOT NULL + """, + {"feed_dates": feed_dates}, + ) + + +def _load_route_direction_feed( + conn: psycopg.Connection, feed_dates: list[datetime.date] +) -> None: + conn.execute( + """ + INSERT INTO gold.route_direction_feed + (route_direction_id, feed_version_date, shape_id) + SELECT DISTINCT rd.route_direction_id, t.feed_version_date, t.shape_id + FROM silver.gtfs_trips t + JOIN gold.route_directions rd + ON rd.route_id = lpad((t.route_id::integer)::text, 3, '0') + AND rd.direction = CASE WHEN t.shape_id LIKE '%%-I' THEN 'I' ELSE 'V' END + WHERE t.feed_version_date = ANY(%(feed_dates)s) AND t.shape_id IS NOT NULL + """, + {"feed_dates": feed_dates}, + ) + + +def _load_route_direction_shapes( + conn: psycopg.Connection, feed_dates: list[datetime.date] +) -> None: + conn.execute( + """ + INSERT INTO gold.route_direction_shapes + (route_direction_feed_id, shape_sequence, latitude, longitude) + SELECT rdf.route_direction_feed_id, s.shape_pt_sequence, + s.shape_pt_lat, s.shape_pt_lon + FROM silver.gtfs_shapes s + JOIN gold.route_direction_feed rdf + ON rdf.feed_version_date = s.feed_version_date + AND rdf.shape_id = s.shape_id + WHERE s.feed_version_date = ANY(%(feed_dates)s) + """, + {"feed_dates": feed_dates}, + ) + + +def _load_stops(conn: psycopg.Connection, feed_dates: list[datetime.date]) -> None: + # Latest-feed-wins: 125 of 5,276 stops (~2.4%) genuinely drift in + # name/lat/lon across the feeds relevant to November 2023 -- a real + # agency correction over time, not noise, so the most recent version + # is the right one to keep. + conn.execute( + """ + INSERT INTO gold.stops (stop_id, stop_name, latitude, longitude) + SELECT DISTINCT ON (stop_id) stop_id, stop_name, stop_lat, stop_lon + FROM silver.gtfs_stops + WHERE feed_version_date = ANY(%(feed_dates)s) + ORDER BY stop_id, feed_version_date DESC + """, + {"feed_dates": feed_dates}, + ) + + +def _load_route_direction_stops( + conn: psycopg.Connection, feed_dates: list[datetime.date] +) -> None: + """Canonicalize each route-direction-feed's stop list. + + A shape's stop list isn't structurally guaranteed 1:1 in GTFS (it + lives on individual scheduled trips via `stop_times`, not the shape + itself), and in this data it genuinely isn't for 34 of 1,234 (feed, + shape) groups. Checked against real data before picking a rule: + majority-trip-count is unsafe on its own (one shape had its single + largest-count variant be a data error, correlating at 0.164 with the + shape's own geometry while three smaller variants -- collectively more + trips -- all scored 0.998). So: score every variant by how well its + stop order tracks the stop's own position along the shape (via + `ST_LineLocatePoint`), discard anything below + `_COHERENT_STOP_PATTERN_THRESHOLD` as a data error rather than a real + alternate pattern, then take the most-used variant among what's left. + `has_alternate_stop_pattern` flags feeds where more than one coherent + variant existed (a real short-turn/full-route split), so the + approximation is traceable rather than silent. + """ + conn.execute( + """ + CREATE TEMP TABLE stop_pattern_choice ON COMMIT DROP AS + WITH shape_lines AS ( + SELECT feed_version_date, shape_id, + ST_MakeLine(geom ORDER BY shape_pt_sequence) AS line + FROM silver.gtfs_shapes + WHERE feed_version_date = ANY(%(feed_dates)s) + GROUP BY feed_version_date, shape_id + ), + trip_stop_seqs AS ( + SELECT t.feed_version_date, t.shape_id, t.trip_id, + array_agg(st.stop_id ORDER BY st.stop_sequence) AS stop_seq + FROM silver.gtfs_trips t + JOIN silver.gtfs_stop_times st + ON st.feed_version_date = t.feed_version_date + AND st.trip_id = t.trip_id + WHERE t.feed_version_date = ANY(%(feed_dates)s) + AND t.shape_id IS NOT NULL + GROUP BY t.feed_version_date, t.shape_id, t.trip_id + ), + variants AS ( + SELECT feed_version_date, shape_id, stop_seq, COUNT(*) AS n_trips + FROM trip_stop_seqs + GROUP BY feed_version_date, shape_id, stop_seq + ), + variant_fit AS ( + SELECT v.feed_version_date, v.shape_id, v.stop_seq, v.n_trips, + corr(u.ord, ST_LineLocatePoint(sl.line, s.geom)) AS geometry_fit + FROM variants v + JOIN shape_lines sl USING (feed_version_date, shape_id) + CROSS JOIN LATERAL unnest(v.stop_seq) WITH ORDINALITY AS u(stop_id, ord) + JOIN silver.gtfs_stops s + ON s.feed_version_date = v.feed_version_date AND s.stop_id = u.stop_id + GROUP BY v.feed_version_date, v.shape_id, v.stop_seq, v.n_trips + ), + ranked AS ( + SELECT *, + ROW_NUMBER() OVER ( + PARTITION BY feed_version_date, shape_id + ORDER BY (geometry_fit >= %(threshold)s) DESC, + n_trips DESC, geometry_fit DESC + ) AS rn, + COUNT(*) FILTER (WHERE geometry_fit >= %(threshold)s) + OVER (PARTITION BY feed_version_date, shape_id) AS n_coherent + FROM variant_fit + ) + SELECT feed_version_date, shape_id, stop_seq, n_coherent + FROM ranked + WHERE rn = 1 + """, + {"feed_dates": feed_dates, "threshold": _COHERENT_STOP_PATTERN_THRESHOLD}, + ) + conn.execute(""" + UPDATE gold.route_direction_feed rdf + SET has_alternate_stop_pattern = true + FROM stop_pattern_choice c + WHERE c.feed_version_date = rdf.feed_version_date + AND c.shape_id = rdf.shape_id + AND c.n_coherent > 1 + """) + conn.execute(""" + INSERT INTO gold.route_direction_stops + (route_direction_feed_id, stop_sequence, stop_id) + SELECT rdf.route_direction_feed_id, u.ord, u.stop_id + FROM stop_pattern_choice c + JOIN gold.route_direction_feed rdf + ON rdf.feed_version_date = c.feed_version_date + AND rdf.shape_id = c.shape_id + CROSS JOIN LATERAL unnest(c.stop_seq) WITH ORDINALITY AS u(stop_id, ord) + """) + + +def _load_trip_source_attrs( + conn: psycopg.Connection, start: datetime.date, end: datetime.date +) -> None: + """Materialize every valid trip's attributes rejoined from raw silver. + + `ml.trip_validity_final` groups trips out of `silver.afc_boardings` + without a formula back to the source rows, but its natural key does + the job: `(trip_date, zero-padded bus_id, trip_start_timestamp, + trip_end_timestamp)` is a verified clean 1:1 grouping, so this is the + single join every other trip-shaped gold table (journeys, trips, + fares) is built from -- computed once here rather than per table. + """ + conn.execute( + """ + CREATE TEMP TABLE trip_source_attrs ON COMMIT DROP AS + SELECT + f.trip_id, f.bus_id, f.trip_date, + f.trip_start_timestamp, f.trip_end_timestamp, + f.route_id, f.gtfs_feed_version_date, + f.gtfs_shape_id_i, f.gtfs_shape_id_v, + MIN(b.direction) AS afc_direction, + MIN(b.line_number) AS line_number, + MIN(b.line_shift) AS line_shift, + MIN(b.line_opened_at) AS line_opened_at, + MIN(b.line_closed_at) AS line_closed_at + FROM ml.trip_validity_final f + JOIN silver.afc_boardings b + ON b.service_date = f.trip_date + AND (CASE WHEN length(b.vehicle_number) < 5 + THEN lpad(b.vehicle_number, 5, '0') + ELSE b.vehicle_number END) = f.bus_id + AND b.trip_opened_at = f.trip_start_timestamp + AND b.trip_closed_at = f.trip_end_timestamp + WHERE f.is_valid AND f.trip_date >= %(start)s AND f.trip_date < %(end)s + GROUP BY f.trip_id, f.bus_id, f.trip_date, f.trip_start_timestamp, + f.trip_end_timestamp, f.route_id, f.gtfs_feed_version_date, + f.gtfs_shape_id_i, f.gtfs_shape_id_v + """, + {"start": start, "end": end}, + ) + conn.execute( + "CREATE INDEX ON trip_source_attrs (trip_date, bus_id, " + "trip_start_timestamp, trip_end_timestamp)" + ) + + +def _load_journeys(conn: psycopg.Connection) -> None: + conn.execute(""" + INSERT INTO gold.journeys + (service_date, bus_id, line_number, line_shift, + line_opened_at, line_closed_at) + SELECT DISTINCT + trip_date, bus_id, line_number, line_shift, + line_opened_at, line_closed_at + FROM trip_source_attrs + """) + + +def _load_trips(conn: psycopg.Connection) -> None: + conn.execute( + """ + INSERT INTO gold.trips + (trip_id, bus_id, journey_id, route_direction_feed_id, + trip_date, trip_start_timestamp, trip_end_timestamp) + SELECT + a.trip_id, a.bus_id, j.journey_id, rdf.route_direction_feed_id, + a.trip_date, a.trip_start_timestamp, a.trip_end_timestamp + FROM trip_source_attrs a + JOIN gold.journeys j + ON j.service_date = a.trip_date AND j.bus_id = a.bus_id + AND j.line_number = a.line_number AND j.line_shift = a.line_shift + AND j.line_opened_at = a.line_opened_at + AND j.line_closed_at = a.line_closed_at + LEFT JOIN gold.route_direction_feed rdf + ON rdf.feed_version_date = a.gtfs_feed_version_date + AND rdf.shape_id = ( + CASE + WHEN a.route_id = ANY(%(reversed_routes)s) THEN + CASE a.afc_direction WHEN 0 THEN a.gtfs_shape_id_v + ELSE a.gtfs_shape_id_i END + ELSE + CASE a.afc_direction WHEN 0 THEN a.gtfs_shape_id_i + ELSE a.gtfs_shape_id_v END + END + ) + """, + {"reversed_routes": list(_REVERSED_DIRECTION_ROUTES)}, + ) + + +def _load_trip_boardings( + conn: psycopg.Connection, start: datetime.date, end: datetime.date +) -> None: + """Materialize every matched boarding row once, for cards/types/fares to share. + + `gold.cards`, `gold.passenger_types`, `gold.fares`, and (in a later, + separately-committed phase) `gold.trip_positions`' fare-origin rows + all need the same trip_source_attrs-to-silver.afc_boardings join; + doing it three separate times over ~15M November rows (rather than + once here) was the single biggest cost in an earlier build attempt. + + A real table (`gold._staging_trip_boardings`), not a session-local + TEMP one: it needs to survive from this phase (build_base) into the + next (build_trip_positions), which commits and reconnects separately. + Not part of the public gold schema -- dropped once by `build()` after + both phases finish -- but plain `gold.*` rather than a second schema + since `DROP SCHEMA gold CASCADE` at the start of every build_base run + already cleans it up automatically even if a previous run's cleanup + was skipped. + + The literal `b.service_date` bound is not redundant with joining to + trip_source_attrs (already scoped to this exact period): without a + literal date range in the query text, the planner has no way to know + afc_boardings' rows can be filtered before the join, since a temp + table's actual contents aren't visible to it as a bound the way a + WHERE literal is. Confirmed the hard way -- omitting this caused a + full scan of afc_boardings' entire ~395M-row history (every month + ever loaded, not just this one) instead of an index scan restricted + to November, spilling 43GB+ of temp files before being killed. + """ + conn.execute( + """ + CREATE TABLE gold._staging_trip_boardings AS + SELECT + row_number() OVER () AS fare_id, + a.trip_id, b.card_id, b.passenger_type, b.event_id, b.boarding_at, + b.fare_paid, b.integration_type, b.integration_bum, b.subsidy_value, + b.metro_transfer_value, b.latitude, b.longitude + FROM silver.afc_boardings b + JOIN trip_source_attrs a + ON a.trip_date = b.service_date + AND a.bus_id = (CASE WHEN length(b.vehicle_number) < 5 + THEN lpad(b.vehicle_number, 5, '0') + ELSE b.vehicle_number END) + AND a.trip_start_timestamp = b.trip_opened_at + AND a.trip_end_timestamp = b.trip_closed_at + WHERE b.service_date >= %(start)s AND b.service_date < %(end)s + """, + {"start": start, "end": end}, + ) + + +def _load_cards_and_passenger_types(conn: psycopg.Connection) -> None: + conn.execute(""" + INSERT INTO gold.cards (card_id) + SELECT DISTINCT card_id FROM gold._staging_trip_boardings + """) + conn.execute(""" + INSERT INTO gold.passenger_types (passenger_type_id) + SELECT DISTINCT passenger_type FROM gold._staging_trip_boardings + """) + + +def _load_fares(conn: psycopg.Connection) -> None: + # fare_id is trip_boardings' own row number, not a fresh identity value + # -- see gold.fares' DDL comment for why (lets trip_positions attach + # fare-origin locations without fares itself ever carrying lat/lon). + conn.execute(""" + INSERT INTO gold.fares + (fare_id, trip_id, card_id, passenger_type_id, event_id, + boarding_at, fare_paid, integration_type, integration_bum, + subsidy_value, metro_transfer_value) + SELECT + fare_id, trip_id, card_id, passenger_type, event_id, boarding_at, + fare_paid, integration_type, integration_bum, subsidy_value, + metro_transfer_value + FROM gold._staging_trip_boardings + """) + + +def _load_trip_positions( + conn: psycopg.Connection, start: datetime.date, end: datetime.date +) -> None: + # AVL-origin: only for trips whose bus resolves to a device for that + # trip's date (gold.bus_device_intervals), scoped to avl_pings' + # own monthly partition for a cheap partition-pruned scan. A trip with + # no device match simply gets no rows here -- it still exists in + # gold.trips and gold.fares. + # + # The inner SELECT is written as a LATERAL subquery with a trailing + # OFFSET 0, not a plain JOIN -- confirmed necessary the hard way. A + # plain JOIN (even textually written in trips-first order) gets + # flattened by the planner into a free join reordering that joins + # bus_device_intervals straight to avl_pings on device_id alone, + # before ever applying a trip's own narrow time window: that plan + # sorts/hashes the *entire* month's ~131M-row avl_pings partition + # (43GB+ of spilled temp files, 20+ minutes, still running when + # killed). OFFSET 0 is a standard Postgres idiom that blocks subquery + # flattening, forcing genuine per-row correlated execution -- one + # indexed lookup per (trip, device) pair using + # avl_pings_*_device_ts_idx, exactly as intended. Verified via + # EXPLAIN (ANALYZE, TIMING OFF) against real November 2023 data: zero + # temp spill, 820s end to end for the real output size (~109M rows -- + # 720K trips average ~152 pings each, consistent with normal AVL + # ping frequency over a typical trip's duration, not a bug). + conn.execute( + """ + INSERT INTO gold.trip_positions + (trip_id, origin, ping_at, latitude, longitude, speed, direction) + SELECT t.trip_id, 'avl', p.metric_timestamp, p.latitude, p.longitude, + p.speed, p.direction %% 360 + FROM gold.trips t + JOIN gold.bus_device_intervals bdi + ON bdi.bus_id = t.bus_id + AND bdi.device_id IS NOT NULL + AND t.trip_date BETWEEN bdi.start_date AND bdi.end_date + CROSS JOIN LATERAL ( + SELECT metric_timestamp, latitude, longitude, speed, direction + FROM silver.avl_pings av + WHERE av.device_id = bdi.device_id + AND av.metric_timestamp >= t.trip_start_timestamp + AND av.metric_timestamp <= t.trip_end_timestamp + AND av.metric_timestamp >= %(start)s AND av.metric_timestamp < %(end)s + OFFSET 0 + ) p + """, + {"start": start, "end": end}, + ) + # Fare-origin: every fare that happens to carry a GPS coordinate. Reads + # location from gold._staging_trip_boardings, not gold.fares -- fares + # carries no lat/lon at all, only trip_positions does. fare_id here is + # exactly _staging_trip_boardings.fare_id, the same value _load_fares + # used as the corresponding gold.fares row's PK, so this is a valid FK + # with no join needed. + conn.execute(""" + INSERT INTO gold.trip_positions + (trip_id, origin, fare_id, ping_at, latitude, longitude) + SELECT trip_id, 'fare', fare_id, boarding_at, latitude, longitude + FROM gold._staging_trip_boardings + WHERE latitude IS NOT NULL AND longitude IS NOT NULL + """) + + +def _create_indexes( + conn: psycopg.Connection, statements: tuple[LiteralString, ...] +) -> None: + for statement in statements: + conn.execute(statement) + + +def _step(label: str, fn: Callable[[], None]) -> None: + """Run one build step with start/elapsed console output. + + A deliberate exception to this codebase's usual silent-library + convention: every step here can plausibly take minutes against + hundreds of millions of source rows with no other feedback otherwise + (this whole build runs inside one transaction, so nothing is visible + to another connection until it commits) -- operator visibility into + which step is running, and how long it took, matters more here than + it does for a fast per-partition silver load. + + Args: + label (str): Human-readable step name to print. + fn (Callable[[], None]): The step to run, taking no arguments + (wrap with a lambda/partial to bind a step's real arguments). + + """ + start_time = time.monotonic() + _logger.info("%s...", label) + fn() + _logger.info("%s done (%.1fs)", label, time.monotonic() - start_time) + + +def build_base(year: int, month: int) -> None: + """Build every gold table except trip_positions, and commit. + + Phase 1 of 2 -- see `build_trip_positions` for phase 2, and `build` + for running both in sequence. Split into two independently-committed + phases (rather than one all-or-nothing transaction) specifically so + `gold.trips`/`gold.bus_device_intervals` can be inspected with a real + `EXPLAIN` from another session and `build_trip_positions` re-run + repeatedly while tuning it, without re-paying this phase's cost (a + few minutes) on every attempt. + + Args: + year (int): Calendar year to build gold for. + month (int): Calendar month to build gold for. + + """ + start, end = _period_bounds(year, month) + + def _create_schema() -> None: + conn.execute("DROP SCHEMA IF EXISTS gold CASCADE") + conn.execute(_DDL) + + with get_connection() as conn, conn.transaction(): + # Scoped to this transaction only (SET LOCAL, not the session-wide + # default). Postgres's 4MB default work_mem forces the + # multi-million-row hash joins below into multi-batch, disk-spilling + # execution -- verified directly against pg_stat_activity during an + # earlier build attempt (DataFileRead wait event, 5+ minutes on a + # single join step that should run in-memory). Parallel workers are + # disabled rather than left to the planner's discretion: this + # container's /dev/shm is only 64MB, and a parallel hash join at + # 512MB work_mem exhausted it outright (DiskFull resizing a shared + # memory segment) -- a serial hash join still gets the work_mem + # benefit from ordinary process memory, no shared segment needed. + # Every phase (this one and build_trip_positions) sets these fresh: + # SET LOCAL only lasts for the transaction it's issued in. + conn.execute("SET LOCAL work_mem = '512MB'") + conn.execute("SET LOCAL max_parallel_workers_per_gather = 0") + + _step("drop/create schema", _create_schema) + + _step("bus_companies", lambda: _load_bus_companies(conn)) + _step("buses", lambda: _load_buses(conn, start, end)) + _step("bus_device_intervals", lambda: _load_bus_device_intervals(conn)) + + feed_dates: list[datetime.date] = [] + + def _resolve_feed_dates() -> None: + feed_dates.extend(_feed_dates(conn, start, end)) + + _step("resolve feed dates", _resolve_feed_dates) + _step("routes", lambda: _load_routes(conn, feed_dates)) + _step("route_directions", lambda: _load_route_directions(conn, feed_dates)) + _step( + "route_direction_feed", + lambda: _load_route_direction_feed(conn, feed_dates), + ) + _step( + "route_direction_shapes", + lambda: _load_route_direction_shapes(conn, feed_dates), + ) + _step("stops", lambda: _load_stops(conn, feed_dates)) + _step( + "route_direction_stops", + lambda: _load_route_direction_stops(conn, feed_dates), + ) + + _step( + "trip_source_attrs (rejoin trips to silver.afc_boardings)", + lambda: _load_trip_source_attrs(conn, start, end), + ) + _step("journeys", lambda: _load_journeys(conn)) + _step("trips", lambda: _load_trips(conn)) + _step( + "trip_boardings (rejoin fares to silver.afc_boardings)", + lambda: _load_trip_boardings(conn, start, end), + ) + _step( + "cards + passenger_types", + lambda: _load_cards_and_passenger_types(conn), + ) + _step("fares", lambda: _load_fares(conn)) + _step("base indexes", lambda: _create_indexes(conn, _INDEXES_BASE)) + + def _analyze_before_positions() -> None: + # gold.trips and gold.bus_device_intervals were just bulk-loaded + # inside this same still-open transaction, so they carry zero + # statistics -- autovacuum can't touch an uncommitted + # transaction's tables, and nothing else runs ANALYZE + # automatically here. Confirmed the hard way: without this, a + # later trip_positions join against these two freshly-loaded + # tables took 20+ minutes; the same join shape against + # permanently-analyzed tables (verified via a standalone + # EXPLAIN against ml.trip_validity_final/bus_matching_final_pairs) + # used proper indexes throughout, so the missing statistics -- + # not the join's inherent size -- were the actual cause. Done + # here, at the end of phase 1, rather than at the start of + # phase 2: analyzing right after these tables are loaded (and + # their own indexes just built) means phase 2 can be re-run + # repeatedly without repeating this too. + conn.execute("ANALYZE gold.trips") + conn.execute("ANALYZE gold.bus_device_intervals") + + _step("analyze trips + bus_device_intervals", _analyze_before_positions) + # Transaction commits here. + + +def build_trip_positions(year: int, month: int) -> None: + """Build gold.trip_positions and commit. Phase 2 of 2, independently re-runnable. + + Requires `build_base` to have already committed for this period -- + reads `gold._staging_trip_boardings` (persisted there, not rebuilt + here) for fare-origin positions' lat/lon. Safe to call more than once + while tuning this step: clears out any existing `gold.trip_positions` + rows first. + + Args: + year (int): Calendar year to build gold for. + month (int): Calendar month to build gold for. + + """ + start, end = _period_bounds(year, month) + with get_connection() as conn, conn.transaction(): + conn.execute("SET LOCAL work_mem = '512MB'") + conn.execute("SET LOCAL max_parallel_workers_per_gather = 0") + + def _clear_trip_positions() -> None: + conn.execute("TRUNCATE gold.trip_positions") + + _step("clear trip_positions", _clear_trip_positions) + _step("trip_positions", lambda: _load_trip_positions(conn, start, end)) + _step( + "trip_positions indexes", + lambda: _create_indexes(conn, _INDEXES_TRIP_POSITIONS), + ) + + +def build(year: int, month: int) -> None: + """Build the entire gold schema from scratch for one calendar month. + + Runs `build_base` then `build_trip_positions` in sequence, each its + own committed phase, then drops `gold._staging_trip_boardings` -- + internal scratch state, not part of the public gold schema. Not + dropped inside `build_trip_positions` itself, since that needs to + stay freely re-runnable (e.g. while tuning it) without requiring + `build_base` to run again first. + + Args: + year (int): Calendar year to build gold for. + month (int): Calendar month to build gold for. + + """ + build_base(year, month) + build_trip_positions(year, month) + with get_connection() as conn, conn.transaction(): + conn.execute("DROP TABLE gold._staging_trip_boardings") diff --git a/src/opa_database/loaders/silver.py b/src/opa_database/loaders/silver.py index 1b459eb..ca95ed9 100644 --- a/src/opa_database/loaders/silver.py +++ b/src/opa_database/loaders/silver.py @@ -3,7 +3,7 @@ from __future__ import annotations import io -from typing import TYPE_CHECKING, LiteralString +from typing import TYPE_CHECKING, LiteralString, NamedTuple import psycopg from psycopg import sql @@ -24,7 +24,7 @@ def get_connection() -> psycopg.Connection: psycopg.Connection: An open connection to the silver database. """ - return psycopg.connect(settings.silver_dsn) + return psycopg.connect(settings.db_dsn) def ensure_schema(conn: psycopg.Connection) -> None: @@ -38,78 +38,139 @@ def ensure_schema(conn: psycopg.Connection) -> None: conn.execute("CREATE SCHEMA IF NOT EXISTS silver;") +def monthly_partition_name(table: str, year: int, month: int) -> str: + """Build a monthly silver partition's bare table name. + + Args: + table (str): Bare table name, e.g. "avl_pings". + year (int): Calendar year. + month (int): Calendar month. + + Returns: + str: e.g. "avl_pings_y2023m11". + + """ + return f"{table}_y{year}m{month:02d}" + + +def daily_partition_name(table: str, date: datetime.date) -> str: + """Build a daily silver partition's bare table name. + + Args: + table (str): Bare table name, e.g. "gtfs_stop_times". + date (datetime.date): The day this partition covers. + + Returns: + str: e.g. "gtfs_stop_times_d20240328". + + """ + return f"{table}_d{date:%Y%m%d}" + + +class IndexSpec(NamedTuple): + """One index to create on a silver partition after it's bulk-loaded. + + Attributes: + suffix (str): Appended to the partition's own table name to form + the index name (e.g. partition "avl_pings_y2023m11" + suffix + "geom_idx" -> index "avl_pings_y2023m11_geom_idx"). + unique (bool): Whether to create a `UNIQUE` index. + definition (LiteralString): The literal index definition text + following the target table name in a `CREATE INDEX` + statement, e.g. "USING GIST (geom)" or + "(event_id) WHERE event_id != '0'". Must be a hardcoded + literal, not built from dynamic/user input. + + """ + + suffix: str + unique: bool + definition: LiteralString + + def replace_period( conn: psycopg.Connection, table: str, + partition: str, df: pl.DataFrame, *, - time_column: str, - start: datetime.date, - end: datetime.date, - table_ddl: LiteralString, - indexes: Sequence[tuple[str, LiteralString]] = (), + partition_start: datetime.date | datetime.datetime, + partition_end: datetime.date | datetime.datetime, + parent_ddl: LiteralString, + indexes: Sequence[IndexSpec] = (), ) -> None: - """Idempotently load a period's worth of rows into a silver table. - - Bootstraps the table (`table_ddl`, a `CREATE TABLE IF NOT EXISTS` - statement), then deletes any existing rows in `[start, end)` and - bulk-loads `df` in their place, all in one transaction — so re-running a - load for the same period reflects a re-run rather than accumulating - duplicates. - - Indexes are dropped before the delete+copy and rebuilt after, rather - than left in place to be incrementally maintained row-by-row: for a - GiST spatial index especially, incremental maintenance during a - multi-million-row COPY is dramatically slower than one batch rebuild - afterward (this is Postgres's own documented recommendation for bulk - loads). This does mean every load rebuilds indexes for the *whole* - table, not just the period being replaced, so cost grows with total - table size — fine while there's a handful of months of history, but - worth revisiting (e.g. native partitioning by month) once it isn't. + """Idempotently load one period's worth of rows into a silver partition. + + Bootstraps the partitioned parent table (`parent_ddl`, a `CREATE + TABLE IF NOT EXISTS ... PARTITION BY RANGE` statement), then drops + and recreates the target partition and bulk-loads `df` into it, all + in one transaction — so re-running a load for the same period + reflects a re-run rather than accumulating duplicates. + + Dropping the whole partition (rather than `DELETE`ing its rows) also + removes its indexes in the same metadata-only operation, so the + fresh partition is bulk-loaded with no indexes present and only gets + them built afterward. Incremental per-row index maintenance during a + multi-million-row `COPY` (especially GiST) is dramatically slower + than one batch build afterward (Postgres's own documented + recommendation for bulk loads) — this preserves that, but scoped to + just the one partition being (re)loaded, so cost scales with one + period's size, never the table's total accumulated history. Args: conn (psycopg.Connection): An open connection to the silver database. - table (str): Fully-qualified table name (e.g. "silver.avl_pings"). + table (str): Fully-qualified parent table name (e.g. + "silver.avl_pings"). + partition (str): Bare name of the partition to (re)load (e.g. + "avl_pings_y2023m11"), resolved as `silver.`. df (pl.DataFrame): The data to load, with columns matching the target table's insertable (non-generated) columns, in order. - time_column (str): Column used to bound the period being replaced. - start (datetime.date): Inclusive start of the period being - replaced. - end (datetime.date): Exclusive end of the period being replaced. - table_ddl (LiteralString): `CREATE TABLE IF NOT EXISTS` statement - to run before loading, so the table exists on first use. Must - be a hardcoded literal (not built from dynamic/user input). - indexes (Sequence[tuple[str, LiteralString]]): `(index_name, - CREATE INDEX ...)` pairs to drop before and recreate after the - load. `CREATE INDEX` statements must be hardcoded literals. + partition_start (datetime.date | datetime.datetime): Inclusive + start of the partition's range. + partition_end (datetime.date | datetime.datetime): Exclusive end + of the partition's range. + parent_ddl (LiteralString): `CREATE TABLE IF NOT EXISTS ... + PARTITION BY RANGE (...)` statement for the parent, run + before loading so it exists on first use. Must be a + hardcoded literal (not built from dynamic/user input). + indexes (Sequence[IndexSpec]): Indexes to create on the + partition after it's loaded. """ ensure_schema(conn) - qualified_table = sql.Identifier(*table.split(".")) + schema = table.split(".", maxsplit=1)[0] + qualified_parent = sql.Identifier(*table.split(".")) + qualified_partition = sql.Identifier(schema, partition) columns = sql.SQL(", ").join(sql.Identifier(c) for c in df.columns) with conn.transaction(): - conn.execute(table_ddl) - for index_name, _ in indexes: - conn.execute( - sql.SQL("DROP INDEX IF EXISTS {}").format( - sql.Identifier("silver", index_name) - ) - ) + conn.execute(parent_ddl) + # Dropping the partition (if this period was already loaded) + # removes its rows AND its indexes in one metadata operation, + # and never touches any other partition regardless of table size. + conn.execute( + sql.SQL("DROP TABLE IF EXISTS {partition}").format( + partition=qualified_partition + ) + ) conn.execute( sql.SQL( - "DELETE FROM {table} WHERE {time_column} >= %s AND {time_column} < %s" + "CREATE TABLE {partition} PARTITION OF {parent} " + "FOR VALUES FROM ({start}) TO ({end})" ).format( - table=qualified_table, - time_column=sql.Identifier(time_column), - ), - (start, end), + partition=qualified_partition, + parent=qualified_parent, + start=sql.Literal(partition_start), + end=sql.Literal(partition_end), + ) ) - copy_query = sql.SQL("COPY {table} ({columns}) FROM STDIN (FORMAT CSV)").format( - table=qualified_table, + copy_query = sql.SQL( + "COPY {partition} ({columns}) FROM STDIN (FORMAT CSV)" + ).format( + partition=qualified_partition, columns=columns, ) # Bulk CSV write beats row-by-row copy.write_row() by orders of @@ -122,5 +183,18 @@ def replace_period( with conn.cursor().copy(copy_query) as copy: copy.write(buffer.getvalue()) - for _, create_statement in indexes: - conn.execute(create_statement) + for spec in indexes: + # CREATE INDEX names are always resolved within the target + # table's own schema, and (unlike DROP INDEX) cannot be + # schema-qualified in the statement itself. + index_name = sql.Identifier(f"{partition}_{spec.suffix}") + conn.execute( + sql.SQL( + "CREATE {unique}INDEX {name} ON {partition} {definition}" + ).format( + unique=sql.SQL("UNIQUE ") if spec.unique else sql.SQL(""), + name=index_name, + partition=qualified_partition, + definition=sql.SQL(spec.definition), + ) + ) diff --git a/src/opa_database/silver/afc.py b/src/opa_database/silver/afc.py index d3a4658..18960f3 100644 --- a/src/opa_database/silver/afc.py +++ b/src/opa_database/silver/afc.py @@ -7,7 +7,12 @@ import polars as pl from opa_database.config import settings -from opa_database.loaders.silver import get_connection, replace_period +from opa_database.loaders.silver import ( + IndexSpec, + get_connection, + monthly_partition_name, + replace_period, +) _TABLE = "silver.afc_boardings" _DECEMBER = 12 @@ -28,7 +33,9 @@ # geom is GENERATED, not inserted directly (see silver/avl.py for the same # pattern): Postgres computes it from latitude/longitude on write. It's # null whenever either input is, matching bronze's ~14% missing rate. -_TABLE_DDL = """ +# Partitioned by month, matching this loader's own whole-month load calls +# (see loaders/silver.py::replace_period). +_PARENT_DDL = """ CREATE TABLE IF NOT EXISTS silver.afc_boardings ( dump_date date NOT NULL, service_date date NOT NULL, @@ -36,7 +43,7 @@ company_modality integer NOT NULL, category_type integer NOT NULL, vehicle_number text NOT NULL, - validator_id text NOT NULL, + validator_id text, line_number text NOT NULL, line_shift integer NOT NULL, line_operator_number text NOT NULL, @@ -64,38 +71,30 @@ longitude double precision, geom geometry(Point, 4326) GENERATED ALWAYS AS (ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)) STORED -); +) PARTITION BY RANGE (dump_date); """ -# Dropped before and rebuilt after each load by replace_period() (see its +# Created on each partition after it's bulk-loaded (see replace_period's # docstring). event_id is a UNIQUE index, not just a plain one: non-zero -# event_ids are verified globally unique (zero overlap across all 435 -# day-pairs in November 2023), so this doubles as a data-integrity check — -# a future violation would fail the load loudly instead of silently -# duplicating. "0" is excluded (a partial index): it's a sentinel the raw -# feed uses for passenger records with no real transaction reference (e.g. -# fare-exempt boardings), not a real id, and it legitimately repeats -# (~2% of rows in a sampled day). +# event_ids were verified globally unique across the whole table (zero +# overlap across all 435 day-pairs in November 2023) back when this was a +# single unpartitioned table. Now that the index is per-partition +# (per-month), it only enforces that within one month — a duplicate +# event_id landing in two different months would no longer be caught. +# Accepted tradeoff: Postgres has no native way to enforce true +# cross-partition uniqueness on a non-partition-key column, and the +# empirical finding backing this index still stands as evidence about the +# real data, not just about the guardrail. "0" is excluded (a partial +# index): it's a sentinel the raw feed uses for passenger records with no +# real transaction reference (e.g. fare-exempt boardings), not a real id, +# and it legitimately repeats (~2% of rows in a sampled day). _INDEXES = ( - ( - "afc_boardings_dump_date_idx", - "CREATE INDEX afc_boardings_dump_date_idx ON silver.afc_boardings (dump_date);", - ), - ( - "afc_boardings_service_date_idx", - "CREATE INDEX afc_boardings_service_date_idx " - "ON silver.afc_boardings (service_date);", - ), - ( - "afc_boardings_event_id_key", - "CREATE UNIQUE INDEX afc_boardings_event_id_key " - "ON silver.afc_boardings (event_id) WHERE event_id != '0';", - ), - ( - "afc_boardings_geom_idx", - "CREATE INDEX afc_boardings_geom_idx " - "ON silver.afc_boardings USING GIST (geom);", + IndexSpec("dump_date_idx", unique=False, definition="(dump_date)"), + IndexSpec("service_date_idx", unique=False, definition="(service_date)"), + IndexSpec( + "event_id_key", unique=True, definition="(event_id) WHERE event_id != '0'" ), + IndexSpec("geom_idx", unique=False, definition="USING GIST (geom)"), ) _COLUMNS = ( @@ -183,14 +182,15 @@ def load(year: int, month: int) -> None: ) start, end = _period_bounds(year, month) + partition = monthly_partition_name("afc_boardings", year, month) with get_connection() as conn: replace_period( conn, _TABLE, + partition, df, - time_column="dump_date", - start=start, - end=end, - table_ddl=_TABLE_DDL, + partition_start=start, + partition_end=end, + parent_ddl=_PARENT_DDL, indexes=_INDEXES, ) diff --git a/src/opa_database/silver/avl.py b/src/opa_database/silver/avl.py index 73be15d..3c9858f 100644 --- a/src/opa_database/silver/avl.py +++ b/src/opa_database/silver/avl.py @@ -7,14 +7,21 @@ import polars as pl from opa_database.config import settings -from opa_database.loaders.silver import get_connection, replace_period +from opa_database.loaders.silver import ( + IndexSpec, + get_connection, + monthly_partition_name, + replace_period, +) _TABLE = "silver.avl_pings" # geom is GENERATED, not inserted directly: Postgres computes it from # latitude/longitude on write, so it doesn't need to travel through bronze -# or be part of the COPY's column list. -_TABLE_DDL = """ +# or be part of the COPY's column list. Partitioned by month: one +# replace_period() call already loads exactly one month, so each call +# maps to exactly one partition (see loaders/silver.py::replace_period). +_PARENT_DDL = """ CREATE TABLE IF NOT EXISTS silver.avl_pings ( vehicle_id integer NOT NULL, device_id text NOT NULL, @@ -27,19 +34,39 @@ metric_timestamp timestamptz NOT NULL, geom geometry(Point, 4326) GENERATED ALWAYS AS (ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)) STORED -); +) PARTITION BY RANGE (metric_timestamp); """ -# Dropped before and rebuilt after each load by replace_period(), rather -# than incrementally maintained per-row during COPY (see its docstring). +# Created on each partition after it's bulk-loaded, rather than +# incrementally maintained per-row during COPY (see replace_period's +# docstring). vehicle_ts_idx/device_ts_idx exist for matching AVL pings +# to a specific vehicle+time window efficiently (e.g. the Trip Validity +# model's per-trip position lookup) -- without them, that kind of query +# has to fall back to a full partition scan. latitude/longitude/speed/ +# odometer ride along as INCLUDE columns (not part of the key) so a +# lookup like that can be satisfied as an index-only scan -- without +# them, Postgres still has to fetch the heap page for every matching +# row just to read those columns, which in practice is the dominant +# cost (random I/O against a 100M+-row partition), confirmed live via +# EXPLAIN (ANALYZE, BUFFERS) while building the Trip Validity model. _INDEXES = ( - ( - "avl_pings_geom_idx", - "CREATE INDEX avl_pings_geom_idx ON silver.avl_pings USING GIST (geom);", + IndexSpec("geom_idx", unique=False, definition="USING GIST (geom)"), + IndexSpec("ts_idx", unique=False, definition="(metric_timestamp)"), + IndexSpec( + "vehicle_ts_idx", + unique=False, + definition=( + "(vehicle_id, metric_timestamp) " + "INCLUDE (latitude, longitude, speed, odometer)" + ), ), - ( - "avl_pings_ts_idx", - "CREATE INDEX avl_pings_ts_idx ON silver.avl_pings (metric_timestamp);", + IndexSpec( + "device_ts_idx", + unique=False, + definition=( + "(device_id, metric_timestamp) " + "INCLUDE (latitude, longitude, speed, odometer)" + ), ), ) @@ -99,14 +126,15 @@ def load(year: int, month: int) -> None: ) start, end = _period_bounds(year, month) + partition = monthly_partition_name("avl_pings", year, month) with get_connection() as conn: replace_period( conn, _TABLE, + partition, df, - time_column="metric_timestamp", - start=start, - end=end, - table_ddl=_TABLE_DDL, + partition_start=start, + partition_end=end, + parent_ddl=_PARENT_DDL, indexes=_INDEXES, ) diff --git a/src/opa_database/silver/gtfs.py b/src/opa_database/silver/gtfs.py index e32c392..41208e4 100644 --- a/src/opa_database/silver/gtfs.py +++ b/src/opa_database/silver/gtfs.py @@ -6,9 +6,11 @@ "November's ridership." Same resolution as AFC's dump_date-vs-service_date split: `replace_period()`'s period here is the export date itself (`feed_version_date`), one snapshot fully replacing itself if reloaded. +Every table is partitioned by day to match — each export IS one complete, +self-contained snapshot, its true natural unit. `shapes`/`stops` get a generated PostGIS point per row; aggregating shape -points into a `LINESTRING` per route is left for a gold/dbt model, same as -the AFC trips/boardings normalization. +points into a `LINESTRING` per route is left for a downstream consumer, +same as the AFC trips/boardings normalization. """ from __future__ import annotations @@ -19,9 +21,14 @@ import polars as pl from opa_database.config import settings -from opa_database.loaders.silver import get_connection, replace_period +from opa_database.loaders.silver import ( + IndexSpec, + daily_partition_name, + get_connection, + replace_period, +) -_TABLE_DDL: dict[str, LiteralString] = { +_PARENT_DDL: dict[str, LiteralString] = { "agency": """ CREATE TABLE IF NOT EXISTS silver.gtfs_agency ( feed_version_date date NOT NULL, @@ -32,7 +39,7 @@ agency_lang text, agency_phone text, agency_fare_url text - ); + ) PARTITION BY RANGE (feed_version_date); """, "calendar": """ CREATE TABLE IF NOT EXISTS silver.gtfs_calendar ( @@ -47,15 +54,16 @@ sunday integer NOT NULL, start_date date NOT NULL, end_date date NOT NULL - ); + ) PARTITION BY RANGE (feed_version_date); """, "calendar_dates": """ CREATE TABLE IF NOT EXISTS silver.gtfs_calendar_dates ( feed_version_date date NOT NULL, service_id text NOT NULL, date date NOT NULL, - exception_type integer NOT NULL - ); + exception_type integer NOT NULL, + copied_from_feed_version_date date + ) PARTITION BY RANGE (feed_version_date); """, "fare_attributes": """ CREATE TABLE IF NOT EXISTS silver.gtfs_fare_attributes ( @@ -66,7 +74,7 @@ payment_method integer NOT NULL, transfers integer, transfer_duration integer - ); + ) PARTITION BY RANGE (feed_version_date); """, "fare_rules": """ CREATE TABLE IF NOT EXISTS silver.gtfs_fare_rules ( @@ -76,7 +84,7 @@ origin_id text, destination_id text, contains_id text - ); + ) PARTITION BY RANGE (feed_version_date); """, "routes": """ CREATE TABLE IF NOT EXISTS silver.gtfs_routes ( @@ -90,7 +98,7 @@ route_url text, route_color text, route_text_color text - ); + ) PARTITION BY RANGE (feed_version_date); """, "shapes": """ CREATE TABLE IF NOT EXISTS silver.gtfs_shapes ( @@ -103,7 +111,7 @@ geom geometry(Point, 4326) GENERATED ALWAYS AS (ST_SetSRID(ST_MakePoint(shape_pt_lon, shape_pt_lat), 4326)) STORED - ); + ) PARTITION BY RANGE (feed_version_date); """, "stops": """ CREATE TABLE IF NOT EXISTS silver.gtfs_stops ( @@ -123,7 +131,7 @@ geom geometry(Point, 4326) GENERATED ALWAYS AS (ST_SetSRID(ST_MakePoint(stop_lon, stop_lat), 4326)) STORED - ); + ) PARTITION BY RANGE (feed_version_date); """, "stop_times": """ CREATE TABLE IF NOT EXISTS silver.gtfs_stop_times ( @@ -136,8 +144,9 @@ stop_headsign text, pickup_type integer, drop_off_type integer, - shape_dist_traveled double precision - ); + shape_dist_traveled double precision, + copied_from_feed_version_date date + ) PARTITION BY RANGE (feed_version_date); """, "trips": """ CREATE TABLE IF NOT EXISTS silver.gtfs_trips ( @@ -151,127 +160,52 @@ block_id text, shape_id text, wheelchair_accessible integer - ); + ) PARTITION BY RANGE (feed_version_date); """, } -_INDEXES: dict[str, tuple[tuple[str, LiteralString], ...]] = { - "agency": ( - ( - "gtfs_agency_fvd_idx", - "CREATE INDEX gtfs_agency_fvd_idx " - "ON silver.gtfs_agency (feed_version_date);", - ), - ), +# Created on each partition after it's bulk-loaded, rather than +# incrementally maintained per-row during COPY (see replace_period's +# docstring). +_INDEXES: dict[str, tuple[IndexSpec, ...]] = { + "agency": (IndexSpec("fvd_idx", unique=False, definition="(feed_version_date)"),), "calendar": ( - ( - "gtfs_calendar_fvd_idx", - "CREATE INDEX gtfs_calendar_fvd_idx " - "ON silver.gtfs_calendar (feed_version_date);", - ), - ( - "gtfs_calendar_service_id_idx", - "CREATE INDEX gtfs_calendar_service_id_idx " - "ON silver.gtfs_calendar (service_id);", - ), + IndexSpec("fvd_idx", unique=False, definition="(feed_version_date)"), + IndexSpec("service_id_idx", unique=False, definition="(service_id)"), ), "calendar_dates": ( - ( - "gtfs_calendar_dates_fvd_idx", - "CREATE INDEX gtfs_calendar_dates_fvd_idx " - "ON silver.gtfs_calendar_dates (feed_version_date);", - ), - ( - "gtfs_calendar_dates_service_id_idx", - "CREATE INDEX gtfs_calendar_dates_service_id_idx " - "ON silver.gtfs_calendar_dates (service_id);", - ), + IndexSpec("fvd_idx", unique=False, definition="(feed_version_date)"), + IndexSpec("service_id_idx", unique=False, definition="(service_id)"), ), "fare_attributes": ( - ( - "gtfs_fare_attributes_fvd_idx", - "CREATE INDEX gtfs_fare_attributes_fvd_idx " - "ON silver.gtfs_fare_attributes (feed_version_date);", - ), + IndexSpec("fvd_idx", unique=False, definition="(feed_version_date)"), ), "fare_rules": ( - ( - "gtfs_fare_rules_fvd_idx", - "CREATE INDEX gtfs_fare_rules_fvd_idx " - "ON silver.gtfs_fare_rules (feed_version_date);", - ), - ( - "gtfs_fare_rules_fare_id_idx", - "CREATE INDEX gtfs_fare_rules_fare_id_idx " - "ON silver.gtfs_fare_rules (fare_id);", - ), + IndexSpec("fvd_idx", unique=False, definition="(feed_version_date)"), + IndexSpec("fare_id_idx", unique=False, definition="(fare_id)"), ), "routes": ( - ( - "gtfs_routes_fvd_idx", - "CREATE INDEX gtfs_routes_fvd_idx " - "ON silver.gtfs_routes (feed_version_date);", - ), - ( - "gtfs_routes_route_id_idx", - "CREATE INDEX gtfs_routes_route_id_idx ON silver.gtfs_routes (route_id);", - ), + IndexSpec("fvd_idx", unique=False, definition="(feed_version_date)"), + IndexSpec("route_id_idx", unique=False, definition="(route_id)"), ), "shapes": ( - ( - "gtfs_shapes_fvd_idx", - "CREATE INDEX gtfs_shapes_fvd_idx " - "ON silver.gtfs_shapes (feed_version_date);", - ), - ( - "gtfs_shapes_shape_id_idx", - "CREATE INDEX gtfs_shapes_shape_id_idx ON silver.gtfs_shapes (shape_id);", - ), - ( - "gtfs_shapes_geom_idx", - "CREATE INDEX gtfs_shapes_geom_idx " - "ON silver.gtfs_shapes USING GIST (geom);", - ), + IndexSpec("fvd_idx", unique=False, definition="(feed_version_date)"), + IndexSpec("shape_id_idx", unique=False, definition="(shape_id)"), + IndexSpec("geom_idx", unique=False, definition="USING GIST (geom)"), ), "stops": ( - ( - "gtfs_stops_fvd_idx", - "CREATE INDEX gtfs_stops_fvd_idx ON silver.gtfs_stops (feed_version_date);", - ), - ( - "gtfs_stops_stop_id_idx", - "CREATE INDEX gtfs_stops_stop_id_idx ON silver.gtfs_stops (stop_id);", - ), - ( - "gtfs_stops_geom_idx", - "CREATE INDEX gtfs_stops_geom_idx ON silver.gtfs_stops USING GIST (geom);", - ), + IndexSpec("fvd_idx", unique=False, definition="(feed_version_date)"), + IndexSpec("stop_id_idx", unique=False, definition="(stop_id)"), + IndexSpec("geom_idx", unique=False, definition="USING GIST (geom)"), ), "stop_times": ( - ( - "gtfs_stop_times_fvd_idx", - "CREATE INDEX gtfs_stop_times_fvd_idx " - "ON silver.gtfs_stop_times (feed_version_date);", - ), - ( - "gtfs_stop_times_trip_id_idx", - "CREATE INDEX gtfs_stop_times_trip_id_idx " - "ON silver.gtfs_stop_times (trip_id);", - ), + IndexSpec("fvd_idx", unique=False, definition="(feed_version_date)"), + IndexSpec("trip_id_idx", unique=False, definition="(trip_id)"), ), "trips": ( - ( - "gtfs_trips_fvd_idx", - "CREATE INDEX gtfs_trips_fvd_idx ON silver.gtfs_trips (feed_version_date);", - ), - ( - "gtfs_trips_trip_id_idx", - "CREATE INDEX gtfs_trips_trip_id_idx ON silver.gtfs_trips (trip_id);", - ), - ( - "gtfs_trips_route_id_idx", - "CREATE INDEX gtfs_trips_route_id_idx ON silver.gtfs_trips (route_id);", - ), + IndexSpec("fvd_idx", unique=False, definition="(feed_version_date)"), + IndexSpec("trip_id_idx", unique=False, definition="(trip_id)"), + IndexSpec("route_id_idx", unique=False, definition="(route_id)"), ), } @@ -299,7 +233,12 @@ "start_date", "end_date", ), - "calendar_dates": ("service_id", "date", "exception_type"), + "calendar_dates": ( + "service_id", + "date", + "exception_type", + "copied_from_feed_version_date", + ), "fare_attributes": ( "fare_id", "price", @@ -351,6 +290,7 @@ "pickup_type", "drop_off_type", "shape_dist_traveled", + "copied_from_feed_version_date", ), "trips": ( "route_id", @@ -365,7 +305,7 @@ ), } -_TABLES = tuple(_TABLE_DDL) +_TABLES = tuple(_PARENT_DDL) def _find_snapshot_days(year: int, month: int) -> list[int]: @@ -411,7 +351,7 @@ def load(year: int, month: int) -> None: A month can contain zero, one, or several feed exports (Nov 2023 had two). Every table is loaded once per export day found, keyed by that - day as its own single-day `feed_version_date` period. + day as its own single-day `feed_version_date` partition. Args: year (int): Calendar year to load. @@ -424,13 +364,14 @@ def load(year: int, month: int) -> None: date = datetime.date(year, month, day) for table in _TABLES: df = _read_table(table, year, month, day) + partition = daily_partition_name(f"gtfs_{table}", date) replace_period( conn, f"silver.gtfs_{table}", + partition, df, - time_column="feed_version_date", - start=date, - end=date + datetime.timedelta(days=1), - table_ddl=_TABLE_DDL[table], + partition_start=date, + partition_end=date + datetime.timedelta(days=1), + parent_ddl=_PARENT_DDL[table], indexes=_INDEXES[table], ) diff --git a/src/opa_database/silver/vehicle_dictionary.py b/src/opa_database/silver/vehicle_dictionary.py index 8c5fb8f..96d2f99 100644 --- a/src/opa_database/silver/vehicle_dictionary.py +++ b/src/opa_database/silver/vehicle_dictionary.py @@ -1,51 +1,173 @@ -"""Silver loader for the vehicle dictionary reference source.""" +"""Silver loaders for the vehicle dictionary family of bronze sources. + +All six sources under `bronze/{vehicle_dictionary,device_dictionary, +vehicle_dictionary_legacy,vehicle_dictionary_legacy2, +vehicle_dictionary_2018,vehicle_dictionary_antigo}` load from this one +module, mirroring `adapters/vehicle_dictionary.py`. Bronze source (and +CLI source-argument) names are unchanged; only the silver table names +carry a `dictionary_` prefix instead (`dictionary_vehicle`, +`dictionary_device`, `dictionary_legacy`, `dictionary_legacy2`, +`dictionary_2018`, `dictionary_antigo`), so they sort together +alphabetically the same way `avl_*`/`afc_*`/`gtfs_*` do. Every one of +them is kept as its own silver table rather than merged into +`silver.dictionary_vehicle`, same reasoning as bronze: none of these +files' id spaces are confirmed compatible with `dictionary_vehicle`'s. + +Every table here follows the same shape: partitioned by day (matching +each loader's own single-snapshot load calls, see +`loaders/silver.py::replace_period`), with a unique index on whichever +column is confirmed unique within a snapshot (verified against each +bronze source's own data) as a data-integrity safeguard, and a plain +index on the other side of the mapping for lookup. Row counts are all in +the low thousands, so partitioning here is about consistency with the +other silver tables rather than a real perf need -- same note as the +original `dictionary_vehicle` table. +""" from __future__ import annotations import datetime +from typing import LiteralString import polars as pl from opa_database.config import settings -from opa_database.loaders.silver import get_connection, replace_period +from opa_database.loaders.silver import ( + IndexSpec, + daily_partition_name, + get_connection, + replace_period, +) -_TABLE = "silver.vehicle_dictionary" +_VEHICLE_DICTIONARY_TABLE = "silver.dictionary_vehicle" +_LEGACY_TABLE = "silver.dictionary_legacy" +_LEGACY2_TABLE = "silver.dictionary_legacy2" +_DEVICE_DICTIONARY_TABLE = "silver.dictionary_device" +_2018_TABLE = "silver.dictionary_2018" +_ANTIGO_TABLE = "silver.dictionary_antigo" -_TABLE_DDL = """ -CREATE TABLE IF NOT EXISTS silver.vehicle_dictionary ( +_VEHICLE_DICTIONARY_PARENT_DDL = """ +CREATE TABLE IF NOT EXISTS silver.dictionary_vehicle ( snapshot_date date NOT NULL, cod_veiculo text NOT NULL, id_veiculo text NOT NULL -); +) PARTITION BY RANGE (snapshot_date); """ -# cod_veiculo is deliberately not unique, even within one snapshot: buses -# get reassigned (~2% of codes map to more than one id_veiculo). id_veiculo -# is unique within a snapshot, so it gets a real constraint as a -# data-integrity safeguard, same reasoning as AFC's event_id. -_INDEXES = ( - ( - "vehicle_dictionary_snapshot_date_idx", - "CREATE INDEX vehicle_dictionary_snapshot_date_idx " - "ON silver.vehicle_dictionary (snapshot_date);", - ), - ( - "vehicle_dictionary_cod_veiculo_idx", - "CREATE INDEX vehicle_dictionary_cod_veiculo_idx " - "ON silver.vehicle_dictionary (cod_veiculo);", +_LEGACY_PARENT_DDL = """ +CREATE TABLE IF NOT EXISTS silver.dictionary_legacy ( + snapshot_date date NOT NULL, + vehicleid text NOT NULL, + numbus text NOT NULL +) PARTITION BY RANGE (snapshot_date); +""" + +_LEGACY2_PARENT_DDL = """ +CREATE TABLE IF NOT EXISTS silver.dictionary_legacy2 ( + snapshot_date date NOT NULL, + id text NOT NULL, + carro text NOT NULL, + obs1 text, + obs2 text +) PARTITION BY RANGE (snapshot_date); +""" + +_DEVICE_DICTIONARY_PARENT_DDL = """ +CREATE TABLE IF NOT EXISTS silver.dictionary_device ( + snapshot_date date NOT NULL, + codigo text NOT NULL, + device_id text, + vehicle_type text NOT NULL, + company text NOT NULL, + plate text, + vehicle_number text NOT NULL, + status text NOT NULL, + action text +) PARTITION BY RANGE (snapshot_date); +""" + +_2018_PARENT_DDL = """ +CREATE TABLE IF NOT EXISTS silver.dictionary_2018 ( + snapshot_date date NOT NULL, + cod_veiculo text NOT NULL, + id_veiculo text NOT NULL +) PARTITION BY RANGE (snapshot_date); +""" + +_ANTIGO_PARENT_DDL = """ +CREATE TABLE IF NOT EXISTS silver.dictionary_antigo ( + snapshot_date date NOT NULL, + cod_veiculo text NOT NULL, + id_veiculo text NOT NULL +) PARTITION BY RANGE (snapshot_date); +""" + +# cod_veiculo/vehicleid/carro/codigo (the AFC/"business" side of each +# mapping) are deliberately not unique, even within one snapshot, in +# general -- dictionary_vehicle's own cod_veiculo is the proven case +# (~2% reassigned). The GPS/device side (id_veiculo, vehicleid's +# counterpart numbus... see below) gets the real unique constraint as a +# data-integrity safeguard instead, verified unique within a snapshot for +# every one of these sources against their first extraction. +_VEHICLE_DICTIONARY_INDEXES = ( + IndexSpec("snapshot_date_idx", unique=False, definition="(snapshot_date)"), + IndexSpec("cod_veiculo_idx", unique=False, definition="(cod_veiculo)"), + IndexSpec("snapshot_id_key", unique=True, definition="(snapshot_date, id_veiculo)"), +) + +# Unlike dictionary_vehicle's cod_veiculo/id_veiculo, both sides of this +# particular mapping were verified unique within a snapshot (0 duplicates +# on either column, first extraction) -- indexed accordingly, unique on +# vehicleid (the GPS-side id, for parity with the other tables here) and +# plain on numbus for lookup from the other direction. +_LEGACY_INDEXES = ( + IndexSpec("snapshot_date_idx", unique=False, definition="(snapshot_date)"), + IndexSpec("numbus_idx", unique=False, definition="(numbus)"), + IndexSpec( + "snapshot_vehicleid_key", unique=True, definition="(snapshot_date, vehicleid)" ), - ( - "vehicle_dictionary_snapshot_id_key", - "CREATE UNIQUE INDEX vehicle_dictionary_snapshot_id_key " - "ON silver.vehicle_dictionary (snapshot_date, id_veiculo);", +) + +# Both id and carro were verified unique within a snapshot (0 duplicates +# on either column, first extraction). +_LEGACY2_INDEXES = ( + IndexSpec("snapshot_date_idx", unique=False, definition="(snapshot_date)"), + IndexSpec("carro_idx", unique=False, definition="(carro)"), + IndexSpec("snapshot_id_key", unique=True, definition="(snapshot_date, id)"), +) + +# codigo (always populated) and device_id (nullable) were both verified +# unique among their non-null values within a snapshot, so both get a +# real unique constraint -- Postgres unique indexes allow any number of +# NULLs, so device_id's nullability doesn't weaken it. vehicle_number is +# the more likely join target for future AVL/AFC matching work, so it +# gets the plain lookup index even though it was also confirmed unique. +_DEVICE_DICTIONARY_INDEXES = ( + IndexSpec("snapshot_date_idx", unique=False, definition="(snapshot_date)"), + IndexSpec("vehicle_number_idx", unique=False, definition="(vehicle_number)"), + IndexSpec("snapshot_codigo_key", unique=True, definition="(snapshot_date, codigo)"), + IndexSpec( + "snapshot_device_id_key", unique=True, definition="(snapshot_date, device_id)" ), ) -_COLUMNS = ("cod_veiculo", "id_veiculo") +_VEHICLE_DICTIONARY_COLUMNS = ("cod_veiculo", "id_veiculo") +_LEGACY_COLUMNS = ("vehicleid", "numbus") +_LEGACY2_COLUMNS = ("id", "carro", "obs1", "obs2") +_DEVICE_DICTIONARY_COLUMNS = ( + "codigo", + "device_id", + "vehicle_type", + "company", + "plate", + "vehicle_number", + "status", + "action", +) -def _find_latest_snapshot() -> datetime.date: - root = settings.bronze_root / "vehicle_dictionary" +def _find_latest_snapshot(source: str) -> datetime.date: + root = settings.bronze_root / source dates = [ datetime.date( int(year_dir.name.removeprefix("year=")), @@ -57,49 +179,186 @@ def _find_latest_snapshot() -> datetime.date: for day_dir in month_dir.glob("day=*") ] if not dates: - msg = f"No vehicle_dictionary bronze snapshots found under {root}" + msg = f"No {source} bronze snapshots found under {root}" raise FileNotFoundError(msg) return max(dates) -def load(snapshot_date: datetime.date | None = None) -> None: - """Load a vehicle dictionary bronze snapshot into the silver layer. - - Defaults to the most recent bronze snapshot available. Keyed by its - own ingestion date (`snapshot_date`) rather than a calendar period — - same "period = bronze's own partition key" pattern as AFC's dump_date - and GTFS's feed_version_date. - - Args: - snapshot_date (datetime.date | None): Bronze snapshot date to - load. Defaults to the most recent snapshot found under - `bronze_root`. - - """ - date = snapshot_date or _find_latest_snapshot() - path = ( +def _bronze_path(source: str, date: datetime.date) -> str: + return str( settings.bronze_root - / "vehicle_dictionary" + / source / f"year={date.year}" / f"month={date.month}" / f"day={date.day}" / "data.parquet" ) + + +def _load_snapshot( + *, + source: str, + table: str, + columns: tuple[str, ...], + parent_ddl: LiteralString, + indexes: tuple[IndexSpec, ...], + snapshot_date: datetime.date | None, +) -> str: + date = snapshot_date or _find_latest_snapshot(source) df = ( - pl.scan_parquet(path) + pl.scan_parquet(_bronze_path(source, date)) .with_columns(pl.lit(date).alias("snapshot_date")) - .select("snapshot_date", *_COLUMNS) + .select("snapshot_date", *columns) .collect() ) + partition = daily_partition_name(table.split(".", maxsplit=1)[1], date) with get_connection() as conn: replace_period( conn, - _TABLE, + table, + partition, df, - time_column="snapshot_date", - start=date, - end=date + datetime.timedelta(days=1), - table_ddl=_TABLE_DDL, - indexes=_INDEXES, + partition_start=date, + partition_end=date + datetime.timedelta(days=1), + parent_ddl=parent_ddl, + indexes=indexes, ) + return table + + +def load_vehicle(snapshot_date: datetime.date | None = None) -> str: + """Load a vehicle_dictionary bronze snapshot into the silver layer. + + Defaults to the most recent bronze snapshot available. Keyed by its + own ingestion date (`snapshot_date`) rather than a calendar period — + same "period = bronze's own partition key" pattern as AFC's dump_date + and GTFS's feed_version_date. + + Args: + snapshot_date (datetime.date | None): Bronze snapshot date to + load. Defaults to the most recent snapshot found under + `bronze_root`. + + Returns: + str: The fully-qualified silver table loaded into. + + """ + return _load_snapshot( + source="vehicle_dictionary", + table=_VEHICLE_DICTIONARY_TABLE, + columns=_VEHICLE_DICTIONARY_COLUMNS, + parent_ddl=_VEHICLE_DICTIONARY_PARENT_DDL, + indexes=_VEHICLE_DICTIONARY_INDEXES, + snapshot_date=snapshot_date, + ) + + +def load_legacy(snapshot_date: datetime.date | None = None) -> str: + """Load a vehicle_dictionary_legacy bronze snapshot into the silver layer. + + Args: + snapshot_date (datetime.date | None): Bronze snapshot date to + load. Defaults to the most recent snapshot found under + `bronze_root`. + + Returns: + str: The fully-qualified silver table loaded into. + + """ + return _load_snapshot( + source="vehicle_dictionary_legacy", + table=_LEGACY_TABLE, + columns=_LEGACY_COLUMNS, + parent_ddl=_LEGACY_PARENT_DDL, + indexes=_LEGACY_INDEXES, + snapshot_date=snapshot_date, + ) + + +def load_legacy2(snapshot_date: datetime.date | None = None) -> str: + """Load a vehicle_dictionary_legacy2 bronze snapshot into the silver layer. + + Args: + snapshot_date (datetime.date | None): Bronze snapshot date to + load. Defaults to the most recent snapshot found under + `bronze_root`. + + Returns: + str: The fully-qualified silver table loaded into. + + """ + return _load_snapshot( + source="vehicle_dictionary_legacy2", + table=_LEGACY2_TABLE, + columns=_LEGACY2_COLUMNS, + parent_ddl=_LEGACY2_PARENT_DDL, + indexes=_LEGACY2_INDEXES, + snapshot_date=snapshot_date, + ) + + +def load_device_dictionary(snapshot_date: datetime.date | None = None) -> str: + """Load a device_dictionary bronze snapshot into the silver layer. + + Args: + snapshot_date (datetime.date | None): Bronze snapshot date to + load. Defaults to the most recent snapshot found under + `bronze_root`. + + Returns: + str: The fully-qualified silver table loaded into. + + """ + return _load_snapshot( + source="device_dictionary", + table=_DEVICE_DICTIONARY_TABLE, + columns=_DEVICE_DICTIONARY_COLUMNS, + parent_ddl=_DEVICE_DICTIONARY_PARENT_DDL, + indexes=_DEVICE_DICTIONARY_INDEXES, + snapshot_date=snapshot_date, + ) + + +def load_2018(snapshot_date: datetime.date | None = None) -> str: + """Load a vehicle_dictionary_2018 bronze snapshot into the silver layer. + + Args: + snapshot_date (datetime.date | None): Bronze snapshot date to + load. Defaults to the most recent snapshot found under + `bronze_root`. + + Returns: + str: The fully-qualified silver table loaded into. + + """ + return _load_snapshot( + source="vehicle_dictionary_2018", + table=_2018_TABLE, + columns=_VEHICLE_DICTIONARY_COLUMNS, + parent_ddl=_2018_PARENT_DDL, + indexes=_VEHICLE_DICTIONARY_INDEXES, + snapshot_date=snapshot_date, + ) + + +def load_antigo(snapshot_date: datetime.date | None = None) -> str: + """Load a vehicle_dictionary_antigo bronze snapshot into the silver layer. + + Args: + snapshot_date (datetime.date | None): Bronze snapshot date to + load. Defaults to the most recent snapshot found under + `bronze_root`. + + Returns: + str: The fully-qualified silver table loaded into. + + """ + return _load_snapshot( + source="vehicle_dictionary_antigo", + table=_ANTIGO_TABLE, + columns=_VEHICLE_DICTIONARY_COLUMNS, + parent_ddl=_ANTIGO_PARENT_DDL, + indexes=_VEHICLE_DICTIONARY_INDEXES, + snapshot_date=snapshot_date, + ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..18a153a --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test suite for opa_database.""" diff --git a/tests/adapters/__init__.py b/tests/adapters/__init__.py new file mode 100644 index 0000000..77c2489 --- /dev/null +++ b/tests/adapters/__init__.py @@ -0,0 +1 @@ +"""Tests for src/opa_database/adapters.""" diff --git a/tests/adapters/test_gtfs.py b/tests/adapters/test_gtfs.py new file mode 100644 index 0000000..b89349e --- /dev/null +++ b/tests/adapters/test_gtfs.py @@ -0,0 +1,32 @@ +"""Unit tests for the GTFS adapter's nearest-export tie-break logic.""" + +import datetime + +from opa_database.adapters import gtfs + + +def test_select_nearest_prefers_closer_date(): + """A closer candidate wins regardless of which side it's on.""" + target = datetime.date(2022, 6, 27) + candidates = [ + (datetime.date(2022, 4, 7), "farther-before"), + (datetime.date(2022, 8, 22), "closer-after"), + ] + assert gtfs._select_nearest(target, candidates) == "closer-after" + + +def test_select_nearest_ties_prefer_earlier(): + """Equidistant candidates resolve to the earlier one.""" + target = datetime.date(2022, 1, 15) + candidates = [ + (datetime.date(2022, 1, 10), "before"), + (datetime.date(2022, 1, 20), "after"), + ] + assert gtfs._select_nearest(target, candidates) == "before" + + +def test_select_nearest_single_candidate(): + """A single candidate is returned regardless of its distance.""" + target = datetime.date(2022, 1, 1) + candidates = [(datetime.date(2022, 3, 1), "only")] + assert gtfs._select_nearest(target, candidates) == "only" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9ea8a5d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,17 @@ +"""Shared pytest setup. + +Importing any adapter/loader module transitively imports +`opa_database.config`, which eagerly constructs the `settings` singleton +at module load time and requires `raw_data_root` to be set (it doesn't +need to exist on disk, just to be present as a field). Locally this is +satisfied by `.env` (gitignored), but CI has no `.env`, so test +collection itself would fail before a single test runs. Setting a +fallback here (the repo root) fixes that without touching CI config or +the production `.env` handling; `setdefault` leaves a real local +`RAW_DATA_ROOT`/`.env` untouched. +""" + +import os +from pathlib import Path + +os.environ.setdefault("RAW_DATA_ROOT", str(Path(__file__).parent.parent)) diff --git a/tests/loaders/__init__.py b/tests/loaders/__init__.py new file mode 100644 index 0000000..19419f3 --- /dev/null +++ b/tests/loaders/__init__.py @@ -0,0 +1 @@ +"""Tests for src/opa_database/loaders.""" diff --git a/tests/loaders/test_silver.py b/tests/loaders/test_silver.py new file mode 100644 index 0000000..fb9410d --- /dev/null +++ b/tests/loaders/test_silver.py @@ -0,0 +1,15 @@ +"""Unit tests for the silver layer's partition-naming helpers.""" + +import datetime + +from opa_database.loaders.silver import daily_partition_name, monthly_partition_name + + +def test_monthly_partition_name_pads_month(): + assert monthly_partition_name("avl_pings", 2023, 11) == "avl_pings_y2023m11" + assert monthly_partition_name("afc_boardings", 2024, 3) == "afc_boardings_y2024m03" + + +def test_daily_partition_name_formats_date(): + date = datetime.date(2024, 3, 28) + assert daily_partition_name("gtfs_stop_times", date) == "gtfs_stop_times_d20240328" diff --git a/uv.lock b/uv.lock index a54a431..d2cf9d4 100644 --- a/uv.lock +++ b/uv.lock @@ -1,23 +1,43 @@ version = 1 revision = 3 requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] [[package]] -name = "agate" -version = "1.9.1" +name = "alembic" +version = "1.19.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "babel" }, - { name = "isodate" }, - { name = "leather" }, - { name = "parsedatetime" }, - { name = "python-slugify" }, - { name = "pytimeparse" }, - { name = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/2b/e4153978368de59918115c9e01d3ebf58a558a7285efa7e960c383c4b59a/alembic-1.19.1.tar.gz", hash = "sha256:e0fca0518118c78acc493e31bcb5402f190057aaf6df8b5b95ce94c4789cf648", size = 2070816, upload-time = "2026-08-08T16:32:01.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/89/e62cc37b69ad357cc8ecd6e7367f5245f523d3cbb338a66197212bdf6749/alembic-1.19.1-py3-none-any.whl", hash = "sha256:b39018cb3d9413a19cbd54cf3c02ad33998641f0538eb77413a488a21c3e14be", size = 265946, upload-time = "2026-08-08T16:32:03.153Z" }, +] + +[[package]] +name = "altair" +version = "6.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "narwhals" }, + { name = "packaging" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/29/77/6f5df1c68bf056f5fdefc60ccc616303c6211e71cd6033c830c12735f605/agate-1.9.1.tar.gz", hash = "sha256:bc60880c2ee59636a2a80cd8603d63f995be64526abf3cbba12f00767bcd5b3d", size = 202303, upload-time = "2023-12-21T20:05:24.316Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/a1/5e6cc638a66da48cfc89a79c2f4810dfec00b63385f9b009ab1f069779bb/altair-6.2.2.tar.gz", hash = "sha256:a1ff9d9cfe81c75414641826312b9471780e19d39293ba0b012933f6b6cba0fe", size = 766606, upload-time = "2026-06-23T12:47:13.384Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/53/89b197cb472a3175d73384761a3413fd58e6b65a794c1102d148b8de87bd/agate-1.9.1-py2.py3-none-any.whl", hash = "sha256:1cf329510b3dde07c4ad1740b7587c9c679abc3dcd92bb1107eabc10c2e03c50", size = 95085, upload-time = "2023-12-21T20:05:21.954Z" }, + { url = "https://files.pythonhosted.org/packages/e3/99/d6031f4f146298951c46b1bf1cc160c2a63f6e44b3c13a30054add100d5f/altair-6.2.2-py3-none-any.whl", hash = "sha256:94014f8ad8617c3cb163d1137359cd6db5ba134b9b46d93cfd8b609fd245a583", size = 797613, upload-time = "2026-06-23T12:47:11.451Z" }, ] [[package]] @@ -29,6 +49,102 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "appnope" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, +] + +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" }, + { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" }, + { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" }, + { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" }, + { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, + { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, + { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, + { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, +] + +[[package]] +name = "arrow" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" }, +] + +[[package]] +name = "asttokens" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/1e/faf0f247f6f881b98fc4d6d07e14085cb89d13665084e6d6ac1dc2c03d0b/asttokens-3.0.2.tar.gz", hash = "sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2", size = 63136, upload-time = "2026-07-12T03:31:49.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/2b/04b8a15f3a1c77bc79ddf5c73875327f34b4fa75982df2b76e45e402d364/asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933", size = 28702, upload-time = "2026-07-12T03:31:47.542Z" }, +] + +[[package]] +name = "async-lru" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/1f/989ecfef8e64109a489fff357450cb73fa73a865a92bd8c272170a6922c2/async_lru-2.3.0.tar.gz", hash = "sha256:89bdb258a0140d7313cf8f4031d816a042202faa61d0ab310a0a538baa1c24b6", size = 16332, upload-time = "2026-03-19T01:04:32.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/e2/c2e3abf398f80732e58b03be77bde9022550d221dd8781bf586bd4d97cc1/async_lru-2.3.0-py3-none-any.whl", hash = "sha256:eea27b01841909316f2cc739807acea1c623df2be8c5cfad7583286397bb8315", size = 8403, upload-time = "2026-03-19T01:04:30.883Z" }, +] + [[package]] name = "attrs" version = "26.1.0" @@ -47,74 +163,309 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] +[[package]] +name = "beautifulsoup4" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, +] + +[[package]] +name = "bleach" +version = "6.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/3c/e12ac860709702bd5ebeb9b56a4fe334f1001246ee1b8f2b7ee28912df7d/bleach-6.4.0.tar.gz", hash = "sha256:4202482733d85cedd04e59fcb2f89f4e4c7c385a78d3c3c23c30446843a37452", size = 204857, upload-time = "2026-06-05T13:01:13.734Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/9d/40b6267367182187139a4000b82a3b287d84d745bccd808e75d916920e9d/bleach-6.4.0-py3-none-any.whl", hash = "sha256:4b6b6a54fff2e69a3dde9d21cc6301220bee3c3cb792187d11403fd795031081", size = 165109, upload-time = "2026-06-05T13:01:12.504Z" }, +] + +[package.optional-dependencies] +css = [ + { name = "tinycss2" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "branca" +version = "0.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/14/9d409124bda3f4ab7af3802aba07181d1fd56aa96cc4b999faea6a27a0d2/branca-0.8.2.tar.gz", hash = "sha256:e5040f4c286e973658c27de9225c1a5a7356dd0702a7c8d84c0f0dfbde388fe7", size = 27890, upload-time = "2025-10-06T10:28:20.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/50/fc9680058e63161f2f63165b84c957a0df1415431104c408e8104a3a18ef/branca-0.8.2-py3-none-any.whl", hash = "sha256:2ebaef3983e3312733c1ae2b793b0a8ba3e1c4edeb7598e10328505280cf2f7c", size = 26193, upload-time = "2025-10-06T10:28:19.255Z" }, +] + +[[package]] +name = "catboost" +version = "1.2.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "graphviz" }, + { name = "matplotlib" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "plotly" }, + { name = "scipy" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/0e/09e8fa0858570fda88090bc3f441b69c18ea3d6f4a02fd41aa5426c157bf/catboost-1.2.10.tar.gz", hash = "sha256:26ae6d423acaf0e9d8160f2477a990431057ed04522d993c2f42dac62743b4f7", size = 39925863, upload-time = "2026-02-18T16:13:29.092Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/52/f5cd568800c87576012d715481730da93bcc34e609c5c204550a9ad0c067/catboost-1.2.10-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b27115d5b443048f710001c8ac666892dfe03498492310b00466203c91cc30a5", size = 28884278, upload-time = "2026-02-18T16:12:07.659Z" }, + { url = "https://files.pythonhosted.org/packages/33/ae/d33a8feba68fa810b30d70c660e4a2c62299472c2e1aa34406ccce306d13/catboost-1.2.10-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:39234b3692b6c9002b4a2ac529025fc210dd72feb9b621b27d17c65b7d3e9f92", size = 96704178, upload-time = "2026-02-18T16:12:11.229Z" }, + { url = "https://files.pythonhosted.org/packages/15/6c/08eabe522ac5cefc605ef81f273d77602130739ec7bcdc0ef192aa0a1f07/catboost-1.2.10-cp312-cp312-manylinux2014_x86_64.whl", hash = "sha256:b28f763776e62f50da90dddf73b36399583295032667a7e46fc5c1f2593eb80f", size = 97136498, upload-time = "2026-02-18T16:12:15.339Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/f467a133b37eef2b3d8697d46a6e7f0da24bd3643f5475817c473ffc41dc/catboost-1.2.10-cp312-cp312-win_amd64.whl", hash = "sha256:6b8a7ef11d7a89fc547760cfafeee895011a4b92cc1f60d00235ef80a71158ed", size = 100214655, upload-time = "2026-02-18T16:12:20.046Z" }, + { url = "https://files.pythonhosted.org/packages/2d/02/3c5f08a7c7969eaa2509d804461db26752fe1c7ecb8ad8510cab51a95fd2/catboost-1.2.10-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:bd3d3b344894f61b5f70124658f302148bb9a51c41d0d5b6c453a72e9dfefc49", size = 28829400, upload-time = "2026-02-18T16:12:23.682Z" }, + { url = "https://files.pythonhosted.org/packages/98/fd/63be2ff7aa9f6a7d63e342f42948259a028bfa50203d5ff687c84804ffb7/catboost-1.2.10-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:59aa166f075f0a5ea57b0ba46e5060bd6a22e849e91e4142f16c2df11295b184", size = 96680675, upload-time = "2026-02-18T16:12:28.407Z" }, + { url = "https://files.pythonhosted.org/packages/fe/2c/fa0479bd79226f037b495a30696b70741beb198f65227c975005e213aa8e/catboost-1.2.10-cp313-cp313-manylinux2014_x86_64.whl", hash = "sha256:42c1b6c7ae5c18cdbe00c8b9493987cc13338fe328baaf1a0b98ddaf58db96a2", size = 97111368, upload-time = "2026-02-18T16:12:32.456Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/a9e9a06418832fbea9d7cefda585d53395358d498537b6bdd3cf7364cd29/catboost-1.2.10-cp313-cp313-win_amd64.whl", hash = "sha256:5ede858e634d6d0f521bf6dd6fad9374f23d37049ee48e0779ccd2a372632cb1", size = 100201430, upload-time = "2026-02-18T16:12:36.731Z" }, + { url = "https://files.pythonhosted.org/packages/56/58/f370f6c64db5e7da92e3b88ab62e2df72f113cf5a1eee35b48f69d54accd/catboost-1.2.10-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:3efc5e4d414b7c13bff6dd0d6c938cf09bb1445097283c7790e54b8ee461820b", size = 28840256, upload-time = "2026-02-18T16:12:40.153Z" }, + { url = "https://files.pythonhosted.org/packages/9d/74/18597f0b2923e3660cd44f942fe9e7cddaa99afc252bc745c48f79566330/catboost-1.2.10-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:bad9a70890cdc591080a908d54a3cd70002ab1e48b2017adff84726da0b3e16d", size = 96688527, upload-time = "2026-02-18T16:12:43.534Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ac/7effae0e47fd9586e46a796f5af61b730c572570cedee333ee9ba8db85a8/catboost-1.2.10-cp314-cp314-manylinux2014_x86_64.whl", hash = "sha256:7b8cc4ea3a6ac4a8d05f3a79c8ee5454360a0a710fa12444963865ad3f0ddfec", size = 97119495, upload-time = "2026-02-18T16:12:47.557Z" }, + { url = "https://files.pythonhosted.org/packages/da/b7/8f9e284a9cdd034f01f017dc5dab0da03dc3eac171a2be205745da3becb6/catboost-1.2.10-cp314-cp314-win_amd64.whl", hash = "sha256:951c5bdf27b8edb6ca624f41134888c666ae68275488803d3c91ce83e154f0c5", size = 101749687, upload-time = "2026-02-18T16:12:51.736Z" }, +] + [[package]] name = "certifi" -version = "2026.6.17" +version = "2026.7.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, ] [[package]] name = "charset-normalizer" -version = "3.4.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, - { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, - { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, - { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, - { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, - { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, - { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, - { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, - { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, - { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, - { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, - { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, - { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, - { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, - { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, - { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, - { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, - { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, - { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, - { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, - { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, - { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, - { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, - { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, - { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, - { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, - { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, - { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, - { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, - { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, - { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, - { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, - { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, - { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, - { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, - { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, - { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, - { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, - { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, - { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, - { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, - { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, - { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, - { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, - { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, - { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, - { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, - { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, - { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/31/4971872b3ed8715346231fb6eb4da8fcba65a4143c189db151ee28a2812b/charset_normalizer-3.5.0.tar.gz", hash = "sha256:49bd5feb59b0bf3cbf6ebcf4352e371c95b9da9bacd4449f8b64d0ad2c10a26e", size = 169295, upload-time = "2026-08-12T14:35:31.624Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/3c/045ea64ea5a550870dd8ab60b2242870328d53f17d2be593b4f9f3121474/charset_normalizer-3.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:98820e1ceb25c6df7a80c4fd8efa59cb121f99bc7c4c1693ad94a2caff5b311d", size = 343861, upload-time = "2026-08-12T14:32:27.254Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1b/7502be709db899d5b4801509829188b3a5a10969411da9c846115a5f1b70/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:608553f476fca509537e804c4a71f5eb166ce63b75141f89c2c686ce1aa36956", size = 237550, upload-time = "2026-08-12T14:32:28.385Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ce/66392661375148d9455c17bee25509a54e28c39969e34befa48ec8777936/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6753de11eef42f1c321b26d682957d92c7f7bbce6530f34bbe0f9291dd37cc6f", size = 229673, upload-time = "2026-08-12T14:32:29.668Z" }, + { url = "https://files.pythonhosted.org/packages/6a/64/f58c32a8d4ecf55b82ee61ee9aa6a664d4afcd36c72feb4c926fd6fe9af8/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f76dc0a47f94cb9b69d86f01e477f4b0371ca70208b9ccea7e063c41eed9046", size = 260768, upload-time = "2026-08-12T14:32:30.891Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ea/36c90e59a96386174377e855479ec154221ef001e96637e0b23be92489c4/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c387c6bf91b4774e359a48a179e2872b8e8bf741e4fde06ba8d1665eb9a4760a", size = 257880, upload-time = "2026-08-12T14:32:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/23/35/5b85772eb82528ef22ba29487ad544a7049dfd27f35b1a5a55dbc0843048/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14f6904a3cf870abf044df3a8c4924ac6c8ef77e9896586fd37e73ae96cff2af", size = 247547, upload-time = "2026-08-12T14:32:33.495Z" }, + { url = "https://files.pythonhosted.org/packages/b6/14/ba11a99c2a22ab04c2d5383a700b378cb463a78ab15f36444cabc10cd671/charset_normalizer-3.5.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cce46dd29d73e135e8087b96eb62a4aca6d69391b7f97808c6588ebed3178f3", size = 243326, upload-time = "2026-08-12T14:32:34.794Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4a/cadba3f2400b45aa1d62a4ae0298bf58a3b30b1158baf15c338c7ce5b601/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b476cdb63df22da2b91837593380be3ddbe406f36c506c1c91d80e7196b66288", size = 238820, upload-time = "2026-08-12T14:32:35.984Z" }, + { url = "https://files.pythonhosted.org/packages/47/41/d5188b9342d75b72c2b05d3ee373f01a691397e770f001ee05e3b37925f5/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1f56ce84b317ef2a59d7d3461891c7597c79247d2192bb8114c68a1a1debfcc0", size = 231661, upload-time = "2026-08-12T14:32:37.191Z" }, + { url = "https://files.pythonhosted.org/packages/13/5f/df38fa972c4e945c3d8cee2bc4e610613af522fd359c7dc7a74c419f0278/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9ce0f885239357379d92fd9a5fddbe20f0e30e0527c29ba69f8e99eeb1304a76", size = 261459, upload-time = "2026-08-12T14:32:38.369Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d7/043ff7720067a3beee05523465ac9c1c846c68b7884930dd483f72ee5ab6/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:96ae7ab5d8155fde927aa0864fbc8ba3cc4fde6d41ab0c7cea9d6012b4978603", size = 242300, upload-time = "2026-08-12T14:32:39.505Z" }, + { url = "https://files.pythonhosted.org/packages/19/f6/33980b7b802a048e546a6d9ad2ea783a6cf6b10a86aaccd10db462d8b913/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bf91921009025e96ce57a03ced6d14604fc3baf0530351638e9504a55da6fa3b", size = 259101, upload-time = "2026-08-12T14:32:41.02Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b7/0d19bde844bff9165377c1da9ef3c4792a4c24bd49b5b7094d9e6f6ab58b/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0b2e44e6d42d1a4ff78ccc219a93c5449105d10b16198d1aea581080df8073f9", size = 249246, upload-time = "2026-08-12T14:32:42.47Z" }, + { url = "https://files.pythonhosted.org/packages/1c/cc/2c34fdfaacdf0e96e880ef562cbf80a9b5f8ea97e0dd9e57ba348a9c65cf/charset_normalizer-3.5.0-cp312-cp312-win32.whl", hash = "sha256:deb99535e9bf0bea8e274c6413eb939a21be35a3f492678dba4d5b1f4d70f142", size = 178025, upload-time = "2026-08-12T14:32:43.909Z" }, + { url = "https://files.pythonhosted.org/packages/76/d0/c34dbd1df23bcbdc1b5d2f48256340d72fa747f1eb03924a9d2fa35ed85b/charset_normalizer-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54dd1a66fa4bce0ccaf0db9dde336e49b3eec646dc4c1c0991279369d373a14", size = 200143, upload-time = "2026-08-12T14:32:45.254Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ea/4e6bf1465d60c3d8f488d5bde140d0cb91cd23ccab0dc2895cc6c6982047/charset_normalizer-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:b8ea208b304587d47931b36481342d20336e0d338ab052f8b4305926482598d6", size = 180046, upload-time = "2026-08-12T14:32:46.545Z" }, + { url = "https://files.pythonhosted.org/packages/1d/be/cc7b7b6fc41984902c0d31b06f5d9297e67705c1dae9352608e5540fad09/charset_normalizer-3.5.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:5c23fa4f6eccdd601949cb00f3988c01d64e671d8faba356397971077022e144", size = 211050, upload-time = "2026-08-12T14:32:47.81Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ae/e3ec8f17313609f43f7b323012fdb1ee37b83432277ca4eceba83e00366c/charset_normalizer-3.5.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:07f6f42b5a6325df35b458004fb5f9f29bf502d89287a33c7cdef3590e31de0f", size = 222768, upload-time = "2026-08-12T14:32:49.027Z" }, + { url = "https://files.pythonhosted.org/packages/c5/50/9f9c0d7ccc1512d49e27a0e7c12c58ec71dfe91698fa4326f058c33e1f1b/charset_normalizer-3.5.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8efc3f1563ed431882dd0dc0411b5f8ace1b1b89074981deaf6bd8af77dbe1bc", size = 193907, upload-time = "2026-08-12T14:32:50.414Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1d/cfe7b745ef7f4c3b7214581955b5a0869ba2ac551a58fc11036281ae167c/charset_normalizer-3.5.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:368eb2fc9482158b3a3386e8f01fa61f479c968e9a19ceab8f0188b86b312991", size = 197135, upload-time = "2026-08-12T14:32:51.605Z" }, + { url = "https://files.pythonhosted.org/packages/28/55/30fafdcfca9ba616bc394240545e4cd52f4f66dea43ded81b7d2d5274fde/charset_normalizer-3.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:826a295a039178479a325be1ae60eded1f0b10f7dda749df59e2440de8f61d64", size = 339892, upload-time = "2026-08-12T14:32:52.82Z" }, + { url = "https://files.pythonhosted.org/packages/f4/08/bdca5fc2bdc36ee443673dc7d12b23885a5a7b282bef85a1a4c3b325b40e/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70ff1c16eb0eb5ee6bb12739292347f981a5ba764cc4df1bc2e69b0405d4ac3b", size = 239439, upload-time = "2026-08-12T14:32:54.058Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9e/506c8d7a7722bba7c8cdd78c1b5ef23bda92bfbe0b3e28ea84673d519a0f/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3cfdab178a4add5483e26a9bb1c16d8018ccf39b4be7a3aea6c3979e6828f2ee", size = 227896, upload-time = "2026-08-12T14:32:55.326Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1a/dd828f2b1d6f4bf10821b9a74d866be05ffcdbfcddfc501d6fe6428762a7/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6083d10a846218502d664375b9448508d9fa580bd834567423156c6abfbe899d", size = 262548, upload-time = "2026-08-12T14:32:56.484Z" }, + { url = "https://files.pythonhosted.org/packages/be/81/196d26f6bd78b93e0d451b69082a71027ceeddd4b0be9170b81bb038f824/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0f211c21aa316cb6e2662e54a1194633a79d98a50a876addacfce7ba5b34b09f", size = 259986, upload-time = "2026-08-12T14:32:57.661Z" }, + { url = "https://files.pythonhosted.org/packages/3a/6a/5b964a1eb0f9075ecd45083eeb21aaec215334f98bac3d400302ea73875d/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b08ebf9488c7ff5eff038e48e6ea938178dfd9dcc8598b5ca941e4ae27b20be", size = 249853, upload-time = "2026-08-12T14:32:59.123Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b4/aee3a9d82edd0e931091ef3e9f03e46491ae3590e96e998d0975dadbe17c/charset_normalizer-3.5.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95450fce59f00c6d08eff6572ec2e736e5054c9450253afd5748f8416f2eb9", size = 244217, upload-time = "2026-08-12T14:33:00.341Z" }, + { url = "https://files.pythonhosted.org/packages/22/3e/33f72ca11c1b619b220fd9f35905ebd171cbd0e0470f2357e467b9e861ee/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5780a29823e1d2bec69b7a104ead4195a43f3e97782efaedbf1f79a0157af715", size = 241307, upload-time = "2026-08-12T14:33:01.589Z" }, + { url = "https://files.pythonhosted.org/packages/78/27/6029dccba958621c7f3a65136f87c5512d712aef9e890f09512cc171bd03/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:054420b5db984971d886e5e4e2c37c760ae6682aedbd066687ff0949d9ed5f08", size = 233305, upload-time = "2026-08-12T14:33:02.866Z" }, + { url = "https://files.pythonhosted.org/packages/18/d7/691c967be459153fe9faf49bf78bc95639ef8bf6dd008f38cc6389a349eb/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d016dc857136c726958102c3b8a3986acdc65ace6fbf12cfdc09cc4bfa2935b2", size = 263465, upload-time = "2026-08-12T14:33:04.166Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2d/9202221be5c90b2a835924191e362690ed8dc8c7d6606100c2bd03fe0f8c/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f2ce3d39fb4a9d674e6639dd5d3146b2e273475d2260f10163228d66fc04433d", size = 245060, upload-time = "2026-08-12T14:33:05.325Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9a/298772fd0a0cbccadf36451a1cd7eef4b66a11e99b4a7f6fafc47cc62c75/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a5613a3a82c974227bde18f03409e30c467f8065cb56d822e3eb83708a5f223d", size = 261091, upload-time = "2026-08-12T14:33:06.475Z" }, + { url = "https://files.pythonhosted.org/packages/82/3b/1a11fe66e555dbe2f5714ade6ba74fa29edc9155d9cf1001d4d6ed096aa7/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fded2e82ff082e5d8e017e2ddcc1411bd8cb83b8585097fc401ef574f756b888", size = 251857, upload-time = "2026-08-12T14:33:07.784Z" }, + { url = "https://files.pythonhosted.org/packages/17/fc/73b817e8af3f1d25ec5cf458d405abba5a144cf9812238a61530f5eac186/charset_normalizer-3.5.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:8f006866047c6ec4b627ec144b1e0bbc7427cb31fd7c08d19897d0ac9032af3d", size = 139745, upload-time = "2026-08-12T14:33:09.123Z" }, + { url = "https://files.pythonhosted.org/packages/b3/fb/ddb66303c86f7dc5043a457dad9fa82b4d6d0cb97094f9bcdde21693fd58/charset_normalizer-3.5.0-cp313-cp313-win32.whl", hash = "sha256:196e270c4e80827b5072eed7d6aa661d133afada94fe366669f9609e718d305e", size = 177217, upload-time = "2026-08-12T14:33:10.305Z" }, + { url = "https://files.pythonhosted.org/packages/fb/88/6018cc8d76ea2b7cb02918f37e23e86c261d1a102713d7e88d2cfb8b211c/charset_normalizer-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:72982d9958a42f8132bf2d6b90214ed66477295ef1188731f98ae3511c6eeb5a", size = 198896, upload-time = "2026-08-12T14:33:11.51Z" }, + { url = "https://files.pythonhosted.org/packages/20/2e/04c0bbfc8d9abf91959f7a3d207d45cbf63a8116984caae2381890019bb5/charset_normalizer-3.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:0b373bab0b867b68b8eb249da9478cab9181a42993437cd2f5dba5fb0b4fbd1b", size = 179193, upload-time = "2026-08-12T14:33:12.726Z" }, + { url = "https://files.pythonhosted.org/packages/43/14/d098868dac5ff27e0258f548b1c74c6484be528384965d8fcf8fc6a4011d/charset_normalizer-3.5.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:d95244906ed69d0f79f190893c65e336c15959003e21449256dc05c001b52ea2", size = 211664, upload-time = "2026-08-12T14:33:14.153Z" }, + { url = "https://files.pythonhosted.org/packages/e7/da/a944b32a46601ae5a4c3499e8d64ecd14fe82313f00da74dcdf00273a0b4/charset_normalizer-3.5.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:d788e2ded0c4c47efa4d73cfe59eaf975ee32f425219873d2cb3e3fbaa00f636", size = 224375, upload-time = "2026-08-12T14:33:15.472Z" }, + { url = "https://files.pythonhosted.org/packages/f7/db/eabb5996be2f529744755e7b2fc9396eff4a64961f034e7fd49d54b9afb2/charset_normalizer-3.5.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:f9f91d3e8382900f3a68fa0ce94294479de9cd2de6bc0c70acd0f0dfd511836b", size = 194364, upload-time = "2026-08-12T14:33:16.607Z" }, + { url = "https://files.pythonhosted.org/packages/78/65/4ad3c5be108930310d8003f5602861d5b89f728293b9f09c3a4837f7ba10/charset_normalizer-3.5.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d54625cbf4e6b60bf0639728cb8b4cb541e340f6d7cafae5806051a40ddf4c45", size = 197643, upload-time = "2026-08-12T14:33:17.88Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/8fee3201b98d52289be60a775797d69be05a04fb6cfb48c1587dad33e649/charset_normalizer-3.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:1f99a8c3a1da5d955edbad18208b3d627bdd54c48a6e739fa877bdca98c686d6", size = 341384, upload-time = "2026-08-12T14:33:19.239Z" }, + { url = "https://files.pythonhosted.org/packages/a7/dd/9e757101d1f76c35c0643684ba499ac3a181fb2b264c68174bf727d627e8/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf1e75dc07a3850b53d1e5f75e04d3ae12afe56284be7821771eaa2466350c73", size = 241637, upload-time = "2026-08-12T14:33:20.619Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/7857023015400bc4aa0a82fbcca29fa2dc7ec25f971a130764cb2dc7a589/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ac5a9cc079c67d75f4ddf343276031879eadbb333d1bb231cce297b8d7b9aae8", size = 226170, upload-time = "2026-08-12T14:33:21.773Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ae/8b52935b304f7b6bbf33151ed2b75266b09aa4b6f8f04230d948885b2577/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0dfe83c1b4d00abbf433998117a14f56a5c2bc68226c0d331709eed0d1ce539b", size = 265093, upload-time = "2026-08-12T14:33:22.999Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/6e838f6bb059f2c0afc60a4e7f294252f043c254656ad4114c50302cae4d/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e4e8fa586df2208ef040684751345f10f503834a757c9a74ecd19c1a2f9b1ccd", size = 262789, upload-time = "2026-08-12T14:33:24.214Z" }, + { url = "https://files.pythonhosted.org/packages/c2/08/189b27e51fddc9d6b3695331da0e31792c1d88b953ad854e57f06e9b2cc8/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82cc5835997ec78afe293a192e385099355770a7db94b2fb1239d36b32796f1c", size = 250580, upload-time = "2026-08-12T14:33:25.707Z" }, + { url = "https://files.pythonhosted.org/packages/ac/55/64854e99b25841f83e8e37d9df2f3d1f96f693439f80e5fabd542a7e47ab/charset_normalizer-3.5.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:19e52bda45086df8a4be4bb5910af6f5d9d3b538c78712c8ae09ef10b85bf458", size = 245008, upload-time = "2026-08-12T14:33:26.971Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/7720c904fa635d4260b4dced6029cf3d298c57b26741365d5a8d28c54043/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3418edd0ecb72a0a3861cf72f31be0ad9b7fe338ce2b58fb5cc80b9aeb792700", size = 243892, upload-time = "2026-08-12T14:33:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/70/50/7bfcb327631d4870c720872b548745f6ec8baa044d51c21b5d1d32ac4e3a/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:125ee619611019471b177c70bc3e9d4cda9fad7e01d93523501d3b188df0193a", size = 230996, upload-time = "2026-08-12T14:33:29.511Z" }, + { url = "https://files.pythonhosted.org/packages/24/51/40c45d6d940c04005ed721aa54bdebf1ebb2930f8a2ae537e8d60484fb27/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a3ad0e3da22852533858663848608f3f24c0d35e5cde415a4903476f2b4c88ec", size = 265834, upload-time = "2026-08-12T14:33:30.689Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d4/ef7a227ef89d215b47f9df79c3966610b17faa13bb2f236989207a631622/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:4ebebb410bc517e1d284c52a123e82704b21e4e7e26a21ebecf7439d0647b8a3", size = 245544, upload-time = "2026-08-12T14:33:31.859Z" }, + { url = "https://files.pythonhosted.org/packages/37/a9/a4ca9156964ded61c7718eba410ce11be2fd2b263fda4bcf08367b6578cd/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:91f9f7c151e772acebe489eaec96e96a2877202d7dd144e3f96b8676881715a0", size = 264110, upload-time = "2026-08-12T14:33:33.13Z" }, + { url = "https://files.pythonhosted.org/packages/38/6a/838364bb8702229c6e5f8b23f80ff0f052a12dfaf3113a12fd6acbe92a44/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:401ea6e7af9e7852ed818f64714b579c1935482049670847ca3bd7ba45dc63fb", size = 252303, upload-time = "2026-08-12T14:33:34.98Z" }, + { url = "https://files.pythonhosted.org/packages/9c/5f/d88032edce951f499a2321cf7ae0d35a043c74be12bc22d81084cc7afbcc/charset_normalizer-3.5.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f7496aed56b06325a1ad419c5bf23c6dd042558e874f71dd1b958f3e255f3053", size = 139964, upload-time = "2026-08-12T14:33:36.195Z" }, + { url = "https://files.pythonhosted.org/packages/37/ae/1c4a46b6b00d1c34d2ee355ef99ad6173674166800d1af0f05f85028d513/charset_normalizer-3.5.0-cp314-cp314-win32.whl", hash = "sha256:606a86c1c3196f3738de39a67a7490bbd61cb31c0e0436070bd0c6a48170b38e", size = 179790, upload-time = "2026-08-12T14:33:37.356Z" }, + { url = "https://files.pythonhosted.org/packages/01/51/f94dcf34fa8eba48c1fb89b6490a5f1426e19488fe5f38aac6c648c99057/charset_normalizer-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:ec6c464cf45867f66a2273e2214d9199a8fbad5cb95ca0fd45f6a2fe1d9d2cf4", size = 203723, upload-time = "2026-08-12T14:33:38.639Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ba/91d386870b5d9e4b0d8c4034f63877cc2e47b99c81ef05f3e6d42bf9a53f/charset_normalizer-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:dc28949de1bb5f7f30a46f15d74ce7ac5aaa63e03c5de04d68f571c7423af834", size = 183423, upload-time = "2026-08-12T14:33:39.899Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c9/534ecb17b7fb95f9052c4a44cf316316a27d4a8f73e8475ff55e778dcdd7/charset_normalizer-3.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:68b7e84ae8239a94f8d2c8f3f3a3a81bcde54805ec8f42a34de927d155688ec6", size = 368967, upload-time = "2026-08-12T14:33:41.093Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/ad7c3242d7fe55cd55126c22c65cb1b49779782cdf8932fd01d12232d86a/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58ca5dc0a0ef99f2801ec0574214c978e9574055bc783830bbb6e7433218609f", size = 239478, upload-time = "2026-08-12T14:33:42.428Z" }, + { url = "https://files.pythonhosted.org/packages/7e/62/77f0b850048e430fc350ec58876b0c020f5c8d0d3956fd1a4d6ae2fa292f/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6c57af4084c10cb3286688d65e4c654190ff5edcbc2411d08cdca0a8a44c59a1", size = 227036, upload-time = "2026-08-12T14:33:43.635Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ba/47d951e1a51dddbaad0a1410baf49fb1d897ceb00281568f1183b79bce9a/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1619a3cc174a7e3963dd34348e6fceb6e50db0ddeb0031bd7c73a58286454fa", size = 260772, upload-time = "2026-08-12T14:33:44.96Z" }, + { url = "https://files.pythonhosted.org/packages/b0/61/8c7ff4c81b2a88271126acf4b83ab3e31f6d63868b0f01d331eaa0f9cb67/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1328cc57dd4372be1265f68232cee890e087416e3e6e93e6ffb32c2bad4d36a4", size = 259273, upload-time = "2026-08-12T14:33:46.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/fd/36129689be08dc287b951306946657ff70d76e287dd57018861f86d0e474/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06f4fb62a9139bef056b8b2da6773c94c2f259f90e4b8e53b166f3d0372d7cf6", size = 248086, upload-time = "2026-08-12T14:33:47.54Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f8/bcae67f994c8fd31dda445e5ebf84045823c31443fe46f0e9ee6aca99aa0/charset_normalizer-3.5.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:478650a70a750d75d5add401606c77f77069c32e4ba2c9131dc6cee566962ca0", size = 242671, upload-time = "2026-08-12T14:33:48.746Z" }, + { url = "https://files.pythonhosted.org/packages/61/92/0472cdad1061c2f0e4d3aee29973eb6e81bb8fe256ff2860cf115b15f1c9/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:48920bf6fe83eb2226756ac623fa54940487154eb18f80889d5735cf234965c0", size = 241311, upload-time = "2026-08-12T14:33:50.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/89/04a03de5d27c77c624d9fcf6287073754bd438df1b58cb7d030c57c2824d/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:168a0cb536b5123a77bc42ecf5e0bf6f923d0d9ae43c42a14eb0677c19ac6c19", size = 229898, upload-time = "2026-08-12T14:33:51.523Z" }, + { url = "https://files.pythonhosted.org/packages/42/a2/639c4278adcb7ed1f4db608dd9ac19b6774fa2285a96b1c0bdb9c124ccbd/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f278e131afa96a3622cef9211c406ea2ad1b68eb06f8837cd443684a40e0ae50", size = 262852, upload-time = "2026-08-12T14:33:52.924Z" }, + { url = "https://files.pythonhosted.org/packages/9d/95/02e34c97bedfd0c5574efb9179c850591acc7f967ba039ed8dd29d332b73/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d6100f877d2ed95f0856a3fde25334153add94bf2224c43f45f88e7039262aaa", size = 242913, upload-time = "2026-08-12T14:33:54.18Z" }, + { url = "https://files.pythonhosted.org/packages/a3/64/0946aeab6462dad9f160a50dfb4704d3f58a5ee708f085abc2105fbbff0c/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4c440122e1ea68b1f8b44a631ebf49c39180f6869b1da22d76e8a724208ec6e9", size = 257938, upload-time = "2026-08-12T14:33:55.802Z" }, + { url = "https://files.pythonhosted.org/packages/79/77/36787d41ead124746506a4425c729f4f17c68280af8a6a5baa0a598cae86/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e5f834965c2fe589837bac1002e07e25734ff70381903ccd95b3d649e22bfa40", size = 249467, upload-time = "2026-08-12T14:33:57.081Z" }, + { url = "https://files.pythonhosted.org/packages/65/10/d9f6c5589cd24198d4ce6cd2948191c18e657272f433e5a00d258d9f5c22/charset_normalizer-3.5.0-cp314-cp314t-win32.whl", hash = "sha256:076cf9d3f3c7e410295c09d96355cf3b1bcae74990034d80e4371e20fe1ba4c6", size = 190624, upload-time = "2026-08-12T14:33:58.449Z" }, + { url = "https://files.pythonhosted.org/packages/6c/81/43e0584a802051a22c725795ebe1df78263abc7de858eef6cdc9b36637e9/charset_normalizer-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3288a560dc3114d5d2ebe309b1ef43f8af355eafe25856832415c2a8196c9db3", size = 215902, upload-time = "2026-08-12T14:33:59.753Z" }, + { url = "https://files.pythonhosted.org/packages/30/f3/af6a1160fef0eac4510d035241e11eccf78e5350e4cd4de79e79fe02a5e5/charset_normalizer-3.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a284c36b9c6616bf0a8aa4aabba668a0c75ba65ccf40a79868aeaa69ad996897", size = 193452, upload-time = "2026-08-12T14:34:01.017Z" }, + { url = "https://files.pythonhosted.org/packages/42/a4/dee470afb7a55c4f78b6fef37306c51fed17ebf94dbe530798c91d394350/charset_normalizer-3.5.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:c38d1e9bc2073b0984d2099ea647fd7f6c0d8f83a1e14e0cd32926f16e4c44ce", size = 341595, upload-time = "2026-08-12T14:34:02.4Z" }, + { url = "https://files.pythonhosted.org/packages/6a/32/9c3126dc429c6d9d7f79c52681a7c4453ed20a26267c9a8275d7ab620aba/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9f45186390aee4d1f26f723c615b67df346766c3b16df000d84d6e374f06757", size = 242177, upload-time = "2026-08-12T14:34:03.741Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9d/5b616a887301ff4cc0916b39ba44257390d3da80deeed6e8b6f2f26b14a8/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f0fde5e5100c735b2274ab898f0742a5dcde492796296cfbe7e0ad6a4cd1a396", size = 236730, upload-time = "2026-08-12T14:34:04.991Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/d6d3e70be93ebe5fabef65e4c7ac113e1d1705cbaeb5fb72467e713aca17/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d00e18e7bbf47e332ab63903d18bae31efc701b1d8cca0382b97784a621fc44", size = 265158, upload-time = "2026-08-12T14:34:06.235Z" }, + { url = "https://files.pythonhosted.org/packages/30/e7/3f1fafa87e2643257474f9c4eec609f2193a61d907dce7dd4f3f2390ebd5/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b81980668800dd1c69faad8aea6e85a8cee0e13bcd3bba7671695ff16260293", size = 262931, upload-time = "2026-08-12T14:34:07.511Z" }, + { url = "https://files.pythonhosted.org/packages/0a/df/ebeb224a949d91829e5e114c6b64372a3c792b00762a9e951ce416f3a32d/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb3e0d1345b9c0fe73673ea656375f38a78ec679c2edeae0c24800f04798a85", size = 251388, upload-time = "2026-08-12T14:34:08.949Z" }, + { url = "https://files.pythonhosted.org/packages/a0/64/9a6ce2e7acc5cf1b4636f78f82e89ff581e06a0216a40678b28bd4d832c4/charset_normalizer-3.5.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2401f7671242e921e604f609d429f6b282ea4ca787a6ffd22ed7372011ddb9d1", size = 251821, upload-time = "2026-08-12T14:34:10.138Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b1/6e69b8056f615e5ccff6b91ca16db2d47922251f016821a300c115267fef/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:96720f2aeed3434bc48f4d52fbad64ecc820cfed88915d664780ed9ba09ede78", size = 244507, upload-time = "2026-08-12T14:34:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/0f/34/02c15d6a0aa6b934dcdc136b111da63ae857b9fd51cf5505b0736337c2eb/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:c455829625df983f716cbaecbba77f2d1dc2e0e0ed1638c059cece15a279344b", size = 240951, upload-time = "2026-08-12T14:34:12.991Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/0fe893d3e1c7d111280bd6c4bd4c1e431487a1124a1bcbce78dfeda3a3a8/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:c41b067eddcfa5ee6b1169c287605be7fb6b0ea22bba6474c5bb978a668def4f", size = 266162, upload-time = "2026-08-12T14:34:14.232Z" }, + { url = "https://files.pythonhosted.org/packages/55/ea/eca03527307670f5d102c295671a800c404ca958cf94fefd10fc963a72f0/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:e31786a947b136329bfdc458c82c06d4ec539b4a4436b7da4df4aafc9902ee80", size = 251835, upload-time = "2026-08-12T14:34:15.48Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/fee5633081e595fe9e191df6f215106c791ad596eddf5e41e39b8ea0f2e2/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:c5c6d47a865147e0ae3322ce92e7fb52ba3169d94b447deda56897ea2aa6fac9", size = 264314, upload-time = "2026-08-12T14:34:16.679Z" }, + { url = "https://files.pythonhosted.org/packages/cf/fb/17f47ae6ca35b562fb6e6f4b05f7aec6034217353eb4a23aaa3566dc7340/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c75191e3c8052045179646cb40e280800a4e0bdfda34d9c949c2f268d44e80e4", size = 253194, upload-time = "2026-08-12T14:34:17.975Z" }, + { url = "https://files.pythonhosted.org/packages/59/88/f2b0f7ebb92493e925889ff29239b3b0073ffafd91230dbfc69e5cf9389c/charset_normalizer-3.5.0-cp315-cp315-win32.whl", hash = "sha256:83b62410bd36bb1178a7d563e2ee0cf21eb1c980c912ab99c2c78f06227f1731", size = 179800, upload-time = "2026-08-12T14:34:19.409Z" }, + { url = "https://files.pythonhosted.org/packages/e2/f0/afb5bfdea52fd943b1960403847a276b8e900c6e4cd6a38752321b4eda64/charset_normalizer-3.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:e3b9eaa99a6d8c9ace4cd303915947ef55088d4cd87c6676874f98c5c03aa040", size = 203726, upload-time = "2026-08-12T14:34:20.656Z" }, + { url = "https://files.pythonhosted.org/packages/fc/71/219783eb691aa2ec879c0e521afdfe2b826f9678eed51b9c039d03e0db2b/charset_normalizer-3.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:fec352b793cdc183cc9e7e0b6c10fd7bff38ec54ba44cc43599b9b56f7f3db2e", size = 183428, upload-time = "2026-08-12T14:34:21.975Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d0/14aef3b9f80f2593c039d897e89034635b9eb0eb44b6ce5173bbd79ff338/charset_normalizer-3.5.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c9bde7a960720c8b8e1b5ef7afaa0c9a2f3b55c44abd635b2b29dd066b298e3a", size = 368728, upload-time = "2026-08-12T14:34:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/10/fc/b249466ddbbeffa448b6597631e9091d1f01b5132ff8e7a0e21a6eb72b63/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8cd1283a9fe6c2065c807e9d5da81afe5e1e004caef39adc0d8ae86dd883698", size = 240925, upload-time = "2026-08-12T14:34:24.504Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b9/c17e72aaa1b3e1ca6c184e8025cf138ed492d01a54f85286ff7d31253a4b/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1c010dd86d3f4c4433c9634d33ce8147393b270dfa54f217f965540b8ae8e075", size = 234932, upload-time = "2026-08-12T14:34:25.822Z" }, + { url = "https://files.pythonhosted.org/packages/18/d7/f84ef0966bbe216f71029e34e7fa425a16b1682e2a40265e679dedf2b655/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5e68229977b2dea28e7061c0c0630a23f2f9f6e9c6fb38d77d3d6dbfe3768b74", size = 261733, upload-time = "2026-08-12T14:34:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/3b/73/3e887fa0781a395339355ed934ab6561ceb5bb52574160f070224039c630/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a60773eb5fda796e6e6f76b9c152d270fe59f9788a51a6ff8ba44082d8548ae4", size = 258460, upload-time = "2026-08-12T14:34:28.431Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/52ebd9849bf9e35d0b21fff115cb6543162a8e1f2f564e8f87121a336b8c/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9419f44e568f7fafcdc0b3b5c766a2364e705a9b34fb8a56b431e0d1f3f4258", size = 249894, upload-time = "2026-08-12T14:34:29.698Z" }, + { url = "https://files.pythonhosted.org/packages/85/f3/9366492b8a5fe0187de282e001d61345740cf79eb4a5f20181d769be02b5/charset_normalizer-3.5.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75e243abbb528c1a774390ed71e3f868a9f37b1373442e4bbadd401cfc505ff4", size = 249540, upload-time = "2026-08-12T14:34:30.96Z" }, + { url = "https://files.pythonhosted.org/packages/0b/af/28bb5e5dbd3e67cb9196a62781ac2b6d79492f4fc7a069b6ca7d6d6c8d58/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:54c963ce6404e52255b737e8a06d356fc762d59096ae566203a67cf2b7d050f2", size = 242734, upload-time = "2026-08-12T14:34:32.481Z" }, + { url = "https://files.pythonhosted.org/packages/26/d6/7ccfa62b53b40fc06b2d3504825aa400764740bd10cf248fdc4272441b93/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:63ea0cc840c66670183578c2630d138c0e944aeadfc33f25173ee240f5db780d", size = 239580, upload-time = "2026-08-12T14:34:33.916Z" }, + { url = "https://files.pythonhosted.org/packages/0b/82/71c0c9b046697b8da66b3acefa8d5f92d00a9ef433ad7c3522b971d0369a/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6c06875a1d4a7537bef70f659b55c6b55b9a47ec3ba8f2db610350c2d9915e6e", size = 263281, upload-time = "2026-08-12T14:34:35.333Z" }, + { url = "https://files.pythonhosted.org/packages/d6/01/d027583c869f40ba980c1c76994adbd522c360a6327e72beb44d7c267385/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:4253da1b4456b633651a8d59eb1dc7a8a8fa38241014dd7c217b353e547ae394", size = 250027, upload-time = "2026-08-12T14:34:36.991Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e9/6475d739e0ec8bb1236e06263dc3affaffdf947d8114ad27024932f325da/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:f044cb1cf44012184715f46584658993b5fee9344d71c4b0c455a17a299730c0", size = 257547, upload-time = "2026-08-12T14:34:38.277Z" }, + { url = "https://files.pythonhosted.org/packages/a5/60/d1f502fcaa048a2aca3ab80bfef8407659c131e4f1792fa805fec14b4960/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a17864853f7c518ae7d4b368af98f427f9396805476af40af8698560f09d7d97", size = 251718, upload-time = "2026-08-12T14:34:39.562Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/76343dcf4381a698807ff8a20d89f66bbdd9f6222b0b17740f77ab764335/charset_normalizer-3.5.0-cp315-cp315t-win32.whl", hash = "sha256:9e726478d7a213847860219d74665a6892a643ac93b8f76580f6cf9ed39996b7", size = 190757, upload-time = "2026-08-12T14:34:41.053Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ea/d18147626a1667cc773c42104ab155a4ca5d6d4d174b7a35e01062213ea5/charset_normalizer-3.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:d7229a99120c6c2792d96f4857c2648ce5530e93667a2c2388c5ef69a6b84775", size = 215431, upload-time = "2026-08-12T14:34:42.501Z" }, + { url = "https://files.pythonhosted.org/packages/47/21/4869598aae0872d94faa5933918a4fe37ab2c5af9d095786e241f9506fed/charset_normalizer-3.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:527e28a5e751d9e11369b9c5f9ab35c748eb9c109101920c7deb40d6eadf8d03", size = 193205, upload-time = "2026-08-12T14:34:43.781Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f3/7b523d807cb5e73562ef8acf21d39cdb9d704955327362c781bc3478a73d/charset_normalizer-3.5.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:5a4ee37248dfac25107c758bda99d545ce73e60b44d2dd39e4a2bb9f2831e9f5", size = 330840, upload-time = "2026-08-12T14:34:45.06Z" }, + { url = "https://files.pythonhosted.org/packages/f0/de/fc68978fe78ca97063c96d764e41ff92ca639948f319271e0ff450e577a2/charset_normalizer-3.5.0-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a864bdcacd8bff58bb4845304e031f821a3ec64b2b7259f2d409cd49c9e59ca3", size = 251862, upload-time = "2026-08-12T14:34:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/a9/cb/82b41a0ab7fb1a88065f1d78ad32696ad88ea3fe8e25b8189d08833938de/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84b736e3b391601bc47b86da381c749c0f894e9191aaca9f31f30c2632206df3", size = 239484, upload-time = "2026-08-12T14:34:47.869Z" }, + { url = "https://files.pythonhosted.org/packages/e8/0c/19608b631f4538f908098d4a2d56a8f79a665e27cc58e9d90479761a9227/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6abb1f356fb865baeb6ebc3fadd843e9a96fbf49b9adcca55037f3cceccb7438", size = 230602, upload-time = "2026-08-12T14:34:49.265Z" }, + { url = "https://files.pythonhosted.org/packages/29/db/f648eb30e14eba301aed61e11672156f137905c1bdbb530151abe8065943/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d366548d2ee28a8cfdcc4296363978cc644a728333be9824d2de4652e83df0a", size = 259208, upload-time = "2026-08-12T14:34:50.632Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e0/ed2c8bdbac484d69614d6993143aeb6cb0f4dd1561c883402517b623c8ef/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d672f329ae504ee240eb39b6effb3318aa8e7e8924c0ce8eee5760b3fad98539", size = 253659, upload-time = "2026-08-12T14:34:52.11Z" }, + { url = "https://files.pythonhosted.org/packages/32/08/b4907cb9ec5b521d9d024ced13611240b86ef065c2eb15b3ad2334dc9940/charset_normalizer-3.5.0-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c54036a518748b6c02e666f6d46c3817561998fb904c3be25b56fb4fe3dc5706", size = 248821, upload-time = "2026-08-12T14:34:53.399Z" }, + { url = "https://files.pythonhosted.org/packages/12/b2/e2d1abcfbc05822f0030869efb4e9f8a3658e13b4821796d4b62da917327/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:22a1889f1c9b752c63c36758a0c2145458e3cadb20fced7a0790002e9dd12b26", size = 240271, upload-time = "2026-08-12T14:34:55.09Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f9/4ba127ad610542fa3eabfa41c45bf12d357860a815b3566374ec0188e213/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b7eb3eab5c646d3de7dcb14a7c9caebace5249c5767da39e1761cb1576e521a3", size = 232155, upload-time = "2026-08-12T14:34:56.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/68/40613182366d00bd6dbd5f6c84a926cbd120960e038a8269e9ae7d782762/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:2080aa129a28267984cdc902898993d788c995c384e285d0d19199f56760d52e", size = 259674, upload-time = "2026-08-12T14:34:57.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/53/4574a14fa4c9de4a6c9f31725354bfa40b67f653e6d594ce1654f9a41b32/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:fd68c825548a611158230e2f9222e210ceb2e3391995c0aa5865cbdf3ab4bd49", size = 246122, upload-time = "2026-08-12T14:34:59.337Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/bd8030d13d92c058ca7b2b9615bbb3169569e144db64d65c149cd45abf5e/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:d8a9316f4da85e937242642b537c6d55d7e9287dd38e5634732f8233932aff45", size = 255221, upload-time = "2026-08-12T14:35:00.71Z" }, + { url = "https://files.pythonhosted.org/packages/77/9d/10ecd3bcbe2666b3d4d4026c97b48f73990682815db516052a1e8f4a31c5/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d90254c8f609338c53ec180fcd4c4f9c16502e238e3fc88ca7fd4c2f38d445b8", size = 253450, upload-time = "2026-08-12T14:35:02.217Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/d044c4872c0938a84f87b5027a698c0e61bacfc5c3551a4e749ca9b7bc5c/charset_normalizer-3.5.0-cp37-abi3-win32.whl", hash = "sha256:8b3e9e29b8b07cc461b9ce7768db7693a93979d0dadf22046f6f3555ded2f516", size = 173594, upload-time = "2026-08-12T14:35:03.845Z" }, + { url = "https://files.pythonhosted.org/packages/10/6b/6046773901f1944b9a89436351529811ee958afc7b774563be9d74a6f0c3/charset_normalizer-3.5.0-cp37-abi3-win_amd64.whl", hash = "sha256:0c8953d9d1617794cfc40d81179571c9ba3805dd029623a15c93f1fb70e60a74", size = 198959, upload-time = "2026-08-12T14:35:05.187Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a6/b57708ac92aefc8e8389d51d5178129b81f03196da61ee2c23e687b8178a/charset_normalizer-3.5.0-cp37-abi3-win_arm64.whl", hash = "sha256:562d24ca7797c1af8852994950c2e623a907b201fc4b0ed29e92af173d3828ca", size = 267055, upload-time = "2026-08-12T14:35:06.533Z" }, + { url = "https://files.pythonhosted.org/packages/22/c7/754d09943a616937df61e4ba367c409ded2a987e872972098d51a6fcf73b/charset_normalizer-3.5.0-py3-none-any.whl", hash = "sha256:993dfcbe75a85a3784abb5084f2c41b915767c90546fcc92803cffa28611baea", size = 67943, upload-time = "2026-08-12T14:35:30.363Z" }, ] [[package]] @@ -139,209 +490,474 @@ wheels = [ ] [[package]] -name = "daff" -version = "1.4.2" +name = "colorlog" +version = "6.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/55/ba79756cb90c8d69d599d57785398ac87bba7b19c80e87f4e8a562197c93/colorlog-6.12.0.tar.gz", hash = "sha256:2a7924c1dadf18b22a0eb8b06d1c7b01d5341707ec1641eb6fcc4fde0c3e8e5f", size = 18151, upload-time = "2026-07-23T13:40:40.71Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/19/0b6647bf5e331521e55d2b63bfbdc210bd9cd605189273f03614a05f702d/colorlog-6.12.0-py3-none-any.whl", hash = "sha256:30d392604e9110045a2c2aeefc27d7a017abbab63f3a8aee594eac0801df784e", size = 12239, upload-time = "2026-07-23T13:40:39.562Z" }, +] + +[[package]] +name = "comm" +version = "0.2.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e1/d0/c0a1374db3afad0f9dfe6c795e5df102af03d49ad5e6e8502fb09eb88110/daff-1.4.2.tar.gz", hash = "sha256:47f0391eda7e2b5011f7ccac006b9178accb465bcb94a2c9f284257fff5d2686", size = 148251, upload-time = "2025-05-04T19:24:11.521Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/fe/d54a874e8d7b88bc03c459f63a993305db50039b734fab751a0466dabfc1/daff-1.4.2-py3-none-any.whl", hash = "sha256:88981a21d065e4378b5c4bd40b975dbfdea9b7ff540071f3bb5e20cc8b3590b5", size = 144922, upload-time = "2025-05-04T19:24:09.999Z" }, + { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, ] [[package]] -name = "dbt-adapters" -version = "1.24.4" +name = "contourpy" +version = "1.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "agate" }, - { name = "dbt-common" }, - { name = "dbt-protos" }, - { name = "mashumaro", extra = ["msgpack"] }, - { name = "protobuf" }, - { name = "pytz" }, - { name = "typing-extensions" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/92/72/b675ec6dfebb23e67639d7190c7d4b91a710a574f69b5fadc7f12280924f/dbt_adapters-1.24.4.tar.gz", hash = "sha256:e31df9e27ae8e1d4fa0c5961767953cd3f122730c436815da4585b373376d31c", size = 146341, upload-time = "2026-07-08T15:43:04.168Z" } + +[[package]] +name = "debugpy" +version = "1.8.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/aa/12037145b7a56eaa5b29b41872f7a21b538e807e13f32c4d3c46e59be084/debugpy-1.8.21.tar.gz", hash = "sha256:a3c53278e84c94e11bd87c53970ec391d1a67396c8b22609fcac576520e611a6", size = 1697577, upload-time = "2026-06-01T19:30:35.156Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/b8/026b608d8834b27fa3e938fae8fa3db38273c9d874be6791dd129b51ec35/dbt_adapters-1.24.4-py3-none-any.whl", hash = "sha256:a8a4bb380168dd632012e33e883433c65def9fadc4f30e365db313907b270b99", size = 176921, upload-time = "2026-07-08T15:43:02.707Z" }, + { url = "https://files.pythonhosted.org/packages/a2/df/bf625547431a9cadc9f4cbfeda38866e2b17f6aed147b625377e87834449/debugpy-1.8.21-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:9f96713896f39c3dff0ee841f47320c3f2983d33c341e009361bb0ebc79adc4e", size = 2483609, upload-time = "2026-06-01T19:30:50.794Z" }, + { url = "https://files.pythonhosted.org/packages/bf/09/59324b903599031ff9faaec1758292409f6561a0ec2492fe4b703327705a/debugpy-1.8.21-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:c193d474f0a211191f2b4449d2d06157c689013035bd952f3b617e0ef422b176", size = 3968900, upload-time = "2026-06-01T19:30:52.341Z" }, + { url = "https://files.pythonhosted.org/packages/14/cd/27f65b805d7fe005c44e1a36b9183ecdfbcdbf9d3e721a5115d461ecc7ee/debugpy-1.8.21-cp312-cp312-win32.whl", hash = "sha256:4743373c1cac7f9e74a1b9915bf1dbe0e900eca657ffb170ae07ac8363205ae9", size = 5336340, upload-time = "2026-06-01T19:30:54.047Z" }, + { url = "https://files.pythonhosted.org/packages/77/1d/c84e30c0c674184948b66f076ab271c01d940618a2824c23cd035a27bc20/debugpy-1.8.21-cp312-cp312-win_amd64.whl", hash = "sha256:bd7ba9dd3daa7c2f942c6ca8d4695a16bf9ac16b63615261c7982bc74f7ed20c", size = 5374751, upload-time = "2026-06-01T19:30:55.891Z" }, + { url = "https://files.pythonhosted.org/packages/77/6b/d817e1f8cc77aa055d37fba092e0febfdff40fe652d8d53d4cd7a86ad98d/debugpy-1.8.21-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:13678151fc401e2d68c9880b91e28714f797d40422994572b24560ef80910a88", size = 2477398, upload-time = "2026-06-01T19:30:57.644Z" }, + { url = "https://files.pythonhosted.org/packages/48/57/412421516afc3055fa577516f00beec3d663f9b0ab330639547ae6c57720/debugpy-1.8.21-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:ecbd158386c31ffe71d46f72d44d56e66331ab9b16cad649156d514368f23ab2", size = 3962096, upload-time = "2026-06-01T19:30:59.235Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2c616337cf6ba7b07ebbc97f02c6c945a8e2f76b365e33ee809c32ee36d1/debugpy-1.8.21-cp313-cp313-win32.whl", hash = "sha256:2c2ae706dec41d99a9ca1f7ebc987a83e65578363be6f6b3ac9067504917fae1", size = 5336288, upload-time = "2026-06-01T19:31:00.79Z" }, + { url = "https://files.pythonhosted.org/packages/f8/99/9175103392f84c4b1bf7622888cdc68da07f0ff7d9e581266428f6776033/debugpy-1.8.21-cp313-cp313-win_amd64.whl", hash = "sha256:aa648733047443eb1d07682c4ef287d36a54507b643ffdf38b09a3ef002c72a0", size = 5376567, upload-time = "2026-06-01T19:31:02.56Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3d/f4bbb323a548bfab2af3d6b4ffd9bf22636e55956a1285d317a1de643aad/debugpy-1.8.21-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9bb2a685287a2ac9b181cde89edcec64845cb51de7faaa75badb9a698bc24782", size = 2477209, upload-time = "2026-06-01T19:31:04.157Z" }, + { url = "https://files.pythonhosted.org/packages/8c/2d/6e7ec524984a1702777868de49a4c53202bddac2a432a76a093469587750/debugpy-1.8.21-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:3d6922439bf33fd38a3e2c447869ebc7b97da5cd3d329ff1ef9bc06c4903437e", size = 3927115, upload-time = "2026-06-01T19:31:05.863Z" }, + { url = "https://files.pythonhosted.org/packages/97/47/d1aa6d64005a98a9144647d99306b419396f9ad7bf1d73c119e17a81fb4d/debugpy-1.8.21-cp314-cp314-win32.whl", hash = "sha256:15d4963bd5ffa48f0da0947fd06757fa7621945048a14ad7705431566d3c0e7c", size = 5336724, upload-time = "2026-06-01T19:31:07.711Z" }, + { url = "https://files.pythonhosted.org/packages/5f/67/b905b90d163af11878c1af8abafa4a25206335e112e284e413454543a6da/debugpy-1.8.21-cp314-cp314-win_amd64.whl", hash = "sha256:fe0744a12353406de0ae8ccff0d0a4a666f00801a3db8fd04e7a5f761cd520e8", size = 5373803, upload-time = "2026-06-01T19:31:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/95/51/67e7cf11a53e40694f720457d5b3a1cdaaa3d5a9a633e482f225456b93ff/debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92", size = 5352888, upload-time = "2026-06-01T19:31:25.186Z" }, ] [[package]] -name = "dbt-common" -version = "1.38.0" +name = "defusedxml" +version = "0.7.1" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "agate" }, - { name = "colorama" }, - { name = "dbt-protos" }, - { name = "deepdiff" }, - { name = "isodate" }, - { name = "jinja2" }, - { name = "jsonschema" }, - { name = "mashumaro", extra = ["msgpack"] }, - { name = "pathspec" }, - { name = "protobuf" }, - { name = "python-dateutil" }, - { name = "requests" }, - { name = "typing-extensions" }, +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c5/27/1cb45b595e280d4e66b122c539655be280236af27a964114b8f163702674/dbt_common-1.38.0.tar.gz", hash = "sha256:05f9c682c1c09b6a0ce79fe18dd7244a4549c3740329692bc19be9e7498f516f", size = 87065, upload-time = "2026-05-04T20:28:19.274Z" } + +[[package]] +name = "fastjsonschema" +version = "2.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/98/474719c58eddaf77fa443b063693e76d49db32bbe851bcbaf58d2700119f/fastjsonschema-2.22.1.tar.gz", hash = "sha256:0b83d1ce8d7845b959dcb20e1a5c3c8883b6541d9c52ab02cce5166b75ec805f", size = 382291, upload-time = "2026-07-27T13:31:08.515Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/d9/11763d94eb3257c11463781c18aef3e9613829b9f0e55609a494e596f574/dbt_common-1.38.0-py3-none-any.whl", hash = "sha256:1646e5384a8e317fa63890e60e41ca50dd4c5a89e02ad9a141c184d4b672b696", size = 88460, upload-time = "2026-05-04T20:28:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/62cc96341f01bdff2ba967441939178fcd1900d11ce7e6554d9954a5d7ec/fastjsonschema-2.22.1-py3-none-any.whl", hash = "sha256:cf377ff5c9a6f4f3125fb35f75a2c5767bd824ffbcf62c209a93cd48d1453999", size = 26239, upload-time = "2026-07-27T13:31:03.251Z" }, ] [[package]] -name = "dbt-core" -version = "1.11.12" +name = "folium" +version = "0.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "agate" }, - { name = "click" }, - { name = "daff" }, - { name = "dbt-adapters" }, - { name = "dbt-common" }, - { name = "dbt-extractor" }, - { name = "dbt-protos" }, - { name = "dbt-semantic-interfaces" }, + { name = "branca" }, { name = "jinja2" }, - { name = "jsonschema" }, - { name = "mashumaro", extra = ["msgpack"] }, - { name = "networkx" }, - { name = "packaging" }, - { name = "pathspec" }, - { name = "protobuf" }, - { name = "pydantic" }, - { name = "pytz" }, - { name = "pyyaml" }, + { name = "numpy" }, { name = "requests" }, - { name = "snowplow-tracker" }, - { name = "sqlparse" }, - { name = "typing-extensions" }, + { name = "xyzservices" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/76/84a1b1b00ce71f9c0c44af7d80f310c02e2e583591fe7d4cb03baecd0d3f/folium-0.20.0.tar.gz", hash = "sha256:a0d78b9d5a36ba7589ca9aedbd433e84e9fcab79cd6ac213adbcff922e454cb9", size = 109932, upload-time = "2025-06-16T20:22:51.803Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/a8/5f764f333204db0390362a4356d03a43626997f26818a0e9396f1b3bd8c9/folium-0.20.0-py2.py3-none-any.whl", hash = "sha256:f0bc2a92acde20bca56367aa5c1c376c433f450608d058daebab2fc9bf8198bf", size = 113394, upload-time = "2025-06-16T20:22:50.318Z" }, +] + +[[package]] +name = "fonttools" +version = "4.63.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" }, + { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd", size = 2876062, upload-time = "2026-05-14T12:03:32.554Z" }, + { url = "https://files.pythonhosted.org/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59", size = 2424594, upload-time = "2026-05-14T12:03:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840, upload-time = "2026-05-14T12:03:36.745Z" }, + { url = "https://files.pythonhosted.org/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801, upload-time = "2026-05-14T12:03:38.833Z" }, + { url = "https://files.pythonhosted.org/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009, upload-time = "2026-05-14T12:03:41.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892, upload-time = "2026-05-14T12:03:43.525Z" }, + { url = "https://files.pythonhosted.org/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380", size = 2291313, upload-time = "2026-05-14T12:03:45.594Z" }, + { url = "https://files.pythonhosted.org/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b", size = 2342299, upload-time = "2026-05-14T12:03:47.414Z" }, + { url = "https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745", size = 2875338, upload-time = "2026-05-14T12:03:50.052Z" }, + { url = "https://files.pythonhosted.org/packages/cd/58/7dfa0c761cb3b2964e2a84c4dc986c926a87de0cb9fb60d5b28ded3f2914/fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03", size = 2422661, upload-time = "2026-05-14T12:03:52.154Z" }, + { url = "https://files.pythonhosted.org/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49", size = 5010526, upload-time = "2026-05-14T12:03:54.647Z" }, + { url = "https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b", size = 4923946, upload-time = "2026-05-14T12:03:56.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6", size = 4962489, upload-time = "2026-05-14T12:03:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4", size = 5071870, upload-time = "2026-05-14T12:04:02.122Z" }, + { url = "https://files.pythonhosted.org/packages/de/40/3ae22b60ff1d41ce0bd044b31238cdc72cef99f28b976f1e128ebd618c9b/fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616", size = 2295026, upload-time = "2026-05-14T12:04:04.47Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5", size = 2347454, upload-time = "2026-05-14T12:04:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/49/4e/652d1580c5f4e39f7d103b0c793e4773129ad633dce4addd0cf4dfebde02/fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001", size = 2958152, upload-time = "2026-05-14T12:04:08.706Z" }, + { url = "https://files.pythonhosted.org/packages/0e/55/ad864c9a9b219f552eb46b32cd7906c466e5a578ba0c3abfcc0fe7413eb6/fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e", size = 2460809, upload-time = "2026-05-14T12:04:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096", size = 5148649, upload-time = "2026-05-14T12:04:12.747Z" }, + { url = "https://files.pythonhosted.org/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f", size = 4932147, upload-time = "2026-05-14T12:04:14.806Z" }, + { url = "https://files.pythonhosted.org/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40", size = 5027237, upload-time = "2026-05-14T12:04:17.675Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196", size = 5053933, upload-time = "2026-05-14T12:04:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/34/3b/214dcc19ee31d3d38fb5ad2755c11ef0514e5dc300bbaf41c0b69f393799/fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8", size = 2359326, upload-time = "2026-05-14T12:04:24.22Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/3ff1a9b523058c2eeb6a9d50f5574e2a738200d0d94107d5bc4105e8da3f/fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419", size = 2425829, upload-time = "2026-05-14T12:04:26.829Z" }, + { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, +] + +[[package]] +name = "fqdn" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/3e/a80a8c077fd798951169626cde3e239adeba7dab75deb3555716415bd9b0/fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f", size = 6015, upload-time = "2021-03-11T07:16:29.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121, upload-time = "2021-03-11T07:16:28.351Z" }, +] + +[[package]] +name = "graphviz" +version = "0.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/b3/3ac91e9be6b761a4b30d66ff165e54439dcd48b83f4e20d644867215f6ca/graphviz-0.21.tar.gz", hash = "sha256:20743e7183be82aaaa8ad6c93f8893c923bd6658a04c32ee115edb3c8a835f78", size = 200434, upload-time = "2025-06-15T09:35:05.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl", hash = "sha256:54f33de9f4f911d7e84e4191749cac8cc5653f815b06738c54db9a15ab8b1e42", size = 47300, upload-time = "2025-06-15T09:35:04.433Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585, upload-time = "2026-08-10T15:09:36.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/7e/9ecd0285e3153532ae07aeb88063c43c72b4221cf0d4d123b02f3682e3ff/greenlet-3.5.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:49520f0c95a48b42cf55414b8e8479beb274ea70431afc33e3f79903c71f4380", size = 295809, upload-time = "2026-08-10T13:25:34.023Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/60e4bbcc89252037b18087f2ec16405d5b2d5be42dde191bbf3667e96102/greenlet-3.5.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55272212cbc5f43d1d723725ab931f1939969b7e9523882ca58b55061769d053", size = 611910, upload-time = "2026-08-10T14:14:35.18Z" }, + { url = "https://files.pythonhosted.org/packages/a4/17/cd5134be659cd4a443e7a61ae670dabec165a814c51162916d637b6dd38e/greenlet-3.5.5-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655bca754a2ef4efcb0eb48a94d3f4593536d0f3d48f8ed44343c01d16a92f95", size = 624198, upload-time = "2026-08-10T14:27:25.229Z" }, + { url = "https://files.pythonhosted.org/packages/78/ac/5c5b959999b6f09c3026b5dfe171575bc3121c5236ce74f495096f25b203/greenlet-3.5.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:147b25a42e5ca5be3d42356e8f608b37af715a1c196e9bf9d1627f3341adfe1d", size = 621439, upload-time = "2026-08-10T13:40:49.391Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8b/6acf112ed8aee499f25b4d6949820fb02ac950ff9c1f3d793bd5be0599f2/greenlet-3.5.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:27493374cff1d1b7919dc8126547f2aea582737e3046147b434b1e12de56389b", size = 1581342, upload-time = "2026-08-10T14:15:05.653Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/734e5f198888876b42d7616ff6644c075baf6b8a2412deadd6b0e1b8b20c/greenlet-3.5.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:12e2ee66c2aba86133f10fd99d6a8856c6d351ffb7be0e4d52ef2cc5fbb705b2", size = 1645744, upload-time = "2026-08-10T13:40:30.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/30/1f42b88dc587b5899ee50616ad56ee40cafaf225df4fb829f10183c62a5c/greenlet-3.5.5-cp312-cp312-win_amd64.whl", hash = "sha256:49ddacd36af37735fab103846f4ee4d18a492dde72730d1699c0c8ebe30d9f18", size = 324171, upload-time = "2026-08-10T13:28:44.472Z" }, + { url = "https://files.pythonhosted.org/packages/76/e5/4dee4d8d2e603fe5fdd7b444e63219f7b9bd852c60c6214511c7157cbe88/greenlet-3.5.5-cp312-cp312-win_arm64.whl", hash = "sha256:5f1b1ff4828cdc1aba4266aff814085d04a1d07959287219af021b838b265d52", size = 308362, upload-time = "2026-08-10T13:26:46.839Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3d/8cef5f724ec0d4add2af8961d504535ec60c3cca9e464f6d03bdba29d85b/greenlet-3.5.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b79fd2a5bc099b5e744f34c4c9a58954a5f4cb7529fb4b6e8446057d61b6edaa", size = 294730, upload-time = "2026-08-10T13:27:51.206Z" }, + { url = "https://files.pythonhosted.org/packages/88/4b/8e7aa3f514273aecff30a16ab1bac09ff54cfc7e6860fdd8058c37ff2499/greenlet-3.5.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:634cf15a233a949136879dd388e25d3296e16f3f1e217d2456797b8579ebc6ed", size = 614536, upload-time = "2026-08-10T14:14:36.589Z" }, + { url = "https://files.pythonhosted.org/packages/85/48/4e95e9dd5a8a397dc6a6345dd7f1935113d0fca4f85e89d3976da9cd988d/greenlet-3.5.5-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:499adea519f748407fc6806d20eedabac2884fd73b9f38d81236e190ba20dfef", size = 626924, upload-time = "2026-08-10T14:27:27.048Z" }, + { url = "https://files.pythonhosted.org/packages/89/5d/398a1c71fa7a277deeb376c999979de6786f08fc2d5747a0b9d6e11738dd/greenlet-3.5.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2eabb980975cba5b93a95f6f69287d05fc05ac955bfd6a320a7c083eeb52c0b0", size = 623906, upload-time = "2026-08-10T13:40:50.501Z" }, + { url = "https://files.pythonhosted.org/packages/04/1b/745450fc5ea9e0cb17d840d248f284db3363de736d362c7d2d883e3eadba/greenlet-3.5.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537", size = 1581430, upload-time = "2026-08-10T14:15:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/d4/29/d51b296e3191bb15d3d81ec375af1909e4466c0f395d744ed475801798a9/greenlet-3.5.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4441153ffba21b90d3ca89fe3d31f5c093ae6c0bf0cfdfc98f54cde22f95b62e", size = 1645684, upload-time = "2026-08-10T13:40:32.133Z" }, + { url = "https://files.pythonhosted.org/packages/12/63/369f1a1625e64e9e31df3963c6044056e3fdfa3fa3fdba3c54ffefa6e987/greenlet-3.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:95c5b1f4b3a193f8a0c2de4bfdcb48d119f7f1063941f1de1f2168051b3e52dd", size = 324075, upload-time = "2026-08-10T13:26:58.974Z" }, + { url = "https://files.pythonhosted.org/packages/45/78/649cb5c09d4d81f6dd1444e75474a7206784743283a21d24171562ac4899/greenlet-3.5.5-cp313-cp313-win_arm64.whl", hash = "sha256:1af90aa4bc129883b340cdd6957a3bc74f60528a4993bbd1f53aaebe1d9981cc", size = 308260, upload-time = "2026-08-10T13:27:50.795Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8c/080e881fa2be95ff1ddbd6994b2bab3b1a78df3b3fcab39306011764fcc7/greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d", size = 295309, upload-time = "2026-08-10T13:26:03.032Z" }, + { url = "https://files.pythonhosted.org/packages/25/cc/0ac614e6586c0e42d4cc281a5819150f4f43685744a4c5ff77139286409d/greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328", size = 661185, upload-time = "2026-08-10T14:14:37.867Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b9/6808725354be8ad305dfe5172377664fc9642d4fc043be246b3314cf4482/greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926", size = 673419, upload-time = "2026-08-10T14:27:28.652Z" }, + { url = "https://files.pythonhosted.org/packages/42/2e/40c509967da7f254680826a2fa0dd22138ec79946c70b97542d74cde8b43/greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e", size = 670822, upload-time = "2026-08-10T13:40:51.833Z" }, + { url = "https://files.pythonhosted.org/packages/2d/22/c3c2eee4a8fe191d6d1d183086c56133d646024e3d70bfd414829f64560b/greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc", size = 1628469, upload-time = "2026-08-10T14:15:08.11Z" }, + { url = "https://files.pythonhosted.org/packages/f7/87/25babd09b94cb1f03e71db815fde463f0262e40cfbd953d58a8d77311351/greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9", size = 1691952, upload-time = "2026-08-10T13:40:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/5cc9701117ea4dc0eb7bf1f4f9b7888a6e2e5277ddfae095805ace50f2b6/greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1", size = 327458, upload-time = "2026-08-10T13:27:02.868Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6b/594fa2de7fae7629168a404a4305d7d7e31a5742c50a801b1839543cb93d/greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07", size = 311146, upload-time = "2026-08-10T13:27:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/24/e0/50cd600b469e5734c72709b6b1838b6bc63f307b573c772c3132d6ecfe92/greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277", size = 305471, upload-time = "2026-08-10T13:26:20.568Z" }, + { url = "https://files.pythonhosted.org/packages/75/a3/77acd66dfc6387b5219b2080806c0cabb73c10eb1bb44b413c40a62015ba/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b", size = 672470, upload-time = "2026-08-10T14:14:39.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/71/0d178142dca3ec19f46fb2212ae73d30ad53b9d548dc64804086033a7089/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272", size = 679973, upload-time = "2026-08-10T14:27:30.072Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/46eb8567302eaf787abf88d09df014e14ae3baf460af1b8b0efdbd3efcd5/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476", size = 676634, upload-time = "2026-08-10T13:40:53.004Z" }, + { url = "https://files.pythonhosted.org/packages/a3/e9/b88bbf5b29970cb84172dc2c32aa3e5e579ceb94c808e81c826454138850/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874", size = 1637320, upload-time = "2026-08-10T14:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/6d/8c/7631ed29cc6f0392f11830076e172ce4885e70b0bc2c1bce1731176d4b4e/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71", size = 1697412, upload-time = "2026-08-10T13:40:34.924Z" }, + { url = "https://files.pythonhosted.org/packages/da/0f/f7dd935f9c4cb1be49098770587f54d8a78518e55c89bce86c4fb4109057/greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0", size = 331514, upload-time = "2026-08-10T13:29:20.611Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e5/681b01f8fbc1b55232822f99e8f8afeb78a55a7c76a7bf9dbdc7ccb03a6d/greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206", size = 295975, upload-time = "2026-08-10T13:28:45.985Z" }, + { url = "https://files.pythonhosted.org/packages/11/f2/69b488cd9e7267bf4b0fe8cdebf25d8d6df680d21bdf41150d23e23d6652/greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad", size = 666823, upload-time = "2026-08-10T14:14:40.222Z" }, + { url = "https://files.pythonhosted.org/packages/84/d4/d5bc2fdebbdda0c94555925ba79948b8395d75a7f6a36cc85dce5bab9f11/greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0", size = 677613, upload-time = "2026-08-10T14:27:31.543Z" }, + { url = "https://files.pythonhosted.org/packages/bd/93/542d8a3a90f3b35c6ad8bf7e56a03010287f2cafa289a5b7985b5207db39/greenlet-3.5.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552", size = 675930, upload-time = "2026-08-10T13:40:54.205Z" }, + { url = "https://files.pythonhosted.org/packages/52/b5/89c9f2e8460d71101037d47a1feed11928615a5edd42370be290e0657eeb/greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007", size = 1633878, upload-time = "2026-08-10T14:15:10.693Z" }, + { url = "https://files.pythonhosted.org/packages/b8/60/297de93f3b02ac78a5e04d32bb8bbe3080f4a73d8ed95016561463b70618/greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773", size = 1696597, upload-time = "2026-08-10T13:40:36.252Z" }, + { url = "https://files.pythonhosted.org/packages/18/25/54c6eaff4f337fb670215e89eb2d00d9499487b658e709d4b477be4a342e/greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e", size = 327700, upload-time = "2026-08-10T13:28:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/67/67/857e88a36301caa0e029870132c2478bd55d896630321432afab03a3115f/greenlet-3.5.5-cp315-cp315-win_arm64.whl", hash = "sha256:2d57406c3efd32d7a81e17a674314e8bd00792cdab49ea3228a49aa1bfb2e769", size = 311750, upload-time = "2026-08-10T13:34:08.815Z" }, + { url = "https://files.pythonhosted.org/packages/10/e2/3144c0a116067ac1e30457b0139a94d60d1d36a86e015de68e9ac87cb3bc/greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c", size = 306387, upload-time = "2026-08-10T13:27:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a1/cb4223a7e9b9f43b8807e8eb212358bfe2dfaa174a9ea2889eb1714dcba2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6", size = 676472, upload-time = "2026-08-10T14:14:41.417Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cd/a154b4498e5d8f12ada291cfb3b8d596eadde2177f5bf09a9be699d2a446/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae", size = 684238, upload-time = "2026-08-10T14:27:32.946Z" }, + { url = "https://files.pythonhosted.org/packages/bf/bb/b0031d260c2968a3c87deebc51d80c64e499377f993aafe06ee3b7488cc2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3", size = 681246, upload-time = "2026-08-10T13:40:55.402Z" }, + { url = "https://files.pythonhosted.org/packages/9a/07/da554b71ab88e649da146e1065d86a48a5c5d92e50ab74ef41b504aa7f56/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0", size = 1642735, upload-time = "2026-08-10T14:15:11.92Z" }, + { url = "https://files.pythonhosted.org/packages/78/76/26a3782a051677668af9d92beaa47cd87ba9dd5072f762961144a03dd4c6/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5", size = 1700925, upload-time = "2026-08-10T13:40:37.656Z" }, + { url = "https://files.pythonhosted.org/packages/28/d9/fe7baf4190c2ae71f267efb9de21b3172bb35bc0ed1ef53dd6027d658e33/greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8", size = 331829, upload-time = "2026-08-10T13:26:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/df/af/419a4e383bd600858a9b67e9b280a60fdc383ee3f2fe5b6c0c1ef04e74d1/greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b", size = 315093, upload-time = "2026-08-10T13:29:34.949Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ae/26/ab9584f57f8a5561693e909f15cbe109d07584defbd2a910efc003bad0ae/dbt_core-1.11.12.tar.gz", hash = "sha256:108ced0a65d1f5330e86920df5c7182f79cd6ddcedf8f2d73c7928a310751ae8", size = 973664, upload-time = "2026-07-01T13:17:45.514Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/60/c8/9f958ad09b2cab584e081058d743ef031350fba306e85ef5d4fc3425b40d/dbt_core-1.11.12-py3-none-any.whl", hash = "sha256:3b7760a3760a6db8a14a6ef38fb86532b2c2b150d49beaa1feb0f50170baa86e", size = 1062483, upload-time = "2026-07-01T13:17:43.867Z" }, + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] [[package]] -name = "dbt-extractor" -version = "0.6.0" +name = "httptools" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/06/1f7b5d277af4bd7c3ab5065f79407c46a73950f0879fac69e51067c87649/dbt_extractor-0.6.0.tar.gz", hash = "sha256:d6cf08ec793b8bc2bd6e260ef818230ae68a4f71436fa489f08d7db1a52e2ffe", size = 270461, upload-time = "2025-04-07T16:46:30.532Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/dd/ec8f9e48e7dd5a52a69cca7907681d1779cf1cc8b02f2aa2acb6a2bf8bb4/dbt_extractor-0.6.0-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:4b6b1e70dde78cb904ca7a8958c2c803e77779b6ce108f4ea7ac479f5700db89", size = 790206, upload-time = "2025-04-07T16:46:05.352Z" }, - { url = "https://files.pythonhosted.org/packages/03/5f/233f326336aa21fbd9e7268f239a8464af145abd398a360d894c3286699d/dbt_extractor-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:dcf14ed245de8df269815ff4c4f555fa72d2621f4fff37c023b8c99d0e421b4f", size = 404381, upload-time = "2025-04-07T16:46:07.471Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2a/e14c13b9a437780c5712525ce537915b531bba45481fc7102deb4492ff83/dbt_extractor-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:af451633390ac19669d3bde6c79822e657d32f5d903b3388bb00d56333fd52d5", size = 435109, upload-time = "2025-04-07T16:46:09.443Z" }, - { url = "https://files.pythonhosted.org/packages/58/2e/1ef1cd2b36973bea0a6823a7b7cd1b3db29b61ddebb015ceaea88b9e9347/dbt_extractor-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:05bcfab7ebd70296ceb31742e8333ba66a2c939de44e61a7088bebafa939aaf6", size = 434550, upload-time = "2025-04-07T16:46:10.916Z" }, - { url = "https://files.pythonhosted.org/packages/40/5a/468a2855181aaee5402efbf9ef757d074cd306eec22bbcd267cdd0edbe94/dbt_extractor-0.6.0-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:71b3f8897138cc6698d313b9a3d0450fd021937ff5463269ee18ed415541781b", size = 470137, upload-time = "2025-04-07T16:46:12.36Z" }, - { url = "https://files.pythonhosted.org/packages/b2/18/611dceb2fa7ea668471f290f34fec55fa3283e3ee9d0475d964e6ffaff97/dbt_extractor-0.6.0-cp39-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:868af715a6328d7317ce6e4db238f850f660fef13fb36b7ab4cf9163ed5f54ff", size = 524331, upload-time = "2025-04-07T16:46:14.177Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ad/9dd410d4d95e336ae6b10c53c939bf1ff8e9991e1adb5ea4aefc4a87c445/dbt_extractor-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c1fd2b083a75e80b13e9874dc9699bfdfddf3baa9b6a8dea48de06d51a082733", size = 517959, upload-time = "2025-04-07T16:46:15.68Z" }, - { url = "https://files.pythonhosted.org/packages/a4/4f/6994cdfb51c5652fad0c8f9cf5b3ec1816cb10e99ed145eb27e6a9bcc16b/dbt_extractor-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:311f0d3a4994751c541a4fa303d205727ba90e90c85286c03d3d9284e2bf0bd4", size = 494850, upload-time = "2025-04-07T16:46:17.265Z" }, - { url = "https://files.pythonhosted.org/packages/df/5e/fad01e18d68ffd09c0f39cdedeed8fcaaea74a8b46d1a944472b5f95b72b/dbt_extractor-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aecfa43f7e6f139e76d47e4e1d7b189655ae19a8cf697686230bacb89a94ae74", size = 442739, upload-time = "2025-04-07T16:46:19.002Z" }, - { url = "https://files.pythonhosted.org/packages/9d/82/49068ee2b9f38aa34d0f3196bb7b71d11af86630d5ed5cb6626108c97cd6/dbt_extractor-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a5cb810edc60c0486f78cc29739ebda70c81b10a1686861e78addc9f91fcd7de", size = 618014, upload-time = "2025-04-07T16:46:21.571Z" }, - { url = "https://files.pythonhosted.org/packages/18/c6/cdaf1ac8959d571b5cb3587b8afef9e5fe60b99fe59aca94560808501d8b/dbt_extractor-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:080fd1edf123926ed97929c65a75874d0fea687ccd5d3ebbc9e81b339f099604", size = 697290, upload-time = "2025-04-07T16:46:23.089Z" }, - { url = "https://files.pythonhosted.org/packages/94/6d/46bdb9a809c66784fcc19b853311568cfd3041c075f0a578cb7116686841/dbt_extractor-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:1b9ed7b15df983a735f87773f6765db8458680c02fcebbf89df4e238503c0e08", size = 644443, upload-time = "2025-04-07T16:46:24.463Z" }, - { url = "https://files.pythonhosted.org/packages/3b/02/b111856273e414ac80ef58d2103c9b7c6a5b29b1ec248999d3d5873ada00/dbt_extractor-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:caeaba8d8c813f8e32d586c12615c0c7d6b99bee4f1be845312e80ef731de164", size = 613017, upload-time = "2025-04-07T16:46:25.913Z" }, - { url = "https://files.pythonhosted.org/packages/c4/de/d1492ab6beaf0a18aee17c7a9562592ac2981e962b4058262f5eb6dabfc5/dbt_extractor-0.6.0-cp39-abi3-win32.whl", hash = "sha256:369dcc3499f160256756585783f1308868076d5a65d0a051348d22da8b90e67d", size = 252721, upload-time = "2025-04-07T16:46:27.295Z" }, - { url = "https://files.pythonhosted.org/packages/60/36/f5b1c4159fa911607f3a49fcbc535e4783870fd887bc0a1b3ad42587cb73/dbt_extractor-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:a79a570fdcb672505ac2bdc12360a2a7aec622ef604d8c607225854ff862518c", size = 277146, upload-time = "2025-04-07T16:46:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, ] [[package]] -name = "dbt-postgres" -version = "1.10.2" +name = "httpx" +version = "0.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "agate" }, - { name = "dbt-adapters" }, - { name = "dbt-common" }, - { name = "dbt-core" }, - { name = "psycopg2-binary" }, + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/61/4b609911d827b9cb8a4b9647579cc6de0e02684fb2d9dce18213c9733628/dbt_postgres-1.10.2.tar.gz", hash = "sha256:83b6b03572452d74bf3b60ef2578c7172d6e5ff4c285c876cd51aa9ed09be4c4", size = 157388, upload-time = "2026-06-24T06:48:13.777Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/3a/32a00d796492ffcaa022eda9decf1f7366bce210715087fd985415b2fd94/dbt_postgres-1.10.2-py3-none-any.whl", hash = "sha256:1367386caa99ca59f8228eae6d0e03f9537f9615f55a5ba5cc80a5b06aa6d32e", size = 35517, upload-time = "2026-06-24T06:48:12.538Z" }, + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] [[package]] -name = "dbt-protos" -version = "1.0.541" +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "ipykernel" +version = "7.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "protobuf" }, + { name = "appnope", marker = "sys_platform == 'darwin'" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio2" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/64/e8/7d67eb52bf39834884fdd02d7d952652b1859ff422bbdbbf9c82f85862f3/dbt_protos-1.0.541.tar.gz", hash = "sha256:c1900f543c1b170bc8e065209537d9b5a8fa5577bc12e06ea26c41f1a4ec7ecc", size = 169206, upload-time = "2026-07-07T11:21:18.8Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/c4/e4a38f579de4225a561305666f7541cdabb30075def2aa1ac17bd73c1fb5/ipykernel-7.3.0.tar.gz", hash = "sha256:9acaaaf97d16355166e4085afe9d225bfbdf2b7ef520f9df3be8f2b248275e09", size = 184899, upload-time = "2026-06-10T08:41:25.481Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/23/a35738c4a934b314c42d90ed3c681503f4015c5301182e4b4b0c40a1af2e/dbt_protos-1.0.541-py3-none-any.whl", hash = "sha256:4e03f88e6b5c13a8cac68c6aa78267d8eb4396e6341a807112d619ab938318d5", size = 241824, upload-time = "2026-07-07T11:21:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/3d/02/77b271f5dc58bfbc0b577c877b2365d1ffea2afe66a80c13f2312820348c/ipykernel-7.3.0-py3-none-any.whl", hash = "sha256:897eb64da762549ef610698fca5e9675195ec6ac8ec7f19d81ce1ca20c876057", size = 120583, upload-time = "2026-06-10T08:41:23.648Z" }, ] [[package]] -name = "dbt-semantic-interfaces" -version = "0.9.0" +name = "ipython" +version = "9.16.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, - { name = "importlib-metadata" }, - { name = "jinja2" }, - { name = "jsonschema" }, - { name = "more-itertools" }, - { name = "pydantic" }, - { name = "python-dateutil" }, - { name = "pyyaml" }, - { name = "typing-extensions" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "psutil", marker = "sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b0/91/c702d8fb143541fda10f5eb7a7a89f34bda38ee043ecb3e3653363d0c5a0/dbt_semantic_interfaces-0.9.0.tar.gz", hash = "sha256:5c921257dce8bb51c9ffb5479f2bdd959e16ebfb98ee833de6daa70788c47271", size = 93865, upload-time = "2025-07-09T20:06:30.454Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/96/b150fe7e25a5a29ae9ac1374e71488639605d39a1ea4abb74c9ce33af235/ipython-9.16.1.tar.gz", hash = "sha256:5a3d1f9a47ff216d6cf9cf863124f6a2c1a198d1354c546a4d24a370a283b64c", size = 4515302, upload-time = "2026-08-03T08:36:15.571Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/82/41708b2b69d5fead88dea5ca0d863d6291da83ca6f1bd19246842d397e2b/dbt_semantic_interfaces-0.9.0-py3-none-any.whl", hash = "sha256:1b54c06ba89190a47a7f0563360930a0cce869e55b484ca09d261ade0e319155", size = 147008, upload-time = "2025-07-09T20:06:32.466Z" }, + { url = "https://files.pythonhosted.org/packages/bc/8e/1239df488393d61076653bfb29f759d0f60cab8e030abdf7c17c31539b51/ipython-9.16.1-py3-none-any.whl", hash = "sha256:4acae635506f6d352d94c4899a19d5f85f8bc4d230932342dca556fdab1c69b4", size = 625974, upload-time = "2026-08-03T08:36:13.654Z" }, ] [[package]] -name = "deepdiff" -version = "8.6.2" +name = "ipython-pygments-lexers" +version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "orderly-set" }, + { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/89/50/767448e792d41bfb6094ee317a355c1cb221dca24b2e178e2203bbea2a77/deepdiff-8.6.2.tar.gz", hash = "sha256:186dcbd181e4d76cef11ab05f802d0056c5d6083c5a6748c1473e9d7481e183e", size = 634860, upload-time = "2026-03-18T17:16:33.785Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/5f/c52bd1255db763d0cdcb7084d2e90c42119cb229302c56bdf1d0aa78abd2/deepdiff-8.6.2-py3-none-any.whl", hash = "sha256:4d22034a866c3928303a9332c279362f714192d9305bac17c498720d095fd1b4", size = 91979, upload-time = "2026-03-18T17:16:32.171Z" }, + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, ] [[package]] -name = "idna" -version = "3.18" +name = "ipywidgets" +version = "8.1.8" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +dependencies = [ + { name = "comm" }, + { name = "ipython" }, + { name = "jupyterlab-widgets" }, + { name = "traitlets" }, + { name = "widgetsnbextension" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/ae/c5ce1edc1afe042eadb445e95b0671b03cee61895264357956e61c0d2ac0/ipywidgets-8.1.8.tar.gz", hash = "sha256:61f969306b95f85fba6b6986b7fe45d73124d1d9e3023a8068710d47a22ea668", size = 116739, upload-time = "2025-11-01T21:18:12.393Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, + { url = "https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl", hash = "sha256:ecaca67aed704a338f88f67b1181b58f821ab5dc89c1f0f5ef99db43c1c2921e", size = 139808, upload-time = "2025-11-01T21:18:10.956Z" }, ] [[package]] -name = "importlib-metadata" -version = "8.9.0" +name = "isoduration" +version = "20.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp" }, + { name = "arrow" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e7/72/c600ae4f68c28fc19f9c31b9403053e5dbb8cace2e6842c7b7c3e4d42fe9/importlib_metadata-8.9.0.tar.gz", hash = "sha256:58850626cef4bd2df100378b0f2aea9724a7b92f10770d547725b047078f99ee", size = 56140, upload-time = "2026-03-20T16:56:26.362Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/1a/3c8edc664e06e6bd06cce40c6b22da5f1429aa4224d0c590f3be21c91ead/isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9", size = 11649, upload-time = "2020-11-01T11:00:00.312Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/f9/97f2ca8bb3ec6e4b1d64f983ebe98b9a192faddff67fac3d6303a537e670/importlib_metadata-8.9.0-py3-none-any.whl", hash = "sha256:e0f761b6ea91ced3b0844c14c9d955224d538105921f8e6754c00f6ca79fba7f", size = 27220, upload-time = "2026-03-20T16:56:25.07Z" }, + { url = "https://files.pythonhosted.org/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042", size = 11321, upload-time = "2020-11-01T10:59:58.02Z" }, ] [[package]] -name = "iniconfig" -version = "2.3.0" +name = "itsdangerous" +version = "2.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, ] [[package]] -name = "isodate" -version = "0.7.2" +name = "jedi" +version = "0.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, + { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" }, ] [[package]] @@ -356,6 +972,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + +[[package]] +name = "json5" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/7d/05c46a96a78147ae3bf99c2f4169ce144a70220b8d6fcd56f6ec368b8ce9/json5-0.15.0.tar.gz", hash = "sha256:7424d1f1eb1d56da6e3d70643f53619862b4ce81440bdb8ecfd6f875e5ba4a71", size = 53278, upload-time = "2026-06-19T20:08:27.716Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/be/59527c99478aade6bb33a68d72e6e18dd4e6ff6eacfc7d01bdb15bc76912/json5-0.15.0-py3-none-any.whl", hash = "sha256:56636a30c0e8a4665fe2179c0212f32eae3796dea89ea6f649b9436ecdb39618", size = 36570, upload-time = "2026-06-19T20:08:26.748Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, +] + [[package]] name = "jsonschema" version = "4.26.0" @@ -371,6 +1014,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, ] +[package.optional-dependencies] +format-nongpl = [ + { name = "fqdn" }, + { name = "idna" }, + { name = "isoduration" }, + { name = "jsonpointer" }, + { name = "rfc3339-validator" }, + { name = "rfc3986-validator" }, + { name = "rfc3987-syntax" }, + { name = "uri-template" }, + { name = "webcolors" }, +] + [[package]] name = "jsonschema-specifications" version = "2025.9.1" @@ -384,12 +1040,340 @@ wheels = [ ] [[package]] -name = "leather" -version = "0.4.1" +name = "jupyter" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ipykernel" }, + { name = "ipywidgets" }, + { name = "jupyter-console" }, + { name = "jupyterlab" }, + { name = "nbconvert" }, + { name = "notebook" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/f3/af28ea964ab8bc1e472dba2e82627d36d470c51f5cd38c37502eeffaa25e/jupyter-1.1.1.tar.gz", hash = "sha256:d55467bceabdea49d7e3624af7e33d59c37fff53ed3a350e1ac957bed731de7a", size = 5714959, upload-time = "2024-08-30T07:15:48.299Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/64/285f20a31679bf547b75602702f7800e74dbabae36ef324f716c02804753/jupyter-1.1.1-py2.py3-none-any.whl", hash = "sha256:7a59533c22af65439b24bbe60373a4e95af8f16ac65a6c00820ad378e3f7cc83", size = 2657, upload-time = "2024-08-30T07:15:47.045Z" }, +] + +[[package]] +name = "jupyter-builder" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/e1/e4ef07e2be228271b59e011a746d97feef69577af1f465b1cff0f1d7c760/jupyter_builder-1.2.2.tar.gz", hash = "sha256:b6cea88f58e44b2c5eba96f28d2e0d16fd453d3ca6dc9c4492ff8a1f2e97f601", size = 981074, upload-time = "2026-08-07T06:47:18.742Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/3b/920bc7f3c2ad25abc0071ae7ae55789f4b85ecf3e4467f0602a88dc668cb/jupyter_builder-1.2.2-py3-none-any.whl", hash = "sha256:6ebcd4c49daf5df6a18068a74a48010406700ed90a76c189fac43eaf85c60c63", size = 915266, upload-time = "2026-08-07T06:47:16.249Z" }, +] + +[[package]] +name = "jupyter-client" +version = "8.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "python-dateutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/dc/5512503b088997c2250b8bf18258fba9d9ce5ead641183700960d3c9d342/jupyter_client-8.9.1.tar.gz", hash = "sha256:a58f730dd9e728ba16ba1d62ebccf7ffe1ebbdbce4e95cfae941b7321ae1f4fa", size = 359256, upload-time = "2026-06-09T13:15:01.033Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/6f/56d39bf385c5c27988aebaf0c18a2a17e960575740100973511018bd904e/jupyter_client-8.9.1-py3-none-any.whl", hash = "sha256:0b7a295bc46e8751e9adae84781f726c851c1d911bd793edc4a3bde942e3da81", size = 109828, upload-time = "2026-06-09T13:14:58.835Z" }, +] + +[[package]] +name = "jupyter-console" +version = "6.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ipykernel" }, + { name = "ipython" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "pyzmq" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/2d/e2fd31e2fc41c14e2bcb6c976ab732597e907523f6b2420305f9fc7fdbdb/jupyter_console-6.6.3.tar.gz", hash = "sha256:566a4bf31c87adbfadf22cdf846e3069b59a71ed5da71d6ba4d8aaad14a53539", size = 34363, upload-time = "2023-03-06T14:13:31.02Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/77/71d78d58f15c22db16328a476426f7ac4a60d3a5a7ba3b9627ee2f7903d4/jupyter_console-6.6.3-py3-none-any.whl", hash = "sha256:309d33409fcc92ffdad25f0bcdf9a4a9daa61b6f341177570fdac03de5352485", size = 24510, upload-time = "2023-03-06T14:13:28.229Z" }, +] + +[[package]] +name = "jupyter-core" +version = "5.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, +] + +[[package]] +name = "jupyter-events" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonschema", extra = ["format-nongpl"] }, + { name = "packaging" }, + { name = "python-json-logger" }, + { name = "pyyaml" }, + { name = "referencing" }, + { name = "rfc3339-validator" }, + { name = "rfc3986-validator" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/f8/475c4241b2b75af0deaae453ed003c6c851766dbc44d332d8baf245dc931/jupyter_events-0.12.1.tar.gz", hash = "sha256:faff25f77218335752f35f23c5fe6e4a392a7bd99a5939ccb9b8fbf594636cf3", size = 62854, upload-time = "2026-04-20T23:17:50.66Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/6c/6fcde0c8f616ed360ffd3587f7db9e225a7e62b583a04494d2f069cf64ea/jupyter_events-0.12.1-py3-none-any.whl", hash = "sha256:c366585253f537a627da52fa7ca7410c5b5301fe893f511e7b077c2d93ec8bcf", size = 19512, upload-time = "2026-04-20T23:17:48.927Z" }, +] + +[[package]] +name = "jupyter-lsp" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-server" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/ff/1e4a61f5170a9a1d978f3ac3872449de6c01fc71eaf89657824c878b1549/jupyter_lsp-2.3.1.tar.gz", hash = "sha256:fdf8a4aa7d85813976d6e29e95e6a2c8f752701f926f2715305249a3829805a6", size = 55677, upload-time = "2026-04-02T08:10:06.749Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/e8/9d61dcbd1dce8ef418f06befd4ac084b4720429c26b0b1222bc218685eff/jupyter_lsp-2.3.1-py3-none-any.whl", hash = "sha256:71b954d834e85ff3096400554f2eefaf7fe37053036f9a782b0f7c5e42dadb81", size = 77513, upload-time = "2026-04-02T08:10:01.753Z" }, +] + +[[package]] +name = "jupyter-server" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "argon2-cffi" }, + { name = "jinja2" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "jupyter-events" }, + { name = "jupyter-server-terminals" }, + { name = "nbconvert" }, + { name = "nbformat" }, + { name = "packaging" }, + { name = "prometheus-client" }, + { name = "pywinpty", marker = "os_name == 'nt'" }, + { name = "pyzmq" }, + { name = "send2trash" }, + { name = "terminado" }, + { name = "tornado" }, + { name = "traitlets" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/dc/db3a582633170186f8c8b31298d7eb26ad0eb031a1f53476c258b64eed05/jupyter_server-2.20.0.tar.gz", hash = "sha256:b5778ba337d8015a3dc2b80803ecdd5ac18d3797fddf61a50ea5fb472b4ebe14", size = 756523, upload-time = "2026-06-17T12:09:09.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/71/8c002223e873a870f5c41dc69b0a7c922301123e4a31d5d01ecb700aef77/jupyter_server-2.20.0-py3-none-any.whl", hash = "sha256:c3b67c93c471e947c18b5026f04f21614218adb706df8f48227d3ee8e0a7cdcc", size = 393143, upload-time = "2026-06-17T12:09:07.234Z" }, +] + +[[package]] +name = "jupyter-server-terminals" +version = "0.5.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywinpty", marker = "os_name == 'nt'" }, + { name = "terminado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/a7/bcd0a9b0cbba88986fe944aaaf91bfda603e5a50bda8ed15123f381a3b2f/jupyter_server_terminals-0.5.4.tar.gz", hash = "sha256:bbda128ed41d0be9020349f9f1f2a4ab9952a73ed5f5ac9f1419794761fb87f5", size = 31770, upload-time = "2026-01-14T16:53:20.213Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/2d/6674563f71c6320841fc300911a55143925112a72a883e2ca71fba4c618d/jupyter_server_terminals-0.5.4-py3-none-any.whl", hash = "sha256:55be353fc74a80bc7f3b20e6be50a55a61cd525626f578dcb66a5708e2007d14", size = 13704, upload-time = "2026-01-14T16:53:18.738Z" }, +] + +[[package]] +name = "jupyterlab" +version = "4.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-lru" }, + { name = "httpx" }, + { name = "ipykernel" }, + { name = "jinja2" }, + { name = "jupyter-builder" }, + { name = "jupyter-core" }, + { name = "jupyter-lsp" }, + { name = "jupyter-server" }, + { name = "jupyterlab-server" }, + { name = "notebook-shim" }, + { name = "packaging" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/c0/45934229995d4c6e38192ca118d93185f60808f64157e3df3c5c28f8c5fd/jupyterlab-4.6.3.tar.gz", hash = "sha256:2e3db6e3a12495ebd188276e985bf5ac502fbde3d1e8628819920210008de498", size = 28319771, upload-time = "2026-08-10T18:50:57.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/47/242f46de028074651c9bd6d8000fc340ed0d3cdd1a0eae4387826123413a/jupyterlab-4.6.3-py3-none-any.whl", hash = "sha256:0a1ebc6567186f1eabd99536e94df7ed9e96d1e7c5ddf3e4406ae16e88abacb7", size = 17168312, upload-time = "2026-08-10T18:50:53.044Z" }, +] + +[[package]] +name = "jupyterlab-pygments" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/51/9187be60d989df97f5f0aba133fa54e7300f17616e065d1ada7d7646b6d6/jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d", size = 512900, upload-time = "2023-11-23T09:26:37.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780", size = 15884, upload-time = "2023-11-23T09:26:34.325Z" }, +] + +[[package]] +name = "jupyterlab-server" +version = "2.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "jinja2" }, + { name = "json5" }, + { name = "jsonschema" }, + { name = "jupyter-server" }, + { name = "packaging" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/2c/90153f189e421e93c4bb4f9e3f59802a1f01abd2ac5cf40b152d7f735232/jupyterlab_server-2.28.0.tar.gz", hash = "sha256:35baa81898b15f93573e2deca50d11ac0ae407ebb688299d3a5213265033712c", size = 76996, upload-time = "2025-10-22T13:59:18.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/07/a000fe835f76b7e1143242ab1122e6362ef1c03f23f83a045c38859c2ae0/jupyterlab_server-2.28.0-py3-none-any.whl", hash = "sha256:e4355b148fdcf34d312bbbc80f22467d6d20460e8b8736bf235577dd18506968", size = 59830, upload-time = "2025-10-22T13:59:16.767Z" }, +] + +[[package]] +name = "jupyterlab-widgets" +version = "3.0.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/2d/ef58fed122b268c69c0aa099da20bc67657cdfb2e222688d5731bd5b971d/jupyterlab_widgets-3.0.16.tar.gz", hash = "sha256:423da05071d55cf27a9e602216d35a3a65a3e41cdf9c5d3b643b814ce38c19e0", size = 897423, upload-time = "2025-11-01T21:11:29.724Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl", hash = "sha256:45fa36d9c6422cf2559198e4db481aa243c7a32d9926b500781c830c80f7ecf8", size = 914926, upload-time = "2025-11-01T21:11:28.008Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, + { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, + { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, + { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, + { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, + { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, + { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, + { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, + { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, + { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, + { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, + { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, + { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, + { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, + { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, + { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, + { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, +] + +[[package]] +name = "lark" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, +] + +[[package]] +name = "lightgbm" +version = "4.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "narwhals" }, + { name = "numpy" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/8e/4db5e29290d7e619c307fdb8dab0a0514090af2ce3ec483050e024ec6126/lightgbm-4.7.0.tar.gz", hash = "sha256:f8e20f682c9aabd000bcf4a7ed8aa6f473c1adfecccae34ec24e823d156f4af0", size = 1792896, upload-time = "2026-07-18T21:00:56.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/05/7213965863cba1ed0150ad045bceed6276a1afaaaedbaeff4699ec4f0ccb/lightgbm-4.7.0-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:dfc1cfe8e760387be1e7ba7a214688be21fdff96e4ed9749188f83e1877c2477", size = 1877851, upload-time = "2026-07-18T21:00:35.225Z" }, + { url = "https://files.pythonhosted.org/packages/b2/86/f4fe714f2e0bf3941705a20d7f6849dc476276d71236e82ea6b0d6539b86/lightgbm-4.7.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:129535462686f274df179133643118c5c5c5667167fe6c3a28d955f0b3c8e868", size = 1498914, upload-time = "2026-07-18T21:00:36.549Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/b29580948b92e8c2f84dea70118ac702ff067dc52ec4ffb5d73c953536a5/lightgbm-4.7.0-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4529acec5c6fefe4768302a529707d0ead90f6a6f42df694b856212e09695b8", size = 3349492, upload-time = "2026-07-18T21:00:37.943Z" }, + { url = "https://files.pythonhosted.org/packages/15/eb/837ea3b40cc36e22eeebb9785c01e42b2c255d033eea1d2d9ee8e2540e55/lightgbm-4.7.0-py3-none-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d23e922acd891e77212e4d0fbcee9ba973c96dee479491341d05ba595357ebb7", size = 3476028, upload-time = "2026-07-18T21:00:39.331Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0b/c5c17d862b12ce292f24cd85d40f2f8f8981668fbdbd43fdc2625eccbc79/lightgbm-4.7.0-py3-none-win_amd64.whl", hash = "sha256:f42d1e5b32b6f170e606d7c689c6165671da98d7bf37f1addec2623efc8740c9", size = 1360833, upload-time = "2026-07-18T21:00:40.865Z" }, +] + +[[package]] +name = "mako" +version = "1.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9e/09/849cf129d7eae1e42f873f2dbd60323267c738390b686a7384fb3fb289ad/leather-0.4.1.tar.gz", hash = "sha256:67119c2aee93be821f077193bd8534e296c05b38bd174d9c5a80c4aa31d1a4d3", size = 44072, upload-time = "2025-12-15T19:01:42.224Z" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/12/b5fa2353e2754cd67fb9f83793fa48ff42c213a5da7e719869d2301f6ab8/mako-1.4.1.tar.gz", hash = "sha256:d7904710b662996425a21627710c4777c45053146942cf8a7aebf757c92b8c27", size = 410165, upload-time = "2026-08-05T06:10:56.611Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/d4/c4dcb02ed11f8884e169b3350fc40aa4c08edf8bed77a8f0f267542e6452/leather-0.4.1-py3-none-any.whl", hash = "sha256:ec61cba1ca3ccb96ed90e38b116fc58757d97d352171006b3288c47ce3fbd183", size = 30340, upload-time = "2025-12-15T19:01:40.823Z" }, + { url = "https://files.pythonhosted.org/packages/a5/54/12ed58d458474aaab5c3d180173e745a4fe131bb330370596876d19ff60f/mako-1.4.1-py3-none-any.whl", hash = "sha256:a359d9a94a541213958742b2698d0a7757bb83551767bc468a74b9905aba9617", size = 80010, upload-time = "2026-08-05T06:10:58.248Z" }, ] [[package]] @@ -456,99 +1440,271 @@ wheels = [ ] [[package]] -name = "mashumaro" -version = "3.9" +name = "matplotlib" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/64/f9a391af28f518b11ad45a8a712353c94a0aefce09d3703200e5c54b610a/matplotlib-3.11.1.tar.gz", hash = "sha256:69647db5746941c793d6e445a4cd349323ffb87d9cc958c2ad84a659b4832d30", size = 32612045, upload-time = "2026-07-18T03:39:46.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/6c/7ef7ebcb2bd9739b2b66b18b076e077f44bb46fdbe28ca0506edb3c62c79/matplotlib-3.11.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e15ef41507f3d525f46154ac9e3ae785dacde9f20e593a25de8986267892ef74", size = 9453849, upload-time = "2026-07-18T03:38:19.593Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f8/6d0c312c8d9738e7d9677f09fe5c986b3239e651a7b73a2deb38b65e4a71/matplotlib-3.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:21a67b961a6d597bca54fae826cd20695ba4a6e4d05424a08da6e13e3176fd6b", size = 9283113, upload-time = "2026-07-18T03:38:21.95Z" }, + { url = "https://files.pythonhosted.org/packages/c9/cf/b4ad2cc81b6672ea29ea04e64e350a9f9b493b0908ccd884c67eeff8f7b2/matplotlib-3.11.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ba8f811b8ddfac493734d6af0b2dff96919d0c28ca0d641858dab4262777c6ea", size = 10035615, upload-time = "2026-07-18T03:38:24.315Z" }, + { url = "https://files.pythonhosted.org/packages/88/90/4e10e033d9b66589d8ed98b84c95cdbb57033d57c1f41339d7393dbd2f2e/matplotlib-3.11.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c52f7ad20ef476806ed212380b1d54d20310c8b86bdc2c9a68b51f0024a44472", size = 10842559, upload-time = "2026-07-18T03:38:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/88/eb/799612d0f8cd3e816a10fec59329fca52cd2353264df80378dfc541ae855/matplotlib-3.11.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8b14eb22961fe865efb0e4ff167e333e428908b00115a8d800ccb65ee108e481", size = 10927532, upload-time = "2026-07-18T03:38:28.532Z" }, + { url = "https://files.pythonhosted.org/packages/88/89/56649bbaa2fd12e20f3be03dbcc135b0c8676d88bac17977599e3eb442a0/matplotlib-3.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:88a2a27dd9691ae448dfae4b26f59036be90c3c28757edd3553a29559d00859f", size = 9333886, upload-time = "2026-07-18T03:38:30.477Z" }, + { url = "https://files.pythonhosted.org/packages/c1/11/4d124efbbad677b7b7552f6f85a3bd432d4232f95400cea98fcd2ae36ef3/matplotlib-3.11.1-cp312-cp312-win_arm64.whl", hash = "sha256:480194afceca4df2f137c2721227d3cba67121fbf4397b69cee7f83714b0a58a", size = 9007545, upload-time = "2026-07-18T03:38:32.833Z" }, + { url = "https://files.pythonhosted.org/packages/04/6c/4798363b7fb5644e309fe1fac30216e9146c9f70859d80d588c18caf5317/matplotlib-3.11.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6771b0cd7838c6a857a7209814158c0ad09bfef878db3033dd82d70ad101f191", size = 9454341, upload-time = "2026-07-18T03:38:35.001Z" }, + { url = "https://files.pythonhosted.org/packages/59/98/6acadbe7f98df19d274bc107ac58bb439fa75df82c33dc110d71a4a8501f/matplotlib-3.11.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2abdee5ffa2fe11b2d19f7a5c63b785fb7c28cc46c7bc1814156341d9d1a33e1", size = 9283627, upload-time = "2026-07-18T03:38:37.061Z" }, + { url = "https://files.pythonhosted.org/packages/24/ea/65cec46fe241390ccea1b1754207ee28eb71c5ab866bd5f22fe47e538fa4/matplotlib-3.11.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0a19dcf73406d3746d25a5ed42d713604c9a3e024d129b102852b0d941cb9f3", size = 10035860, upload-time = "2026-07-18T03:38:39.663Z" }, + { url = "https://files.pythonhosted.org/packages/c7/10/63fdccccbabe002fb0960876baabc5e3f24d9c1bb4cfb25651457f74b3a0/matplotlib-3.11.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7389b77ed2ab0552f46d9a90b81b7b8e6dfcdc42adc36c37a0865799843e0e3e", size = 10843594, upload-time = "2026-07-18T03:38:42.144Z" }, + { url = "https://files.pythonhosted.org/packages/98/51/a1155945bff7b91381875022ac1522c5dfdac0d006be8e7df389b3134eae/matplotlib-3.11.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c90be0b73568da4f662afac580956a76e308437e641b4a45aa08925eeb67d95f", size = 10927962, upload-time = "2026-07-18T03:38:44.302Z" }, + { url = "https://files.pythonhosted.org/packages/0d/3a/3d5e1f42dc761bf53401a62a83ff93389b37de9d2c093b2a3aa49ac34f1b/matplotlib-3.11.1-cp313-cp313-win_amd64.whl", hash = "sha256:68408341f2312836fbbdf6b3c78047f65b2d8752f5fd221c3e72d348f5b34f8b", size = 9334074, upload-time = "2026-07-18T03:38:46.616Z" }, + { url = "https://files.pythonhosted.org/packages/e2/db/3f5ea5a5b64060ef5e1ff60a19170423e41ce21b8497a6fe15a36e0b43e3/matplotlib-3.11.1-cp313-cp313-win_arm64.whl", hash = "sha256:0c1f44890d435c1b4ef52f701ad5828cb450ea97bcc83918fda6be74965d6cd2", size = 9007662, upload-time = "2026-07-18T03:38:49.112Z" }, + { url = "https://files.pythonhosted.org/packages/98/6e/c7ae5e0531425b69c0826b00ebbc264c85cab853f1cd6e096c9983c2cdc1/matplotlib-3.11.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:5e510088c27a89d53580a752f959146893563e63c330e161d159b0fee652af6f", size = 9503790, upload-time = "2026-07-18T03:38:51.527Z" }, + { url = "https://files.pythonhosted.org/packages/92/79/15be162e0a2ed546939674e2e97d0e33ec2447d86d4d4e611fa295bb178c/matplotlib-3.11.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:1524e2bdd48a93557aa47ddcfe9c225dfdd57d5a01a5c49128c20f0632980ee1", size = 9336148, upload-time = "2026-07-18T03:38:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7f/36ffe144fc4aacfe0e3ed2318f72b6755d1e73b041d619b4d393e60f5a66/matplotlib-3.11.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:11664c551345553db92e61cae6cf1376f138f8c47cafdf13b64b18f3e3e9e464", size = 10049244, upload-time = "2026-07-18T03:38:55.911Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/55812d68c0a840d3a463638f48c00ab1fe338518ec49a640cb6473b444af/matplotlib-3.11.1-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e1f8922ba31959cf6a9dfb51be64b7f7bc582801a3957dc0c2f3afcd3537adf", size = 10860798, upload-time = "2026-07-18T03:38:58.282Z" }, + { url = "https://files.pythonhosted.org/packages/7a/64/cca444b4eb5e6c768c44fc5e1f0b5211f20ca2b282778051996e996a2bdf/matplotlib-3.11.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83235693abde86e5e0129998f80ee39fc7f58e6d56a88fafb28a9278833e9d5f", size = 10943282, upload-time = "2026-07-18T03:39:00.465Z" }, + { url = "https://files.pythonhosted.org/packages/e5/0f/a49c329d394f2e9ef38506982107e8b04ecf94dd41a9d8423ff82cc737c7/matplotlib-3.11.1-cp313-cp313t-win_amd64.whl", hash = "sha256:9a076f4fc5cdc43fdf510f5981418d25c2db4973418d9f22d8bb3dc8045ada78", size = 9383532, upload-time = "2026-07-18T03:39:02.468Z" }, + { url = "https://files.pythonhosted.org/packages/e4/50/103e86afb806d8f64d04ede14e4cfc09dbfc25f512421ff85fdd6ebd59cf/matplotlib-3.11.1-cp313-cp313t-win_arm64.whl", hash = "sha256:216fbb93a74add02ddb4cb38ef5348f59ac00b3e84567eaf16598772d40e150a", size = 9059665, upload-time = "2026-07-18T03:39:04.607Z" }, + { url = "https://files.pythonhosted.org/packages/35/04/3079499fa8cb661ea66d13d6439d5a3ae6710a7afd5c7f72e08914f275f8/matplotlib-3.11.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:30c492d4ba9448595b6fd8708c6725963f8148e25c0d8842948da5b05f0ee8d3", size = 9456022, upload-time = "2026-07-18T03:39:07.041Z" }, + { url = "https://files.pythonhosted.org/packages/53/a2/69acfe84ec1f32930e801a5782a07fc5c79c8c6599a507b806d859d5da8e/matplotlib-3.11.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ac104be2768ffdd8655db9e71b768cbb45f2b9aa7b450cf1595e8f65d3822319", size = 9285475, upload-time = "2026-07-18T03:39:09.562Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b3/31b15a2ca56d4ddd6aaa1c884c2f51cf9a61cfaf5ca6f6fbd6343d38e6df/matplotlib-3.11.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be943cb68bc6660ead58c55b3aa6366cba2ef7feb06460fbcce32360376f19f", size = 10847102, upload-time = "2026-07-18T03:39:11.532Z" }, + { url = "https://files.pythonhosted.org/packages/64/0d/a17e966e620545c1548125af0b29ac812dd17b197a18a7462ac12fa859ee/matplotlib-3.11.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5af0dcda57d471440a7b5b623e70e0a61003518443d9098f211a96ecfbbc25be", size = 11131087, upload-time = "2026-07-18T03:39:13.764Z" }, + { url = "https://files.pythonhosted.org/packages/97/c5/5e100efdd67abb7de20befaa333612ef9bfc63417fb71398f904f25d083c/matplotlib-3.11.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3d3fd84082b1afbd9398466c81309e20045be20d48fe0fb18c43504d164cbbb2", size = 10929036, upload-time = "2026-07-18T03:39:16.888Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/d719a0a36930ecc8dfc801ff340f9dcfc4223f8ca5d39d06b4020032fff8/matplotlib-3.11.1-cp314-cp314-win_amd64.whl", hash = "sha256:9601a1e90be21e4884c53b4f3dc3ee0544654946f9975258d691f1c2e2f119c6", size = 9489571, upload-time = "2026-07-18T03:39:19.449Z" }, + { url = "https://files.pythonhosted.org/packages/48/65/facabdc2f1f6caba7e856db64dfedddca25f7608df07d96a1c8fd114fd3b/matplotlib-3.11.1-cp314-cp314-win_arm64.whl", hash = "sha256:ae30c6109848ac0f9fa36c5d6270938487614c47ba31860bd5361266dabc5685", size = 9164486, upload-time = "2026-07-18T03:39:21.424Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/18da6cd01cf96354534f98c468a25380c68ce582a2c9dd0cae12b04af4f2/matplotlib-3.11.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:dadfe80797174e2984aae3be0b77594a3c72d2c0a40fbd4a0de48d2728caf3ae", size = 9504876, upload-time = "2026-07-18T03:39:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/b0/f0b63555a18b79d038c81fd6126f35fc4dfce0eaff48d96103348c7cf935/matplotlib-3.11.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:89b193b255f4f6f7948dbcee3691f4f341ab05d9a8874a67b45ddb4182922eda", size = 9336120, upload-time = "2026-07-18T03:39:25.797Z" }, + { url = "https://files.pythonhosted.org/packages/c6/dd/f210ec7c4a6f198d5567237048a93d0811fb5a1f1691f13320e592f95b41/matplotlib-3.11.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:191163532cdefcb1571ca38a6d7e6474baccde64495783e6ba47aa07ec4b9bbb", size = 10858033, upload-time = "2026-07-18T03:39:27.999Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/d6d5324507c5fbb316db48e258c09c2807f3de03d9af47017e120070926f/matplotlib-3.11.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9fdf1c818ab05d0e74002091ddaf414478a3a449ec9d51c8976d45be7e3a01e2", size = 11141827, upload-time = "2026-07-18T03:39:30.092Z" }, + { url = "https://files.pythonhosted.org/packages/0f/68/3c22e9320bdce2c4d2f1320643ef706db7a24cb7420eea28b97a2d67f5a8/matplotlib-3.11.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b937b9dba5f5f6c1e31c47abe2186c865c0914fd18f2ce0dfc39c9adcef5951d", size = 10943061, upload-time = "2026-07-18T03:39:32.356Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/907ed190ee81a9df581e0ed5456134fc0f7cb55ffcfda2f9e54ca900761c/matplotlib-3.11.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f2912f647f3fbe1ccf085f91e213936f9101bead81a5e670565b1f1b3712f4fb", size = 9540074, upload-time = "2026-07-18T03:39:34.789Z" }, + { url = "https://files.pythonhosted.org/packages/23/d4/97c19b77e0a6e3b48581185bb65088f431cd20186076cc0f650a1757ea46/matplotlib-3.11.1-cp314-cp314t-win_arm64.whl", hash = "sha256:54d47b8ae8b579633a3902ca5b4ad6c1e132a5626d64447b2e22a66394e79987", size = 9213472, upload-time = "2026-07-18T03:39:37.141Z" }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, +] + +[[package]] +name = "mistune" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/92/328a294a6de83bacb95bed01f04e0eaff4e3616ee359fc821a5dfc539b02/mistune-3.3.4.tar.gz", hash = "sha256:58b5c96d6fcb61190dfe5fae498d2b2065f99cf61e9649418fd54cf1ada86dfe", size = 121426, upload-time = "2026-07-22T05:22:30.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/e4/288365afae98953bc01de09f686f40d8ee84578135aa7767d5d4e60b5278/mistune-3.3.4-py3-none-any.whl", hash = "sha256:ee015381e955e370962968befe1d729ab60fafb6a715ac6751763fbce38c8d4a", size = 66862, upload-time = "2026-07-22T05:22:29.419Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "narwhals" +version = "2.24.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/1d/58946e5aab18393e793bd4add6985b95d0e01c3a2d832f38f54468b10dcd/narwhals-2.24.0.tar.gz", hash = "sha256:b5c0f684ccd9d7475b564111e319a4964abcf2baf79d3cf6b1003d06ac9b828d", size = 661143, upload-time = "2026-07-13T10:49:19.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/85/a5bfaebfd305ac18b57b0854d74e37e586809061a91fda62f0bd50c8518e/narwhals-2.24.0-py3-none-any.whl", hash = "sha256:42fdedf44e5b2ca7505630d45b4ac3058f38d8485cba9fe1652ca23152df7489", size = 461030, upload-time = "2026-07-13T10:49:17.571Z" }, +] + +[[package]] +name = "nbclient" +version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "nbformat" }, + { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/36/14/fbadc6e84948643ff11c7c28afe9375b7c1d08f9c55429e999f65cdef7e3/mashumaro-3.9.tar.gz", hash = "sha256:c179f3f29f7b88acc9472427ce9fc673072a04b3888ce4bd1cac94c266c8e587", size = 106417, upload-time = "2023-08-02T20:15:47.225Z" } +sdist = { url = "https://files.pythonhosted.org/packages/28/a5/b3bae4b590c0cbcada2c63a34f7580024e834a8ba213e949a2f906705787/nbclient-0.11.0.tar.gz", hash = "sha256:04a134a5b087f2c5887f228aca155db50169b8cd9334dee6942c8e927e56081a", size = 62535, upload-time = "2026-06-05T07:52:41.746Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/e8/71112134a310f7e0266cde3eed750b594408322f2ba14d936b4b19259f1f/mashumaro-3.9-py3-none-any.whl", hash = "sha256:545099fa7f35d7da516a627d31b8f58249c4326c0e14dc8ea5da004a06061b17", size = 74827, upload-time = "2023-08-02T20:15:45.356Z" }, + { url = "https://files.pythonhosted.org/packages/36/c9/94d73e5a01c5b926c3fa2496e97d7a8dc28ed5a77c0b2ed712f1a62e6694/nbclient-0.11.0-py3-none-any.whl", hash = "sha256:ef7fa0d59d6e1d41103933d8a445a18d5de860ca6b613b87b8574accdb3c2895", size = 25288, upload-time = "2026-06-05T07:52:40.115Z" }, ] -[package.optional-dependencies] -msgpack = [ - { name = "msgpack" }, -] - -[[package]] -name = "more-itertools" -version = "10.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, -] - -[[package]] -name = "msgpack" -version = "1.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647", size = 183960, upload-time = "2026-06-18T16:13:52.594Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/dd/9e8cbd8f5582ca4b590336f2b91ee5662f6a6ca562b565abaf696a0f81ff/msgpack-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35", size = 83531, upload-time = "2026-06-18T16:12:58.249Z" }, - { url = "https://files.pythonhosted.org/packages/50/2e/ebdb85a8da151397a2790363676b7ed7c125924fe618e4c6d8befb0cc62c/msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c", size = 82657, upload-time = "2026-06-18T16:12:59.396Z" }, - { url = "https://files.pythonhosted.org/packages/26/aa/753ad8b007b464e1d8aa0c8e650b9c5f4f725e658fc5ac8a7635c55b7f6e/msgpack-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0", size = 410634, upload-time = "2026-06-18T16:13:00.383Z" }, - { url = "https://files.pythonhosted.org/packages/6a/fd/6adabd4f6d5e686f97dd02ce7fce3fe4cf672cbac36b8f67ff4040e8ad8b/msgpack-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a", size = 419989, upload-time = "2026-06-18T16:13:01.776Z" }, - { url = "https://files.pythonhosted.org/packages/5a/cc/85039b7b0eb168aaad7383a23c97e291a11f08351cb45a606ce865e4e3f1/msgpack-1.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6", size = 377544, upload-time = "2026-06-18T16:13:03.637Z" }, - { url = "https://files.pythonhosted.org/packages/ed/bf/35963899493b32030c85fc513b723ae66144ac70c11ebc52e889e16e3d99/msgpack-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a", size = 400842, upload-time = "2026-06-18T16:13:05.012Z" }, - { url = "https://files.pythonhosted.org/packages/a6/df/8e2ac970c8f99264cd9997d1c73df5466bc19da3301d7dc5500862a9b089/msgpack-1.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1", size = 374108, upload-time = "2026-06-18T16:13:06.232Z" }, - { url = "https://files.pythonhosted.org/packages/17/dd/fa8bd265110dfa51c20cb529f9e6d240a16fafe7e645004c6af2d01353ba/msgpack-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64", size = 414939, upload-time = "2026-06-18T16:13:07.478Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b9/8377a5ad8953fc0437c70cc98d9ae29f27fe5ac5109fbec0812085865735/msgpack-1.2.1-cp312-cp312-win32.whl", hash = "sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac", size = 64504, upload-time = "2026-06-18T16:13:08.822Z" }, - { url = "https://files.pythonhosted.org/packages/57/7f/ce1e377df7e62461fefd9eb23bfb93a4a523f40a517b377b8f844d836828/msgpack-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24", size = 71421, upload-time = "2026-06-18T16:13:09.828Z" }, - { url = "https://files.pythonhosted.org/packages/8f/32/ebfe84c9929f08f188d56c7a2fd913406a9ddad76a634697c1c43b8112e6/msgpack-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07", size = 64775, upload-time = "2026-06-18T16:13:11.056Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ac/dcddcab6f6c20ecb387ca5e980371cdb3f87ff69aeca388be97eebc4c074/msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064", size = 83151, upload-time = "2026-06-18T16:13:12.173Z" }, - { url = "https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056", size = 82351, upload-time = "2026-06-18T16:13:13.194Z" }, - { url = "https://files.pythonhosted.org/packages/e3/10/ddf7b06db879e8792d13934ddda09ff20bd2a583fd84c9b59aae9b0e650b/msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc", size = 407518, upload-time = "2026-06-18T16:13:14.233Z" }, - { url = "https://files.pythonhosted.org/packages/79/d3/36a46a8ed992b781acbc05928bd5bee3c810cb0c3563bf81a7b0c04a1a76/msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d", size = 416405, upload-time = "2026-06-18T16:13:15.435Z" }, - { url = "https://files.pythonhosted.org/packages/f9/84/e8e9598b557c0ba6ddae901a73780a4c75ac667dddf59414b1e56a42fb34/msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155", size = 376257, upload-time = "2026-06-18T16:13:17.022Z" }, - { url = "https://files.pythonhosted.org/packages/40/16/738fe6d875ad7e2a9429c165322a4ec088f4f273cdfae63d96a89c467961/msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402", size = 397469, upload-time = "2026-06-18T16:13:18.287Z" }, - { url = "https://files.pythonhosted.org/packages/ca/be/6d5952df75a7f24f35833af764c3a6860780364cb3a0030beb8099e1b2b4/msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c", size = 372802, upload-time = "2026-06-18T16:13:19.685Z" }, - { url = "https://files.pythonhosted.org/packages/e1/39/e2ef7dbf0473bcb8dc7c50bf782a892d67414877b63e47fc88eb189ef5e6/msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6", size = 411273, upload-time = "2026-06-18T16:13:21.028Z" }, - { url = "https://files.pythonhosted.org/packages/ef/c5/133f4512a56e983a93445c836c9d94d88f3bc2e0980ff4b9e577bd8416ce/msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707", size = 64471, upload-time = "2026-06-18T16:13:22.293Z" }, - { url = "https://files.pythonhosted.org/packages/e2/98/577e10b055096a7dd40732358cabaf7180a20c79ed1dcdbb618e4b9deac7/msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9", size = 71274, upload-time = "2026-06-18T16:13:23.455Z" }, - { url = "https://files.pythonhosted.org/packages/ba/ee/0c0048e7cfbef23c6a94791b8959ab28155232e7956de8a305b5ff588f05/msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a", size = 64795, upload-time = "2026-06-18T16:13:24.687Z" }, - { url = "https://files.pythonhosted.org/packages/77/58/cce442852c6b9e1639c7c8ac8fd9143121cb32dab0f308df4d1426a8eb9c/msgpack-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d", size = 83610, upload-time = "2026-06-18T16:13:25.724Z" }, - { url = "https://files.pythonhosted.org/packages/60/5c/15b4c7a0182f75ffa90751958ba36a9c01cafee367d49a3edc10ed140b01/msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7", size = 83138, upload-time = "2026-06-18T16:13:26.781Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a6/99e58722feaffc5f2fbcc0c8c0d1451ab9f84097f7af87291b46af2390f4/msgpack-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889", size = 406090, upload-time = "2026-06-18T16:13:28.072Z" }, - { url = "https://files.pythonhosted.org/packages/19/03/8c63e8cf52958534ef688625965ab04c269a6cadd8caef16758b380a821a/msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720", size = 412106, upload-time = "2026-06-18T16:13:29.427Z" }, - { url = "https://files.pythonhosted.org/packages/63/d2/155d9e71b40e41fd934bc0c48b9b2770f22263e1ac20aad8e29fdca7be3f/msgpack-1.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190", size = 374851, upload-time = "2026-06-18T16:13:30.631Z" }, - { url = "https://files.pythonhosted.org/packages/98/48/deaf2326262a8d5ea3295ce9649912ecd3f551ba7ec8e33c665d2ba583f3/msgpack-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d", size = 396168, upload-time = "2026-06-18T16:13:31.977Z" }, - { url = "https://files.pythonhosted.org/packages/10/2a/b4410f906c2ec0008f1608d3ab5143afc3ad3f4e6da0fed3ea2231d0bef4/msgpack-1.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24", size = 371959, upload-time = "2026-06-18T16:13:33.282Z" }, - { url = "https://files.pythonhosted.org/packages/59/86/1edc67270099a528fa2093ea60fe191233cd238e4bd30cfacf7db79fc959/msgpack-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7", size = 408457, upload-time = "2026-06-18T16:13:34.567Z" }, - { url = "https://files.pythonhosted.org/packages/82/90/8b630fef07d8c5ab457b71ff2c217910c83d333c7a68472c186e87cc504a/msgpack-1.2.1-cp314-cp314-win32.whl", hash = "sha256:98b58bdb89c46190e4609bb36abe17c6d4105ad13f9c5f8f6f64d320f8ced3fb", size = 65942, upload-time = "2026-06-18T16:13:36.056Z" }, - { url = "https://files.pythonhosted.org/packages/16/f1/467b81e98b24dd3885d7b1857728797b4ffc76a7a7483af4fb321a07de3c/msgpack-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b", size = 72627, upload-time = "2026-06-18T16:13:37.079Z" }, - { url = "https://files.pythonhosted.org/packages/a7/1d/5d8c4c89985feb6acefb82a09e501c60392261856d2408d20bfe4f0360b1/msgpack-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:b50b727bd652bdc37d950336c848ef20ec54a4cafc38dce19b1cd86ad625d0f7", size = 66908, upload-time = "2026-06-18T16:13:38.23Z" }, - { url = "https://files.pythonhosted.org/packages/1b/02/ad2afb678b4de94496cd432b581759b756a92c1192d8c767edd6b132efdc/msgpack-1.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273", size = 86000, upload-time = "2026-06-18T16:13:39.44Z" }, - { url = "https://files.pythonhosted.org/packages/54/74/0b797484013128837f3b1cbb6cea019277c4de4e377dc512b4d9a0f92940/msgpack-1.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1", size = 86544, upload-time = "2026-06-18T16:13:40.447Z" }, - { url = "https://files.pythonhosted.org/packages/a9/b4/b774d7eb95561739907fec675582f83203cf41c597a418c2589b4bfb8e9d/msgpack-1.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc", size = 427661, upload-time = "2026-06-18T16:13:41.574Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f9/3243191dc9937e00756c8bc1b0272fed8f23758e43df2a3b46f533e5090f/msgpack-1.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde", size = 426375, upload-time = "2026-06-18T16:13:42.936Z" }, - { url = "https://files.pythonhosted.org/packages/23/c7/1693111db9944ba4ad4b67a1e788400d78a0b6af7a6523dc7e4e58f8274b/msgpack-1.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4", size = 380495, upload-time = "2026-06-18T16:13:44.306Z" }, - { url = "https://files.pythonhosted.org/packages/3e/2b/92f86956a0c13e8662f7e2ad630c4eb4db07497b967589bd5245e018b2c1/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d", size = 410897, upload-time = "2026-06-18T16:13:45.629Z" }, - { url = "https://files.pythonhosted.org/packages/da/ea/1479f72d200313a76fc2f823a79d1e07ed052ab7b8a0280640aa7b95de42/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355", size = 378519, upload-time = "2026-06-18T16:13:46.998Z" }, - { url = "https://files.pythonhosted.org/packages/f5/4d/fa006060ffa1011d32bfae826fe766fe73e02982183601633b7121058ab3/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c", size = 419815, upload-time = "2026-06-18T16:13:48.205Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e1/aab6c946570496b78e67804721f3d5e2d62a93081b9b37df77764ef56347/msgpack-1.2.1-cp314-cp314t-win32.whl", hash = "sha256:c1c79a604a2969a868a78b6ebd27a887e00c624f14f66b3038e0590cb23332d1", size = 70914, upload-time = "2026-06-18T16:13:49.385Z" }, - { url = "https://files.pythonhosted.org/packages/13/0a/e608956488a2af014cfe6e3d665e090b8ee42aa14b07f8f95b8880d66b09/msgpack-1.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f12038a35fabd52e56a3547bab42401af49a45caa6dd00b34c44de235bc93ee2", size = 77999, upload-time = "2026-06-18T16:13:50.467Z" }, - { url = "https://files.pythonhosted.org/packages/d2/8a/27e2e57055176e366a46b85d02d68e7a5bcfbdd8474c9706375d965f24d3/msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107", size = 71160, upload-time = "2026-06-18T16:13:51.498Z" }, +[[package]] +name = "nbconvert" +version = "7.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "bleach", extra = ["css"] }, + { name = "defusedxml" }, + { name = "jinja2" }, + { name = "jupyter-core" }, + { name = "jupyterlab-pygments" }, + { name = "markupsafe" }, + { name = "mistune" }, + { name = "nbclient" }, + { name = "nbformat" }, + { name = "packaging" }, + { name = "pandocfilters" }, + { name = "pygments" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/b1/708e53fe2e429c103c6e6e159106bcf0357ac41aa4c28772bd8402339051/nbconvert-7.17.1.tar.gz", hash = "sha256:34d0d0a7e73ce3cbab6c5aae8f4f468797280b01fd8bd2ca746da8569eddd7d2", size = 865311, upload-time = "2026-04-08T00:44:14.914Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl", hash = "sha256:aa85c087b435e7bf1ffd03319f658e285f2b89eccab33bc1ba7025495ab3e7c8", size = 261927, upload-time = "2026-04-08T00:44:12.845Z" }, ] [[package]] -name = "mypy-extensions" -version = "1.1.0" +name = "nbformat" +version = "5.11.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +dependencies = [ + { name = "fastjsonschema" }, + { name = "jsonschema" }, + { name = "jupyter-core" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/80f407a9525bc5bd9865e5c37db3b78867fa43217f8aac5eab22b5f028b3/nbformat-5.11.0.tar.gz", hash = "sha256:7dbaed4a69cae28c2b4d44ab7430a6af4544fb89455023f6f21550be757b60c8", size = 151822, upload-time = "2026-08-06T12:29:55.597Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, + { url = "https://files.pythonhosted.org/packages/be/4a/0eece9dad5e73230ca972f7bc29456ce7d74772f123d401fcf67379008f7/nbformat-5.11.0-py3-none-any.whl", hash = "sha256:f70a17f591a9ccd1c601d5e61a4b20972703926df0ba42458ce14bf575766bb6", size = 79820, upload-time = "2026-08-06T12:29:54.178Z" }, +] + +[[package]] +name = "nest-asyncio2" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/73/731debf26e27e0a0323d7bda270dc2f634b398e38f040a09da1f4351d0aa/nest_asyncio2-1.7.2.tar.gz", hash = "sha256:1921d70b92cc4612c374928d081552efb59b83d91b2b789d935c665fa01729a8", size = 14743, upload-time = "2026-02-13T00:34:04.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/3c/3179b85b0e1c3659f0369940200cd6d0fa900e6cefcc7ea0bc6dd0e29ffb/nest_asyncio2-1.7.2-py3-none-any.whl", hash = "sha256:f5dfa702f3f81f6a03857e9a19e2ba578c0946a4ad417b4c50a24d7ba641fe01", size = 7843, upload-time = "2026-02-13T00:34:02.691Z" }, ] [[package]] -name = "networkx" -version = "3.6.1" +name = "notebook" +version = "7.6.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +dependencies = [ + { name = "jupyter-builder" }, + { name = "jupyter-server" }, + { name = "jupyterlab" }, + { name = "jupyterlab-server" }, + { name = "notebook-shim" }, + { name = "tornado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/d9/5c76de84e96e1cf8aae3fce930875e0615e14f3557bbcdb634aab52da9d3/notebook-7.6.2.tar.gz", hash = "sha256:cc02b5f0bb972160cccfe44ad8a1a202036206ba3439469c514f03aefa9ae807", size = 5500147, upload-time = "2026-08-11T13:21:16.632Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fd/3e552ff5b24dd305c6e9a10e8645e29c03091c317429f326ae10dbae1ac6/notebook-7.6.2-py3-none-any.whl", hash = "sha256:5fe9e09c335cb4b7de21627b860f77210e70e54b1fb1276ad942a4a7e1d858d3", size = 5547102, upload-time = "2026-08-11T13:21:13.302Z" }, +] + +[[package]] +name = "notebook-shim" +version = "0.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-server" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/d2/92fa3243712b9a3e8bafaf60aac366da1cada3639ca767ff4b5b3654ec28/notebook_shim-0.2.4.tar.gz", hash = "sha256:b4b2cfa1b65d98307ca24361f5b30fe785b53c3fd07b7a47e89acb5e6ac638cb", size = 13167, upload-time = "2024-02-14T23:35:18.353Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/33/bd5b9137445ea4b680023eb0469b2bb969d61303dedb2aac6560ff3d14a1/notebook_shim-0.2.4-py3-none-any.whl", hash = "sha256:411a5be4e9dc882a074ccbcae671eda64cceb068767e9a3419096986560e1cef", size = 13307, upload-time = "2024-02-14T23:35:16.286Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/80/db0b4559e57ec36362bedbb05530a87fafbcb6067708c946967a41d449e7/numpy-2.5.2.tar.gz", hash = "sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860", size = 20773161, upload-time = "2026-08-09T13:48:27.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/72/dccb0aaf40972777283303919f613964227266d0c13adebb79ac124f1c3e/numpy-2.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:14e373cfc6387177e8409dac3c7159be8eb05cd77096cd7c950268b86f62831c", size = 16891693, upload-time = "2026-08-09T13:44:51.702Z" }, + { url = "https://files.pythonhosted.org/packages/60/2e/b5aee50a1f74ac815cf8331812cb8251e29024025de462e0c047641c614c/numpy-2.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbd96c833ecc8cc069ce518078fc8c60cb9cbfb0fea5b7a803ad65035596d03", size = 11903109, upload-time = "2026-08-09T13:44:55.501Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f4/29e78102a80601cf034d4e9767022cffeca2c3b4c926e1754572ca95593d/numpy-2.5.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:6e8172ddfcf5cf74b811d372b570b83c60bd2de87a6fbfbebdadb4a9bd9c6cbb", size = 5350202, upload-time = "2026-08-09T13:44:58.401Z" }, + { url = "https://files.pythonhosted.org/packages/11/4b/dcd3b7eadaf4035d2c7a4289d232523a6964f602598ef7674e4bd7291f93/numpy-2.5.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:65f188481f1669e26f62b701e8205d19e460fa4a9b52a1414ba382330e4a3414", size = 6687736, upload-time = "2026-08-09T13:45:00.813Z" }, + { url = "https://files.pythonhosted.org/packages/e5/21/4947e0e9d6c9fc2e2ff15b8949049ee44f63adb9cacc729ab8793f97e712/numpy-2.5.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ee9c4eeb8454b3660a8b53493563c3e121c2fc94fbd72b848ef814ed7b676a9", size = 15612696, upload-time = "2026-08-09T13:45:04.151Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5f/62d28cf019460c7f1394105b4d49d9911a9c444cb77ab0bd95a204c5a6de/numpy-2.5.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3cdec01fa790a186d430433fdd4d4ffb70eed6f0eeb4bf05c8dbe2dce0a9bcb8", size = 16722264, upload-time = "2026-08-09T13:45:07.714Z" }, + { url = "https://files.pythonhosted.org/packages/14/25/3f0be4c1b9fdf5dd5e708a6806978564d7c46a055c000496309ff2a2f8af/numpy-2.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7999d4ddb0c4025018373fd787510d46e04c769467af22869707b3c1cfd459ab", size = 16974396, upload-time = "2026-08-09T13:45:11.316Z" }, + { url = "https://files.pythonhosted.org/packages/22/72/6262cbdeeb45da9d971e40715f579d791603ba8ec0b5e2db1ac55454421d/numpy-2.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c1f017dc0875c9209d219f97feceb7d54c2661bb243deb4114478e1295808af7", size = 18476044, upload-time = "2026-08-09T13:45:14.869Z" }, + { url = "https://files.pythonhosted.org/packages/36/33/29208b8b075bde62d26a81d14b358c42b0f69b6cabd98d4ff97f37f22b05/numpy-2.5.2-cp312-cp312-win32.whl", hash = "sha256:d6a48072864e3324e194a8fbb3c657bcc5b5c869dbc64c9537b1d5c862572c0a", size = 6072817, upload-time = "2026-08-09T13:45:17.867Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/87fea2769fe1c47c1b5b01d8310772c9d1a85d485de7cf386ef7a3332b02/numpy-2.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:28ac63476ec7651484215ee7fa15a1f78b57c14621f01e392afe17b9a1390ce4", size = 12464674, upload-time = "2026-08-09T13:45:20.734Z" }, + { url = "https://files.pythonhosted.org/packages/14/52/032b97e00461ab0809bbe4c588b035620e5a14b8cdee47ecddefc7b17d33/numpy-2.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:27650bb0e7140fa3d37b9923b4803645e0b125d190f326eecfd3f4dad8e8ade1", size = 10397131, upload-time = "2026-08-09T13:45:23.73Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d2/6b24738a0ef4557d189b150046cd07823c50e4273e8aebd651222e24306f/numpy-2.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8e4cb9a754c8a0c62eaa88273a5fba3391f4a610d1dee893c0755da31c083f15", size = 16886595, upload-time = "2026-08-09T13:45:27.323Z" }, + { url = "https://files.pythonhosted.org/packages/65/60/f2d208d366f263f39c6e69ed309290717aab41078b6d04c9be2a84fa2a07/numpy-2.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:52c808f96484f5571a5cc863775ce50247c17dfb3b0361f8ed6b4b0456f80080", size = 11896845, upload-time = "2026-08-09T13:45:31.638Z" }, + { url = "https://files.pythonhosted.org/packages/3c/79/81e0bf24f4d020a2b1d5cd297a9f60c3f24eeb116f9bba5870443f7b6a4a/numpy-2.5.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:29d81e97f668489cba8ebfd796b9bdd453525d35dd9e162e2daec94bf3fc7740", size = 5343880, upload-time = "2026-08-09T13:45:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/ba/cc/e3141cf06d1a8a2c7e107543fe1269c1d1af760d4d683c0794a4ee1127c2/numpy-2.5.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:afb3f0632d6b2e3ba04dbce8d1e48d321b369138b73830b5ca371a0e8d479d56", size = 6682264, upload-time = "2026-08-09T13:45:36.7Z" }, + { url = "https://files.pythonhosted.org/packages/29/f1/2a64a307d92c5d98f5255a4014eb43bb6103ee477087b61ecae44a3aa9b9/numpy-2.5.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0aadf13b60048d501e05fa699efaf7734e2494f3498a4c2a5521d822640324f3", size = 15609566, upload-time = "2026-08-09T13:45:39.518Z" }, + { url = "https://files.pythonhosted.org/packages/7b/44/59a1eb68e773c4098d107ef34a0dbdeca501d72ffcfbff9a7707343921ce/numpy-2.5.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29b86ff8a6cc556b47ec6b64b194815cc80e6bf5eedcc6cddfd65318cb0b4eee", size = 16709995, upload-time = "2026-08-09T13:45:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4c/3e54d4ddbc359a1295f8b633e8106bcd4d7d4a206e82df051bdfb3058755/numpy-2.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6950c4b7dd562453090548ba7f5da7e59f57f85663f15d5dcc60e249192f7e59", size = 16972511, upload-time = "2026-08-09T13:45:47.094Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9f/02e371638ebf19b66d46231e4be52999e87f32d1961b113bc45656608b22/numpy-2.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9727f472d2f3888053b8a75ab0cb94745a9de224bb5846dbadc0092101bc71d", size = 18465609, upload-time = "2026-08-09T13:45:50.808Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/ad6645abc7a3510fe48e8ea1ab4598166f500057ef4ebf38bfad4f1577de/numpy-2.5.2-cp313-cp313-win32.whl", hash = "sha256:4f9744f9fbdcea0bc552e8f19e1f141f811a3f9bc2be2cc6e86d982cab23e3f4", size = 6070204, upload-time = "2026-08-09T13:45:54.111Z" }, + { url = "https://files.pythonhosted.org/packages/15/20/f3489f86d81ea460b2bcdceaed094142ca6579f6be0ec527b781d39afe68/numpy-2.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:85aaccb24182c25df891ad0ec333585967e115269d5f1b17f2c9ae005bc96657", size = 12460532, upload-time = "2026-08-09T13:45:57.167Z" }, + { url = "https://files.pythonhosted.org/packages/d5/21/35b31dde1b283b79de828b80f876afd8c94e28fe1e9c375f89e261cc4c0d/numpy-2.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:bd68ece1553d2023c09a4226d9e41c586ad2d20594d1a456186c33513d2cb3f2", size = 10396725, upload-time = "2026-08-09T13:46:00.478Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f8/c3b222bf075b50afd8e949a07a15c4b312a4a84bd8102a332bcd953cbbb4/numpy-2.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d787cf769c3baeb5f6235e778edb52c08dfa923789b5958f28e6450f96107cb1", size = 16885180, upload-time = "2026-08-09T13:46:03.939Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/2c1d4b1987795a92b5bbf7c24fe249ab96aa2573ab0d7604802c189d7b86/numpy-2.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:24b9dc2e3d84aa58523798805194e23e736f3f6ce2d1a5b92583ae734e6dbda8", size = 11907878, upload-time = "2026-08-09T13:46:07.045Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ee/d08226fc858044355983a6e5b94f08ff6f3969e0a2b160a4a89f0ddb3445/numpy-2.5.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:9e9413326d726c2545bfa65d2c0876871e8d8386e77f992c1d426e180bbd4323", size = 5354922, upload-time = "2026-08-09T13:46:10.04Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/6d3d933056440ebbc5e6bad92065fc6c26a48a84a36b1208580e94eea76c/numpy-2.5.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:60e902ac295855348a5ca2ea4c89108989a9f5fddfad3dfc0a8f36b10358567e", size = 6679168, upload-time = "2026-08-09T13:46:12.275Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3b/ecd49dd90033cceb2704d88ca905d4d7d89b0e8c739608754ffd325fa820/numpy-2.5.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e500dc868e9313530ce12ba470fe50ff3afe3d62993ed6eff652dacd555b65", size = 15624501, upload-time = "2026-08-09T13:46:15.322Z" }, + { url = "https://files.pythonhosted.org/packages/c7/99/461bd36dbdfac6c1c53efa370bd55a83227542d0d118f1677dbf1a3dacd5/numpy-2.5.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318b9a4c845dbea06708a29c84ee429cc3065048db34cdb799047643492050ee", size = 16713701, upload-time = "2026-08-09T13:46:18.949Z" }, + { url = "https://files.pythonhosted.org/packages/f9/9c/2b251df9e8a5d647b62b0cbc1b90a91850c1cf4859ecb532fd0b4eacff6c/numpy-2.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34c319e2963be042673fb46570501b2f06c41924e17e3563d58646b4380dfb68", size = 16986065, upload-time = "2026-08-09T13:46:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/8f/25/20de43f53ff1390534a124475055a19f01fe10c920a0fd11b8e18d6d6052/numpy-2.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f06571a052127dc1b4e8b83029b4d1b20daa2b64a31cdd181fc6bc774e9000eb", size = 18470031, upload-time = "2026-08-09T13:46:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/56/5e/0c577ca308d6da5eb79b546ba10bbe5b60148192194e2da060913b1de4f1/numpy-2.5.2-cp314-cp314-win32.whl", hash = "sha256:2cc779226e476d1e1f08c74068c419e60f41a9e0e069c92f6671d31d5c985e98", size = 6121028, upload-time = "2026-08-09T13:46:30.046Z" }, + { url = "https://files.pythonhosted.org/packages/15/5c/7bcbd5b11f94199073320410cddcbb80cee62415bfeb540874b265c2d922/numpy-2.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:7587f53dfbd5edc0f7b87c6217b4c6d2d1f2ef9c3da70bc1315e7db5f8d7ec9d", size = 12597627, upload-time = "2026-08-09T13:46:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/87/bc/4d0b06fba0da90ccc75af62823cb9dcedb6c9ea0cffa058cb2c9ee773a77/numpy-2.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:3e4c367352d3747784248a227fbec218e193b56f7e6692e3b64fc805478ecfdf", size = 10680414, upload-time = "2026-08-09T13:46:36.036Z" }, + { url = "https://files.pythonhosted.org/packages/cd/17/f429aac9dc08833a0d0f188eba38c532a751b1a1f2ca6018a37b455cb321/numpy-2.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b879fb674276e331513fb136b78dbc6bd3c848309e0d841cfd63be3896c4cfc1", size = 12026967, upload-time = "2026-08-09T13:46:39.084Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9f/d0849de96a2a4ceaa16662f18ee13eaa9c0aa418269fdc8c4857c56b11da/numpy-2.5.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:fd0d703772bba096843785bd38371e31bb4a0c1151497ad5739d182114a73f7f", size = 5473874, upload-time = "2026-08-09T13:46:42.075Z" }, + { url = "https://files.pythonhosted.org/packages/89/3c/8df216d4a4a5422a3de045301cf7df8ea47286d76f5cb7160b0128ac26b7/numpy-2.5.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:3a2f061cebd9e3d23bdcfaaded5e2293a4c6a5b60fa42df85d410a725ce621bf", size = 6789276, upload-time = "2026-08-09T13:46:44.387Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3a/20d7e9891c4ddfadd6ff8d95bf4b29f353d8e1770553de2099880551dfb9/numpy-2.5.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6df895598c0edcb41030126c89e0f353b07d93238116143b7405e937359736c4", size = 15659154, upload-time = "2026-08-09T13:46:47.538Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d6/f3aa3d2688bf501b858835c6bd087ae9b51a56ae6fca8e2b0990abd177af/numpy-2.5.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ab3d4a901f844ea836c3e80bf463c6a27d7f3c14e8e292fcf28d348b25b9bce", size = 16748909, upload-time = "2026-08-09T13:46:51.442Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8f/1c5cae8d2baf86ab802ae97a00be55bc7e21ebc11b12bbc33376c5f05342/numpy-2.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cebc2d6dbb605a7703d59751dea4bd6b0ab127a5a4338a6f432df1936fef8b26", size = 17027685, upload-time = "2026-08-09T13:46:55.095Z" }, + { url = "https://files.pythonhosted.org/packages/5c/27/71d3467404aedc1c24ce79610f91b52b0b0f466c43a701aa56fc75c145ab/numpy-2.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eaca7ff36f0f52e2111ec71f169d8fd3e889e7ddc0d2592e0d703fd8d3ce8fac", size = 18501181, upload-time = "2026-08-09T13:46:59.09Z" }, + { url = "https://files.pythonhosted.org/packages/14/2f/42921d27c40aea7e077f4a423ae509fd9220b028cd787bafefd8ab2b3a5f/numpy-2.5.2-cp314-cp314t-win32.whl", hash = "sha256:ddf47472af2e4280d79bac82304f5e80150211f1b9e614b760061d5fdfbb6eba", size = 6271085, upload-time = "2026-08-09T13:47:01.903Z" }, + { url = "https://files.pythonhosted.org/packages/75/e6/bad5f5d56de9b1971bac959963dda276d35c40f1854475005434bbe08692/numpy-2.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:44ef9675d908e65f9953063837c3277730f3f4437615a4cdab67b366cabaf884", size = 12787971, upload-time = "2026-08-09T13:47:04.963Z" }, + { url = "https://files.pythonhosted.org/packages/df/05/f608795cb34391acd67e38d94a3c36abd8d8576293a3a80727d7595c372c/numpy-2.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:eaa088384c46f519dacb93b7ec483a6d6b19a4a2085ae4f25ab9b1c43d387d1e", size = 10750306, upload-time = "2026-08-09T13:47:07.976Z" }, + { url = "https://files.pythonhosted.org/packages/33/c6/28de0191c5f82b7d42a0a51390ba98587048aa93a39fafb05bdbe6e8d00c/numpy-2.5.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:078f9b027b478c9379b9677babbf0f8b8f1ecfada27636d7b9a93990c638739f", size = 16885274, upload-time = "2026-08-09T13:47:11.439Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/973ca116000d244897e468ea1aff30b589e5022e3c8744b71706fe33bd57/numpy-2.5.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:50a68f4bacd8a2b33d8da3d2269d0d78500f86ea582e4786dc10f5ef2c2c6842", size = 11907846, upload-time = "2026-08-09T13:47:15.128Z" }, + { url = "https://files.pythonhosted.org/packages/78/d9/8c4b3937ef204cb2fd88d389ccd0f265a2ffb11f35a01d2064cf46714bd6/numpy-2.5.2-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:e79aba74ffaf5f78a050d777c184cddf8fdffabab38acf5f3ef1fecbc17895d6", size = 5354892, upload-time = "2026-08-09T13:47:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/b6ee65ea2999fdb7023935e108e6fb776ee4082aa15f159acfa857e578c8/numpy-2.5.2-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:9a0731745a72a184490a582fb4af2533512bd071ace67785b5fdffc0ae58dce8", size = 6679309, upload-time = "2026-08-09T13:47:20.456Z" }, + { url = "https://files.pythonhosted.org/packages/43/f3/acb18d8b137a393c8e7803a8c994c9e64bde3930692a69d826993113a159/numpy-2.5.2-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ec954036759bcee3aa484f8603bd9c14f3e776293b85578b8734c2d72777c69", size = 15625850, upload-time = "2026-08-09T13:47:24.365Z" }, + { url = "https://files.pythonhosted.org/packages/a9/bf/a8e9bb0db815a0e265b5744ebedd3af0bd5faad8604e5b50a1cd012f3c91/numpy-2.5.2-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc649493697006bc90614a5f0bbc8cb3cb1866715c474e473694968d7e6b99ab", size = 16713664, upload-time = "2026-08-09T13:47:27.965Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/6e913736b3dd6582344af32418b5fb9dab34282e8a8174ae1d54ceb0fc13/numpy-2.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cf7de32f486e4ac9e2d93b810f9e9ac72a728dd46a32a0bb403222f27f653514", size = 16986749, upload-time = "2026-08-09T13:47:31.541Z" }, + { url = "https://files.pythonhosted.org/packages/80/09/7d3b23eff5c7428ef6c01e6f7052bb60d504c4d33e317b36b8959c24ad97/numpy-2.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2ffa7bacab3e2ee1b19ed31766bb60bb380b68c23f051e199c5cc598afd68710", size = 18470495, upload-time = "2026-08-09T13:47:35.364Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a4/68a321d825374f6eb677ffe8ef8c6b9a328304e6fd2e39d9530822776607/numpy-2.5.2-cp315-cp315-win32.whl", hash = "sha256:6b588cc8f902d6bff201c19fd00c43ab8545671e3554d014e12e14139e5e8617", size = 6120696, upload-time = "2026-08-09T13:47:38.561Z" }, + { url = "https://files.pythonhosted.org/packages/c8/23/deafbb1700f79fae9cd1e91220f133d124cc267de1b584da3fbf6db2f6cd/numpy-2.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:07d4e89f3a9ab0a9ba24264ccdb642b3dd951b2281e8883a5481a4aa79cc31a7", size = 12597324, upload-time = "2026-08-09T13:47:41.401Z" }, + { url = "https://files.pythonhosted.org/packages/33/cd/3272ba105e3bbbdaeb11357eda31e7a6825ffe159e8171665660299a948f/numpy-2.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:a610dc7e3c52edd39c2bc2375ff9c3fd59cb3ad00e4472d36f83bc1457145788", size = 10680466, upload-time = "2026-08-09T13:47:44.873Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/58370637b1bb70a5c9ce2b43f4b521ccb224e36ccb76a6596b17ae4b447c/numpy-2.5.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:40f4d451aed46a8046a1aae41c4e55fb3612273df9c502480135e1501576a34b", size = 16993947, upload-time = "2026-08-09T13:47:48.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/93/2abcb807712b289d6d60fe4cf30532f98974a8396d885650f3ba5a13026e/numpy-2.5.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c081cbe16ba1ab53078e5ff29013621e33c509eedab055775d956427712c236e", size = 12025331, upload-time = "2026-08-09T13:47:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3a/2898e003a5fbaf87e76c039b4ee1f5eb390471b4ffe74887c1f34c4e791e/numpy-2.5.2-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:0090ccdd57ec2703e9b49d0bf554767370581c1dd0a6b2bb2b2d9def317d042a", size = 5472336, upload-time = "2026-08-09T13:47:55.403Z" }, + { url = "https://files.pythonhosted.org/packages/61/a5/23f69d07c544597b29758b31b55c27dc9d541012a2c1496189fef702aec2/numpy-2.5.2-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:6a9bb119fb8dd21ba30b3f0e555b7e2b081bd9883af21ec9c1c633d161cda3a8", size = 6788387, upload-time = "2026-08-09T13:47:58.192Z" }, + { url = "https://files.pythonhosted.org/packages/15/ea/c0dbdbcf22f43782510a3e492dd3da73c6112b69cac8929d16d127536fc4/numpy-2.5.2-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a839318485284a6fb31be4f8f2c91c8f2cb22f4543c4a8903f12b0671ffe07cc", size = 15667096, upload-time = "2026-08-09T13:48:01.562Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5e/29c73c31748cdb0f7566642125ba17fd5b56780cddf891b085dab27e4466/numpy-2.5.2-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba0a474801b8dc67b66bf465548abc90e82b44d2611b5770f33008dcabffe8ec", size = 16751730, upload-time = "2026-08-09T13:48:05.706Z" }, + { url = "https://files.pythonhosted.org/packages/47/95/02501e8454796bb58dadf7a99d3181e0b464bf264e1003039572f9779fac/numpy-2.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0a4035ae1129ff8777f08bfbd44f1e5d8e9c049ce0c2dd78fc0d92c13e7251c0", size = 17038686, upload-time = "2026-08-09T13:48:09.627Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b5/53a681d91b5c82687067d8ea5035e02d917b5509d6f334cb06484a954714/numpy-2.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:77843ca236b777e67f8d6b3660ea116e499612703a0ecd7093f316201eb9d8e2", size = 18507727, upload-time = "2026-08-09T13:48:13.744Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/6e11443f7b64ee376c860506091103bf68f92d2cab9e8d96d4501babf07c/numpy-2.5.2-cp315-cp315t-win32.whl", hash = "sha256:7354826bc6f8f69402e9b7fe28d15fcd34feebd74f856f111585c5b0c9fb0251", size = 6269775, upload-time = "2026-08-09T13:48:17.543Z" }, + { url = "https://files.pythonhosted.org/packages/f1/18/195d6b86cd72dbbc501edfa778005fa6b87afd34c153e46028cd3a0938f4/numpy-2.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:e5651f3f87add730ee6608d915009e19c911fba0cb000c7e3ea994b7d768eb12", size = 12782559, upload-time = "2026-08-09T13:48:21.023Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/458c344f0f0c178f4481dad5cca790626ffe4c34eabf9467069d06ee4999/numpy-2.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:5f8e00be2ec6f45f4e8a41a527f68d44a7d96fee92a650e4d8b1326f77f61e6e", size = 10748103, upload-time = "2026-08-09T13:48:24.21Z" }, +] + +[[package]] +name = "nvidia-nccl-cu13" +version = "2.31.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a0/530efd7db8857c0436868bb7df9764f09fde2bd4d1f0bae546eec9fc40d0/nvidia_nccl_cu13-2.31.2-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:b5563f8e2534f363d93ace022670ba016d3717e190ac4eba564d05fbbe8495b1", size = 252479893, upload-time = "2026-08-11T23:22:01.53Z" }, + { url = "https://files.pythonhosted.org/packages/14/fb/94933e00bb3dcfdf66ea3456739c6a51d322353f7cc64fa1f5f660e695ac/nvidia_nccl_cu13-2.31.2-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:0bcaf0308854cb55fcc35af72e2c83143f3b71e65a4e865e2c586b1cdcdb5ae0", size = 252442223, upload-time = "2026-08-11T23:22:40.341Z" }, ] [[package]] @@ -557,8 +1713,6 @@ version = "1.0.0" source = { editable = "." } dependencies = [ { name = "click" }, - { name = "dbt-core" }, - { name = "dbt-postgres" }, { name = "pandera" }, { name = "polars" }, { name = "psycopg", extra = ["binary"] }, @@ -568,17 +1722,30 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "ipykernel" }, + { name = "jupyter" }, { name = "prek" }, { name = "pytest" }, { name = "ruff" }, { name = "ty" }, ] +ml = [ + { name = "catboost" }, + { name = "folium" }, + { name = "joblib" }, + { name = "lightgbm" }, + { name = "matplotlib" }, + { name = "optuna" }, + { name = "pandas" }, + { name = "scikit-learn" }, + { name = "streamlit" }, + { name = "streamlit-folium" }, + { name = "xgboost" }, +] [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.4.1" }, - { name = "dbt-core", specifier = ">=1.11.12" }, - { name = "dbt-postgres", specifier = ">=1.10.2" }, { name = "pandera", specifier = ">=0.32.0" }, { name = "polars", specifier = ">=1.41.2" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.3" }, @@ -588,19 +1755,43 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "ipykernel", specifier = ">=7.3.0" }, + { name = "jupyter", specifier = ">=1.1.1" }, { name = "prek", specifier = ">=0.4.5" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "ruff", specifier = ">=0.15.18" }, { name = "ty", specifier = ">=0.0.51" }, ] +ml = [ + { name = "catboost", specifier = ">=1.2.10" }, + { name = "folium", specifier = ">=0.20.0" }, + { name = "joblib", specifier = ">=1.5.3" }, + { name = "lightgbm", specifier = ">=4.7.0" }, + { name = "matplotlib", specifier = ">=3.11.1" }, + { name = "optuna", specifier = ">=4.9.0" }, + { name = "pandas", specifier = ">=3.0.5" }, + { name = "scikit-learn", specifier = ">=1.9.0" }, + { name = "streamlit", specifier = ">=1.61.1" }, + { name = "streamlit-folium", specifier = ">=0.27.4" }, + { name = "xgboost", specifier = ">=3.4.1" }, +] [[package]] -name = "orderly-set" -version = "5.5.0" +name = "optuna" +version = "4.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4a/88/39c83c35d5e97cc203e9e77a4f93bf87ec89cf6a22ac4818fdcc65d66584/orderly_set-5.5.0.tar.gz", hash = "sha256:e87185c8e4d8afa64e7f8160ee2c542a475b738bc891dc3f58102e654125e6ce", size = 27414, upload-time = "2025-07-10T20:10:55.885Z" } +dependencies = [ + { name = "alembic" }, + { name = "colorlog" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "sqlalchemy" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/aa/05f5e3f662cc96a4c478fc3446b8ed6359825a2b504ecb614a9ac84e4a4d/optuna-4.9.0.tar.gz", hash = "sha256:b322e5cbdf1655fb84c37646c4a7a1f391de1b47806bbe222e015825d0a82b87", size = 485834, upload-time = "2026-06-01T06:23:30.424Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/27/fb8d7338b4d551900fa3e580acbe7a0cf655d940e164cb5c00ec31961094/orderly_set-5.5.0-py3-none-any.whl", hash = "sha256:46f0b801948e98f427b412fcabb831677194c05c3b699b80de260374baa0b1e7", size = 13068, upload-time = "2025-07-10T20:10:54.377Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f3/e5fcd5d9b15771ed6dc10e3a7eeddc672e418f4f4c4653d216cc1d857e2d/optuna-4.9.0-py3-none-any.whl", hash = "sha256:f52f3be6148654850c92a5860d398fd88ec6b2c84ab68d9c3d07dcff02e7afee", size = 425553, upload-time = "2026-06-01T06:23:28.804Z" }, ] [[package]] @@ -612,6 +1803,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pandas" +version = "3.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/54/1dc810ea558d1320b597aa140a514f2fdf1d2ea09c38cf556f13ea712ec9/pandas-3.0.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fa290c16964d4963fbfbc358928239cf3bd755b20e988ce944877def2f44471d", size = 10411717, upload-time = "2026-07-22T22:18:08.307Z" }, + { url = "https://files.pythonhosted.org/packages/68/56/fbe81c09195924d8b7b8d4461a20458fe80a6a5ed6b24f0314da684277e1/pandas-3.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2e26bb46934b8a2ca0c3de1d3d606fc5f6746584791b2db264d58cf370e08dc", size = 9957095, upload-time = "2026-07-22T22:18:10.6Z" }, + { url = "https://files.pythonhosted.org/packages/e0/51/fac252f4a913ed5eabf3c11b880a9e8d5a6c10f0b2129d0462212d238b4d/pandas-3.0.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73fa87b08a7ef706f8aafda39ddaccf2a99047bea62d8c88a0361bcafb2237bc", size = 10485458, upload-time = "2026-07-22T22:18:12.834Z" }, + { url = "https://files.pythonhosted.org/packages/12/98/e976540c1addf70442be7842a18cf70884a964abbf69442504f4d2939989/pandas-3.0.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d373ce03ffd84010ed9839fa73672a9c8256990532e158440c0085db7d914b34", size = 10998091, upload-time = "2026-07-22T22:18:15.209Z" }, + { url = "https://files.pythonhosted.org/packages/a4/8c/1f29b5be8d3fc47dd7567eb167fabba2085879b31e0287ce7cba6d3d2ff4/pandas-3.0.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a29c53d85ea98c5e792c59ef82ee9fbe6ca902c0d0adb6b23f45ef894cd7bf6", size = 11499501, upload-time = "2026-07-22T22:18:17.689Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e2/bd9c98ad2df7b38bde002adde4cdf353519da51881634323b126c55997f9/pandas-3.0.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a5ad3b02ed6bc7d7ae9b70804b2c6aa31827489d150f8e623ce82491b82085d7", size = 12060559, upload-time = "2026-07-22T22:18:20.147Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9a/ffbd852d58bd74a617fe2f8ee6a58a96982271ce41cf981eab22190b4a4b/pandas-3.0.5-cp312-cp312-pyemscripten_2024_0_wasm32.whl", hash = "sha256:b2acb4650527eec6822c3dadb2b771277b65e7dae7a267d4bccf65fd1bb3fbce", size = 7197652, upload-time = "2026-07-22T22:18:22.502Z" }, + { url = "https://files.pythonhosted.org/packages/70/b5/d2d3e9ae73362ba4229651b0ee1455cf78073a1ce585f6ff693782ce263e/pandas-3.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:80a611068e8a3ac23f7398c6c14eb46dc974e5cc9997f653e2dcfd1da74edd41", size = 9831691, upload-time = "2026-07-22T22:18:24.534Z" }, + { url = "https://files.pythonhosted.org/packages/52/51/dea1e89d6a6796b9c43f85a09b484ee03edb8a4c4842e73e200a8c11301c/pandas-3.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:25ff585b972a18ef1fe9ffa3ac6544d9950508aa76832e5147640b6022821e49", size = 9105796, upload-time = "2026-07-22T22:18:27.064Z" }, + { url = "https://files.pythonhosted.org/packages/bf/09/7b95c4a0025227d6f118c4039b423412ac6a982db02864166185d812fbc7/pandas-3.0.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c1c05a767fe8e5b4fe9e1c29806829c582052eaedb9120a3da83ba3f69e24a5b", size = 10385742, upload-time = "2026-07-22T22:18:29.346Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0c/dc78fd8c4da477b4b5e8ad37295af352190d21ef63a9ee1bc071753074cc/pandas-3.0.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b86765f268b56f7e665b93bce9d5df69dee7f99e595cf8fb839483ab315942a3", size = 9932067, upload-time = "2026-07-22T22:18:31.833Z" }, + { url = "https://files.pythonhosted.org/packages/3e/71/3592c055cf44df9808550f9368ceda80ff2b224d355ef73fe251dcda1802/pandas-3.0.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c597ecf5616b5c420372c1d4d4c00dbbfba7398bea857dcc984347e1ea48417b", size = 10466756, upload-time = "2026-07-22T22:18:34.195Z" }, + { url = "https://files.pythonhosted.org/packages/e3/70/4363150359f95b4cb4bcbb34ca23572bb5495749a621a8f3d5a1ddfd293c/pandas-3.0.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b11c36e218331d0387cbe3a0a5f75162357a1d92d57b2b08a336ff94b19b2be", size = 10938525, upload-time = "2026-07-22T22:18:36.81Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d0/317e7a0c67c0e69fa905a0161409397a7dc2d46ff611f6ca4803352c042b/pandas-3.0.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cf52e1f61d229496da17dc7ab54acdee627357e7008fd4fecba3d0ba2937fa58", size = 11489303, upload-time = "2026-07-22T22:18:39.287Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8d/36dade89b49e4f9d5cbdbe863772581f98c0c6d78fc39ad4c557f6f2e17e/pandas-3.0.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:db172144bb56422bd157812f3b021eacc255451470b31e2c633c349490a1cfee", size = 11989004, upload-time = "2026-07-22T22:18:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ba/18c4ec8a746e177da05a9e7a7963781d8ea195780724f854601b6ebd6b78/pandas-3.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:0d298e951f23016ce4699951d044ae6418dbc91bf68cefca0f77666fcbb4e5c6", size = 9826896, upload-time = "2026-07-22T22:18:44.539Z" }, + { url = "https://files.pythonhosted.org/packages/de/ec/28a57266b753799a87b8bc79e7887ac6fd981b8c6d2978a0b7e7b6bd708c/pandas-3.0.5-cp313-cp313-win_arm64.whl", hash = "sha256:66266d3442a5e8b3c90274c2b8b230bee42dd1c286bc822cc2f9f2c7e12b883e", size = 9094790, upload-time = "2026-07-22T22:18:47.468Z" }, + { url = "https://files.pythonhosted.org/packages/51/2f/cf6aae281264f4463f0875bcbb15fd2bb6d291cc535187dad1732475e4a9/pandas-3.0.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f264fc46911cc8131a7322a16199bbf8e353d27c10bb211f5bd0c814324dc36", size = 10390034, upload-time = "2026-07-22T22:18:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/06/ec/5189518c7a7659c4bdcc6b1eb32c46c6f3c86b0661ffd84143d1112c7732/pandas-3.0.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:53730687fcd161883b24e10411c06d6a4c0f2275d2faf3bb2bc25deb4ba8007c", size = 9980065, upload-time = "2026-07-22T22:18:52.249Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f1/598503ce8d7e3c35601e0747ba288c7864baae66380725bc12f13f884dfe/pandas-3.0.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:960d3ebcf249f75206899fcd2c6de53f736b7265759ced0d3e559df0b8b709b0", size = 10545532, upload-time = "2026-07-22T22:18:54.813Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/ceae2adf7034e07e9910299fe412e1819c4f0dd520700a888bcb03625448/pandas-3.0.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e94c2c5ca43bd3ca32bf64d32308887b65e5f9bfd8023ea52755107a999f93b", size = 10963120, upload-time = "2026-07-22T22:18:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/66/25/86e0f4451874eb79e688deeebe3c451fec4557f8952005818d800ee8ac7e/pandas-3.0.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e819dd5f62966b481a8cb649d3299ebd886a1ea91ed5a99bf7ce77c98d18ab94", size = 11563178, upload-time = "2026-07-22T22:18:59.729Z" }, + { url = "https://files.pythonhosted.org/packages/f3/45/8643daa3b4147e433adfcccefdd0380d3aad79d86b15d8999730fe1944d5/pandas-3.0.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c5ed2e7c06e91d340dfd091d7934f9bc82e4a36b95f647f090b9d1c9ac649da", size = 12028708, upload-time = "2026-07-22T22:19:02.164Z" }, + { url = "https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:cd8f7c6dc98527058ee6264219343f5392240a6f1bfa654fc5d79023020d0c92", size = 9951806, upload-time = "2026-07-22T22:19:04.596Z" }, + { url = "https://files.pythonhosted.org/packages/69/32/7ac03886b304049a9d2625ee88f59af760d8a93bd30ed9239bce7b9869a8/pandas-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:5183427f5a8156d480f30333777bc978be93650a49a7c01db26adffe95b31e85", size = 9238297, upload-time = "2026-07-22T22:19:06.836Z" }, + { url = "https://files.pythonhosted.org/packages/be/ed/1d1f2ee5547d5167face2376d11c8b2a4c7bfff5a416ee7a9046891fab1e/pandas-3.0.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:303da736987d481074ca720ada325f8bd80c64ebc2d45ed79b29df3aaa4a26ca", size = 10849690, upload-time = "2026-07-22T22:19:09.391Z" }, + { url = "https://files.pythonhosted.org/packages/57/55/17e17152e98fbb0c4b1e562bc65387a2f20a80db0f4a86bf8d3a0e4248d4/pandas-3.0.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3b2801bbb049d0136f6c213eae02b5fca969384fc2064dd728d8620552aa49da", size = 10509945, upload-time = "2026-07-22T22:19:11.773Z" }, + { url = "https://files.pythonhosted.org/packages/88/90/817d44dbf83facf9556f33576d9af0a241981e7bb5c00606c0bcb5df8dda/pandas-3.0.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cce3a9d11d2b1f82c69a27ec1f4948a170e2c403c4bbfa8cca62e3fdebe2ef3a", size = 10392197, upload-time = "2026-07-22T22:19:14.024Z" }, + { url = "https://files.pythonhosted.org/packages/f1/da/889f00c0a6f5aa1545add70abbf01502dff87ab577adb855bd631c54d2f2/pandas-3.0.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef01af4d8dc6cd2c8d6c7736f149574ef93fe043811eeb5e445f2647154b5040", size = 10862726, upload-time = "2026-07-22T22:19:16.351Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/f1e934fb3c98fce859c6147c6785816c7b5b9ab7821115c5d8c4de9842b9/pandas-3.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e2759e890db96dfcffdbd9b86c3c2cb6afaf58def482820317e06163ec1066cd", size = 11414864, upload-time = "2026-07-22T22:19:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/fe/be/d448af7d657d82e1888dd8551f79c6d6fb161080b5b9752d84d910ec2319/pandas-3.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b58b1b39d46a5862e3fb18f50d1a201398619d16a0f9f73f57eea5583cf0e63c", size = 11925105, upload-time = "2026-07-22T22:19:21.515Z" }, + { url = "https://files.pythonhosted.org/packages/29/c1/ccb4238212c8c4f496c584f3044d94e0c030ed8e1d68999db46c91c2242f/pandas-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:1c10461f6eeb35d8f05b6184c65c8b9991663b66c46b1d559b682cb34ae7c6ea", size = 10387612, upload-time = "2026-07-22T22:19:24.257Z" }, + { url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" }, +] + [[package]] name = "pandera" version = "0.32.0" @@ -629,21 +1866,126 @@ wheels = [ ] [[package]] -name = "parsedatetime" -version = "2.6" +name = "pandocfilters" +version = "1.5.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a8/20/cb587f6672dbe585d101f590c3871d16e7aec5a576a1694997a3777312ac/parsedatetime-2.6.tar.gz", hash = "sha256:4cb368fbb18a0b7231f4d76119165451c8d2e35951455dfee97c62a87b04d455", size = 60114, upload-time = "2020-05-31T23:50:57.443Z" } +sdist = { url = "https://files.pythonhosted.org/packages/70/6f/3dd4940bbe001c06a65f88e36bad298bc7a0de5036115639926b0c5c0458/pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e", size = 8454, upload-time = "2024-01-18T20:08:13.726Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/a4/3dd804926a42537bf69fb3ebb9fd72a50ba84f807d95df5ae016606c976c/parsedatetime-2.6-py3-none-any.whl", hash = "sha256:cb96edd7016872f58479e35879294258c71437195760746faffedb692aef000b", size = 42548, upload-time = "2020-05-31T23:50:56.315Z" }, + { url = "https://files.pythonhosted.org/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc", size = 8663, upload-time = "2024-01-18T20:08:11.28Z" }, ] [[package]] -name = "pathspec" -version = "0.12.1" +name = "parso" +version = "0.8.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +dependencies = [ + { name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/d7/e7bfbc86e9f99ff7807e24de7703f032e9c9ba80bb355cf26e0e9bc5a75e/platformdirs-4.11.3.tar.gz", hash = "sha256:66a73d38a849810252df809a3d8bcbda8e26f6c189920e7535ad608a48dbb5ab", size = 33050, upload-time = "2026-08-13T22:43:27.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/a9/c34aebedd3a4c9afe5101b1b8713710b3fec18087c8a36c35d2f909861bd/platformdirs-4.11.3-py3-none-any.whl", hash = "sha256:5ed065d443751de711da036041a7a214122efc4a4de393b3f4137ba5576540e7", size = 23491, upload-time = "2026-08-13T22:43:26.121Z" }, +] + +[[package]] +name = "plotly" +version = "6.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "narwhals" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/07/795c79dbce40c39bece88e69d049babbd23ffa95b5d117f248db8ea03abb/plotly-6.9.0.tar.gz", hash = "sha256:967ad33e8c704fed051800d11d985eb206a9c795c14206b30a6f463ed9c67d0d", size = 6919903, upload-time = "2026-07-09T14:55:59.982Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, + { url = "https://files.pythonhosted.org/packages/24/18/d8544811ab076f876c4892b3714f5b0dad335e1dc33aef826df431b8325d/plotly-6.9.0-py3-none-any.whl", hash = "sha256:36bebe2f1bb13884774fe61689c329071446f6ce4a8927fb1f0d6fb24f581236", size = 9909646, upload-time = "2026-07-09T14:55:55.421Z" }, ] [[package]] @@ -707,19 +2049,68 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/97/0e/589ff0eab9034909b1ec8654ee03483797305fb743b3554ce6140d82da9d/prek-0.4.5-py3-none-win_arm64.whl", hash = "sha256:646a86a1a082dbd99fed96314b1064f5644bb34c1f4037a63547a18e2160fb86", size = 5509019, upload-time = "2026-06-15T11:36:46.595Z" }, ] +[[package]] +name = "prometheus-client" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/73/f1334c29c2af4cd9dba6c7817e61b611bd0215e2eb5565c6064a4de18802/prometheus_client-0.26.0.tar.gz", hash = "sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b", size = 92910, upload-time = "2026-07-24T19:36:41.893Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl", hash = "sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6", size = 64494, upload-time = "2026-07-24T19:36:40.854Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.53" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6", size = 435492, upload-time = "2026-07-26T20:56:14.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" }, +] + [[package]] name = "protobuf" -version = "6.33.6" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, - { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, - { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, - { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, - { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, - { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, - { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] [[package]] @@ -781,44 +2172,73 @@ wheels = [ ] [[package]] -name = "psycopg2-binary" -version = "2.9.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2a/60/a3624f79acea344c16fbef3a94d28b89a8042ddfb8f3e4ca83f538671409/psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c", size = 379686, upload-time = "2026-04-21T09:40:34.304Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/9f/ef4ef3c8e15083df90ca35265cfd1a081a2f0cc07bb229c6314c6af817f4/psycopg2_binary-2.9.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433", size = 3712459, upload-time = "2026-04-20T23:34:30.549Z" }, - { url = "https://files.pythonhosted.org/packages/b5/01/3dd14e46ba48c1e1a6ec58ee599fa1b5efa00c246d5046cd903d0eeb1af1/psycopg2_binary-2.9.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6", size = 3822936, upload-time = "2026-04-20T23:34:32.77Z" }, - { url = "https://files.pythonhosted.org/packages/a6/f7/0640e4901119d8a9f7a1784b927f494e2198e213ceb593753d1f2c8b1b30/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580", size = 4578676, upload-time = "2026-04-20T23:34:35.18Z" }, - { url = "https://files.pythonhosted.org/packages/b0/55/44df3965b5f297c50cc0b1b594a31c67d6127a9d133045b8a66611b14dfb/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f", size = 4274917, upload-time = "2026-04-20T23:34:37.982Z" }, - { url = "https://files.pythonhosted.org/packages/b0/4b/74535248b1eac0c9336862e8617c765ac94dac76f9e25d7c4a79588c8907/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d", size = 5894843, upload-time = "2026-04-20T23:34:40.856Z" }, - { url = "https://files.pythonhosted.org/packages/f2/ba/f1bf8d2ae71868ad800b661099086ee52bc0f8d9f05be1acd8ebb06757cc/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9", size = 4110556, upload-time = "2026-04-20T23:34:44.016Z" }, - { url = "https://files.pythonhosted.org/packages/45/46/c15706c338403b7c420bcc0c2905aad116cc064545686d8bf85f1999ea00/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d", size = 3655714, upload-time = "2026-04-20T23:34:46.233Z" }, - { url = "https://files.pythonhosted.org/packages/b3/7c/a2d5dc09b64a4564db242a0fe418fde7d33f6f8259dd2c5b9d7def00fb5a/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e", size = 3301154, upload-time = "2026-04-20T23:34:49.528Z" }, - { url = "https://files.pythonhosted.org/packages/c0/e8/cc8c9a4ce71461f9ec548d38cadc41dc184b34c73e6455450775a9334ccd/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6", size = 3048882, upload-time = "2026-04-20T23:34:51.86Z" }, - { url = "https://files.pythonhosted.org/packages/19/6a/31e2296bc0787c5ab75d3d118e40b239db8151b5192b90b77c72bc9256e9/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b", size = 3351298, upload-time = "2026-04-20T23:34:54.124Z" }, - { url = "https://files.pythonhosted.org/packages/5f/a8/75f4e3e11203b590150abed2cf7794b9c9c9f7eceddae955191138b44dde/psycopg2_binary-2.9.12-cp312-cp312-win_amd64.whl", hash = "sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c", size = 2757230, upload-time = "2026-04-20T23:34:56.242Z" }, - { url = "https://files.pythonhosted.org/packages/91/bb/4608c96f970f6e0c56572e87027ef4404f709382a3503e9934526d7ba051/psycopg2_binary-2.9.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965", size = 3712419, upload-time = "2026-04-20T23:34:58.754Z" }, - { url = "https://files.pythonhosted.org/packages/5e/af/48f76af9d50d61cf390f8cd657b503168b089e2e9298e48465d029fcc713/psycopg2_binary-2.9.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7", size = 3822990, upload-time = "2026-04-20T23:35:00.821Z" }, - { url = "https://files.pythonhosted.org/packages/7a/df/aba0f99397cd811d32e06fc0cc781f1f3ce98bc0e729cb423925085d781a/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777", size = 4578696, upload-time = "2026-04-20T23:35:03.409Z" }, - { url = "https://files.pythonhosted.org/packages/95/9c/eaa74021ac4e4d5c2f83d82fc6615a63f4fe6c94dc4e94c3990427053f67/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5", size = 4274982, upload-time = "2026-04-20T23:35:05.583Z" }, - { url = "https://files.pythonhosted.org/packages/35/ed/c25deff98bd26187ba48b3b250a3ffc3037c46c5b89362534a15d200e0db/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9", size = 5894867, upload-time = "2026-04-20T23:35:07.902Z" }, - { url = "https://files.pythonhosted.org/packages/9a/81/8d0e21ca77373c6c9589e5c4528f6e8f0c08c62cafc76fb0bddb7a2cee22/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019", size = 4110578, upload-time = "2026-04-20T23:35:10.149Z" }, - { url = "https://files.pythonhosted.org/packages/00/fc/f481e2435bd8f742d0123309174aae4165160ad3ef17c1b99c3622c241d2/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c", size = 3655816, upload-time = "2026-04-20T23:35:12.56Z" }, - { url = "https://files.pythonhosted.org/packages/53/79/b9f46466bdbe9f239c96cde8be33c1aace4842f06013b47b730dc9759187/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f", size = 3301307, upload-time = "2026-04-20T23:35:15.029Z" }, - { url = "https://files.pythonhosted.org/packages/3f/19/7dc003b32fe35024df89b658104f7c8538a8b2dcbde7a4e746ce929742e7/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be", size = 3048968, upload-time = "2026-04-20T23:35:16.757Z" }, - { url = "https://files.pythonhosted.org/packages/91/58/2dbd7db5c604d45f4950d988506aae672a14126ec22998ced5021cbb76bb/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290", size = 3351369, upload-time = "2026-04-20T23:35:18.933Z" }, - { url = "https://files.pythonhosted.org/packages/42/ee/dee8dcaad07f735824de3d6563bc67119fa6c28257b17977a8d624f02fab/psycopg2_binary-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0", size = 2757347, upload-time = "2026-04-20T23:35:21.283Z" }, - { url = "https://files.pythonhosted.org/packages/13/1b/708c0dca874acfad6d65314271859899a79007686f3a1f74e82a2ed4b645/psycopg2_binary-2.9.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2", size = 3712428, upload-time = "2026-04-20T23:35:23.453Z" }, - { url = "https://files.pythonhosted.org/packages/d6/39/ddbea9d4b4de6aca9431b6ed253f530f8a02d3b8f9bcfd0dbfe2b3de6fe4/psycopg2_binary-2.9.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2", size = 3823184, upload-time = "2026-04-20T23:35:25.92Z" }, - { url = "https://files.pythonhosted.org/packages/bf/a0/bc2fef74b106fa345567122a0659e6d94512ed7dc0131ec44c9e5aba3725/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f", size = 4579157, upload-time = "2026-04-20T23:35:28.542Z" }, - { url = "https://files.pythonhosted.org/packages/57/d7/d4e3b2005d3de607ca4fbb0e8742e248056e52184a6b94ebda3c1c2c329b/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354", size = 4274970, upload-time = "2026-04-20T23:35:30.418Z" }, - { url = "https://files.pythonhosted.org/packages/2e/42/c9853f8db3967fe08bcde11f53d53b85d351750cae726ce001cb68afa9c1/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033", size = 5895175, upload-time = "2026-04-20T23:35:33.584Z" }, - { url = "https://files.pythonhosted.org/packages/eb/fd/b82b5601a97630308bef079f545ffec481bbbc795c2ba5ec416a01d03f60/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e", size = 4110658, upload-time = "2026-04-20T23:35:35.638Z" }, - { url = "https://files.pythonhosted.org/packages/62/8c/32ca69b0389ef25dd22937bf9e8fbe2ce27aea20b05ded48c4ce4cb42475/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5", size = 3656251, upload-time = "2026-04-20T23:35:37.854Z" }, - { url = "https://files.pythonhosted.org/packages/c4/29/96992a2b59e3b9d730fcf9612d0a387305025dc867a9fc490a9e496e074e/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5", size = 3301810, upload-time = "2026-04-20T23:35:39.927Z" }, - { url = "https://files.pythonhosted.org/packages/56/ad/44b06659949b243ae10112cd3b20a197f9bf3e81d5651379b9eb889bfaad/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d", size = 3048977, upload-time = "2026-04-20T23:35:41.806Z" }, - { url = "https://files.pythonhosted.org/packages/1d/f2/10a1bcebadb6aa55e280e1f58975c36a7b560ea525184c7aa4064c466633/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd", size = 3351466, upload-time = "2026-04-20T23:35:43.993Z" }, - { url = "https://files.pythonhosted.org/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", size = 2848762, upload-time = "2026-04-20T23:35:46.421Z" }, +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, +] + +[[package]] +name = "pyarrow" +version = "24.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", size = 1180261, upload-time = "2026-04-21T10:51:25.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/a9/9686d9f07837f91f775e8932659192e02c74f9d8920524b480b85212cc68/pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810", size = 34981559, upload-time = "2026-04-21T10:47:22.17Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/0ddf0e9b6ead3474ab087ae598c76b031fc45532bf6a63f3a553440fb258/pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a", size = 36663654, upload-time = "2026-04-21T10:47:28.315Z" }, + { url = "https://files.pythonhosted.org/packages/7c/3b/926382efe8ce27ba729071d3566ade6dfb86bdf112f366000196b2f5780a/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66", size = 45679394, upload-time = "2026-04-21T10:47:34.821Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb", size = 48863122, upload-time = "2026-04-21T10:47:42.056Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e", size = 49376032, upload-time = "2026-04-21T10:47:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6", size = 51929490, upload-time = "2026-04-21T10:47:55.981Z" }, + { url = "https://files.pythonhosted.org/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826", size = 27355660, upload-time = "2026-04-21T10:48:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d3/a1abf004482026ddc17f4503db227787fa3cfe41ec5091ff20e4fea55e57/pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba", size = 34976759, upload-time = "2026-04-21T10:48:07.258Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4a/34f0a36d28a2dd32225301b79daad44e243dc1a2bb77d43b60749be255c4/pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68", size = 36658471, upload-time = "2026-04-21T10:48:13.347Z" }, + { url = "https://files.pythonhosted.org/packages/1f/78/543b94712ae8bb1a6023bcc1acf1a740fbff8286747c289cd9468fced2a5/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2", size = 45675981, upload-time = "2026-04-21T10:48:20.201Z" }, + { url = "https://files.pythonhosted.org/packages/84/9f/8fb7c222b100d314137fa40ec050de56cd8c6d957d1cfff685ce72f15b17/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0", size = 48859172, upload-time = "2026-04-21T10:48:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d3/1ea72538e6c8b3b475ed78d1049a2c518e655761ea50fe1171fc855fcab7/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495", size = 49385733, upload-time = "2026-04-21T10:48:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/c3/be/c3d8b06a1ba35f2260f8e1f771abbee7d5e345c0937aab90675706b1690a/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f", size = 51934335, upload-time = "2026-04-21T10:48:42.099Z" }, + { url = "https://files.pythonhosted.org/packages/9c/62/89e07a1e7329d2cde3e3c6994ba0839a24977a2beda8be6005ea3d860b99/pyarrow-24.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91", size = 27271748, upload-time = "2026-04-21T10:49:42.532Z" }, + { url = "https://files.pythonhosted.org/packages/17/1a/cff3a59f80b5b1658549d46611b67163f65e0664431c076ad728bf9d5af4/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275", size = 35238554, upload-time = "2026-04-21T10:48:48.526Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/cce0f42a327bfef2c420fb6078a3eb834826e5d6697bf3009fe11d2ad051/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b", size = 36782301, upload-time = "2026-04-21T10:48:55.181Z" }, + { url = "https://files.pythonhosted.org/packages/2a/66/8e560d5ff6793ca29aca213c53eec0dd482dd46cb93b2819e5aab52e4252/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42", size = 45721929, upload-time = "2026-04-21T10:49:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/27/0c/a26e25505d030716e078d9f16eb74973cbf0b33b672884e9f9da1c83b871/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b", size = 48825365, upload-time = "2026-04-21T10:49:11.714Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/771f9ecb0c65e73fe9dccdd1717901b9594f08c4515d000c7c62df573811/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37", size = 49451819, upload-time = "2026-04-21T10:49:21.474Z" }, + { url = "https://files.pythonhosted.org/packages/48/da/61ae89a88732f5a785646f3ec6125dbb640fa98a540eb2b9889caa561403/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca", size = 51909252, upload-time = "2026-04-21T10:49:31.164Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1a/8dd5cafab7b66573fa91c03d06d213356ad4edd71813aa75e08ce2b3a844/pyarrow-24.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d", size = 27388127, upload-time = "2026-04-21T10:49:37.334Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/d022a34ff05d2cbedd8ccf841fc1f532ecfa9eb5ed1711b56d0e0ea71fc9/pyarrow-24.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838", size = 35007997, upload-time = "2026-04-21T10:49:48.796Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ff/f01485fda6f4e5d441afb8dd5e7681e4db18826c1e271852f5d3957d6a80/pyarrow-24.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b", size = 36678720, upload-time = "2026-04-21T10:49:55.858Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/2d2d5fea814237923f71b36495211f20b43a1576f9a4d6da7e751a64ec6f/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795", size = 45741852, upload-time = "2026-04-21T10:50:04.624Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3a/28ba9c1c1ebdbb5f1b94dfebb46f207e52e6a554b7fe4132540fde29a3a0/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26", size = 48889852, upload-time = "2026-04-21T10:50:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/df/51/4a389acfd31dca009f8fb82d7f510bb4130f2b3a8e18cf00194d0687d8ac/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde", size = 49445207, upload-time = "2026-04-21T10:50:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/0bab2b23d2ae901b1b9a03c0efd4b2d070256f8ce3fc43f6e58c167b2081/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76", size = 51954117, upload-time = "2026-04-21T10:50:29.14Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/f4e9145da0417b3d2c12035a8492b35ff4a3dbc653e614fcfb51d9dedb38/pyarrow-24.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e", size = 28001155, upload-time = "2026-04-21T10:51:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/79/4f/46a49a63f43526da895b1a45bbb51d5baf8e4d77159f8528fc3e5490007f/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05", size = 35250387, upload-time = "2026-04-21T10:50:35.552Z" }, + { url = "https://files.pythonhosted.org/packages/a0/da/d5e0cd5ef00796922404806d5f00325cdadc3441ce2c13fe7115f2df9a64/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a", size = 36797102, upload-time = "2026-04-21T10:50:42.417Z" }, + { url = "https://files.pythonhosted.org/packages/34/c7/5904145b0a593a05236c882933d439b5720f0a145381179063722fbfc123/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072", size = 45745118, upload-time = "2026-04-21T10:50:49.324Z" }, + { url = "https://files.pythonhosted.org/packages/13/d3/cca42fe166d1c6e4d5b80e530b7949104d10e17508a90ae202dac205ce2a/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931", size = 48844765, upload-time = "2026-04-21T10:50:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/b0/49/942c3b79878ba928324d1e17c274ed84581db8c0a749b24bcf4cbdf15bd3/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699", size = 49471890, upload-time = "2026-04-21T10:51:02.439Z" }, + { url = "https://files.pythonhosted.org/packages/76/97/ff71431000a75d84135a1ace5ca4ba11726a231a8007bbb320a4c54075d5/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136", size = 51932250, upload-time = "2026-04-21T10:51:10.576Z" }, + { url = "https://files.pythonhosted.org/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282, upload-time = "2026-04-21T10:51:16.815Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] [[package]] @@ -925,6 +2345,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, ] +[[package]] +name = "pydeck" +version = "0.9.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/c9/f71032fca47ecc09d30904d9a610234b07139f89eabc2f054b141edcc30f/pydeck-0.9.3.tar.gz", hash = "sha256:695775cbfe51f5fdffbd9735ba469987fdc5efc96bc40a0ee4808170509c78b2", size = 5900912, upload-time = "2026-07-02T23:27:08.704Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/34/3998411437aff304a9ed4fa37a6fe1ef3132bcd2b5eac59851b80c86123c/pydeck-0.9.3-py2.py3-none-any.whl", hash = "sha256:d8a47c11c81fb12d51b1feb42427ff4f0e13cb599e48931021b2cba98b6849a6", size = 11428091, upload-time = "2026-07-02T23:27:06.399Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -934,6 +2367,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + [[package]] name = "pytest" version = "9.1.1" @@ -972,33 +2414,39 @@ wheels = [ ] [[package]] -name = "python-slugify" -version = "8.0.4" +name = "python-json-logger" +version = "4.1.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "text-unidecode" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/ff/3cc9165fd44106973cd7ac9facb674a65ed853494592541d339bdc9a30eb/python_json_logger-4.1.0.tar.gz", hash = "sha256:b396b9e3ed782b09ff9d6e4f1683d46c83ad0d35d2e407c09a9ebbf038f88195", size = 17573, upload-time = "2026-03-29T04:39:56.805Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" }, + { url = "https://files.pythonhosted.org/packages/27/be/0631a861af4d1c875f096c07d34e9a63639560a717130e7a87cbc82b7e3f/python_json_logger-4.1.0-py3-none-any.whl", hash = "sha256:132994765cf75bf44554be9aa49b06ef2345d23661a96720262716438141b6b2", size = 15021, upload-time = "2026-03-29T04:39:55.266Z" }, ] [[package]] -name = "pytimeparse" -version = "1.1.8" +name = "python-multipart" +version = "0.0.32" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/37/5d/231f5f33c81e09682708fb323f9e4041408d8223e2f0fb9742843328778f/pytimeparse-1.1.8.tar.gz", hash = "sha256:e86136477be924d7e670646a98561957e8ca7308d44841e21f5ddea757556a0a", size = 9403, upload-time = "2018-05-18T17:40:42.76Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/b4/afd75551a3b910abd1d922dbd45e49e5deeb4d47dc50209ce489ba9844dd/pytimeparse-1.1.8-py2.py3-none-any.whl", hash = "sha256:04b7be6cc8bd9f5647a6325444926c3ac34ee6bc7e69da4367ba282f076036bd", size = 9969, upload-time = "2018-05-18T17:40:41.28Z" }, + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] [[package]] -name = "pytz" -version = "2026.2" +name = "pywinpty" +version = "3.0.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/ef/2d27f30c59a67be7025b2d7858c8c2d282b74d66544b2384730b82de74fd/pywinpty-3.0.5.tar.gz", hash = "sha256:61db0db063de9865adbea66db294628f8577f608d9764a4c7d3384eeacc4e81b", size = 16223484, upload-time = "2026-06-11T00:11:58.93Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, + { url = "https://files.pythonhosted.org/packages/45/34/942cc95ca4e26489875aa8a95192766247a687379ec29543eebe73ec945f/pywinpty-3.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:d62946adf14b15b54c0b8d785f93fe18b04da23f4ad59e2e8c4612646e9abd23", size = 2090915, upload-time = "2026-06-10T23:43:14.98Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/5b9053004844139ea8bd86209c57ade12b134b2782f383a095784c8531ec/pywinpty-3.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:e9391c05fbfa7a992a97e831fc6849887b4014a614192e3d984a7ca59592b376", size = 815934, upload-time = "2026-06-10T23:41:42.384Z" }, + { url = "https://files.pythonhosted.org/packages/b9/f4/2a464b9893cceb3b3f416356e94fdc3e1bca9476993927e4e6d99fe95382/pywinpty-3.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:48db1b0ad9d0a1b81dcaaa7163a99a7808deaceb0c1b2344716dc1fc090c3c4c", size = 2090471, upload-time = "2026-06-10T23:42:11.071Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2c/a138491a0afbdb50eb79395577bd326d4b0fbde7209417d1a8087ff2493a/pywinpty-3.0.5-cp313-cp313-win_arm64.whl", hash = "sha256:2c6008fb2d3774b48693b2fcb7f2cc317ade9dc581289a964ffeeaf81307c9b5", size = 815518, upload-time = "2026-06-10T23:42:02.363Z" }, + { url = "https://files.pythonhosted.org/packages/6f/15/54400049a380582acd1282665c70fcf11e0bd3713679aca78e24c3aae738/pywinpty-3.0.5-cp313-cp313t-win_amd64.whl", hash = "sha256:22ce1b780d89821cc52daf6eac0708af22d93d000ce9c7c07e37489db8594598", size = 2089920, upload-time = "2026-06-10T23:44:13.395Z" }, + { url = "https://files.pythonhosted.org/packages/94/0c/6f24f3c0799f502259b24bdf841a99ad2b0d59df5c2525b4e2a286d14be2/pywinpty-3.0.5-cp313-cp313t-win_arm64.whl", hash = "sha256:9c2919a81bc5cfb09b86fc5a002112b2de95ca4304a07413cbeeb746a1307a5c", size = 814520, upload-time = "2026-06-10T23:43:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/e9/23/f3cd1b1e5fc56517f54452c49f92049e7dd9ffc8a63de22a495581f50d04/pywinpty-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:03bb3c16d691d9242267201830bcd0e64a9b663170e9042bc84b210da9de15ac", size = 2090663, upload-time = "2026-06-10T23:43:59.845Z" }, + { url = "https://files.pythonhosted.org/packages/9d/dd/96d6cbfc6d9ddab5c1c2f92c26545ae8997446a2ba7ee2024cd43c81f49b/pywinpty-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:89c5c6ef08997a3b4b277b214a35fe15cab4dd6d119f0140aa71df5b1168fdbc", size = 815700, upload-time = "2026-06-10T23:40:50.001Z" }, + { url = "https://files.pythonhosted.org/packages/30/36/d98087bce0acaa4cce7f196103cfa7be3f63ce65f52473bb3e38784ae5d9/pywinpty-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7b566165e0c5fdd6abe167a5ac8b954be6a843eb55a85946576d6bc1dea03d6d", size = 2090093, upload-time = "2026-06-10T23:40:58.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/fd/fe2b0db922ba052ce3976a08f3fc05d0c05047c8b4ebb6102e832b8ef563/pywinpty-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:24366280a8aa677323da87bec729cb3ea3b35367386cece0978bdc6e4695c690", size = 814517, upload-time = "2026-06-10T23:42:34.946Z" }, ] [[package]] @@ -1047,6 +2495,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "pyzmq" +version = "27.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, + { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" }, + { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" }, + { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" }, + { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" }, + { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" }, + { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" }, + { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" }, + { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" }, + { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" }, + { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, +] + [[package]] name = "referencing" version = "0.37.0" @@ -1076,6 +2567,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, +] + +[[package]] +name = "rfc3986-validator" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/88/f270de456dd7d11dcc808abfa291ecdd3f45ff44e3b549ffa01b126464d0/rfc3986_validator-0.1.1.tar.gz", hash = "sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055", size = 6760, upload-time = "2019-10-28T16:00:19.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/51/17023c0f8f1869d8806b979a2bffa3f861f26a3f1a66b094288323fba52f/rfc3986_validator-0.1.1-py2.py3-none-any.whl", hash = "sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9", size = 4242, upload-time = "2019-10-28T16:00:13.976Z" }, +] + +[[package]] +name = "rfc3987-syntax" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lark" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/06/37c1a5557acf449e8e406a830a05bf885ac47d33270aec454ef78675008d/rfc3987_syntax-1.1.0.tar.gz", hash = "sha256:717a62cbf33cffdd16dfa3a497d81ce48a660ea691b1ddd7be710c22f00b4a0d", size = 14239, upload-time = "2025-07-18T01:05:05.015Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/71/44ce230e1b7fadd372515a97e32a83011f906ddded8d03e3c6aafbdedbb7/rfc3987_syntax-1.1.0-py3-none-any.whl", hash = "sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f", size = 8046, upload-time = "2025-07-18T01:05:03.843Z" }, +] + [[package]] name = "rpds-py" version = "2026.6.3" @@ -1197,6 +2721,105 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/4c/67bb45e41609eb4726f1bfeb59e083cf91d14c696d4bd14c234a980be93d/ruff-0.15.18-py3-none-win_arm64.whl", hash = "sha256:b2c9257fcbd4a3e5b977a1904e6facca016bafe2edc17df24db67cfaee03b4e4", size = 11329958, upload-time = "2026-06-18T18:25:43.686Z" }, ] +[[package]] +name = "scikit-learn" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "narwhals" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/20/75f915ff375d6249e6550ac740fdbbd66159a068fd3af1400ff62036b07a/scikit_learn-1.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2bd41b0d201bc81575531b96b713d3eb5e5f50fb0b82101ff0f92294fdc236ac", size = 8741122, upload-time = "2026-06-02T11:53:24.08Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d5/2b5148f2279196775e1db2aeb85d14b70ac80e7e32b3b28e7ebeafb0901d/scikit_learn-1.9.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5be45aa4a42a68a533913a6ed736cf309de2226411c79ef8d609a5456f1939b1", size = 8261512, upload-time = "2026-06-02T11:53:27.183Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ee/5adbc77656b71f9456a2f5a7a9fdb4bcf9207a6b962889f1c2f9323afa4e/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e50ed4da51974e86e940690e9a3d82e729b62b5a49f7c9bac534d515d39d86f", size = 8837603, upload-time = "2026-06-02T11:53:30.328Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c2/63fdda36c56437eeb44aaf9493c8bcd62ce230ab1598924fc626ffbfa943/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:056c92bb67ad4c28463c2f2653d9701449201e7e7a9e94e321be0f71c4fef2b8", size = 9132097, upload-time = "2026-06-02T11:53:33.456Z" }, + { url = "https://files.pythonhosted.org/packages/83/a4/c8e67227c680e2259c8864ae72ff48b06e16a6f51253a22167aa02a8aa4e/scikit_learn-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:4306775fad04cc4b472a1b15af1ae9cede1540fbfcc17fbce3767cd8dc7ae283", size = 8211173, upload-time = "2026-06-02T11:53:36.602Z" }, + { url = "https://files.pythonhosted.org/packages/cf/fd/3c0863792e98e67e9184aa4029288a175935eb65443afcd30d4f143450cf/scikit_learn-1.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:26e22435f63bcdcf396b574273f29f13dd531f5ea035801f5be10ba1540a4e60", size = 7867451, upload-time = "2026-06-02T11:53:39.075Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/cf3310626b6d48d3e9be69a1223f9180360b5e6edb045f50fade723ce494/scikit_learn-1.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:80746d63bd4b6eaca54d36fe5feaf4d28bb38dc6f9470f81c7cad7c40155f119", size = 8705188, upload-time = "2026-06-02T11:53:41.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/04/5acd7ae280c5f93b6ac5ef6cdec14eef4c8d1cd91d85b3292989c94d96b1/scikit_learn-1.9.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5b934c45c252844a91d69fda3a34cff5e7307e1db10d77cb10a3980312c74713", size = 8228299, upload-time = "2026-06-02T11:53:44.817Z" }, + { url = "https://files.pythonhosted.org/packages/0c/39/ffe829a5b8ecb40a518724a997794657fdc354ada5e8fe8e64d998c0bac9/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:38c3dcb9a1ffb85505ec53d54c7b4aea0cff70050425a7760c2af661ac85df05", size = 8789690, upload-time = "2026-06-02T11:53:47.461Z" }, + { url = "https://files.pythonhosted.org/packages/1f/88/8dab5de10c638c083772a6be83a3d8106ced492f74a928c8693638e5bb50/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da76d09304a4706db7cc1e3ebaa3b6b98a67365cc11d2996c4f1e58ba47df714", size = 9087723, upload-time = "2026-06-02T11:53:50.702Z" }, + { url = "https://files.pythonhosted.org/packages/20/3f/7917ca72464038f6240ec70c29f94862d08a34a74291ae4d4ec5eb8186a0/scikit_learn-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:5808d98f15c6bf6d9d96d2348c1997392a5888ce7097e664105f930c4bca1277", size = 8184330, upload-time = "2026-06-02T11:53:53.396Z" }, + { url = "https://files.pythonhosted.org/packages/78/c7/15739eb2f61fda3c54639e9942414e5a19ad8a8d1f5a3266afad7cb7df80/scikit_learn-1.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:d77f54c017633791bc0225a43e2f8d03745fdcfe4880268fcc4df15f505dec2e", size = 7840653, upload-time = "2026-06-02T11:53:56.035Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/c9a35cf59b20a86fec24d306f1547b78dec194b08d367ce2a3e4854169d9/scikit_learn-1.9.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9656acd4e93f74e0b66c8a36c88830a99252dfa900044d36bc2212ae89a47162", size = 8713289, upload-time = "2026-06-02T11:53:58.788Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a7/552a7821597c632b907f7bfe8f36f9f572777af8ef8a48353041cf8e091a/scikit_learn-1.9.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:24360002ae845e7866522b0a5bbf690802e7bc388cac8663502e78aa98598aa2", size = 8245141, upload-time = "2026-06-02T11:54:01.694Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/f4a0c4fe9711154cddabf913471153af79056382ddc612cfe5ee0ff4b72e/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5162ad10a418c8a282dde04c9aa06965de3e9a65f33c1440c0ae69bb1a09d913", size = 8847671, upload-time = "2026-06-02T11:54:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/f0/af/4d72d9e475ac83719160c662619e4bf7b95c19507cd582e7d0167a3c3dae/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fea2cc5677ab49d6f5bade978c866da44957b712d92e9635e8b4f723013c3cb", size = 9118104, upload-time = "2026-06-02T11:54:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/a2/d5/6a58eea2cb9abbb9b3f2bb8b2cfb3243d1152d69f442d256c7af71304769/scikit_learn-1.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:64fa347efc1c839c487433e40c5144d38c336e8a2b59c81aa8660373945c2673", size = 8290674, upload-time = "2026-06-02T11:54:10.087Z" }, + { url = "https://files.pythonhosted.org/packages/65/5b/d4c879cf358f1187141cf90ced473f087183489090244f50c124a2ee478b/scikit_learn-1.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:1b944b6db288f6b926e3650026ddafb988929de95d11fc2cc5fa117773c9ba42", size = 7978807, upload-time = "2026-06-02T11:54:12.769Z" }, + { url = "https://files.pythonhosted.org/packages/8a/43/bfae3121ec67ae09150d453c442c7c1cc166e9aefe056e6ab3b7728a5cfc/scikit_learn-1.9.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4ccacf04ca5f4b492158a5f28afe0ace43f81b2571e4b9a66d34848b46128949", size = 9031941, upload-time = "2026-06-02T11:54:15.436Z" }, + { url = "https://files.pythonhosted.org/packages/75/b0/20a4546eb17f3b25d3c66df15810411c14ed5065bcfab50b53c96fb627b2/scikit_learn-1.9.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ee1a8db2c18c08e34c7412d4b10be1cac214cd4ea7dc9715a6a327eb49a37c96", size = 8613528, upload-time = "2026-06-02T11:54:18.842Z" }, + { url = "https://files.pythonhosted.org/packages/18/3c/e440e039bb82cd19004edaaad00acbde0fb9b461083c3ecf37941c557312/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:147e9329ef0e39f75d4cffa02b2aa48d827832684926cd5210d9a2cb5c57246b", size = 8855050, upload-time = "2026-06-02T11:54:21.699Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/b341b8dab5998da6270a3a42c2152c578501354d36f944b5856757035ef8/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bad8f8b9950321b54c965fdcbac6c6c55e79e16646b49977bcf3668d3870a1a", size = 9097190, upload-time = "2026-06-02T11:54:24.454Z" }, + { url = "https://files.pythonhosted.org/packages/fb/de/b650b4d69b84468cfa2e28a3ff7b8103743029e6446ce1a97fe060ef688c/scikit_learn-1.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:78fc56eafd4edb9575d2d8950d1dd152061abb573341a1cb7e099fc40f6c6666", size = 8963204, upload-time = "2026-06-02T11:54:27.428Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/ff83d76d7418112e5a61326443cdda87be3545dd8d6599c95b2481a4419e/scikit_learn-1.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:051075bda8b7aab87b1906ab3d4740a1e1224a19d7b3781a576736edc94e76aa", size = 8222661, upload-time = "2026-06-02T11:54:30.192Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" }, + { url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" }, + { url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107, upload-time = "2026-06-19T15:00:14.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303, upload-time = "2026-06-19T15:00:16.819Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960, upload-time = "2026-06-19T15:00:19.635Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074, upload-time = "2026-06-19T15:00:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038, upload-time = "2026-06-19T15:00:24.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390, upload-time = "2026-06-19T15:00:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324, upload-time = "2026-06-19T15:00:31.014Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785, upload-time = "2026-06-19T15:00:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943, upload-time = "2026-06-19T15:00:36.903Z" }, + { url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/915a19b3de2f7430062b509653563db1633ddbb6f021b06731521115d4e2/scipy-1.18.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4c256ee70c0d1a8a2ace807e199ccd4e3f57037433842abb3fb36bc17eaa9578", size = 31036253, upload-time = "2026-06-19T15:00:43.216Z" }, + { url = "https://files.pythonhosted.org/packages/d7/88/b72def7262e150d16be13fca37a96481138d624e700340bc3362a7588929/scipy-1.18.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:2ef3abc54a4ffc53765374b0d5728532dfdd2585ed23f6b11c206a1f0b1b9af8", size = 28673758, upload-time = "2026-06-19T15:00:46.663Z" }, + { url = "https://files.pythonhosted.org/packages/91/02/2e636a61a525632c373cf6a9c24442a3ffb79e364d38e98b32042964ac32/scipy-1.18.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2a6af57bd9e4a75d70e4117e78a1bbee84f79ae3fbb6d0111005d6ebcc4cb8d", size = 20415514, upload-time = "2026-06-19T15:00:49.399Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/2135974442f6aba159d9d39d774a1c8cb19947016725d69fecc685df45bf/scipy-1.18.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:3f1ac564d3bf6c03d861d2cd87a1bea0da2887136f7fb1bf519c05a8971452d6", size = 23034398, upload-time = "2026-06-19T15:00:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e6/ba89ec5abf6ee9257c0d1ec985573f3ae32742c24bc03e016388a40b1b15/scipy-1.18.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40395a5fcd1abee49a5c7aaa98c29db393eedc835138560a588c47ec16156690", size = 33998032, upload-time = "2026-06-19T15:00:54.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0", size = 35283333, upload-time = "2026-06-19T15:00:58.152Z" }, + { url = "https://files.pythonhosted.org/packages/53/a4/cbdeef6eb3830a8462a9d4ada814de5fc984345cc9ecf17cbec51a036f1e/scipy-1.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7a7f3b01647384dbc3a711e8c6778e0aabbe93959249fef5c7393396bcac0867", size = 35610216, upload-time = "2026-06-19T15:01:01.155Z" }, + { url = "https://files.pythonhosted.org/packages/80/4d/b2b82502b65f661d1b789c1665dcdf315d5f12194e06fc0b37946294ebae/scipy-1.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6aa94e78ec192a30063a5e72e561c28af769dc311190b24fe91774eff1969709", size = 37418960, upload-time = "2026-06-19T15:01:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/902d836831474b0ab5a37d16404f7bc5fafd9efba632890e271ba952635f/scipy-1.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:2d8bbdc6c817f5b4006a54d799d4f5bab6f910193cbb9a1ff310833d4d270f61", size = 37288845, upload-time = "2026-06-19T15:01:07.822Z" }, + { url = "https://files.pythonhosted.org/packages/b6/43/8d73b337a3bdb14daa0314f0434210747c02d79d729ce1777574a817dcf6/scipy-1.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:18e9575f1569b2c54174e6159d32942e03731177f63dce7975f0a0c88d102f5b", size = 24988971, upload-time = "2026-06-19T15:01:11.076Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b4/f11918b0508a2787031a0499a03fbe3546f3bb5ca05d01038c45b278c09a/scipy-1.18.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f351e0dd702687d12a402b867a1b4146a256923e1c38317cbc472f6372b94707", size = 31399325, upload-time = "2026-06-19T15:01:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d1/1f287b57c0ff0ee5185dff3946d92c8017d39b0e431f0ae79a3ff1859512/scipy-1.18.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7c7a51b33ce387193c97f228320cf8e87361daa1bba750638677729598b3e677", size = 29092110, upload-time = "2026-06-19T15:01:16.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1a/7b74eb6c392fdcb27d414c0e7558a6d0231eb3b6d73571f479bb81ea8794/scipy-1.18.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:84031d7b052a54fae2f8632e0ec802073d385476eb9a63079bce6e23ef9283d4", size = 20833811, upload-time = "2026-06-19T15:01:20.488Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ad/f3941716320a7b9cb4d68734a903b45fe16eff5fb7da7e16f2e619304979/scipy-1.18.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:56abf29a7c067dde59be8b9a22d606a4ea1b2f2a4b756d9d903c62818f5dacce", size = 23396644, upload-time = "2026-06-19T15:01:23.364Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/1446b62ffe07f9719b7d9b1b6a4e05a772833ae8f441fe4c22c34c9b250f/scipy-1.18.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ad44305cfa24b1ba5803cbbebf033590ccbac1aa5d612d727b785325ab408b0", size = 34079318, upload-time = "2026-06-19T15:01:26.002Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/b87da667098bb470fa30c7011b0ba351ee976dd395c78798c66e941665a3/scipy-1.18.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:945c1761b93f38d7f99ae81ae80c63e621471608c7eeead563f6df025585cd58", size = 35324320, upload-time = "2026-06-19T15:01:28.881Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a1/c7932f91909759b0267f75fdea34e91309f96b895757534b76a90b6b4344/scipy-1.18.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a4441f15d620578772a49e5ab48c0ee1f7a0220e387110283062729136b2553", size = 35699541, upload-time = "2026-06-19T15:01:31.968Z" }, + { url = "https://files.pythonhosted.org/packages/f7/86/5185061a1fcc41d18c5dc2463969b3a3964b31d9ac67b2fb05d4c7ff7670/scipy-1.18.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9aac6192fac56bf2ca534389d24623f07b39ff83317d58287285e7fbd622ff76", size = 37472480, upload-time = "2026-06-19T15:01:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/31/8e/f04c68e39919a010d34f2ee1367fd705b0a25a02f609d755f0bfbc0a15fc/scipy-1.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e40baea28ae7f5475c779741e2d90b1247c78531207b49c7030e698ff81cee3f", size = 37365390, upload-time = "2026-06-19T15:01:38.091Z" }, + { url = "https://files.pythonhosted.org/packages/d5/19/969dc072906c84dd0a3b05dcf57ea750936087d7873549e408b35cfc3f97/scipy-1.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:368e0a705903c466aa5f08eefb39e6b1b6b2d659e7352a31fd9e2438365be0f8", size = 25279661, upload-time = "2026-06-19T15:01:40.817Z" }, +] + +[[package]] +name = "send2trash" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/f0/184b4b5f8d00f2a92cf96eec8967a3d550b52cf94362dad1100df9e48d57/send2trash-2.1.0.tar.gz", hash = "sha256:1c72b39f09457db3c05ce1d19158c2cbef4c32b8bedd02c155e49282b7ea7459", size = 17255, upload-time = "2026-01-14T06:27:36.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/78/504fdd027da3b84ff1aecd9f6957e65f35134534ccc6da8628eb71e76d3f/send2trash-2.1.0-py3-none-any.whl", hash = "sha256:0da2f112e6d6bb22de6aa6daa7e144831a4febf2a87261451c4ad849fe9a873c", size = 17610, upload-time = "2026-01-14T06:27:35.218Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -1207,34 +2830,213 @@ wheels = [ ] [[package]] -name = "snowplow-tracker" -version = "1.1.0" +name = "soupsieve" +version = "2.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/99/a6ca3beb3ccacb41fb3321d8a60e5566f9e6467601ef8eba6a17e1b89778/soupsieve-2.9.2.tar.gz", hash = "sha256:4a55d8cf158a9c2e587fa4922f1bbb91d68ac829e2d6f25403a85747c71daf74", size = 122445, upload-time = "2026-08-07T00:57:24.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/dc/ad025c1ee131eba60c69f4dd5779b18fcf1e6b21a343e2162a84d5d133c7/soupsieve-2.9.2-py3-none-any.whl", hash = "sha256:8089a26fd974ca7a1f30276d3d8492ab266ab15af581642dfe8aa162e0c1c823", size = 37370, upload-time = "2026-08-07T00:57:23.524Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/21/77b4c147963073040dc3c3a5cb7a8c3001a1893c0209432cb77f9df836aa/sqlalchemy-2.0.52.tar.gz", hash = "sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97", size = 9945637, upload-time = "2026-08-11T19:07:09.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/d5/1b77a026d161f98a08f11af1a5f6c47b98ee7c7e2648af525a1004826c78/sqlalchemy-2.0.52-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:be8c49131665dfe2cc74c498aa1240ffb548d0fd901325dd11c2c7a18956f727", size = 2170940, upload-time = "2026-08-11T20:58:11.25Z" }, + { url = "https://files.pythonhosted.org/packages/54/bd/f444444adb37b5d53753fb1730ee7a421628e2e3b756c4da461af7e6394a/sqlalchemy-2.0.52-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b2d9e507a458832adcfbd8af6e2036ddf069b7710b799448542ebccae2dceee", size = 3383415, upload-time = "2026-08-11T21:02:38.534Z" }, + { url = "https://files.pythonhosted.org/packages/be/57/2eadf93a552568c57e8680b7e58bb5e9770d80942a1bdbaf4f2f63f0d7c8/sqlalchemy-2.0.52-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8738008376d22f30f411ea3efecf39b51110b6996d80bb73786f30bcfdd5fd3b", size = 3398577, upload-time = "2026-08-11T21:16:59.092Z" }, + { url = "https://files.pythonhosted.org/packages/15/c3/2887cf9dd111d1fbf05d22165b404c221ef43e029f7a2695e7302f27a7cc/sqlalchemy-2.0.52-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37a4d548327b6cab9c7d8cdb4e0e82feabee0110c4d150059068e2d1cfbd99ee", size = 3328225, upload-time = "2026-08-11T21:02:40.183Z" }, + { url = "https://files.pythonhosted.org/packages/02/0f/466bdf9e1feeeef5587f868c187d8687e21ff8c85b1775e9041130181132/sqlalchemy-2.0.52-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e49f51a5d59857a7a0dcaf9469febf7197d9394bd88f00d69c2c4e848112cdbf", size = 3357374, upload-time = "2026-08-11T21:17:01.076Z" }, + { url = "https://files.pythonhosted.org/packages/22/20/5c2b4583904af4173076dda1c9e53c9e2ffc7a702d2efde0216bbacbf7cb/sqlalchemy-2.0.52-cp312-cp312-win32.whl", hash = "sha256:afda3ec521d0517d0de783fc70030775841900896d832de5bbd066549290470e", size = 2129366, upload-time = "2026-08-11T21:14:50.991Z" }, + { url = "https://files.pythonhosted.org/packages/ed/06/543dab8ef62d4e9fb96fb31a30c2b8b14a8763bccf48d428294d6b3041c0/sqlalchemy-2.0.52-cp312-cp312-win_amd64.whl", hash = "sha256:2d5e53e36e37129fe0be8b9d08b6e4052c10a963ee6cda56c8c10dcc194b99ca", size = 2157344, upload-time = "2026-08-11T21:14:52.453Z" }, + { url = "https://files.pythonhosted.org/packages/7f/18/e30c6fe1eca1bf34a39fbdd6066121cc9974c850faf6f349eac563697a26/sqlalchemy-2.0.52-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2eb3c6a64b1bfe6704777cfd504e7b8ad093a5f3e03ce67663a5e6742f294e43", size = 2167724, upload-time = "2026-08-11T20:58:12.679Z" }, + { url = "https://files.pythonhosted.org/packages/d0/56/2e17d161a4f7ecc1c2ffb93e607b4e1898bb551b451b283235acb8f6ce47/sqlalchemy-2.0.52-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:923bb183c1dc64fdf7b717965e3d59938ec4f8b8710b419a21ce403e5da9a9e1", size = 3321189, upload-time = "2026-08-11T21:02:41.932Z" }, + { url = "https://files.pythonhosted.org/packages/cf/b8/8490916e893f3f8d74dc9cc54c078619364999dee37047a188e73abbc852/sqlalchemy-2.0.52-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:651d6d8782e80679e6151707c7b490834d46ada526328895abf567f25e63d29c", size = 3338185, upload-time = "2026-08-11T21:17:02.597Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f7/752cc8ee453da222829b3f5c4613614bf750d97429363b70414fa10478e4/sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b08cddb8989775e3c88799d86704bdfc3ee6e9846118201aa5997f16f27e3a15", size = 3271698, upload-time = "2026-08-11T21:02:43.963Z" }, + { url = "https://files.pythonhosted.org/packages/51/e6/074ade0c07b9e4c8e8bca46820320ed94df9702afdb6f2af06623068d2e6/sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ab66fa9618269390d4dfa222f2f2f88f7bc4bf5da13905131b818217db7e8057", size = 3308936, upload-time = "2026-08-11T21:17:04.172Z" }, + { url = "https://files.pythonhosted.org/packages/66/07/557c0d04716705599227945ac14e0a17ad0338e899f37d8c2ddff4dcc663/sqlalchemy-2.0.52-cp313-cp313-win32.whl", hash = "sha256:c63bda077685c85ca513286547a531ba57e7a68cf0a7ed3bafcc2bbd18896f4d", size = 2127308, upload-time = "2026-08-11T21:14:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/96/4e/226eda27654318ce525d043025221f689abef883da2c7126f9065121618c/sqlalchemy-2.0.52-cp313-cp313-win_amd64.whl", hash = "sha256:9876b09b9f1ce7398b0ffece585c0a911244c53191187341f6bcae640e133751", size = 2153876, upload-time = "2026-08-11T21:14:55.527Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f5/71cb30af58c9b80a4e1fac0b73bb48f86d497a774a6a2eb6d2f1e657bb73/sqlalchemy-2.0.52-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:410d52be41d17f1a236d19520fbe776257dc16516ed06bd16d433311842aefd9", size = 2169537, upload-time = "2026-08-11T20:58:13.855Z" }, + { url = "https://files.pythonhosted.org/packages/4c/93/d07ebd645d1b07b6b5ed63450a70f063a346a7e0f2c8810daf2e532400cb/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfe9ce533dbe4d0a2ae1486546619bd30b76bcd670539a44d910361376175f5e", size = 3319606, upload-time = "2026-08-11T21:02:45.829Z" }, + { url = "https://files.pythonhosted.org/packages/ae/5c/290c84c7c2566ecd3b65baaae0fddec9bc33b033b398a06123bb86fbfc6e/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:812bae5138bfc0aa46fb0686da0fc7f581f68e2bbb05bc24c3713bebaedd1437", size = 3323642, upload-time = "2026-08-11T21:17:05.675Z" }, + { url = "https://files.pythonhosted.org/packages/13/f5/2cc160590ca49173359557880b92a0572293ccb899e8f6cedf150c5a3ddf/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:50bff43b632a56fbf5ed9afdd76307e1512b62051bcd5afb341ae67205bbb6c8", size = 3268125, upload-time = "2026-08-11T21:02:47.649Z" }, + { url = "https://files.pythonhosted.org/packages/35/f3/ea8933fc9f7d1353e9c2ff9965eae687c4cef181120574591ed2fa0633e1/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:49565daf5af554f538e23aef1fc81a95a4e49658f152285e45c02f5fc44f04cd", size = 3289516, upload-time = "2026-08-11T21:17:07.267Z" }, + { url = "https://files.pythonhosted.org/packages/45/67/05cf86541c1e1716fca1e4a996954a439cd74501707cda607fb7cb02ef50/sqlalchemy-2.0.52-cp314-cp314-win32.whl", hash = "sha256:ab9da41e61b9979b910499d633b241df20c51ee5037e5405b11c2faac3cbe1a2", size = 2130249, upload-time = "2026-08-11T21:14:57.273Z" }, + { url = "https://files.pythonhosted.org/packages/96/d7/8ac6ffa1e36169e762ef65bd835046abb2251b1bc17f8f6708e14ed8d31f/sqlalchemy-2.0.52-cp314-cp314-win_amd64.whl", hash = "sha256:a593db51b3bae75db17a5738ad5f992244b3a03863f83c28117ee482c6a3f76d", size = 2156718, upload-time = "2026-08-11T21:14:58.667Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4b/e01a737eef378e734cc6394a82248a6ce13b167dfa36c731075ce9fc9c64/sqlalchemy-2.0.52-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1e61d08bdf4ee2f41024569e3400de7d6734ba498144766b11260936ccfa582", size = 2190344, upload-time = "2026-08-11T19:53:21.393Z" }, + { url = "https://files.pythonhosted.org/packages/b3/3f/3582293d1e185e71d19d7c731c3e2ee20ba21981c4a1115c0806c1f62120/sqlalchemy-2.0.52-py3-none-any.whl", hash = "sha256:3b81b8363a919ce53453591cdb93702e6bd54ade6c4fa2f468fc053baee5ed89", size = 1950700, upload-time = "2026-08-11T20:47:21.603Z" }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "streamlit" +version = "1.61.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "altair" }, + { name = "anyio" }, + { name = "blinker" }, + { name = "click" }, + { name = "httptools" }, + { name = "itsdangerous" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "pillow" }, + { name = "protobuf" }, + { name = "pyarrow" }, + { name = "pydeck" }, + { name = "python-multipart" }, { name = "requests" }, + { name = "starlette" }, + { name = "tenacity" }, + { name = "toml" }, { name = "typing-extensions" }, + { name = "uvicorn" }, + { name = "watchdog", marker = "sys_platform != 'darwin'" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/4a/6d0bf2f78de59924564caf47298341c09f61bf3814f15f0d15ef09fe1e3d/streamlit-1.61.1.tar.gz", hash = "sha256:68acf1ff1b6005b19205c2777d832deea0c1edd6fbbf7f43f091b96220e5e35f", size = 9907073, upload-time = "2026-08-05T14:51:01.956Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/e3/deeea39117e1ffbce29755b45bceb214692e0a0aa496a17ce742b98ef3e1/streamlit-1.61.1-py3-none-any.whl", hash = "sha256:3f0ebf6764c3f938f8107414c9533942fa85a84b57affdaa7727d562210d8ece", size = 10512871, upload-time = "2026-08-05T14:50:59.246Z" }, +] + +[[package]] +name = "streamlit-folium" +version = "0.27.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "branca" }, + { name = "folium" }, + { name = "jinja2" }, + { name = "streamlit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/4c/3874663b7db06c7e354ec488a884ccc07cc013daf9b6595bfe0333f7509f/streamlit_folium-0.27.4.tar.gz", hash = "sha256:ff9572ed74d04164b391f59caad4ab022cbca99f27bbafe88dbd7251e4679598", size = 535256, upload-time = "2026-08-03T17:59:03.28Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/44/ebf8b1a1ae14184d4c3d09bde4450933198152b4cbf21a425dccc69cd7b7/streamlit_folium-0.27.4-py3-none-any.whl", hash = "sha256:0214076ff10e9417a5c1079920adbe91e35b9a844b3f45ee9c15277b91c7cb08", size = 536915, upload-time = "2026-08-03T17:59:01.969Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[package]] +name = "terminado" +version = "0.18.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess", marker = "os_name != 'nt'" }, + { name = "pywinpty", marker = "os_name == 'nt'" }, + { name = "tornado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/11/965c6fd8e5cc254f1fe142d547387da17a8ebfd75a3455f637c663fb38a0/terminado-0.18.1.tar.gz", hash = "sha256:de09f2c4b85de4765f7714688fff57d3e75bad1f909b589fde880460c753fd2e", size = 32701, upload-time = "2024-03-12T14:34:39.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl", hash = "sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0", size = 14154, upload-time = "2024-03-12T14:34:36.569Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + +[[package]] +name = "tinycss2" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ff/77/1ab6e5bafb9c80d8128f065a355377a04ac5b3c38eb719d920a9909d346e/snowplow_tracker-1.1.0.tar.gz", hash = "sha256:95d8fdc8bd542fd12a0b9a076852239cbaf0599eda8721deaf5f93f7138fe755", size = 34135, upload-time = "2025-02-21T10:58:48.112Z" } + +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + +[[package]] +name = "tornado" +version = "6.5.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/10/d3/343e5bb989d6515b1646cf3d40135d73f3d5e45339bded401b56cdac24dd/tornado-6.5.8.tar.gz", hash = "sha256:9452e1b208a8bd771e2cb1f2ff564985b9b214bdebbe622793e1799e0a6bd23f", size = 520493, upload-time = "2026-08-07T02:12:42.971Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/10/1c76269cbf2d6e127f4415044d9ddb0295858230678bbf4bfba905593c82/snowplow_tracker-1.1.0-py3-none-any.whl", hash = "sha256:24ea32ddac9cca547421bf9ab162f5f33c00711c6ef118ad5f78093cee962224", size = 44128, upload-time = "2025-02-21T10:58:45.818Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d5/007086fd8df5489338e204f65adce33fd4f21a4999dbb2b9cff2f897b5f4/tornado-6.5.8-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:cc6aa787d7cfab7c3d35189dc7a56fbd2399a569624c730c6b55b3d6531d0403", size = 449487, upload-time = "2026-08-07T02:12:28.682Z" }, + { url = "https://files.pythonhosted.org/packages/70/c8/5a24a99495903f594f6a199dd7beead1cbc0a13e2cb9102727bcaaf2a997/tornado-6.5.8-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9715b5eb79735b2bcd454ce216a9275b7c0470e64ea1bf5742f78b2f72b26eeb", size = 447649, upload-time = "2026-08-07T02:12:30.306Z" }, + { url = "https://files.pythonhosted.org/packages/6e/de/f2e733f386b85962d1b1dc82cd63d169b5b4580062b35397eac9244a41fe/tornado-6.5.8-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:547d63f450d570c14fe0e8db2cfb14c9bbd1c2503b4a6612586267955aa47b58", size = 450707, upload-time = "2026-08-07T02:12:31.95Z" }, + { url = "https://files.pythonhosted.org/packages/0b/94/20efeee9a01c141e9ac47c397f81679dfda24b32768fc4fff24e76d36c2c/tornado-6.5.8-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e2360a0ffbe145eca8af0b19cb7203d79b1a98dd4cccdd6b368f6f49c2e3808", size = 451677, upload-time = "2026-08-07T02:12:33.512Z" }, + { url = "https://files.pythonhosted.org/packages/42/ec/a96ccb8ccf0de2b7bc2c5fa1608a4803735018242e90c4882365a9fd418f/tornado-6.5.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5d242290bdf7ab3151bc1065fdd75c0dcc21cbc7b49f22a4c56329c2d6566d22", size = 451510, upload-time = "2026-08-07T02:12:35.346Z" }, + { url = "https://files.pythonhosted.org/packages/29/b5/93185859245ad3f00e62175f29607346788b696369347f0146e0421286bb/tornado-6.5.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7b94ff0e128fe0542f3bd331fb44d06260fc4ac16881545159f34ef08aad4195", size = 450917, upload-time = "2026-08-07T02:12:36.963Z" }, + { url = "https://files.pythonhosted.org/packages/97/cf/fe33cf062834487d34d1559746a4a12521033c22645b6d74d4bca702e018/tornado-6.5.8-cp39-abi3-win32.whl", hash = "sha256:67832909c4779c64942380cb5f044a5c6163d00831472d80e25e115de9917836", size = 451952, upload-time = "2026-08-07T02:12:38.512Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e1/468ad54333e92ccb62627e62cb88e5fc14a2171daa67ed47b1b8542d5b86/tornado-6.5.8-cp39-abi3-win_amd64.whl", hash = "sha256:11881db6b7c168494be2c2d12e65931451bdf7ee718535418ae1d8855dd5a0ee", size = 452391, upload-time = "2026-08-07T02:12:39.971Z" }, + { url = "https://files.pythonhosted.org/packages/ad/3e/cd5e4f06e34cde33b8ef66cf36aa2b5ad46354cc1af7d2136bbe365fee1d/tornado-6.5.8-cp39-abi3-win_arm64.whl", hash = "sha256:68a7468c7e289f8514d7d664101753903217eff1bb6822c6b5994a0b5f5bcb26", size = 451411, upload-time = "2026-08-07T02:12:41.469Z" }, ] [[package]] -name = "sqlparse" -version = "0.5.5" +name = "tqdm" +version = "4.70.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, ] [[package]] -name = "text-unidecode" -version = "1.3" +name = "traitlets" +version = "5.16.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/2e/a7fbfe268c8a3b32546930c0297c101d65a4a14c304ad5790a9f478f0e4e/traitlets-5.16.1.tar.gz", hash = "sha256:ed900c2b631aa3a112811139fa97b8d2c3bad5e989656bba4b7e52c7852c18c1", size = 166137, upload-time = "2026-08-03T08:32:36.848Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/0d785f0bc5e4315a96c989bb476d0fc07ea4f85132550c7b156ca2035d52/traitlets-5.16.1-py3-none-any.whl", hash = "sha256:f775618166caa0396c8e337099240f2bd3e5e917d203b2e6fbe21a58d3cb1f6b", size = 86211, upload-time = "2026-08-03T08:32:34.48Z" }, ] [[package]] @@ -1317,6 +3119,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, ] +[[package]] +name = "uri-template" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/c7/0336f2bd0bcbada6ccef7aaa25e443c118a704f828a0620c6fa0207c1b64/uri-template-1.3.0.tar.gz", hash = "sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7", size = 21678, upload-time = "2023-06-21T01:49:05.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/00/3fca040d7cf8a32776d3d81a00c8ee7457e00f80c649f1e4a863c8321ae9/uri_template-1.3.0-py3-none-any.whl", hash = "sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363", size = 11140, upload-time = "2023-06-21T01:49:03.467Z" }, +] + [[package]] name = "urllib3" version = "2.7.0" @@ -1327,10 +3138,182 @@ wheels = [ ] [[package]] -name = "zipp" -version = "4.1.0" +name = "uvicorn" +version = "0.52.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/28/64ca011edf31c715b4fad359c587ea52391aaffa125065695590241ff617/uvicorn-0.52.3.tar.gz", hash = "sha256:18857b9e6579300be55c91c0a1cfd37d9a2cf0cabea33b88275f199eb73b8b58", size = 100621, upload-time = "2026-08-13T16:50:02.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/2b/ebd108734a8204c6b4b93c681c9a38c5273b3ccd5d129fee4ffc1d97772c/uvicorn-0.52.3-py3-none-any.whl", hash = "sha256:116af2710dbf47c80f463cd20ee4884b6662f4c9f227d797ddc7279d2fcc2c7c", size = 79859, upload-time = "2026-08-13T16:50:01.323Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, +] + +[[package]] +name = "webcolors" +version = "25.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/7a/eb316761ec35664ea5174709a68bbd3389de60d4a1ebab8808bfc264ed67/webcolors-25.10.0.tar.gz", hash = "sha256:62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf", size = 53491, upload-time = "2025-10-31T07:51:03.977Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/cc/e097523dd85c9cf5d354f78310927f1656c422bd7b2613b2db3e3f9a0f2c/webcolors-25.10.0-py3-none-any.whl", hash = "sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d", size = 14905, upload-time = "2025-10-31T07:51:01.778Z" }, +] + +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + +[[package]] +name = "websockets" +version = "16.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/9d/681cda21c9eee743203a6cb79b9d3d05adad9aa60ec660c6c9bf4dd619ca/websockets-16.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00", size = 179600, upload-time = "2026-07-17T22:49:13.92Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8d/6195a88b45e8d2a8f745fc2046e36f885a3c9763e6767d2c46229bf9510c/websockets-16.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b", size = 177272, upload-time = "2026-07-17T22:49:15.453Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/fe2d498c64dea0095c9a9f9a351af4cd6eef31b618395582bc1f38ba45ff/websockets-16.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175", size = 177542, upload-time = "2026-07-17T22:49:16.875Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ed/f1831681fce0e3242346e5458486003c5f124ed69e5e0b847fd029db4973/websockets-16.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1", size = 187137, upload-time = "2026-07-17T22:49:18.323Z" }, + { url = "https://files.pythonhosted.org/packages/6f/79/4ff9dcc1bb46f6b4c536936dde1fd60f9b564f3304307274db97f4c9496d/websockets-16.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15", size = 188374, upload-time = "2026-07-17T22:49:19.65Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/5c49b6efb36cab733d23773f6de575e1dba65736ead17d5d2b2a1daef779/websockets-16.1.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa", size = 191155, upload-time = "2026-07-17T22:49:21.331Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f6/56ccceda3a4838d18f1d40821480da4775397e8b1eecf4031e20c50e2e90/websockets-16.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab", size = 189011, upload-time = "2026-07-17T22:49:22.889Z" }, + { url = "https://files.pythonhosted.org/packages/86/d6/ad5286241a2bce1107e2798d3bfbd62cf79aee167bdb654f8cb1e9dbf949/websockets-16.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847", size = 187766, upload-time = "2026-07-17T22:49:24.339Z" }, + { url = "https://files.pythonhosted.org/packages/bc/67/d65c970b7e347fdca69479beb7811c2060529956730a7a4e3ae7c66b0e31/websockets-16.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428", size = 185173, upload-time = "2026-07-17T22:49:25.743Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5b/14af3cd4ee69d8ea9baca58f3dc3cfb1ba78332a347fd478cb096549d60e/websockets-16.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf", size = 187809, upload-time = "2026-07-17T22:49:27.147Z" }, + { url = "https://files.pythonhosted.org/packages/7b/11/be301710d70de97e3e7b3586e6d492c9c06d6a61bf1c2202c36cf0c75607/websockets-16.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751", size = 186412, upload-time = "2026-07-17T22:49:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/db/07/fe1435bf6fe738a3d3b54dbe0c18dabf12cba4d909ac8b58b539ce27c1f4/websockets-16.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f", size = 188290, upload-time = "2026-07-17T22:49:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0a/81f394aff8efcbb01208c1ced77df0a3c7fcce584a88c7273663697946c2/websockets-16.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2", size = 185844, upload-time = "2026-07-17T22:49:31.447Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/dd485b995473f415510251fe9bd708f2d24458f439fce958daf8d66dc7c6/websockets-16.1.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383", size = 186823, upload-time = "2026-07-17T22:49:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0b/f78de76ff446f1e66af12b43c48a35f31744de93cfdec2f4ea67d5d7bbf1/websockets-16.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3", size = 187102, upload-time = "2026-07-17T22:49:34.616Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/4cf892007778eaf84ad162bfc98046e0ed89b63ac55949e3236626b2a23f/websockets-16.1.1-cp312-cp312-win32.whl", hash = "sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747", size = 179943, upload-time = "2026-07-17T22:49:36.213Z" }, + { url = "https://files.pythonhosted.org/packages/d9/de/6abe251d28c3a3f217096575400b27750b18e0b1d2fff3a2a239960fea07/websockets-16.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7", size = 180243, upload-time = "2026-07-17T22:49:37.626Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fd/6ec6c6d2850aea25b1b2aa9901a016980bb87d01e89b3eb00470b1b5d471/websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1", size = 179587, upload-time = "2026-07-17T22:49:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d8/1d299d2dd34087db39831a34cc645ef8a6f89d78efada6983093513cd81c/websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df", size = 177272, upload-time = "2026-07-17T22:49:40.293Z" }, + { url = "https://files.pythonhosted.org/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac", size = 177530, upload-time = "2026-07-17T22:49:41.786Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8", size = 187197, upload-time = "2026-07-17T22:49:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0b/13/88137fbaf726ebe29d62c1117fa11fa2bbb6209dc79d4ad738efbe36a2aa/websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6", size = 188433, upload-time = "2026-07-17T22:49:45.147Z" }, + { url = "https://files.pythonhosted.org/packages/01/6d/46c2f2ce6751cb26f39293e1ecbf8544cb01321397cd476c2756b98c216d/websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854", size = 189868, upload-time = "2026-07-17T22:49:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/29/2b/170a9e8097636cfde4dc3c592b6e00b18a44a2f5407606d96ca542dd5838/websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a", size = 189059, upload-time = "2026-07-17T22:49:47.972Z" }, + { url = "https://files.pythonhosted.org/packages/a7/48/f0d4ebc9ab4b473b8861b9e20fdb663d515d42f7befdf62cdb60fee7a1ec/websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49", size = 187814, upload-time = "2026-07-17T22:49:49.344Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ba/39a41d3ae8e72696a9492581900611c5a91e2b07563b0bcd2523adea9854/websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785", size = 185229, upload-time = "2026-07-17T22:49:50.787Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/ac15b604f850d1907f0a85ed721cefe47cd45034b3620069b829746cccbe/websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56", size = 187874, upload-time = "2026-07-17T22:49:52.228Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/3fbd5d71d59299c3770faa5884d4f45070236ca5a35ab3a61830812c409a/websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509", size = 186469, upload-time = "2026-07-17T22:49:53.776Z" }, + { url = "https://files.pythonhosted.org/packages/b4/fc/dd90349bba58af2a53ef2ddd9c32716c81eb6d59a0687939fff561860878/websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1", size = 188347, upload-time = "2026-07-17T22:49:55.202Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/f73ba86427682da59b78c11d77ba56d5b801c32e84afe79b274bbd6a9bb2/websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead", size = 185903, upload-time = "2026-07-17T22:49:56.75Z" }, + { url = "https://files.pythonhosted.org/packages/34/7c/f95eb20e80104173b3a0a092291f89ea4047ef6e608e0a57ca06eb14eecb/websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e", size = 186855, upload-time = "2026-07-17T22:49:58.467Z" }, + { url = "https://files.pythonhosted.org/packages/b0/35/dd875b3e050ff232d60fa377707f890e369f74d134f1be32e8f68879747c/websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87", size = 187140, upload-time = "2026-07-17T22:50:00.016Z" }, + { url = "https://files.pythonhosted.org/packages/e8/dc/5cbfcb41824502f6af93b8f3943a4d06c67c23c7d2e31eb18748c4a5b2a7/websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea", size = 179928, upload-time = "2026-07-17T22:50:01.685Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68", size = 180242, upload-time = "2026-07-17T22:50:03.117Z" }, + { url = "https://files.pythonhosted.org/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635, upload-time = "2026-07-17T22:50:05.001Z" }, + { url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320, upload-time = "2026-07-17T22:50:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544, upload-time = "2026-07-17T22:50:07.834Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d5/abc614d2297f6c1c3e01e61260364457a47c25cc1cf6a879038902bc6aa8/websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1", size = 187270, upload-time = "2026-07-17T22:50:09.275Z" }, + { url = "https://files.pythonhosted.org/packages/52/71/4c99af3b87dff1b2927981f6876607d4acb45338c665242168d3982f7758/websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7", size = 188509, upload-time = "2026-07-17T22:50:10.722Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b4/5c8ca14b0df7eb84ed0524165c5359150210140817a3312aee57bf62a1cf/websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31", size = 189882, upload-time = "2026-07-17T22:50:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/25/c1/bedfba9e70557129cb8083748d167bdcc01483dedf0f0df143676df05cbe/websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0", size = 189114, upload-time = "2026-07-17T22:50:13.789Z" }, + { url = "https://files.pythonhosted.org/packages/df/09/aa835b2787835aebd839114be5de51b797cb480b63ba42b26d34dfe147cb/websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3", size = 187861, upload-time = "2026-07-17T22:50:15.179Z" }, + { url = "https://files.pythonhosted.org/packages/20/26/f6408330694dbc9830857d9d23bc14ac4f6875127a480cfdda8d5ca21198/websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562", size = 185286, upload-time = "2026-07-17T22:50:16.741Z" }, + { url = "https://files.pythonhosted.org/packages/17/9a/e0675e70dd8a80762cf35bb18799d3f290a4890ffe6439bc51d222796083/websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b", size = 187935, upload-time = "2026-07-17T22:50:18.213Z" }, + { url = "https://files.pythonhosted.org/packages/33/c1/3234cfb86afde01b81e9bddcc6e534c440975d60a13991259e833069ab3e/websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a", size = 186444, upload-time = "2026-07-17T22:50:19.67Z" }, + { url = "https://files.pythonhosted.org/packages/89/87/9c15206e1d778923d8daa9657de07aa62ea815e13448319c98458c37b281/websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c", size = 188409, upload-time = "2026-07-17T22:50:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/cf5de5c67676de2d3eef8b2a518f168f6796595447a5b7161ba0d012915c/websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499", size = 185958, upload-time = "2026-07-17T22:50:22.719Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/731b6ddede2e4136912ec4cff2cffbda35af73546be4762c3d7bd3bd79af/websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985", size = 186911, upload-time = "2026-07-17T22:50:24.108Z" }, + { url = "https://files.pythonhosted.org/packages/8c/7f/39c634472c4469a24a7c09cecddffb08fac6d0e74f73881a94ee8a40a196/websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9", size = 187204, upload-time = "2026-07-17T22:50:25.548Z" }, + { url = "https://files.pythonhosted.org/packages/26/89/9667c256c256dafcc62d21328ce7a40067da857969b68ee9af375b0aaf72/websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328", size = 179603, upload-time = "2026-07-17T22:50:27.086Z" }, + { url = "https://files.pythonhosted.org/packages/bd/dd/1c099d6c0fc5deb6b46ccdbb6981fdb4b12c917869cb3952408409dc18db/websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc", size = 179948, upload-time = "2026-07-17T22:50:28.521Z" }, + { url = "https://files.pythonhosted.org/packages/35/25/9956b2d5e0529d5d23924f21bba1440d4c5c88a562e4f08550871ffa97a7/websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573", size = 179963, upload-time = "2026-07-17T22:50:29.982Z" }, + { url = "https://files.pythonhosted.org/packages/17/06/55ffc976c488b6aee9ea05761ff7c4e88e7c1fd82818c8ca7b556ad2f90c/websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999", size = 177497, upload-time = "2026-07-17T22:50:31.396Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/f7dac2e980bacc92bdc26cebae4ae4d50cae5380732c50980598fc0bbae4/websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe", size = 177698, upload-time = "2026-07-17T22:50:32.829Z" }, + { url = "https://files.pythonhosted.org/packages/b2/39/26762f734113e22da2b942c3aca85798e0c0405d64c256549540ff31e5a1/websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d", size = 187561, upload-time = "2026-07-17T22:50:34.24Z" }, + { url = "https://files.pythonhosted.org/packages/11/94/c3f330851806b9b02138b774d593478323e73c99238681b4b93efe64e02d/websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392", size = 188732, upload-time = "2026-07-17T22:50:36.088Z" }, + { url = "https://files.pythonhosted.org/packages/d1/f2/eb2c450f052de334ae33cf200ece6e87b0e14d186807074e4eb1cd2cdea2/websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7", size = 190872, upload-time = "2026-07-17T22:50:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/70/31/2ac8cecf3a74f7fed9132129fc3d90b3998a1554570c11a69b2a8c20332d/websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499", size = 189305, upload-time = "2026-07-17T22:50:39.53Z" }, + { url = "https://files.pythonhosted.org/packages/6a/cf/8ab19650d3c0d4562c92e70ab47c257c4aa5c6a713ed87fe63766b31fefc/websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43", size = 188033, upload-time = "2026-07-17T22:50:40.912Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/a49a38a6127a4acb134fb1912b215d900cc657605cff32445bf519f3acc4/websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458", size = 185748, upload-time = "2026-07-17T22:50:42.559Z" }, + { url = "https://files.pythonhosted.org/packages/95/3e/ad1fa40388c7f2e0bb2c7930d0090b6c5498594bd1cdaec18864df3d9e97/websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62", size = 188285, upload-time = "2026-07-17T22:50:43.974Z" }, + { url = "https://files.pythonhosted.org/packages/35/b8/d5db28ca264b9104f82196f92dc8843e35fd391f763d42e4ad358f5bc97e/websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb", size = 186777, upload-time = "2026-07-17T22:50:45.474Z" }, + { url = "https://files.pythonhosted.org/packages/42/9c/726cb39d0cc43ae848dce4aa2acb04eecc6738b1264ec6d700bf6bcfb9f8/websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51", size = 188682, upload-time = "2026-07-17T22:50:46.973Z" }, + { url = "https://files.pythonhosted.org/packages/be/c7/1168704de8c2dd483edabe4a22cbe4465dd8be8dd95561d214f9fe092871/websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0", size = 186377, upload-time = "2026-07-17T22:50:48.413Z" }, + { url = "https://files.pythonhosted.org/packages/ca/40/f9ff2d630ffce4e7dfea0b2288e1caf9ebbf9ff8a9ec9396136ce8b94935/websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217", size = 187148, upload-time = "2026-07-17T22:50:49.845Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/e177c8299f78d7cbe2d14df228643c10c70c0e86e108e092056bbcc16e46/websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737", size = 187578, upload-time = "2026-07-17T22:50:51.619Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/b6987faf330f5af5c787a2610124c2e8403d51724f9001ec4fff6311fe7a/websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7", size = 179729, upload-time = "2026-07-17T22:50:53.269Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6e/fbac6ed878dd362fbad7d415fa4f84d38e3e33fed8cde45c64e783acf826/websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231", size = 180072, upload-time = "2026-07-17T22:50:54.969Z" }, + { url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" }, +] + +[[package]] +name = "widgetsnbextension" +version = "4.0.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/f4/c67440c7fb409a71b7404b7aefcd7569a9c0d6bd071299bf4198ae7a5d95/widgetsnbextension-4.0.15.tar.gz", hash = "sha256:de8610639996f1567952d763a5a41af8af37f2575a41f9852a38f947eb82a3b9", size = 1097402, upload-time = "2025-11-01T21:15:55.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl", hash = "sha256:8156704e4346a571d9ce73b84bee86a29906c9abfd7223b7228a28899ccf3366", size = 2196503, upload-time = "2025-11-01T21:15:53.565Z" }, +] + +[[package]] +name = "xgboost" +version = "3.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/a9/295320f741c5be4be996c73ee65a2a11852028c50daa7229adb0d61c330b/xgboost-3.4.1.tar.gz", hash = "sha256:6968a4c71efdfa859df0dfcad0d99211c95c28c4ffd6aecff46efff77d18026a", size = 1231819, upload-time = "2026-08-15T08:39:21.197Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/ea/0bdcd374241a86f1986e87e272516f0a70d841c3aa86aa9ca167fb651573/xgboost-3.4.1-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:1ea15f15f661825b6a67d87674fb9604a1abb38dd0d4c5cf0486fc85f5203e83", size = 2541584, upload-time = "2026-08-15T08:38:48.484Z" }, + { url = "https://files.pythonhosted.org/packages/f7/94/e5c37a8972ad780edc1d8459d1931356344ca133f7f99ba9cfda516b5bba/xgboost-3.4.1-py3-none-macosx_12_0_arm64.whl", hash = "sha256:a7afd7dbace0951c93aa85ffe046e54bc40893f5b51cd3e7991eb157bf9c7c7c", size = 2365501, upload-time = "2026-08-15T08:38:52.366Z" }, + { url = "https://files.pythonhosted.org/packages/a7/11/4ff1f36ca5c32c642c71c88bec1508ee98b2c3b1e9eb169e8c82de303522/xgboost-3.4.1-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:7faaf99de26719c22bfae883a02bd56b5a3c2203122616e563cc72b7191b5c96", size = 57196172, upload-time = "2026-08-15T08:39:03.288Z" }, + { url = "https://files.pythonhosted.org/packages/99/c7/bd05c5c430feb347aa040fcc8870135d70b256718deee9bc7d2ca74a77ff/xgboost-3.4.1-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:6adf2afa396da2ae8ed30295b50b99d4712eed9a6e0ce6cfe069290e4335e51f", size = 57615456, upload-time = "2026-08-15T08:39:09.983Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3c/925394671f6a1668e2a71886de66e80be694eaf37f615cec74eefaf43107/xgboost-3.4.1-py3-none-win_amd64.whl", hash = "sha256:2d30fa513673101f542fdcbd18f30c8f96c064046f798635ac08663e9969f81b", size = 48942686, upload-time = "2026-08-15T08:39:16.182Z" }, + { url = "https://files.pythonhosted.org/packages/90/2f/f2fbe984ca095709fd246546125e78834740f347e3aa7561a22a1e928510/xgboost-3.4.1-py3-none-win_arm64.whl", hash = "sha256:e9312b30e5679d27c1d8b9ee97e092b964d960a672d5d406d9fb3cd0845c9797", size = 2094178, upload-time = "2026-08-15T08:39:19.308Z" }, +] + +[[package]] +name = "xyzservices" +version = "2026.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/08/3cb9f67a8d48021aca2a02292cc26eecd71d949ae70ad66420a8730cc302/xyzservices-2026.3.0.tar.gz", hash = "sha256:d226866a5d8e9fef337034d8da37a8298f0a1d9d1489b4018e69579eb321fea4", size = 1135736, upload-time = "2026-03-30T14:42:25.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a9/d23012099dc88ec69a29c6407b41d89681cb674c2043cd5b467c7e299c08/xyzservices-2026.3.0-py3-none-any.whl", hash = "sha256:503183d4b322bfebc3c50cdd21192aa3e81e36c5efbf9133d54ae82143e0576b", size = 94101, upload-time = "2026-03-30T14:42:24.608Z" }, ]