diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d3e15dc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/ruff-action@v3 + + smoke-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v4 + - run: uv sync + - name: Verify env imports and runs + run: | + uv run python -c " + from src.models.fire_env import WildfireEnv + env = WildfireEnv() + obs, _ = env.reset(seed=42) + assert obs.shape == (631,) + for _ in range(10): + obs, r, done, trunc, info = env.step(env.action_space.sample()) + print('smoke test passed') + " diff --git a/README.md b/README.md index e69de29..bd12cf3 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,35 @@ +# FireGrid + +Empirical RL benchmark for wildfire tactical suppression. Compares DQN, A2C, PPO, and heuristic baselines on a 25x25 grid environment with critical assets and finite suppression budgets. + +## Setup + +```bash +uv sync +``` + +### Pre-commit hooks (optional) + +Install [lefthook](https://github.com/evilmartians/lefthook) for local lint/format checks on commit: + +```bash +# pick one +brew install lefthook +npm i -g lefthook + +# then wire it up +lefthook install +``` + +## Usage + +```bash +# Train PPO agent (200k steps) +uv run python -m src.models.train_rl_agent + +# Quick test (10k steps) +uv run python -m src.models.train_rl_agent --timesteps 10000 + +# Train XGBoost spread model +uv run python -m src.models.spread_model +``` diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..36155ba --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,8 @@ +pre-commit: + commands: + lint: + glob: "*.py" + run: uv run ruff check {staged_files} + format-check: + glob: "*.py" + run: uv run ruff format --check {staged_files} diff --git a/pyproject.toml b/pyproject.toml index ac47b5c..9a10321 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,3 +15,9 @@ dependencies = [ "scikit-learn>=1.7.0", "pandas>=2.3.0", ] + +[dependency-groups] +dev = [ + "ruff>=0.11.0", + "pytest>=8.0.0", +] diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..4e33b21 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,30 @@ +target-version = "py314" +line-length = 100 +extend-exclude = ["drd-archive"] + +[lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "UP", # pyupgrade + "B", # flake8-bugbear + "SIM", # flake8-simplify + "RUF", # ruff-specific rules + "N", # pep8-naming +] +ignore = [ + "E501", # line too long — handled by formatter + "RUF001", # ambiguous unicode in strings + "RUF002", # ambiguous unicode in docstrings + "RUF003", # ambiguous unicode in comments + "RUF012", # mutable class attribute (gymnasium pattern) + "N806", # uppercase variable in function (ML convention: X, X_train, etc.) +] + +[lint.isort] +known-first-party = ["src"] + +[format] +quote-style = "double" diff --git a/src/ingestion/cffdrs.py b/src/ingestion/cffdrs.py index 960d71f..662554b 100644 --- a/src/ingestion/cffdrs.py +++ b/src/ingestion/cffdrs.py @@ -23,8 +23,7 @@ import io import logging import math -from datetime import datetime, timezone -from typing import Optional +from datetime import UTC, datetime import httpx @@ -49,7 +48,7 @@ def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float: return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) -def _parse_float(val: str) -> Optional[float]: +def _parse_float(val: str) -> float | None: """Safely parse a float from a CSV string, returning None if invalid.""" try: f = float(val) @@ -58,7 +57,7 @@ def _parse_float(val: str) -> Optional[float]: return None -def fetch_cffdrs_stations(year: Optional[int] = None) -> list[dict]: +def fetch_cffdrs_stations(year: int | None = None) -> list[dict]: """ Download the full CWFIS annual FWI observation CSV and parse it. @@ -66,7 +65,7 @@ def fetch_cffdrs_stations(year: Optional[int] = None) -> list[dict]: Filters to BC + AB stations only. """ if year is None: - year = datetime.now(timezone.utc).year + year = datetime.now(UTC).year url = CFFDRS_BASE_URL.format(year=year) logger.info(f"Fetching CFFDRS station data from {url}") @@ -81,7 +80,7 @@ def fetch_cffdrs_stations(year: Optional[int] = None) -> list[dict]: except httpx.HTTPStatusError as e: logger.error(f"CFFDRS HTTP {e.response.status_code}") # Try prior year as fallback (may not have current year yet) - if e.response.status_code == 404 and year == datetime.now(timezone.utc).year: + if e.response.status_code == 404 and year == datetime.now(UTC).year: logger.info("Trying prior year as fallback...") return fetch_cffdrs_stations(year - 1) return [] @@ -133,9 +132,9 @@ def fetch_cffdrs_stations(year: Optional[int] = None) -> list[dict]: def get_cffdrs_for_location( latitude: float, longitude: float, - stations: Optional[list[dict]] = None, + stations: list[dict] | None = None, max_radius_km: float = 200.0, -) -> Optional[dict]: +) -> dict | None: """ Find the nearest CWFIS weather station and return its CFFDRS indices. @@ -220,7 +219,6 @@ def get_cffdrs_for_fires(fires: list[dict]) -> dict[str, dict]: # ── Manual test ─────────────────────────────────────────────────────────────── if __name__ == "__main__": - import json logging.basicConfig(level=logging.INFO) test_fires = [ diff --git a/src/ingestion/cwfis.py b/src/ingestion/cwfis.py index 2bcd31d..ab42513 100644 --- a/src/ingestion/cwfis.py +++ b/src/ingestion/cwfis.py @@ -14,13 +14,10 @@ import csv import io import logging -from datetime import datetime, timezone -from typing import Optional +from datetime import UTC, datetime import httpx -from src.core.config import settings - logger = logging.getLogger(__name__) # ── CWFIS Open Data URLs ────────────────────────────────────────────────────── @@ -43,7 +40,7 @@ def _severity_from_status(status: str) -> str: return "low" -def _normalize_cwfis_row(row: dict) -> Optional[dict]: +def _normalize_cwfis_row(row: dict) -> dict | None: """ Normalize a single CWFIS CSV row into a FireGrid FireEvent dict. @@ -75,9 +72,9 @@ def _normalize_cwfis_row(row: dict) -> Optional[dict]: # Build ISO timestamp from start date try: - started_at = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=timezone.utc).isoformat() + started_at = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=UTC).isoformat() except (ValueError, TypeError): - started_at = datetime.now(timezone.utc).isoformat() + started_at = datetime.now(UTC).isoformat() # Build a stable fire_id using province + fire number safe_num = fire_number.replace(" ", "_").replace("/", "-") @@ -93,7 +90,7 @@ def _normalize_cwfis_row(row: dict) -> Optional[dict]: "longitude": lon, "area_hectares": hectares, "started_at": started_at, - "updated_at": datetime.now(timezone.utc).isoformat(), + "updated_at": datetime.now(UTC).isoformat(), "source": "CWFIS_NRCAN", } except (ValueError, KeyError, TypeError) as e: diff --git a/src/ingestion/dummy.py b/src/ingestion/dummy.py index 21f0d1c..e2a528f 100644 --- a/src/ingestion/dummy.py +++ b/src/ingestion/dummy.py @@ -5,8 +5,7 @@ """ import random -from datetime import datetime, timedelta, timezone - +from datetime import UTC, datetime, timedelta # ── Seed for reproducible dummy data ─────────────────────────────────────────── random.seed(42) @@ -44,8 +43,8 @@ def _rand_coord() -> tuple[float, float]: "latitude": 49.9071, "longitude": -119.4960, "area_hectares": 4200.0, - "started_at": (datetime.now(timezone.utc) - timedelta(hours=18)).isoformat(), - "updated_at": datetime.now(timezone.utc).isoformat(), + "started_at": (datetime.now(UTC) - timedelta(hours=18)).isoformat(), + "updated_at": datetime.now(UTC).isoformat(), "source": "dummy", }, { @@ -57,8 +56,8 @@ def _rand_coord() -> tuple[float, float]: "latitude": 50.6745, "longitude": -120.3273, "area_hectares": 800.0, - "started_at": (datetime.now(timezone.utc) - timedelta(hours=6)).isoformat(), - "updated_at": datetime.now(timezone.utc).isoformat(), + "started_at": (datetime.now(UTC) - timedelta(hours=6)).isoformat(), + "updated_at": datetime.now(UTC).isoformat(), "source": "dummy", }, { @@ -70,8 +69,8 @@ def _rand_coord() -> tuple[float, float]: "latitude": 49.3845, "longitude": -121.4483, "area_hectares": 250.0, - "started_at": (datetime.now(timezone.utc) - timedelta(hours=8)).isoformat(), - "updated_at": datetime.now(timezone.utc).isoformat(), + "started_at": (datetime.now(UTC) - timedelta(hours=8)).isoformat(), + "updated_at": datetime.now(UTC).isoformat(), "source": "dummy", }, { @@ -83,8 +82,8 @@ def _rand_coord() -> tuple[float, float]: "latitude": 56.2370, "longitude": -117.2900, "area_hectares": 12500.0, - "started_at": (datetime.now(timezone.utc) - timedelta(hours=36)).isoformat(), - "updated_at": datetime.now(timezone.utc).isoformat(), + "started_at": (datetime.now(UTC) - timedelta(hours=36)).isoformat(), + "updated_at": datetime.now(UTC).isoformat(), "source": "dummy", }, ] @@ -130,7 +129,7 @@ def get_dummy_burn_probability(fire_id: str) -> dict: "fire_id": fire_id, "model": "dummy_v0", "horizon_hours": 24, - "generated_at": datetime.now(timezone.utc).isoformat(), + "generated_at": datetime.now(UTC).isoformat(), "wind_speed_kmh": random.uniform(20, 60), "wind_direction_deg": random.uniform(220, 280), # SW winds, pushing NE "cells": cells, @@ -229,7 +228,7 @@ def get_dummy_choke_points(fire_id: str) -> dict: return { "fire_id": fire_id, "model": "greedy_heuristic_v0", - "generated_at": datetime.now(timezone.utc).isoformat(), + "generated_at": datetime.now(UTC).isoformat(), "total_choke_points": len(recommendations), "recommendations": recommendations, } diff --git a/src/ingestion/firms.py b/src/ingestion/firms.py index 07062f9..cf1704f 100644 --- a/src/ingestion/firms.py +++ b/src/ingestion/firms.py @@ -14,8 +14,7 @@ import csv import io import logging -from datetime import datetime, timezone -from typing import Optional +from datetime import UTC, datetime import httpx @@ -63,7 +62,7 @@ def _frp_to_severity(frp: float) -> str: return "low" -def _normalize_hotspot(row: dict, idx: int) -> Optional[dict]: +def _normalize_hotspot(row: dict, idx: int) -> dict | None: """ Normalize a single FIRMS CSV row into a FireGrid FireEvent dict. Returns None if the row is missing critical fields. @@ -78,9 +77,9 @@ def _normalize_hotspot(row: dict, idx: int) -> Optional[dict]: # Build ISO timestamp from acquisition date + time try: dt_str = f"{acq_date} {acq_time.zfill(4)}" - detected_at = datetime.strptime(dt_str, "%Y-%m-%d %H%M").replace(tzinfo=timezone.utc).isoformat() + detected_at = datetime.strptime(dt_str, "%Y-%m-%d %H%M").replace(tzinfo=UTC).isoformat() except ValueError: - detected_at = datetime.now(timezone.utc).isoformat() + detected_at = datetime.now(UTC).isoformat() province = _assign_province(lat, lon) severity = _frp_to_severity(frp) @@ -104,7 +103,7 @@ def _normalize_hotspot(row: dict, idx: int) -> Optional[dict]: "confidence": row.get("confidence", "n"), "satellite": row.get("satellite", "N20"), "started_at": detected_at, - "updated_at": datetime.now(timezone.utc).isoformat(), + "updated_at": datetime.now(UTC).isoformat(), "source": "NASA_FIRMS_VIIRS", } except (ValueError, KeyError) as e: diff --git a/src/ingestion/weather.py b/src/ingestion/weather.py index a04b72c..f9474b9 100644 --- a/src/ingestion/weather.py +++ b/src/ingestion/weather.py @@ -20,8 +20,7 @@ """ import logging -from datetime import datetime, timezone -from typing import Optional +from datetime import UTC, datetime import httpx @@ -47,7 +46,7 @@ def get_fire_weather( longitude: float, *, timeout: int = 10, -) -> Optional[dict]: +) -> dict | None: """ Fetch current weather conditions at a fire's coordinates. @@ -112,7 +111,7 @@ def get_fire_weather( "precipitation_mm": current.get("precipitation", 0.0), "surface_pressure_hpa": current.get("surface_pressure"), "dew_point_c": current.get("dew_point_2m"), - "fetched_at": datetime.now(timezone.utc).isoformat(), + "fetched_at": datetime.now(UTC).isoformat(), } @@ -146,7 +145,6 @@ def get_weather_for_fires(fires: list[dict]) -> dict[str, dict]: # ── Manual test ─────────────────────────────────────────────────────────────── if __name__ == "__main__": - import json logging.basicConfig(level=logging.INFO) # Test fires diff --git a/src/models/fire_env.py b/src/models/fire_env.py index cb17549..835c9fc 100644 --- a/src/models/fire_env.py +++ b/src/models/fire_env.py @@ -21,13 +21,10 @@ from __future__ import annotations -from typing import Optional - -import numpy as np import gymnasium as gym +import numpy as np from gymnasium import spaces - # Cell types UNBURNED = 0 BURNING = 1 @@ -110,7 +107,7 @@ def __init__( # ── Gym interface ───────────────────────────────────────────────────────── - def reset(self, seed: Optional[int] = None, options: Optional[dict] = None): + def reset(self, seed: int | None = None, options: dict | None = None): super().reset(seed=seed) self.grid = np.zeros((self.grid_size, self.grid_size), dtype=np.int32) self.step_count = 0 @@ -256,10 +253,7 @@ def _execute_action(self, action: int) -> tuple[float, bool, bool]: nr, nc = r + dr, c + dc if self._in_bounds(nr, nc): cell = self.grid[nr, nc] - if cell == BURNING: - self.grid[nr, nc] = SUPPRESSED - suppressed += 1 - elif cell == UNBURNED: + if cell in (BURNING, UNBURNED): self.grid[nr, nc] = SUPPRESSED suppressed += 1 self.heli_left -= 1 @@ -294,7 +288,7 @@ def _spread_fire(self) -> int: """Stochastic fire spread. Returns number of asset cells lost this step.""" new_burning = [] asset_cells_lost = 0 - burning_cells = list(zip(*np.where(self.grid == BURNING))) + burning_cells = list(zip(*np.where(self.grid == BURNING), strict=True)) for (r, c) in burning_cells: for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]: diff --git a/src/models/rl_agent.py b/src/models/rl_agent.py index 26f543b..af51486 100644 --- a/src/models/rl_agent.py +++ b/src/models/rl_agent.py @@ -14,9 +14,6 @@ import logging import math from pathlib import Path -from typing import Optional - -import numpy as np logger = logging.getLogger(__name__) @@ -87,8 +84,8 @@ def _greedy_fallback( def get_tactical_recommendations( fire_id: str, - fire_data: Optional[dict] = None, - spread_output: Optional[dict] = None, + fire_data: dict | None = None, + spread_output: dict | None = None, n_inference_steps: int = 60, ) -> list[dict]: """ @@ -108,6 +105,7 @@ def get_tactical_recommendations( try: from stable_baselines3 import PPO + from src.models.fire_env import WildfireEnv spread_rate_m_per_min = spread_1h / 60.0 @@ -122,7 +120,7 @@ def get_tactical_recommendations( for _ in range(n_inference_steps): action, _ = model.predict(obs, deterministic=True) - obs, reward, done, truncated, info = env.step(int(action)) + obs, reward, done, truncated, _info = env.step(int(action)) if int(action) in deployment_actions: lat, lon = _grid_to_latlon( diff --git a/src/models/spread_model.py b/src/models/spread_model.py index c7b18a7..f593c9c 100644 --- a/src/models/spread_model.py +++ b/src/models/spread_model.py @@ -33,14 +33,13 @@ import logging import math from pathlib import Path -from typing import Optional +import joblib import numpy as np import pandas as pd -from xgboost import XGBRegressor -from sklearn.model_selection import train_test_split from sklearn.metrics import mean_absolute_error, r2_score -import joblib +from sklearn.model_selection import train_test_split +from xgboost import XGBRegressor logger = logging.getLogger(__name__) @@ -221,8 +220,8 @@ def train_spread_model(n_samples: int = 6000) -> tuple[XGBRegressor, XGBRegresso # ── Lazy Model Loading ──────────────────────────────────────────────────────── -_model_1h: Optional[XGBRegressor] = None -_model_3h: Optional[XGBRegressor] = None +_model_1h: XGBRegressor | None = None +_model_3h: XGBRegressor | None = None def _load_models() -> tuple[XGBRegressor, XGBRegressor]: @@ -280,7 +279,7 @@ def predict_spread_from_features(features: dict) -> dict: } -def predict_spread(fire_id: str, fire_data: Optional[dict] = None) -> dict: +def predict_spread(fire_id: str, fire_data: dict | None = None) -> dict: """ High-level call: fetch live weather → build features → predict. Called by GET /api/v1/predictions/{fire_id}. @@ -375,7 +374,7 @@ def predict_spread(fire_id: str, fire_data: Optional[dict] = None) -> dict: print() print("Feature importances (1h model — sorted):") - fi = dict(zip(FEATURE_COLS, model_1h.feature_importances_)) + fi = dict(zip(FEATURE_COLS, model_1h.feature_importances_, strict=True)) for feat, imp in sorted(fi.items(), key=lambda x: -x[1]): bar = "█" * int(imp * 50) print(f" {feat:<28} {bar} ({imp:.3f})") diff --git a/src/models/train_rl_agent.py b/src/models/train_rl_agent.py index f64fbb7..505cead 100644 --- a/src/models/train_rl_agent.py +++ b/src/models/train_rl_agent.py @@ -38,6 +38,7 @@ def train( try: from stable_baselines3 import PPO from stable_baselines3.common.env_util import make_vec_env + from src.models.fire_env import WildfireEnv except ImportError as e: print(f"Missing dependency: {e}") @@ -50,8 +51,8 @@ def train( print(f" Timesteps: {total_timesteps:,}") print(f" Spread rate: {spread_rate_m_per_min} m/min") print(f" Environments: {n_envs} parallel") - print(f" Grid: 25×25 with critical assets") - print(f" Budgets: heli=8, crew=20") + print(" Grid: 25×25 with critical assets") + print(" Budgets: heli=8, crew=20") print() env_kwargs = {"base_spread_rate_m_per_min": spread_rate_m_per_min} diff --git a/uv.lock b/uv.lock index 0a1606b..20671ca 100644 --- a/uv.lock +++ b/uv.lock @@ -24,6 +24,12 @@ dependencies = [ { name = "xgboost" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + [package.metadata] requires-dist = [ { name = "gymnasium", specifier = ">=1.2.3" }, @@ -37,6 +43,12 @@ requires-dist = [ { name = "xgboost", specifier = ">=3.0.0" }, ] +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.0.0" }, + { name = "ruff", specifier = ">=0.11.0" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -272,6 +284,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[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 = "jinja2" version = "3.1.6" @@ -652,6 +673,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "protobuf" version = "6.33.5" @@ -721,6 +751,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, ] +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + [[package]] name = "pyparsing" version = "3.3.2" @@ -730,6 +769,22 @@ 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.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -783,6 +838,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "ruff" +version = "0.15.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" }, + { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" }, + { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" }, + { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" }, + { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" }, + { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" }, + { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, +] + [[package]] name = "scikit-learn" version = "1.8.0"