diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a2de55c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,135 @@ +# ASPC CI — Python core + frontend + Docker build + +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + python: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install uv + uses: astral-sh/setup-uv@v5 + - name: Install package with dev extras + run: uv pip install --system -e ".[dev]" + - name: Ruff + run: ruff check spc_core adapters apps services sample_data resilience_data combinatorial scripts tests benchmarks + - name: Mypy (spc_core) + run: mypy spc_core --ignore-missing-imports + - name: Pytest + run: PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 pytest -q --tb=short -m "not integration" + - name: Resilience judgment report + run: PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python scripts/resilience_report.py + - name: Combinatorial sparse matrix + run: PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python -m combinatorial run --mode sparse --max-sparse 40 + continue-on-error: true + - name: Accuracy benchmarks + run: python benchmarks/accuracy.py + - name: Acceptance — no stubs, all ChartTypes real + run: | + python - <<'PY' + import pathlib + root = pathlib.Path(".") + bad = [] + for p in list(root.glob("spc_core/**/*.py")) + list(root.glob("adapters/**/*.py")) + list(root.glob("apps/**/*.py")) + list(root.glob("services/**/*.py")): + text = p.read_text(encoding="utf-8") + if "raise NotImplementedError" in text: + bad.append(str(p)) + assert not bad, f"NotImplementedError stubs remain: {bad}" + from spc_core import ChartType, analyze_control_chart, establish + from spc_core.ewma import ewma_chart + from spc_core.cusum import cusum_chart + import numpy as np + x = np.random.default_rng(0).normal(10, 1, 40) + assert analyze_control_chart(x).chart_type == ChartType.I_MR + assert ewma_chart(x).limits.chart_type == ChartType.EWMA + assert cusum_chart(x).limits.chart_type == ChartType.CUSUM + pipe = establish(x) + recs = pipe.chart.to_records() + assert len(recs) == len(pipe.chart.plotted_values) + print("acceptance ok", len(recs), "records") + PY + + integration: + runs-on: ubuntu-latest + needs: [python] + services: + timescaledb: + image: timescale/timescaledb:latest-pg16 + env: + POSTGRES_USER: aspc + POSTGRES_PASSWORD: aspc + POSTGRES_DB: aspc + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U aspc -d aspc" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: astral-sh/setup-uv@v5 + - name: Install package with dev extras + run: uv pip install --system -e ".[dev]" + - name: Integration tests + env: + ASPC_INTEGRATION: "1" + ASPC_PERSISTENCE_BACKEND: timescale + ASPC_TIMESCALE_DSN: postgresql+psycopg://aspc:aspc@localhost:5432/aspc + ASPC_REDIS_URL: redis://localhost:6379/0 + ASPC_AUTH_ENABLED: "false" + ASPC_DEV_INSECURE: "1" + ASPC_JWT_SECRET: ci-integration-secret + run: PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 pytest -q --tb=short tests/integration + + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: frontend/package-lock.json + - run: npm ci || npm install + - run: npm run lint + - run: npm run test + - run: npm run build + + docker: + runs-on: ubuntu-latest + needs: [python] + steps: + - uses: actions/checkout@v4 + - name: Build API image + run: docker build -f deploy/docker/Dockerfile.api -t aspc-api:ci . + - name: Build frontend image + run: docker build -f deploy/docker/Dockerfile.frontend -t aspc-frontend:ci . diff --git a/.gitignore b/.gitignore index e7e0761..dd8607e 100644 --- a/.gitignore +++ b/.gitignore @@ -13,8 +13,9 @@ develop-eggs/ dist/ eggs/ .eggs/ -lib/ -lib64/ +# Only ignore packaging dirs at the repo root — do NOT ignore frontend/src/lib/ +/lib/ +/lib64/ parts/ sdist/ var/ @@ -166,11 +167,31 @@ tmp/ logs/ # Data files (uncomment if you don't want to track data) -# data_samples/ # *.csv # API keys and secrets .env.local .env.production secrets.json -config.json \ No newline at end of file +config.json +# ASPC v2 runtime artifacts +aspc.db +*.db-journal +var/uploads/ +var/reports/ +examples/data/ +resilience_data/JUDGMENT.md +combinatorial/out/static/ +combinatorial/out/results.json +frontend/node_modules/ +frontend/.next/ +frontend/out/ +deploy/compose/data/ +*.parquet + +# Local working notes (keep on disk, never commit) +refactoring/ +refactoring elements/ +benchmarks/ +.vercel +.env* diff --git a/README.md b/README.md index f4b7b30..359ea67 100644 --- a/README.md +++ b/README.md @@ -1,433 +1,148 @@ -# Agentic tool for statistical process control +# ASPC — Production Statistical Process Control -A Quality Management System for **Statistical Process Control (SPC)**, **Measurement System Analysis (MSA)**, and **Process Capability** — powered by agents. +Correct, tested SPC for **batch** and **real-time** work: Shewhart / EWMA / CUSUM charts, MSA, capability, TimescaleDB, Redpanda/MQTT streaming, and a Next.js operator dashboard. ---- +Phase I establishes and freezes versioned limits; Phase II evaluates new data against those limits. No stubs — every advertised `ChartType` has real math. -## Overview +## Why ASPC? -Quality engineers spend too much time clicking through interfaces and manually interpreting charts. -**SPC Quality AI** automates the tedious parts of quality analysis just upload your data and ask questions in plain English. +Manufacturing needs SPC that is **correct**, **gated before go-live**, and **usable in real time** against frozen limits — not only a desktop chart after the shift. See: -No more: +| Doc | Contents | +|-----|----------| +| [docs/overview/problem-and-solution.md](docs/overview/problem-and-solution.md) | Problem, solution, Phase I → freeze → Phase II | +| [docs/overview/capabilities.md](docs/overview/capabilities.md) | Statistical + operational capabilities | +| [docs/overview/use-cases.md](docs/overview/use-cases.md) | Stamping / pharma / molding integration patterns | +| [docs/overview/benchmarking.md](docs/overview/benchmarking.md) | Performance, accuracy, resilience evidence | -* Guessing which control chart to use -* Manually calculating control limits -* Repeating the same MSA interpretations - -Instead, the AI agents handle: - -* Automatic detection of the correct chart type -* Identification of out-of-control points -* Plain-language interpretation of results -* Professional report generation - -They’re not perfect, but they’re remarkably efficient assistants for modern quality engineers. - ---- +Reproduce benches: `python benchmarks/performance.py` and `python benchmarks/accuracy.py` ([benchmarks/README.md](benchmarks/README.md)). Claims are scoped in [docs/overview/benchmarking.md](docs/overview/benchmarking.md): formula fidelity ≠ Minitab/JMP parity; MSA and `valid_range` gates are **opt-in** (absence warns / skips, does not always STOP). ## Features -### Statistical Process Control (SPC) - -* **Control Charts:** I-MR, Xbar-R, Xbar-S, P, NP, C, and U charts -* **Automatic Chart Selection** based on dataset characteristics -* **Out-of-Control Detection** with statistical rules -* **Data Quality Validation** before analysis -* **Interactive Visualizations** via Plotly - -### Measurement System Analysis (MSA) - -* **Gage R&R Studies** (ANOVA method) -* **Bias Studies** with significance testing -* **Linearity Studies** for accuracy verification -* **Stability Studies** for long-term consistency -* **Comprehensive MSA Reports** with interpretation - -### Process Capability Analysis +- Gated Phase I pipeline (`establish`) with ok / warn / stop gates and a 10-item go-live checklist +- Charts: I-MR, Xbar-R, Xbar-S, P, NP, C, U, EWMA, CUSUM +- MSA: Gage R&R (ANOVA / range), bias, linearity, stability, NDC and 10:1 resolution gates +- Capability: Cp/Cpk/Pp/Ppk, DPMO / sigma level, parametric · transformed · nonparametric routing +- FastAPI + CLI, JWT / API-key auth, WebSocket live alerts, SSE replay +- Operator onboarding and Live go-live console (Phase I → freeze → register stream → go-live) +- Explainable SPC signals and Lab UI (`spc_core.explain` + `/lab`) +- Signed out-of-control webhooks for downstream alerting +- DevEx CLI: `aspc doctor`, `aspc demo up`, `aspc resilience` +- Docker Compose stack: Redpanda, Mosquitto, TimescaleDB, Redis, stream engine, MQTT bridge, UI -* **Capability Indices:** Cp, Cpk, Pp, Ppk, Cpm -* **Normality Testing:** Anderson–Darling, Shapiro–Wilk, Kolmogorov–Smirnov -* **Process Centering and Variation Analysis** +## Architecture ---- +Hexagonal modular monolith: pure `spc_core` stats, `adapters` for I/O, `apps` (FastAPI + CLI), and optional `services` (stream-engine, mqtt-bridge). Full structural scan: [docs/architecture.md](docs/architecture.md). -## Quick Start - -### 1. Installation - -```bash -# Clone the repository -git clone https://github.com/M1ndSmith/ASPC.git -cd ASPC - -# Create and activate virtual environment -python -m venv aspcvenv -source aspcvenv/bin/activate # On Windows: aspcvenv\Scripts\activate - -# Install dependencies -pip install -r requirements.txt - -# Set up your API key -cp env.example .env -# Edit .env and add your GROQ_API_KEY (or key for your chosen LLM) ``` - -### 2. Configure LLM (Optional) - -The system uses **Groq** by default (free tier available). -To switch providers, edit `agent_config/config.yaml`: - -```yaml -llm: - provider: "groq" # or "openai", "anthropic" - model: "llama-3.1-8b-instant" -``` - -### 3. Start the API Server - -```bash -uvicorn api.main:app --host 0.0.0.0 --port 8000 +spc_core/ Pure statistics (Shewhart, EWMA, CUSUM, MSA, capability, gated pipeline, explain) +adapters/ I/O, SQLite/TimescaleDB, Plotly, Kafka/MQTT sources, stream engine, webhooks +apps/api/ FastAPI — JWT + API-key auth, REST, SSE replay, WebSocket live +apps/cli/ aspc CLI (doctor, demo, resilience, analyze, serve) +services/stream_engine/ Kafka consumer → Phase II eval → Tier1/Tier2 + Redis +services/mqtt_bridge/ MQTT → Redpanda bridge +frontend/ Next.js operator dashboard (analyze, live, onboarding, lab, MSA) +deploy/compose/ Full stack orchestration +migrations/ Alembic (Timescale hypertables + analysis tables) +sample_data/ Deterministic synthetic datasets for tests and demos +resilience_data/ Standards-mapped judgment corpus (CSV + expect blocks) +combinatorial/ Finite batch + in-process Phase II matrix (sparse CI / exhaustive local) +docs/ Full documentation ``` -Server available at: [http://localhost:8000](http://localhost:8000) - ---- +## Quick start -## Using the CLI +**Minimal (batch SPC):** API + SQLite — analyze charts, MSA, capability, reports. No Live streaming. -The CLI (`./spc`) provides the easiest way to interact with the agents. -It automatically activates the virtual environment. - -### Basic Commands +Install [uv](https://docs.astral.sh/uv/), then: ```bash -# Check server health -./spc health - -# Run control chart analysis -./spc chat control-charts "Analyze this data" -f your_data.csv +uv venv && source .venv/bin/activate +uv pip install -e ".[dev]" -# Run MSA study -./spc chat msa "Run a Gage R&R study" -f measurement_data.csv +uv run python -m sample_data --out examples/data +aspc control-chart -f examples/data/spc_individual_out_of_control.csv --json +aspc doctor -# Run capability analysis -./spc chat capability "Calculate Cp and Cpk with USL=10.5, LSL=9.5" -f process_data.csv +aspc serve --port 8000 ``` -### Available Agents - -| Agent | Purpose | -| ---------------- | ------------------------------ | -| `control-charts` | Control chart and SPC analysis | -| `msa` | Measurement System Analysis | -| `capability` | Process Capability Analysis | - -### CLI Options - -| Option | Short | Description | Default | -| ------------- | ----- | ---------------------------- | ----------------------- | -| `--file` | `-f` | CSV file to upload | None | -| `--thread-id` | `-t` | Conversation thread ID | `cli_session` | -| `--user-id` | `-u` | User identifier | `cli_user` | -| `--timeout` | | Request timeout (seconds) | 120 | -| `--verbose` | `-v` | Verbose output with metadata | False | -| `--output` | `-o` | Save response to JSON file | None | -| `--url` | | API base URL | `http://localhost:8000` | - ---- - -### CLI Examples - -**Control Charts** +Dashboard: ```bash -# Basic analysis -./spc chat control-charts "Analyze this data" -f data.csv - -# Verbose output -./spc chat control-charts "Check for out-of-control points" -f data.csv -v - -# Save results -./spc chat control-charts "Generate full report" -f data.csv -o report.json -``` - -**MSA Example** - -```bash -./spc chat msa "Conduct a Gage R&R study" -f gage_data.csv -``` - -**Capability Example** - -```bash -./spc chat capability "Assess capability with USL=10.5, LSL=9.5, target=10.0" -f process_data.csv -``` - -**Conversational Threads** - -```bash -# Initial message -./spc chat control-charts "Analyze this data" -f data.csv -t analysis_001 - -# Follow-up queries using same thread -./spc chat control-charts "What are the main issues?" -t analysis_001 -./spc chat control-charts "Give me recommendations" -t analysis_001 -``` - -**Advanced Usage** - -Custom API URL: - -```bash -./spc --url http://remote-server:8000 chat msa "Run study" -f data.csv -``` - -Extended Timeout: - -```bash -./spc chat control-charts "Analyze large dataset" -f big.csv --timeout 300 -``` - -Batch Automation: - -```bash -#!/bin/bash -for file in data/*.csv; do - echo "Processing $file..." - ./spc chat control-charts "Analyze and report" -f "$file" -t batch_$(date +%Y%m%d) -done -``` - ---- - -## API Usage - -You can also call the API directly. - -### Python Example - -```python -import requests - -response = requests.post( - "http://localhost:8000/chat/control-charts", - data={"message": "Analyze this data", "thread_id": "my_session", "user_id": "engineer1"}, - files={"file": open("data.csv", "rb")} -) -print(response.json()["response"]) +cd frontend && cp .env.example .env.local && npm install && npm run dev +# http://localhost:3000 — onboarding at /onboarding, Lab at /lab +# .env.example uses NEXT_PUBLIC_API_URL=/backend (Next proxies to :8000; avoids CORS) ``` -### curl Example +**Full stack (Live streaming):** TimescaleDB + Redis + Redpanda + MQTT + stream-engine. Requires Compose secrets — see [docs/deployment.md](docs/deployment.md). ```bash -curl -X POST http://localhost:8000/chat/control-charts \ - -F "message=Analyze this data" \ - -F "thread_id=session1" \ - -F "user_id=user1" \ - -F "file=@data.csv" -``` - -### Endpoints - -| Method | Endpoint | Description | -| ------ | ------------------ | ---------------------------------------------------------- | -| `GET` | `/` | API overview | -| `GET` | `/health` | Health check | -| `POST` | `/chat/{agent_id}` | Chat with an agent (`control-charts`, `msa`, `capability`) | - ---- - -## Data Format - -Each CSV should include the relevant measurement columns. -The system will auto-detect column roles but supports explicit naming. - -**Control Charts** - -```csv -date,measurement -2024-01-01,10.2 -2024-01-02,10.1 -2024-01-03,9.9 +cp deploy/compose/.env.example deploy/compose/.env # edit secrets +docker compose -f deploy/compose/docker-compose.yml --env-file deploy/compose/.env up -d --build +# or: aspc demo up ``` -**Subgroup Data** - -```csv -subgroup,measurement -1,10.2 -1,10.1 -1,9.9 -2,10.0 -2,10.3 -``` - -**MSA (Gage R&R)** - -```csv -Part,Operator,Measurement,Trial -1,A,10.2,1 -1,A,10.1,2 -1,B,10.3,1 -1,B,10.2,2 -``` - -**Process Capability** - -```csv -measurement -10.2 -10.1 -9.9 -10.0 -``` - -The system is tolerant of alternative column names and will infer structure automatically. - ---- - -## Sample Data - -### Control Charts - -* `spc_individual_in_control.csv` -* `spc_individual_out_of_control.csv` -* `spc_subgroup_data.csv` -* `spc_np_chart_data.csv` -* `spc_c_chart_data.csv` - -### MSA Studies - -* `msa_gage_rr_excellent.csv` -* `msa_gage_rr_poor.csv` -* `msa_bias_study.csv` -* `msa_linearity_study.csv` -* `msa_stability_study.csv` - -### Process Capability - -* `capability_excellent.csv` -* `capability_off_center.csv` -* `capability_high_variation.csv` -* `capability_skewed_data.csv` - ---- - -## Output +| Service | Port | +|---------|------| +| API | 8000 | +| Frontend | 3000 | +| Redpanda | 19092 (loopback) | +| Mosquitto | 1883 (loopback) | +| TimescaleDB | 5433 (loopback) | +| Redis | 6379 (loopback) | +| Grafana (ops profile) | 3001 (loopback) | -Each analysis produces: - -* Detailed statistical summaries -* HTML report with charts -* Plain-language recommendations -* Out-of-control detection (SPC) -* Acceptance criteria (MSA) -* Capability indices (Cp, Cpk, Pp, Ppk) - -Reports are stored in `temp_uploads/`. - ---- - -## Project Structure - -``` -├── agent_config/ # LLM and agent settings -│ ├── config.yaml # Model provider config -│ └── agent_prompts/ # Agent prompt templates -├── api/ # FastAPI server -├── control_chart_system/ # SPC logic -├── msa_system/ # MSA logic -├── process_capability_system/ # Capability analysis logic -├── data_samples/ # Example datasets -├── spc_cli.py # Command-line tool -``` - ---- - -## Example Workflow +## Quality & testing ```bash -# 1. Start the server -uvicorn api.main:app --host 0.0.0.0 --port 8000 & - -# 2. Validate measurement system -./spc chat msa "Conduct Gage R&R study" -f gage_data.csv - -# 3. If MSA passes, monitor process -./spc chat control-charts "Check process stability" -f process_data.csv - -# 4. If process is stable, check capability -./spc chat capability "Calculate Cp and Cpk with USL=10.5, LSL=9.5" -f process_data.csv -``` - ---- - -## Use Cases +# Unit tests (CI default excludes integration) +PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 pytest -q -m "not integration" -### Manufacturing +# Resilience judgment catalog +aspc resilience +# or: python scripts/resilience_report.py → resilience_data/JUDGMENT.md -* Monitor process stability using control charts -* Validate measurement systems for precision -* Assess process capability for continuous improvement +# Combinatorial dual-mode matrix (sparse for CI; exhaustive local) +python -m combinatorial report --mode sparse +# → combinatorial/out/JUDGMENT.md, COVERAGE.json, ENGINE_BEHAVIOR_REPORT.md -### Research & Development +# Operator-console Playwright (Compose UI+API must be up) +cd frontend && E2E_USERNAME=admin E2E_PASSWORD='…' npm run test:e2e -* Verify measurement reliability before experiments -* Ensure data integrity in prototypes or trials -* Automate statistical exploration of test results - ---- - -## Conversation Memory - -Agents retain context within a session (`--thread-id`), allowing natural multi-step discussions: - -```bash -./spc chat control-charts "Analyze this" -f data.csv -t project_001 -./spc chat control-charts "Explain those out-of-control points" -t project_001 -./spc chat control-charts "Give improvement suggestions" -t project_001 +# Accuracy / performance benches +python benchmarks/accuracy.py +python benchmarks/performance.py ``` ---- - -## Reports example - -control_chart - -msa_report - -capability analysis - - -**agent chat** -agent_chat - ---- -## Contributing - -This is a **work in progress** project. -Contributions are welcome code, documentation, or any ideas. - -1. Open an issue describing your suggestion or bug -2. Fork and submit a pull request - ---- +Details: [resilience_data/README.md](resilience_data/README.md), [docs/development.md](docs/development.md), [docs/overview/health-and-roadmap.md](docs/overview/health-and-roadmap.md). -## Credits +## Documentation -Built with: +| Guide | Description | +|-------|-------------| +| [docs/overview/problem-and-solution.md](docs/overview/problem-and-solution.md) | Why ASPC — problem, solution, architecture | +| [docs/architecture.md](docs/architecture.md) | Structural scan: layers, flows, deploy topology | +| [docs/overview/health-and-roadmap.md](docs/overview/health-and-roadmap.md) | Health insights, roadmap, contract probes | +| [docs/overview/benchmarking.md](docs/overview/benchmarking.md) | Performance, accuracy, robustness | +| [docs/index.md](docs/index.md) | Doc map and Phase I → freeze → Phase II model | +| [docs/concepts.md](docs/concepts.md) | Charts, rules, MSA, capability, flags | +| [docs/pipeline.md](docs/pipeline.md) | Gated `establish()`, checklist, `SPCRecord` | +| [docs/cli.md](docs/cli.md) | `aspc` command reference | +| [docs/api.md](docs/api.md) | REST, auth, WebSocket, SSE | +| [docs/python-api.md](docs/python-api.md) | Library usage and extras | +| [docs/configuration.md](docs/configuration.md) | YAML + `ASPC_*` env | +| [docs/deployment.md](docs/deployment.md) | Compose, images, migrations (minimal vs full) | +| [docs/development.md](docs/development.md) | Tests, lint, `sample_data`, CI | +| [resilience_data/README.md](resilience_data/README.md) | Standards-mapped resilience corpus | +| [combinatorial/out/ENGINE_BEHAVIOR_REPORT.md](combinatorial/out/ENGINE_BEHAVIOR_REPORT.md) | Engine behavior from combinatorial matrix | -* **Langchain** — Prebuilt agents -* **FastAPI** — REST API -* **Plotly** — Visualization -* **scipy**, **numpy**, **pandas** — Statistical backbone +Interactive OpenAPI: `http://localhost:8000/docs` when the API is running. ---- +## Standards -**Disclaimer:** -This tool uses AI (LLMs) for statistical interpretation. -Always verify results with standard software and domain expertise before making decisions. -The AI assists quality engineers — it doesn’t replace them. +AIAG MSA-4 · AIAG SPC · ISO 7870 · Six Sigma DMAIC +## License +See [LICENSE](LICENSE). diff --git a/adapters/__init__.py b/adapters/__init__.py new file mode 100644 index 0000000..47dd5ec --- /dev/null +++ b/adapters/__init__.py @@ -0,0 +1,19 @@ +"""I/O, persistence, rendering, and streaming adapters around spc_core.""" +from .factory import get_repository +from .io_files import FileReadError, load_columns, read_csv, save_upload +from .persistence import Repository, SQLiteRepository +from .stream import FileReplaySource, stream_evaluate +from .stream_engine import StreamEngine + +__all__ = [ + "FileReadError", + "FileReplaySource", + "Repository", + "SQLiteRepository", + "StreamEngine", + "get_repository", + "load_columns", + "read_csv", + "save_upload", + "stream_evaluate", +] diff --git a/adapters/archive.py b/adapters/archive.py new file mode 100644 index 0000000..58acea1 --- /dev/null +++ b/adapters/archive.py @@ -0,0 +1,127 @@ +"""Cold-archive export of raw measurements to Parquet/CSV.""" +from __future__ import annotations + +import csv +from collections.abc import Iterable, Mapping, Sequence +from datetime import datetime +from pathlib import Path +from typing import Any + +_COLUMNS = ( + "stream_key", + "ts", + "value", + "quality_flag", + "machine_id", + "gage_id", + "limits_version", +) + + +def export_parquet(rows: Sequence[Mapping[str, Any]] | Iterable[Mapping[str, Any]], + path: str | Path) -> Path: + """Write observation rows to Parquet (polars) or CSV fallback. + + Each row should be a mapping with at least ``stream_key``, ``ts``, ``value``. + Extra keys are preserved when using polars; CSV fallback writes known columns. + """ + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + materialised = [dict(r) for r in rows] + + try: + import polars as pl + except ImportError: + pl = None # type: ignore[assignment] + + if pl is not None: + df = pl.DataFrame(materialised) if materialised else pl.DataFrame({c: [] for c in _COLUMNS}) + if path.suffix.lower() == ".csv": + df.write_csv(path) + else: + # Ensure .parquet suffix for clarity when caller omitted it + if path.suffix.lower() not in (".parquet", ".pq", ".csv"): + path = path.with_suffix(".parquet") + df.write_parquet(path) + return path + + # CSV fallback when polars is unavailable + out = path if path.suffix.lower() == ".csv" else path.with_suffix(".csv") + fieldnames = list(_COLUMNS) + for row in materialised: + for k in row: + if k not in fieldnames: + fieldnames.append(k) + with out.open("w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore") + writer.writeheader() + for row in materialised: + serialised = {} + for k, v in row.items(): + if hasattr(v, "isoformat"): + serialised[k] = v.isoformat() + else: + serialised[k] = v + writer.writerow(serialised) + return out + + +def export_raw_to_parquet( + repo_or_engine: Any, + stream_key: str, + start: datetime, + end: datetime, + path: str | Path, +) -> Path: + """Dump ``raw_measurements`` for ``stream_key`` in ``[start, end]`` via :func:`export_parquet`. + + Accepts a :class:`TimescaleDBRepository` (preferred) or a SQLAlchemy ``Engine``. + """ + rows = _fetch_rows(repo_or_engine, stream_key, start, end) + return export_parquet(rows, path) + + +def _fetch_rows( + repo_or_engine: Any, + stream_key: str, + start: datetime, + end: datetime, +) -> list[dict[str, Any]]: + if hasattr(repo_or_engine, "query_raw_measurements"): + return list(repo_or_engine.query_raw_measurements(stream_key, start, end)) + + try: + from sqlalchemy import select + from sqlalchemy.orm import Session + except ImportError as exc: # pragma: no cover + raise ImportError( + "Engine-based export requires the tsdb extra. " + "Install with: pip install 'aspc[tsdb]'" + ) from exc + + from adapters.db_models import RawMeasurementRow + + engine = repo_or_engine + with Session(engine) as session: + stmt = ( + select(RawMeasurementRow) + .where( + RawMeasurementRow.stream_key == stream_key, + RawMeasurementRow.ts >= start, + RawMeasurementRow.ts <= end, + ) + .order_by(RawMeasurementRow.ts) + ) + return [ + { + "id": r.id, + "stream_key": r.stream_key, + "ts": r.ts, + "value": r.value, + "quality_flag": r.quality_flag, + "machine_id": r.machine_id, + "gage_id": r.gage_id, + "limits_version": r.limits_version, + } + for r in session.scalars(stmt).all() + ] diff --git a/adapters/db_models.py b/adapters/db_models.py new file mode 100644 index 0000000..72d5456 --- /dev/null +++ b/adapters/db_models.py @@ -0,0 +1,156 @@ +"""SQLAlchemy 2.0 declarative models for ASPC persistence (SQLite + TimescaleDB).""" +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import ( + JSON, + BigInteger, + Boolean, + DateTime, + Float, + Index, + Integer, + PrimaryKeyConstraint, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +def _utcnow() -> datetime: + return datetime.now(UTC) + + +class Base(DeclarativeBase): + """Shared declarative base for all ASPC tables.""" + + +class ControlLimitRow(Base): + __tablename__ = "control_limits" + + version: Mapped[str] = mapped_column(String(64), primary_key=True) + chart_type: Mapped[str] = mapped_column(String(32), nullable=False) + payload: Mapped[dict] = mapped_column(JSON, nullable=False) + meta: Mapped[dict | None] = mapped_column(JSON, nullable=True) + tenant_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=_utcnow + ) + + +class AnalysisRunRow(Base): + __tablename__ = "analysis_runs" + + run_id: Mapped[str] = mapped_column(String(64), primary_key=True) + analysis_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + limits_version: Mapped[str | None] = mapped_column(String(64), nullable=True) + source_file: Mapped[str | None] = mapped_column(Text, nullable=True) + user_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + tenant_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + report: Mapped[dict] = mapped_column(JSON, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=_utcnow, index=True + ) + + +class AuditLogRow(Base): + __tablename__ = "audit_log" + + event_id: Mapped[str] = mapped_column(String(64), primary_key=True) + event: Mapped[str] = mapped_column(String(128), nullable=False, index=True) + detail: Mapped[dict] = mapped_column(JSON, nullable=False) + user_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=_utcnow + ) + + +class OocEventRow(Base): + __tablename__ = "ooc_events" + __table_args__ = ( + UniqueConstraint("stream_key", "ts", "rule_id", name="uq_ooc_stream_ts_rule"), + Index("ix_ooc_stream_ts", "stream_key", "ts"), + Index("ix_ooc_acked", "acked"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + stream_key: Mapped[str] = mapped_column(String(256), nullable=False) + limits_version: Mapped[str | None] = mapped_column(String(64), nullable=True) + index: Mapped[int] = mapped_column(Integer, nullable=False) + value: Mapped[float] = mapped_column(Float, nullable=False) + rule_id: Mapped[str] = mapped_column(String(32), nullable=False) + rule_name: Mapped[str] = mapped_column(String(128), nullable=False) + description: Mapped[str] = mapped_column(Text, nullable=False) + side: Mapped[str | None] = mapped_column(String(16), nullable=True) + ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + acked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + acked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + acked_by: Mapped[str | None] = mapped_column(String(128), nullable=True) + tenant_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + + +class CapabilityHistoryRow(Base): + __tablename__ = "capability_history" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + run_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + cpk: Mapped[float | None] = mapped_column(Float, nullable=True) + ppk: Mapped[float | None] = mapped_column(Float, nullable=True) + sigma_level: Mapped[float | None] = mapped_column(Float, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=_utcnow + ) + + +class RawMeasurementRow(Base): + """Hot-path observations; becomes a Timescale hypertable on PostgreSQL. + + ``id`` is assigned in application code (not DB autoincrement) so the composite + primary key ``(id, ts)`` works on both SQLite and TimescaleDB (hypertables + require the partition column ``ts`` in every unique constraint). + """ + + __tablename__ = "raw_measurements" + __table_args__ = ( + PrimaryKeyConstraint("id", "ts", name="pk_raw_measurements"), + Index("ix_raw_stream_ts", "stream_key", "ts"), + ) + + id: Mapped[int] = mapped_column(BigInteger, nullable=False) + stream_key: Mapped[str] = mapped_column(String(256), nullable=False) + ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + value: Mapped[float] = mapped_column(Float, nullable=False) + quality_flag: Mapped[str | None] = mapped_column(String(64), nullable=True) + machine_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + gage_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + limits_version: Mapped[str | None] = mapped_column(String(64), nullable=True) + tenant_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + + +class StreamRegistryRow(Base): + __tablename__ = "stream_registry" + + stream_key: Mapped[str] = mapped_column(String(256), primary_key=True) + topic: Mapped[str | None] = mapped_column(String(512), nullable=True) + limits_version: Mapped[str | None] = mapped_column(String(64), nullable=True) + chart_type: Mapped[str | None] = mapped_column(String(32), nullable=True) + ruleset: Mapped[str] = mapped_column(String(64), nullable=False, default="nelson") + active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + measurement_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + meta: Mapped[dict | None] = mapped_column(JSON, nullable=True) + tenant_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=_utcnow + ) + + +# Canonical names requested by the platform schema (aliases of *Row models). +ControlLimit = ControlLimitRow +AnalysisRun = AnalysisRunRow +AuditLog = AuditLogRow +OocEvent = OocEventRow +CapabilityHistory = CapabilityHistoryRow +RawMeasurement = RawMeasurementRow +StreamRegistration = StreamRegistryRow diff --git a/adapters/excel.py b/adapters/excel.py new file mode 100644 index 0000000..f16649f --- /dev/null +++ b/adapters/excel.py @@ -0,0 +1,95 @@ +"""Excel bridge — export SPC reports and import simple measurement sheets.""" +from __future__ import annotations + +import csv +import io +from pathlib import Path +from typing import Any + + +def spc_report_to_xlsx_bytes(report: dict[str, Any]) -> bytes: + """Build a minimal XLSX workbook from an SPC report dict. + + Uses openpyxl when available; otherwise raises ImportError with install hint. + """ + try: + from openpyxl import Workbook + except ImportError as exc: # pragma: no cover + raise ImportError( + "openpyxl is required for Excel export. Install with: pip install openpyxl" + ) from exc + + wb = Workbook() + ws = wb.active + ws.title = "Summary" + limits = report.get("limits") or {} + comps = limits.get("components") or {} + primary = next(iter(comps.values()), {}) if comps else {} + rows = [ + ("chart_type", report.get("chart_type") or limits.get("chart_type")), + ("limits_version", limits.get("version")), + ("center", primary.get("center") if isinstance(primary, dict) else None), + ("ucl", primary.get("ucl") if isinstance(primary, dict) else None), + ("lcl", primary.get("lcl") if isinstance(primary, dict) else None), + ("phase", report.get("phase")), + ("n_points", len(report.get("plotted_values") or [])), + ("n_signals", len(report.get("signals") or [])), + ] + ws.append(["field", "value"]) + for k, v in rows: + ws.append([k, v if not isinstance(v, list) else (v[0] if v else None)]) + + ws2 = wb.create_sheet("Points") + ws2.append(["index", "value", "ooc"]) + plotted = report.get("plotted_values") or [] + ooc = {int(s.get("index")) for s in (report.get("signals") or []) if isinstance(s, dict)} + for i, v in enumerate(plotted): + ws2.append([i, float(v), 1 if i in ooc else 0]) + + ws3 = wb.create_sheet("Signals") + ws3.append(["rule_id", "rule_name", "index", "value", "description", "side"]) + for s in report.get("signals") or []: + if not isinstance(s, dict): + continue + ws3.append( + [ + s.get("rule_id"), + s.get("rule_name"), + s.get("index"), + s.get("value"), + s.get("description"), + s.get("side"), + ] + ) + + buf = io.BytesIO() + wb.save(buf) + return buf.getvalue() + + +def import_sheet_to_csv_bytes(path: Path | str) -> bytes: + """Read first sheet of an xlsx/xls and emit CSV bytes (header + rows). + + Falls back to reading CSV/text as-is. + """ + p = Path(path) + suffix = p.suffix.lower() + if suffix in {".csv", ".txt"}: + return p.read_bytes() + if suffix not in {".xlsx", ".xlsm", ".xltx", ".xltm"}: + raise ValueError(f"Unsupported spreadsheet type: {suffix}") + try: + from openpyxl import load_workbook + except ImportError as exc: # pragma: no cover + raise ImportError( + "openpyxl is required for Excel import. Install with: pip install openpyxl" + ) from exc + wb = load_workbook(p, read_only=True, data_only=True) + ws = wb.active + out = io.StringIO() + writer = csv.writer(out) + for row in ws.iter_rows(values_only=True): + if row is None or all(c is None for c in row): + continue + writer.writerow(["" if c is None else c for c in row]) + return out.getvalue().encode("utf-8") diff --git a/adapters/factory.py b/adapters/factory.py new file mode 100644 index 0000000..ffba767 --- /dev/null +++ b/adapters/factory.py @@ -0,0 +1,62 @@ +"""Repository factory — select SQLite or TimescaleDB backend.""" +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from adapters.persistence import Repository, SQLiteRepository + + +def get_repository( + cfg: Any = None, + backend: str | None = None, + *, + sqlite_path: str | Path | None = None, + timescale_dsn: str | None = None, +) -> Repository: + """Return a :class:`Repository` for the requested backend. + + Parameters + ---------- + cfg: + Optional :class:`apps.config.Config` (or duck-typed object with + ``persistence_backend``, ``sqlite_path``, ``timescale_dsn``). + When provided, backend/paths are taken from config unless overridden. + backend: + ``"sqlite"`` (default) or ``"timescale"`` / ``"timescaledb"`` / ``"postgres"``. + sqlite_path: + Path for the SQLite file (default ``aspc.db``). + timescale_dsn: + SQLAlchemy DSN for Timescale/Postgres. Async DSNs (``+asyncpg``) are + normalized to sync ``+psycopg``. + """ + if cfg is not None: + backend = backend or getattr(cfg, "persistence_backend", None) or "sqlite" + if sqlite_path is None: + sqlite_path = getattr(cfg, "sqlite_path", None) + if timescale_dsn is None: + timescale_dsn = getattr(cfg, "timescale_dsn", None) + + key = (backend or "sqlite").strip().lower() + if key in ("sqlite", "sqllite", "file"): + return SQLiteRepository(sqlite_path or "aspc.db") + if key in ("timescale", "timescaledb", "postgres", "postgresql", "tsdb"): + if not timescale_dsn: + raise ValueError("timescale_dsn is required for the TimescaleDB backend") + from adapters.persistence_tsdb import TimescaleDBRepository + + return TimescaleDBRepository(timescale_dsn) + raise ValueError( + f"Unknown persistence backend '{backend}'. " + "Use 'sqlite' or 'timescale'." + ) + + +def require_streaming_repository(repo: Repository) -> Repository: + """Raise if ``repo`` cannot support the Phase II stream engine.""" + if not hasattr(repo, "save_raw_measurement"): + raise TypeError( + f"{type(repo).__name__} does not support streaming measurements. " + "Set ASPC_PERSISTENCE_BACKEND=timescale (SQLite has no Tier-1/Tier-2 tables)." + ) + return repo diff --git a/adapters/io_files.py b/adapters/io_files.py new file mode 100644 index 0000000..d72d93e --- /dev/null +++ b/adapters/io_files.py @@ -0,0 +1,169 @@ +"""File I/O adapters — CSV/Parquet readers with safe path handling. + +Converts files into the plain column-dict shape that ``spc_core.ingest`` expects. +""" +from __future__ import annotations + +import csv +import re +import uuid +from pathlib import Path +from typing import Any + + +class FileReadError(Exception): + """Raised when a data file cannot be read or is empty.""" + + +_SAFE_NAME = re.compile(r"^[\w.\- ]+$") + + +def safe_filename(name: str) -> str: + """Reject path traversal and unsafe characters; return basename only.""" + if name is None or not str(name).strip(): + raise FileReadError(f"Unsafe filename: {name!r}") + raw = str(name) + # Reject any path separators or parent-dir tokens before taking basename. + if "/" in raw or "\\" in raw or ".." in raw: + raise FileReadError(f"Unsafe filename: {name!r}") + base = Path(raw).name + if not base or base in (".", "..") or not _SAFE_NAME.match(base): + raise FileReadError(f"Unsafe filename: {name!r}") + return base + + +def read_csv(path: str | Path, encoding: str = "utf-8") -> dict[str, list[Any]]: + """Read a CSV into ``{column: [values...]}``. + + Raises ``FileReadError`` for missing/empty files (replaces the legacy + ``pd.errors.EmptyDataData`` typo that raised AttributeError). + """ + p = Path(path) + if not p.exists(): + raise FileReadError(f"File not found: {path}") + if p.stat().st_size == 0: + raise FileReadError(f"File is empty: {path}") + + with p.open(newline="", encoding=encoding) as f: + reader = csv.DictReader(f) + if not reader.fieldnames: + raise FileReadError(f"No header row in CSV: {path}") + columns: dict[str, list[Any]] = {name: [] for name in reader.fieldnames} + row_count = 0 + for row in reader: + row_count += 1 + for name in reader.fieldnames: + raw = row.get(name, "") + columns[name].append(_coerce(raw)) + if row_count == 0: + raise FileReadError(f"CSV has headers but no data rows: {path}") + return columns + + +def read_parquet(path: str | Path) -> dict[str, list[Any]]: + """Read a Parquet file via Polars (optional dependency).""" + try: + import polars as pl + except ImportError as exc: + raise FileReadError( + "polars is required for Parquet support. Install with: pip install aspc[data]" + ) from exc + p = Path(path) + if not p.exists(): + raise FileReadError(f"File not found: {path}") + df = pl.read_parquet(p) + if df.height == 0: + raise FileReadError(f"Parquet file is empty: {path}") + return {col: df[col].to_list() for col in df.columns} + + +def load_columns(path: str | Path) -> dict[str, list[Any]]: + """Dispatch on extension: .csv / .parquet / .pq.""" + p = Path(path) + suffix = p.suffix.lower() + if suffix == ".csv": + return read_csv(p) + if suffix in (".parquet", ".pq"): + return read_parquet(p) + raise FileReadError(f"Unsupported file type '{suffix}'. Use .csv or .parquet") + + +def save_upload(content: bytes, dest_dir: str | Path, filename: str, + max_bytes: int | None = None, + allowed_extensions: list[str] | None = None) -> Path: + """Write an uploaded file safely into dest_dir.""" + dest = Path(dest_dir) + dest.mkdir(parents=True, exist_ok=True) + name = safe_filename(filename) + if allowed_extensions: + ext = Path(name).suffix.lower() + if ext not in {e.lower() if e.startswith(".") else f".{e.lower()}" + for e in allowed_extensions}: + raise FileReadError(f"Extension '{ext}' not allowed. Allowed: {allowed_extensions}") + if max_bytes is not None and len(content) > max_bytes: + raise FileReadError(f"File exceeds max size ({max_bytes} bytes)") + target = dest / f"{uuid.uuid4().hex}_{name}" + target.write_bytes(content) + return target + + +def save_upload_stream( + stream, + dest_dir: str | Path, + filename: str, + *, + max_bytes: int | None = None, + allowed_extensions: list[str] | None = None, + chunk_size: int = 64 * 1024, +) -> Path: + """Stream an upload to disk with a running size check (avoids reading whole body first).""" + dest = Path(dest_dir) + dest.mkdir(parents=True, exist_ok=True) + name = safe_filename(filename) + if allowed_extensions: + ext = Path(name).suffix.lower() + if ext not in {e.lower() if e.startswith(".") else f".{e.lower()}" + for e in allowed_extensions}: + raise FileReadError(f"Extension '{ext}' not allowed. Allowed: {allowed_extensions}") + target = dest / f"{uuid.uuid4().hex}_{name}" + written = 0 + try: + with target.open("wb") as out: + while True: + chunk = stream.read(chunk_size) + if not chunk: + break + written += len(chunk) + if max_bytes is not None and written > max_bytes: + raise FileReadError(f"File exceeds max size ({max_bytes} bytes)") + out.write(chunk) + except Exception: + if target.exists(): + target.unlink(missing_ok=True) + raise + if written == 0: + target.unlink(missing_ok=True) + raise FileReadError("Uploaded file is empty") + return target + + +def resolve_under(base: str | Path, user_path: str) -> Path: + """Resolve ``user_path`` and ensure it stays inside ``base`` (no traversal).""" + root = Path(base).resolve() + candidate = (root / user_path).resolve() if not Path(user_path).is_absolute() else Path(user_path).resolve() + try: + candidate.relative_to(root) + except ValueError as exc: + raise FileReadError(f"Path escapes allowed directory: {user_path!r}") from exc + return candidate + + +def _coerce(raw: str) -> Any: + if raw is None or raw == "": + return None + try: + if "." in raw or "e" in raw.lower(): + return float(raw) + return int(raw) + except ValueError: + return raw diff --git a/adapters/persistence.py b/adapters/persistence.py new file mode 100644 index 0000000..031d071 --- /dev/null +++ b/adapters/persistence.py @@ -0,0 +1,189 @@ +"""Persistence adapters — repository interface with SQLite (and TimescaleDB). + +Stores provenance: which analysis ran, on what data, with which frozen limits version, +at what time. This is the audit trail the legacy ephemeral-upload approach lacked. + +TimescaleDB lives in ``adapters.persistence_tsdb`` (optional ``aspc[tsdb]`` extra). +""" +from __future__ import annotations + +import json +import sqlite3 +from abc import ABC, abstractmethod +from datetime import UTC, datetime +from pathlib import Path +from typing import Any +from uuid import uuid4 + + +class Repository(ABC): + """Abstract store for SPC analysis runs and frozen control limits.""" + + @abstractmethod + def save_limits(self, limits_payload: dict[str, Any], version: str, + chart_type: str, meta: dict | None = None) -> str: + ... + + @abstractmethod + def get_limits(self, version: str) -> dict[str, Any] | None: + ... + + @abstractmethod + def save_run(self, analysis_type: str, report: dict[str, Any], + limits_version: str | None = None, + source_file: str | None = None, + user_id: str | None = None) -> str: + ... + + @abstractmethod + def get_run(self, run_id: str) -> dict[str, Any] | None: + ... + + @abstractmethod + def list_runs(self, analysis_type: str | None = None, limit: int = 50) -> list[dict]: + ... + + @abstractmethod + def save_audit(self, event: str, detail: dict[str, Any], + user_id: str | None = None) -> str: + ... + + +class SQLiteRepository(Repository): + """File-backed SQLite store — fine for prototyping and single-node deployment.""" + + def __init__(self, db_path: str | Path = "aspc.db"): + self.db_path = Path(db_path) + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self._init_schema() + + def _connect(self) -> sqlite3.Connection: + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + return conn + + def _init_schema(self) -> None: + with self._connect() as conn: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS control_limits ( + version TEXT PRIMARY KEY, + chart_type TEXT NOT NULL, + payload TEXT NOT NULL, + meta TEXT, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS analysis_runs ( + run_id TEXT PRIMARY KEY, + analysis_type TEXT NOT NULL, + limits_version TEXT, + source_file TEXT, + user_id TEXT, + report TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS audit_log ( + event_id TEXT PRIMARY KEY, + event TEXT NOT NULL, + detail TEXT NOT NULL, + user_id TEXT, + created_at TEXT NOT NULL + ); + """ + ) + + def save_limits(self, limits_payload: dict[str, Any], version: str, + chart_type: str, meta: dict | None = None) -> str: + now = datetime.now(UTC).isoformat() + with self._connect() as conn: + conn.execute( + "INSERT OR REPLACE INTO control_limits " + "(version, chart_type, payload, meta, created_at) VALUES (?,?,?,?,?)", + (version, chart_type, json.dumps(limits_payload, default=str), + json.dumps(meta or {}), now), + ) + return version + + def get_limits(self, version: str) -> dict[str, Any] | None: + with self._connect() as conn: + row = conn.execute( + "SELECT * FROM control_limits WHERE version = ?", (version,) + ).fetchone() + if not row: + return None + return { + "version": row["version"], + "chart_type": row["chart_type"], + "payload": json.loads(row["payload"]), + "meta": json.loads(row["meta"] or "{}"), + "created_at": row["created_at"], + } + + def save_run(self, analysis_type: str, report: dict[str, Any], + limits_version: str | None = None, + source_file: str | None = None, + user_id: str | None = None) -> str: + run_id = str(uuid4()) + now = datetime.now(UTC).isoformat() + with self._connect() as conn: + conn.execute( + "INSERT INTO analysis_runs " + "(run_id, analysis_type, limits_version, source_file, user_id, report, created_at) " + "VALUES (?,?,?,?,?,?,?)", + (run_id, analysis_type, limits_version, source_file, user_id, + json.dumps(report, default=str), now), + ) + self.save_audit( + "analysis_run", + {"run_id": run_id, "analysis_type": analysis_type, + "limits_version": limits_version, "source_file": source_file}, + user_id=user_id, + ) + return run_id + + def get_run(self, run_id: str) -> dict[str, Any] | None: + with self._connect() as conn: + row = conn.execute( + "SELECT * FROM analysis_runs WHERE run_id = ?", (run_id,) + ).fetchone() + if not row: + return None + return { + "run_id": row["run_id"], + "analysis_type": row["analysis_type"], + "limits_version": row["limits_version"], + "source_file": row["source_file"], + "user_id": row["user_id"], + "report": json.loads(row["report"]), + "created_at": row["created_at"], + } + + def list_runs(self, analysis_type: str | None = None, limit: int = 50) -> list[dict]: + with self._connect() as conn: + if analysis_type: + rows = conn.execute( + "SELECT run_id, analysis_type, limits_version, source_file, " + "user_id, created_at FROM analysis_runs " + "WHERE analysis_type = ? ORDER BY created_at DESC LIMIT ?", + (analysis_type, limit), + ).fetchall() + else: + rows = conn.execute( + "SELECT run_id, analysis_type, limits_version, source_file, " + "user_id, created_at FROM analysis_runs " + "ORDER BY created_at DESC LIMIT ?", + (limit,), + ).fetchall() + return [dict(r) for r in rows] + + def save_audit(self, event: str, detail: dict[str, Any], + user_id: str | None = None) -> str: + event_id = str(uuid4()) + now = datetime.now(UTC).isoformat() + with self._connect() as conn: + conn.execute( + "INSERT INTO audit_log (event_id, event, detail, user_id, created_at) " + "VALUES (?,?,?,?,?)", + (event_id, event, json.dumps(detail, default=str), user_id, now), + ) + return event_id diff --git a/adapters/persistence_tsdb.py b/adapters/persistence_tsdb.py new file mode 100644 index 0000000..50cb1fa --- /dev/null +++ b/adapters/persistence_tsdb.py @@ -0,0 +1,588 @@ +"""TimescaleDB / PostgreSQL repository (optional ``aspc[tsdb]`` extra). + +Uses a SQLAlchemy sync engine with the ``psycopg`` driver. Also works against +SQLite for local tests (tables only — no hypertables or retention policies). +""" +from __future__ import annotations + +import hashlib +import logging +import os +from datetime import UTC, datetime +from typing import Any +from uuid import uuid4 + +try: + from sqlalchemy import create_engine, func, select, text + from sqlalchemy.dialects.postgresql import insert as pg_insert + from sqlalchemy.engine import Engine + from sqlalchemy.exc import IntegrityError + from sqlalchemy.orm import Session, sessionmaker +except ImportError as exc: # pragma: no cover - exercised when extra missing + raise ImportError( + "TimescaleDB persistence requires the tsdb extra. " + "Install with: pip install 'aspc[tsdb]'" + ) from exc + +logger = logging.getLogger(__name__) + +from adapters.db_models import ( + AnalysisRunRow, + AuditLogRow, + Base, + CapabilityHistoryRow, + ControlLimitRow, + OocEventRow, + RawMeasurementRow, + StreamRegistryRow, +) +from adapters.persistence import Repository + + +def normalize_sync_dsn(dsn: str) -> str: + """Convert common ASPC DSNs to a SQLAlchemy sync ``psycopg`` URL.""" + if dsn.startswith("postgresql+psycopg"): + return dsn + for prefix in ( + "postgresql+asyncpg://", + "postgresql://", + "postgres://", + ): + if dsn.startswith(prefix): + return "postgresql+psycopg://" + dsn[len(prefix) :] + return dsn + + +def init_schema(engine: Engine) -> None: + """Create tables; on PostgreSQL also enable Timescale hypertables + retention.""" + Base.metadata.create_all(engine) + if engine.dialect.name != "postgresql": + return + with engine.begin() as conn: + conn.execute(text("CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE")) + conn.execute( + text( + "SELECT create_hypertable(" + "'raw_measurements', 'ts', if_not_exists => TRUE)" + ) + ) + # Retention may already exist on re-init; log (don't swallow silently). + try: + conn.execute( + text( + """ + SELECT add_retention_policy( + 'raw_measurements', + INTERVAL '90 days', + if_not_exists => TRUE + ); + """ + ) + ) + except Exception as exc: # noqa: BLE001 + logger.warning( + "Timescale retention policy not applied on raw_measurements: %s", + exc, + ) + + +class TimescaleDBRepository(Repository): + """SQLAlchemy-backed repository for PostgreSQL/TimescaleDB (or SQLite).""" + + def __init__(self, dsn: str | Engine, *, echo: bool = False, init: bool = True): + if isinstance(dsn, Engine): + self.engine = dsn + else: + self.engine = create_engine(normalize_sync_dsn(dsn), echo=echo, future=True) + self._Session = sessionmaker(bind=self.engine, expire_on_commit=False, future=True) + # Compose runs Alembic in a migrate service; set ASPC_TSDB_INIT=0 to skip + # concurrent create_all races from api + stream-engine. + env_init = os.getenv("ASPC_TSDB_INIT", "1").lower() not in ("0", "false", "no") + if init and env_init: + init_schema(self.engine) + + def _session(self) -> Session: + return self._Session() + + # --- Repository ABC ----------------------------------------------------- + + def save_limits( + self, + limits_payload: dict[str, Any], + version: str, + chart_type: str, + meta: dict | None = None, + ) -> str: + now = datetime.now(UTC) + with self._session() as session: + row = session.get(ControlLimitRow, version) + if row is None: + row = ControlLimitRow( + version=version, + chart_type=chart_type, + payload=limits_payload, + meta=meta or {}, + created_at=now, + ) + session.add(row) + else: + row.chart_type = chart_type + row.payload = limits_payload + row.meta = meta or {} + session.commit() + return version + + def get_limits(self, version: str) -> dict[str, Any] | None: + with self._session() as session: + row = session.get(ControlLimitRow, version) + if row is None: + return None + return { + "version": row.version, + "chart_type": row.chart_type, + "payload": row.payload, + "meta": row.meta or {}, + "created_at": row.created_at.isoformat() + if isinstance(row.created_at, datetime) + else row.created_at, + } + + def save_run( + self, + analysis_type: str, + report: dict[str, Any], + limits_version: str | None = None, + source_file: str | None = None, + user_id: str | None = None, + ) -> str: + run_id = str(uuid4()) + now = datetime.now(UTC) + with self._session() as session: + session.add( + AnalysisRunRow( + run_id=run_id, + analysis_type=analysis_type, + limits_version=limits_version, + source_file=source_file, + user_id=user_id, + report=report, + created_at=now, + ) + ) + session.commit() + self.save_audit( + "analysis_run", + { + "run_id": run_id, + "analysis_type": analysis_type, + "limits_version": limits_version, + "source_file": source_file, + }, + user_id=user_id, + ) + return run_id + + def get_run(self, run_id: str) -> dict[str, Any] | None: + with self._session() as session: + row = session.get(AnalysisRunRow, run_id) + if row is None: + return None + return { + "run_id": row.run_id, + "analysis_type": row.analysis_type, + "limits_version": row.limits_version, + "source_file": row.source_file, + "user_id": row.user_id, + "report": row.report, + "created_at": row.created_at.isoformat() + if isinstance(row.created_at, datetime) + else row.created_at, + } + + def list_runs( + self, analysis_type: str | None = None, limit: int = 50 + ) -> list[dict]: + with self._session() as session: + stmt = select(AnalysisRunRow).order_by(AnalysisRunRow.created_at.desc()).limit( + limit + ) + if analysis_type: + stmt = stmt.where(AnalysisRunRow.analysis_type == analysis_type) + rows = session.scalars(stmt).all() + return [ + { + "run_id": r.run_id, + "analysis_type": r.analysis_type, + "limits_version": r.limits_version, + "source_file": r.source_file, + "user_id": r.user_id, + "created_at": r.created_at.isoformat() + if isinstance(r.created_at, datetime) + else r.created_at, + } + for r in rows + ] + + def save_audit( + self, + event: str, + detail: dict[str, Any], + user_id: str | None = None, + ) -> str: + event_id = str(uuid4()) + now = datetime.now(UTC) + with self._session() as session: + session.add( + AuditLogRow( + event_id=event_id, + event=event, + detail=detail, + user_id=user_id, + created_at=now, + ) + ) + session.commit() + return event_id + + # --- Streaming / Tier-1 / Tier-2 ---------------------------------------- + + @staticmethod + def _measurement_id(stream_key: str, ts: datetime, value: float) -> int: + """Deterministic id so Kafka redelivery is a no-op on the PK (id, ts).""" + blob = f"{stream_key}|{ts.isoformat()}|{value:.12g}".encode() + return int(hashlib.sha256(blob).hexdigest()[:15], 16) + + def save_raw_measurement( + self, + stream_key: str, + ts: datetime, + value: float, + **meta: Any, + ) -> None: + if ts.tzinfo is None: + ts = ts.replace(tzinfo=UTC) + row_id = int(meta["id"]) if meta.get("id") is not None else self._measurement_id( + stream_key, ts, float(value) + ) + values = dict( + id=row_id, + stream_key=stream_key, + ts=ts, + value=float(value), + quality_flag=meta.get("quality_flag"), + machine_id=meta.get("machine_id"), + gage_id=meta.get("gage_id"), + limits_version=meta.get("limits_version"), + ) + with self._session() as session: + if self.engine.dialect.name == "postgresql": + stmt = ( + pg_insert(RawMeasurementRow) + .values(**values) + .on_conflict_do_nothing(constraint="pk_raw_measurements") + ) + result = session.execute(stmt) + if (result.rowcount or 0) > 0: + reg = session.get(StreamRegistryRow, stream_key) + if reg is not None: + reg.measurement_count = int(reg.measurement_count or 0) + 1 + session.commit() + return + try: + session.add(RawMeasurementRow(**values)) + reg = session.get(StreamRegistryRow, stream_key) + if reg is not None: + reg.measurement_count = int(reg.measurement_count or 0) + 1 + session.commit() + except IntegrityError: + session.rollback() + + def count_raw_measurements(self, stream_key: str) -> int: + with self._session() as session: + return int( + session.scalar( + select(func.count()) + .select_from(RawMeasurementRow) + .where(RawMeasurementRow.stream_key == stream_key) + ) + or 0 + ) + + def recent_raw_measurements( + self, + stream_key: str, + *, + limit: int = 15, + ) -> list[dict[str, Any]]: + with self._session() as session: + stmt = ( + select(RawMeasurementRow) + .where(RawMeasurementRow.stream_key == stream_key) + .order_by(RawMeasurementRow.ts.desc()) + .limit(limit) + ) + rows = list(reversed(session.scalars(stmt).all())) + return [ + { + "id": r.id, + "stream_key": r.stream_key, + "ts": r.ts, + "value": r.value, + "quality_flag": r.quality_flag, + "machine_id": r.machine_id, + "gage_id": r.gage_id, + "limits_version": r.limits_version, + } + for r in rows + ] + + def save_ooc_event( + self, + stream_key: str, + ts: datetime, + *, + limits_version: str | None, + index: int, + value: float, + rule_id: str, + rule_name: str, + description: str, + side: str | None = None, + ) -> bool: + """Insert an OOC event. Returns True if inserted, False if duplicate.""" + if ts.tzinfo is None: + ts = ts.replace(tzinfo=UTC) + with self._session() as session: + if self.engine.dialect.name == "postgresql": + stmt = ( + pg_insert(OocEventRow) + .values( + stream_key=stream_key, + limits_version=limits_version, + index=index, + value=float(value), + rule_id=rule_id, + rule_name=rule_name, + description=description, + side=side, + ts=ts, + acked=False, + ) + .on_conflict_do_nothing( + constraint="uq_ooc_stream_ts_rule" + ) + ) + result = session.execute(stmt) + session.commit() + return (result.rowcount or 0) > 0 + + existing = session.execute( + select(OocEventRow).where( + OocEventRow.stream_key == stream_key, + OocEventRow.ts == ts, + OocEventRow.rule_id == rule_id, + ) + ).scalar_one_or_none() + if existing is not None: + return False + session.add( + OocEventRow( + stream_key=stream_key, + limits_version=limits_version, + index=index, + value=float(value), + rule_id=rule_id, + rule_name=rule_name, + description=description, + side=side, + ts=ts, + acked=False, + ) + ) + session.commit() + return True + + def list_ooc_events( + self, + stream_key: str | None = None, + *, + unacked_only: bool = False, + limit: int = 100, + ) -> list[dict[str, Any]]: + with self._session() as session: + stmt = select(OocEventRow).order_by(OocEventRow.ts.desc()).limit(limit) + if stream_key: + stmt = stmt.where(OocEventRow.stream_key == stream_key) + if unacked_only: + stmt = stmt.where(OocEventRow.acked.is_(False)) + return [_ooc_to_dict(r) for r in session.scalars(stmt).all()] + + def ack_alert( + self, + event_id: int, + *, + acked_by: str | None = None, + ) -> dict[str, Any] | None: + """Acknowledge an OOC alert by id. Returns the updated row or None if missing.""" + with self._session() as session: + row = session.get(OocEventRow, event_id) + if row is None: + return None + row.acked = True + row.acked_at = datetime.now(UTC) + row.acked_by = acked_by + session.commit() + session.refresh(row) + return _ooc_to_dict(row) + + def save_capability( + self, + run_id: str, + *, + cpk: float | None = None, + ppk: float | None = None, + sigma_level: float | None = None, + ) -> int: + with self._session() as session: + row = CapabilityHistoryRow( + run_id=run_id, + cpk=cpk, + ppk=ppk, + sigma_level=sigma_level, + created_at=datetime.now(UTC), + ) + session.add(row) + session.commit() + session.refresh(row) + return int(row.id) + + def register_stream( + self, + stream_key: str, + *, + topic: str | None = None, + limits_version: str | None = None, + chart_type: str | None = None, + ruleset: str = "nelson", + active: bool = True, + meta: dict | None = None, + ) -> str: + now = datetime.now(UTC) + with self._session() as session: + row = session.get(StreamRegistryRow, stream_key) + if row is None: + session.add( + StreamRegistryRow( + stream_key=stream_key, + topic=topic, + limits_version=limits_version, + chart_type=chart_type, + ruleset=ruleset, + active=active, + meta=meta or {}, + created_at=now, + ) + ) + else: + if topic is not None: + row.topic = topic + if limits_version is not None: + row.limits_version = limits_version + if chart_type is not None: + row.chart_type = chart_type + row.ruleset = ruleset + row.active = active + if meta is not None: + row.meta = meta + session.commit() + return stream_key + + def get_stream(self, stream_key: str) -> dict[str, Any] | None: + with self._session() as session: + row = session.get(StreamRegistryRow, stream_key) + if row is None: + return None + return _stream_to_dict(row) + + def list_streams(self, active_only: bool = False) -> list[dict[str, Any]]: + with self._session() as session: + stmt = select(StreamRegistryRow).order_by(StreamRegistryRow.stream_key) + if active_only: + stmt = stmt.where(StreamRegistryRow.active.is_(True)) + return [_stream_to_dict(r) for r in session.scalars(stmt).all()] + + def set_stream_active(self, stream_key: str, active: bool) -> None: + with self._session() as session: + row = session.get(StreamRegistryRow, stream_key) + if row is None: + raise KeyError(f"Unknown stream_key: {stream_key}") + row.active = active + session.commit() + + def query_raw_measurements( + self, + stream_key: str, + start: datetime, + end: datetime, + ) -> list[dict[str, Any]]: + with self._session() as session: + stmt = ( + select(RawMeasurementRow) + .where( + RawMeasurementRow.stream_key == stream_key, + RawMeasurementRow.ts >= start, + RawMeasurementRow.ts <= end, + ) + .order_by(RawMeasurementRow.ts) + ) + rows = session.scalars(stmt).all() + return [ + { + "id": r.id, + "stream_key": r.stream_key, + "ts": r.ts, + "value": r.value, + "quality_flag": r.quality_flag, + "machine_id": r.machine_id, + "gage_id": r.gage_id, + "limits_version": r.limits_version, + } + for r in rows + ] + + +def _stream_to_dict(row: StreamRegistryRow) -> dict[str, Any]: + return { + "stream_key": row.stream_key, + "topic": row.topic, + "limits_version": row.limits_version, + "chart_type": row.chart_type, + "ruleset": row.ruleset, + "active": row.active, + "measurement_count": int(getattr(row, "measurement_count", 0) or 0), + "tenant_id": getattr(row, "tenant_id", None), + "meta": row.meta or {}, + "created_at": row.created_at.isoformat() + if isinstance(row.created_at, datetime) + else row.created_at, + } + + +def _ooc_to_dict(row: OocEventRow) -> dict[str, Any]: + return { + "id": row.id, + "stream_key": row.stream_key, + "limits_version": row.limits_version, + "index": row.index, + "value": row.value, + "rule_id": row.rule_id, + "rule_name": row.rule_name, + "description": row.description, + "side": row.side, + "ts": row.ts.isoformat() if isinstance(row.ts, datetime) else row.ts, + "acked": bool(row.acked), + "acked_at": row.acked_at.isoformat() + if isinstance(row.acked_at, datetime) + else row.acked_at, + "acked_by": row.acked_by, + } diff --git a/adapters/protocols.py b/adapters/protocols.py new file mode 100644 index 0000000..d4201f2 --- /dev/null +++ b/adapters/protocols.py @@ -0,0 +1,59 @@ +"""Typed repository protocols for streaming and API capability checks.""" +from __future__ import annotations + +from datetime import datetime +from typing import Any, Protocol, runtime_checkable + + +@runtime_checkable +class StreamRepository(Protocol): + """Minimal persistence surface required by :class:`StreamEngine`.""" + + def save_raw_measurement( + self, stream_key: str, ts: datetime, value: float, **meta: Any + ) -> None: ... + + def save_ooc_event( + self, + stream_key: str, + ts: datetime, + *, + limits_version: str | None, + index: int, + value: float, + rule_id: str, + rule_name: str, + description: str, + side: str | None = None, + ) -> bool: ... + + def get_limits(self, version: str) -> dict[str, Any] | None: ... + + +@runtime_checkable +class StreamingOpsRepository(Protocol): + """Timescale-backed ops used by stream registry / alerts API routes.""" + + def register_stream( + self, + stream_key: str, + *, + topic: str | None = None, + limits_version: str | None = None, + chart_type: str | None = None, + ruleset: str = "nelson", + active: bool = True, + meta: dict[str, Any] | None = None, + ) -> str: ... + + def list_streams(self, active_only: bool = False) -> list[dict[str, Any]]: ... + + def get_stream(self, stream_key: str) -> dict[str, Any] | None: ... + + def ack_alert(self, event_id: int, *, acked_by: str | None = None) -> dict[str, Any] | None: ... + + def get_limits(self, version: str) -> dict[str, Any] | None: ... + + def save_audit( + self, event: str, detail: dict[str, Any], *, user_id: str | None = None + ) -> str: ... diff --git a/adapters/render_plotly.py b/adapters/render_plotly.py new file mode 100644 index 0000000..36c3770 --- /dev/null +++ b/adapters/render_plotly.py @@ -0,0 +1,145 @@ +"""Plotly HTML renderer — consumes spc_core report models, produces HTML strings/files. + +Rendering lives outside the core so the statistics library stays free of Plotly. +""" +from __future__ import annotations + +from pathlib import Path + +from spc_core.report import CapabilityReport, MSAReport, SPCReport + + +def render_control_chart_html(report: SPCReport, title: str | None = None) -> str: + try: + import plotly.graph_objects as go + import plotly.io as pio + from plotly.subplots import make_subplots + except ImportError as exc: + raise ImportError( + "plotly is required for HTML reports. Install with: pip install aspc[render]" + ) from exc + + title = title or f"Control Chart — {report.chart_type.value}" + values = report.plotted_values + seq = list(range(1, len(values) + 1)) + primary = report.limits.primary + + has_secondary = report.secondary_values is not None + if has_secondary: + fig = make_subplots(rows=2, cols=1, subplot_titles=[title, report.secondary_name or "Secondary"], + vertical_spacing=0.12) + else: + fig = make_subplots(rows=1, cols=1, subplot_titles=[title]) + + fig.add_trace(go.Scatter(x=seq, y=values, mode="lines+markers", name="Values", + line=dict(color="blue")), row=1, col=1) + + ucl = primary.ucl if not isinstance(primary.ucl, list) else primary.ucl + lcl = primary.lcl if not isinstance(primary.lcl, list) else primary.lcl + if isinstance(ucl, list): + fig.add_trace(go.Scatter(x=seq, y=ucl, mode="lines", name="UCL", + line=dict(color="red", dash="dash")), row=1, col=1) + fig.add_trace(go.Scatter(x=seq, y=lcl, mode="lines", name="LCL", + line=dict(color="red", dash="dash")), row=1, col=1) + else: + fig.add_trace(go.Scatter(x=seq, y=[ucl] * len(seq), mode="lines", name="UCL", + line=dict(color="red", dash="dash")), row=1, col=1) + fig.add_trace(go.Scatter(x=seq, y=[lcl] * len(seq), mode="lines", name="LCL", + line=dict(color="red", dash="dash")), row=1, col=1) + fig.add_trace(go.Scatter(x=seq, y=[primary.center] * len(seq), mode="lines", name="CL", + line=dict(color="green")), row=1, col=1) + + if report.signals: + ooc_idx = sorted({s.index for s in report.signals}) + ooc_x = [i + 1 for i in ooc_idx if i < len(values)] + ooc_y = [values[i] for i in ooc_idx if i < len(values)] + fig.add_trace(go.Scatter(x=ooc_x, y=ooc_y, mode="markers", name="OOC", + marker=dict(color="red", size=10, symbol="x")), row=1, col=1) + + if has_secondary and report.secondary_values is not None: + sec = report.secondary_values + sec_seq = list(range(1, len(sec) + 1)) + fig.add_trace(go.Scatter(x=sec_seq, y=sec, mode="lines+markers", name=report.secondary_name, + line=dict(color="green")), row=2, col=1) + # secondary component if present + comps = report.limits.components + sec_key = report.secondary_name + if sec_key and sec_key in comps: + scomp = comps[sec_key] + fig.add_trace(go.Scatter(x=sec_seq, y=[scomp.ucl] * len(sec_seq) if not isinstance(scomp.ucl, list) else scomp.ucl, + mode="lines", name="Sec UCL", + line=dict(color="red", dash="dash")), row=2, col=1) + + fig.update_layout(height=600 if has_secondary else 400, showlegend=True) + + signal_html = "" + if report.signals: + items = "".join( + f"
  • [{s.rule_id}] point {s.index}: {s.description} (value={s.value:.4f})
  • " + for s in report.signals + ) + signal_html = f"

    Signals ({len(report.signals)})

    " + else: + signal_html = "

    Signals

    No out-of-control signals detected.

    " + + return f""" +{title} + +

    {title}

    +

    Phase: {report.phase.value} | Limits version: {report.limits.version} | +Subgroup size: {report.subgroup_size} | Points: {len(values)}

    +{pio.to_html(fig, include_plotlyjs="cdn", full_html=False)} +{signal_html} +""" + + +def render_capability_html(report: CapabilityReport, title: str = "Process Capability Report") -> str: + r = report.result + rows = "".join( + f"{k}{v}" + for k, v in r.items() + if k != "notes" and v is not None + ) + norm = "" + if report.normality: + n = report.normality + norm = ( + f"

    Normality

    is_normal={n.get('is_normal')} | " + f"shapiro_p={n.get('shapiro_p')} | anderson_stat={n.get('anderson_stat')} | " + f"{n.get('recommendation')}

    " + ) + return f""" +{title} + + +

    {title}

    +

    Method: {r.get('method')} | Rating: {r.get('rating')}

    +{norm} +{rows}
    MetricValue
    +""" + + +def render_msa_html(report: MSAReport, title: str | None = None) -> str: + title = title or f"MSA Report — {report.study_type}" + rows = "".join( + f"{k}{v}" + for k, v in report.result.items() + if k != "detail" + ) + return f""" +{title} + + +

    {title}

    +{rows}
    MetricValue
    +""" + + +def save_html(html: str, path: str | Path) -> Path: + p = Path(path) + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(html, encoding="utf-8") + return p diff --git a/adapters/stream.py b/adapters/stream.py new file mode 100644 index 0000000..975ebfa --- /dev/null +++ b/adapters/stream.py @@ -0,0 +1,72 @@ +"""Streaming adapters — file replay and live observation sources. + +Batch = replay a column through Phase2Evaluator. The same evaluator is used for +live streams; only the source changes. Kafka/MQTT sources live in +``adapters.stream_sources`` (optional extras) so the core adapter stays +dependency-light. +""" +from __future__ import annotations + +import csv +import time +from abc import ABC, abstractmethod +from collections.abc import Callable, Iterator +from pathlib import Path + +from spc_core.evaluator import Phase2Evaluator +from spc_core.models import ControlLimits, Signal + + +class ObservationSource(ABC): + """Yields scalar observations (or subgroup lists) for the Phase II evaluator.""" + + @abstractmethod + def __iter__(self) -> Iterator[float | list[float]]: + ... + + +class FileReplaySource(ObservationSource): + """Replay a CSV column as a stream (optionally with a delay for demos).""" + + def __init__(self, path: str | Path, value_col: str, delay_s: float = 0.0): + self.path = Path(path) + self.value_col = value_col + self.delay_s = delay_s + + def __iter__(self) -> Iterator[float]: + with self.path.open(newline="", encoding="utf-8") as f: + reader = csv.DictReader(f) + if self.value_col not in (reader.fieldnames or []): + raise ValueError(f"Column '{self.value_col}' not in {self.path}") + for row in reader: + raw = row[self.value_col] + if raw == "" or raw is None: + continue + if self.delay_s > 0: + time.sleep(self.delay_s) + yield float(raw) + + +def stream_evaluate( + source: ObservationSource, + limits: ControlLimits, + ruleset: str = "nelson", + on_signal: Callable[[Signal], None] | None = None, +) -> list[Signal]: + """Feed a source into Phase2Evaluator; optionally call ``on_signal`` for each hit. + + Returns the full list of signals collected during the stream. + """ + ev = Phase2Evaluator(limits, ruleset=ruleset) + all_signals: list[Signal] = [] + for obs in source: + if isinstance(obs, (list, tuple)): + signals = ev.observe_subgroup(obs) + else: + signals = ev.observe(float(obs)) + if signals: + all_signals.extend(signals) + if on_signal: + for s in signals: + on_signal(s) + return all_signals diff --git a/adapters/stream_engine.py b/adapters/stream_engine.py new file mode 100644 index 0000000..91aa23e --- /dev/null +++ b/adapters/stream_engine.py @@ -0,0 +1,409 @@ +"""Keyed Phase II stream engine — frozen limits, Tier-1 raw + Tier-2 OOC + Redis live.""" +from __future__ import annotations + +import json +import logging +from collections import OrderedDict, deque +from datetime import UTC, datetime +from typing import Any + +from adapters.protocols import StreamRepository +from spc_core.evaluator import Phase2Evaluator +from spc_core.explain import explain_signal +from spc_core.models import ControlLimits, Signal + +logger = logging.getLogger(__name__) + +# Warm this many prior points into the rule buffer after restart. +_RULE_WARMUP = 15 +# Soft cap on in-memory stream state (LRU eviction of inactive keys). +_DEFAULT_MAX_STREAMS = 10_000 +_HEALTH_WINDOW = 100 + + +class StreamEngine: + """Per-stream :class:`Phase2Evaluator` state machine. + + Limits are frozen at ``register`` / ``load_limits`` time and never recomputed. + Each observation is persisted as a Tier-1 raw measurement; OOC signals are + written idempotently to Tier-2 and published as JSON to Redis channel + ``spc:live:{tenant}:{key}`` (tenant omitted when unset). + """ + + def __init__( + self, + repo: StreamRepository, + *, + redis_client: Any = None, + redis_url: str | None = None, + max_streams: int = _DEFAULT_MAX_STREAMS, + restore_state: bool = True, + webhook_url: str | None = None, + webhook_secret: str | None = None, + tenant_id: str | None = None, + ): + if not hasattr(repo, "save_raw_measurement"): + raise TypeError( + f"{type(repo).__name__} does not support streaming " + "(missing save_raw_measurement). Use the TimescaleDB backend." + ) + self.repo = repo + self._evaluators: OrderedDict[str, Phase2Evaluator] = OrderedDict() + self._limits: dict[str, ControlLimits] = {} + self._limits_versions: dict[str, str] = {} + self._rulesets: dict[str, str] = {} + self._quality_flags: dict[str, deque[str]] = {} + self._redis = redis_client + self._redis_url = redis_url + self._max_streams = max(1, int(max_streams)) + self._restore_state = restore_state + self._webhook_url = webhook_url + self._webhook_secret = webhook_secret + self._tenant_id = tenant_id + + def _get_redis(self) -> Any: + if self._redis is not None: + return self._redis + if not self._redis_url: + return None + try: + import redis as redis_lib + except ImportError: + logger.warning("redis package not installed; live publish disabled") + return None + self._redis = redis_lib.Redis.from_url(self._redis_url, decode_responses=True) + return self._redis + + def register( + self, + stream_key: str, + limits: ControlLimits, + ruleset: str = "nelson", + ) -> None: + """Attach a frozen Phase I limit set to ``stream_key``. + + When the repository can supply recent measurements, the evaluator index + and rule buffer are restored so a process restart does not reset Phase II. + """ + if stream_key in self._evaluators: + self.unregister(stream_key) + + while len(self._evaluators) >= self._max_streams: + oldest, _ = self._evaluators.popitem(last=False) + self._limits.pop(oldest, None) + self._limits_versions.pop(oldest, None) + self._rulesets.pop(oldest, None) + self._quality_flags.pop(oldest, None) + logger.warning("Evicted stream %s (max_streams=%s)", oldest, self._max_streams) + + ev = Phase2Evaluator(limits, ruleset=ruleset) + if self._restore_state: + self._restore_evaluator(stream_key, ev) + self._evaluators[stream_key] = ev + self._limits[stream_key] = limits + self._limits_versions[stream_key] = limits.version + self._rulesets[stream_key] = ruleset + self._quality_flags.setdefault(stream_key, deque(maxlen=_HEALTH_WINDOW)) + + def _restore_evaluator(self, stream_key: str, ev: Phase2Evaluator) -> None: + recent_fn = getattr(self.repo, "recent_raw_measurements", None) + if not callable(recent_fn): + return + count = None + get_stream = getattr(self.repo, "get_stream", None) + if callable(get_stream): + try: + row = get_stream(stream_key) + if row and row.get("measurement_count") is not None: + count = int(row["measurement_count"]) + except Exception: # noqa: BLE001 + logger.debug("get_stream watermark unavailable for %s", stream_key) + if count is None: + count_fn = getattr(self.repo, "count_raw_measurements", None) + if not callable(count_fn): + return + try: + count = int(count_fn(stream_key)) + except Exception: # noqa: BLE001 + logger.exception("Failed to count measurements for %s", stream_key) + return + try: + recent = recent_fn(stream_key, limit=_RULE_WARMUP) + except Exception: # noqa: BLE001 + logger.exception("Failed to restore evaluator state for %s", stream_key) + return + if count <= 0: + return + values = [float(r["value"]) for r in recent] + ev.seed_state(index=count - 1, values=values) + logger.info( + "Restored stream %s evaluator at index=%s (warmed %s points)", + stream_key, + count - 1, + len(values), + ) + + def unregister(self, stream_key: str) -> None: + """Drop in-memory evaluator state for a deactivated stream.""" + self._evaluators.pop(stream_key, None) + self._limits.pop(stream_key, None) + self._limits_versions.pop(stream_key, None) + self._rulesets.pop(stream_key, None) + self._quality_flags.pop(stream_key, None) + + def load_limits( + self, + stream_key: str, + limits_version: str, + *, + ruleset: str = "nelson", + ) -> ControlLimits: + """Load frozen limits from the repository and register the stream.""" + stored = self.repo.get_limits(limits_version) + if not stored: + raise KeyError(f"Limits version not found: {limits_version}") + limits = _limits_from_payload(stored["payload"]) + self.register(stream_key, limits, ruleset=ruleset) + return limits + + def registered_keys(self) -> list[str]: + return sorted(self._evaluators) + + def handle_observation( + self, + stream_key: str, + value: float | list[float], + ts: datetime | None = None, + **meta: Any, + ) -> list[Signal]: + """Evaluate one observation; write raw + any OOC events; publish live; return signals. + + Scalar charts (I-MR, EWMA, …) expect a float. Xbar-R / Xbar-S expect a + non-empty list of subgroup members; the subgroup mean is persisted and plotted. + """ + ev = self._evaluators.get(stream_key) + if ev is None: + raise KeyError(f"Stream '{stream_key}' is not registered") + # Touch LRU order + self._evaluators.move_to_end(stream_key) + + if ts is None: + ts = datetime.now(UTC) + elif ts.tzinfo is None: + ts = ts.replace(tzinfo=UTC) + + if isinstance(value, (list, tuple)): + subgroup = [float(v) for v in value] + if not subgroup: + raise ValueError("Empty subgroup observation") + if not ev.is_subgroup_chart: + raise ValueError( + f"{ev.limits.chart_type.value} expects scalar observations; " + f"got subgroup of size {len(subgroup)}" + ) + plotted = float(sum(subgroup) / len(subgroup)) + else: + if ev.is_subgroup_chart: + raise ValueError( + f"{ev.limits.chart_type.value} plots subgroup means; " + "send value as a JSON list of subgroup observations" + ) + subgroup = None + plotted = float(value) + + limits_version = self._limits_versions[stream_key] + qflag = meta.get("quality_flag") + if qflag is not None: + buf = self._quality_flags.setdefault(stream_key, deque(maxlen=_HEALTH_WINDOW)) + buf.append(str(qflag)) + self.repo.save_raw_measurement( + stream_key, + ts, + plotted, + limits_version=limits_version, + quality_flag=qflag, + machine_id=meta.get("machine_id"), + gage_id=meta.get("gage_id"), + ) + + if subgroup is not None: + signals = ev.observe_subgroup(subgroup) + else: + signals = ev.observe(plotted) + + for sig in signals: + inserted = self.repo.save_ooc_event( + stream_key, + ts, + limits_version=limits_version, + index=sig.index, + value=sig.value, + rule_id=sig.rule_id, + rule_name=sig.rule_name, + description=sig.description, + side=sig.side, + ) + if not inserted: + logger.debug( + "Duplicate OOC suppressed %s rule=%s ts=%s", + stream_key, + sig.rule_id, + ts, + ) + + primary = self._limits[stream_key].primary + ucl = primary.ucl_at(0) if isinstance(primary.ucl, list) else primary.ucl + lcl = primary.lcl_at(0) if isinstance(primary.lcl, list) else primary.lcl + explanations = [ + explain_signal(s, limits_version=limits_version, limits=self._limits[stream_key]) + for s in signals + ] + health = self._sensor_health(stream_key) + # Always publish the stream index so the dashboard can map markers. + payload = { + "type": "point", + "stream_key": stream_key, + "value": plotted, + "timestamp": ts.isoformat(), + "ts": ts.isoformat(), + "index": ev.index, + "ucl": float(ucl) if ucl is not None else None, + "center": float(primary.center), + "lcl": float(lcl) if lcl is not None else None, + "limits_version": limits_version, + "signals": [ + { + "rule_id": s.rule_id, + "rule_name": s.rule_name, + "index": s.index, + "value": s.value, + "description": s.description, + "side": s.side, + } + for s in signals + ], + "explanations": explanations, + "sensor_health": health, + "ooc": bool(signals), + } + self._publish(stream_key, payload) + if signals: + self._fire_webhooks(stream_key, payload, meta) + return signals + + def _sensor_health(self, stream_key: str) -> dict[str, Any]: + buf = self._quality_flags.get(stream_key) or deque() + n = len(buf) + if n == 0: + return {"window": 0, "counts": {}, "rates": {}} + counts: dict[str, int] = {} + for f in buf: + counts[f] = counts.get(f, 0) + 1 + rates = {k: round(v / n, 4) for k, v in counts.items()} + return {"window": n, "counts": counts, "rates": rates} + + def _fire_webhooks( + self, + stream_key: str, + payload: dict[str, Any], + meta: dict[str, Any], + ) -> None: + try: + from adapters.webhooks import deliver_webhook, resolve_webhook_url + except ImportError: + return + stream_meta: dict[str, Any] = {} + get_stream = getattr(self.repo, "get_stream", None) + if callable(get_stream): + try: + row = get_stream(stream_key) + if row and isinstance(row.get("meta"), dict): + stream_meta = row["meta"] + except Exception: # noqa: BLE001 + pass + if meta.get("webhook_url"): + stream_meta = {**stream_meta, "webhook_url": meta["webhook_url"]} + url = resolve_webhook_url(stream_meta, global_url=self._webhook_url) + if not url: + return + event = { + "event": "ooc", + "stream_key": stream_key, + "tenant_id": self._tenant_id or stream_meta.get("tenant_id"), + "limits_version": payload.get("limits_version"), + "index": payload.get("index"), + "value": payload.get("value"), + "signals": payload.get("signals"), + "explanations": payload.get("explanations"), + "ts": payload.get("ts"), + } + deliver_webhook(url, event, secret=self._webhook_secret) + + def handle_message(self, msg: dict[str, Any]) -> list[Signal]: + """Handle a source dict ``{key, value, timestamp}`` (plus optional meta). + + ``value`` may be a scalar or a list (Xbar subgroup). + """ + key = str(msg.get("key") or msg.get("stream_key") or "") + if not key: + raise ValueError(f"Message missing key: {msg!r}") + value = msg.get("value") + if value is None: + raise ValueError(f"Message missing value: {msg!r}") + raw_ts = msg.get("timestamp") or msg.get("ts") + ts: datetime | None + if raw_ts is None: + ts = None + elif isinstance(raw_ts, datetime): + ts = raw_ts + else: + ts = datetime.fromisoformat(str(raw_ts).replace("Z", "+00:00")) + meta = { + k: v + for k, v in msg.items() + if k not in ("key", "stream_key", "value", "timestamp", "ts") + } + if isinstance(value, (list, tuple)): + return self.handle_observation(key, [float(v) for v in value], ts, **meta) + return self.handle_observation(key, float(value), ts, **meta) + + def live_channel(self, stream_key: str, tenant_id: str | None = None) -> str: + """Redis pub/sub channel for a stream (optional tenant prefix).""" + tid = tenant_id if tenant_id is not None else self._tenant_id + if tid: + return f"spc:live:{tid}:{stream_key}" + return f"spc:live:{stream_key}" + + def _publish(self, stream_key: str, payload: dict[str, Any]) -> None: + client = self._get_redis() + if client is None: + return + channel = self.live_channel(stream_key) + try: + client.publish(channel, json.dumps(payload, default=str)) + except Exception: # noqa: BLE001 — live publish must not break evaluation + logger.exception("Failed to publish to Redis channel %s", channel) + + def close(self) -> None: + if self._redis is not None: + try: + self._redis.close() + except Exception: # noqa: BLE001 + pass + self._redis = None + + +def _limits_from_payload(payload: dict[str, Any]) -> ControlLimits: + from spc_core.models import ChartType, ControlLimits, LimitSet + + components = { + name: LimitSet(**comp) for name, comp in payload["components"].items() + } + return ControlLimits( + chart_type=ChartType(payload["chart_type"]), + subgroup_size=payload["subgroup_size"], + components=components, + sigma=payload.get("sigma"), + source_n_points=payload.get("source_n_points"), + notes=payload.get("notes") or {}, + ) diff --git a/adapters/stream_sources.py b/adapters/stream_sources.py new file mode 100644 index 0000000..da7f045 --- /dev/null +++ b/adapters/stream_sources.py @@ -0,0 +1,420 @@ +"""Live observation sources — Kafka and MQTT (optional ``aspc[stream]`` extra). + +Provides async iterators plus a sync ``iter_sync()`` that yields dicts +``{key, value, timestamp}`` for the stream engine. +""" +from __future__ import annotations + +import asyncio +import json +import logging +import os +import queue +import threading +from collections.abc import AsyncIterator, Iterator +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any + +from adapters.stream import ObservationSource + +logger = logging.getLogger(__name__) + + +@dataclass +class Observation: + """Normalized measurement from a live source. + + ``value`` is a scalar for I-MR / attribute / EWMA / CUSUM streams, or a list + of floats for Xbar-R / Xbar-S subgroup payloads. + """ + + key: str + ts: datetime + value: float | list[float] + raw: dict[str, Any] = field(default_factory=dict) + + def as_dict(self) -> dict[str, Any]: + return { + "key": self.key, + "value": self.value, + "timestamp": self.ts, + **{k: v for k, v in self.raw.items() if k not in ("key", "value", "timestamp", "ts")}, + } + + +def _parse_payload(payload: bytes | str | dict, *, default_key: str = "default") -> Observation: + if isinstance(payload, dict): + data = payload + else: + text = payload.decode("utf-8") if isinstance(payload, (bytes, bytearray)) else str(payload) + text = text.strip() + try: + data = json.loads(text) + except json.JSONDecodeError: + # Bare numeric payload + return Observation( + key=default_key, + ts=datetime.now(UTC), + value=float(text), + raw={"value": float(text)}, + ) + + if not isinstance(data, dict): + return Observation( + key=default_key, + ts=datetime.now(UTC), + value=float(data), + raw={"value": float(data)}, + ) + + key = str(data.get("key") or data.get("stream_key") or data.get("topic") or default_key) + raw_ts = data.get("ts") or data.get("timestamp") or data.get("time") + if raw_ts is None: + ts = datetime.now(UTC) + elif isinstance(raw_ts, datetime): + ts = raw_ts if raw_ts.tzinfo else raw_ts.replace(tzinfo=UTC) + elif isinstance(raw_ts, (int, float)): + # Treat large numbers as ms epoch + epoch = float(raw_ts) + if epoch > 1e12: + epoch /= 1000.0 + ts = datetime.fromtimestamp(epoch, tz=UTC) + else: + ts = datetime.fromisoformat(str(raw_ts).replace("Z", "+00:00")) + + value = data.get("value") + if value is None: + value = data.get("measurement") or data.get("v") + if value is None: + raise ValueError(f"Observation payload missing value: {data!r}") + if isinstance(value, (list, tuple)): + parsed: float | list[float] = [float(v) for v in value] + if not parsed: + raise ValueError(f"Observation payload has empty subgroup: {data!r}") + else: + parsed = float(value) + return Observation(key=key, ts=ts, value=parsed, raw=dict(data)) + + +class _AsyncSourceBase: + """Mixin: sync iteration via background asyncio loop, yielding dicts.""" + + async def __aiter__(self) -> AsyncIterator[dict[str, Any]]: + raise TypeError(f"{type(self).__name__} must implement async __aiter__") + + def iter_sync(self, *, timeout: float | None = None) -> Iterator[dict[str, Any]]: + """Yield ``{key, value, timestamp}`` dicts from a background async consumer.""" + q: queue.Queue[dict[str, Any] | BaseException | None] = queue.Queue(maxsize=256) + stop = threading.Event() + + async def _pump() -> None: + try: + async for msg in self: # type: ignore[attr-defined] + if stop.is_set(): + break + q.put(msg) + except BaseException as exc: # noqa: BLE001 — forward to consumer + q.put(exc) + finally: + q.put(None) + + def _runner() -> None: + asyncio.run(_pump()) + + thread = threading.Thread(target=_runner, name=type(self).__name__, daemon=True) + thread.start() + try: + while True: + item = q.get(timeout=timeout) if timeout else q.get() + if item is None: + break + if isinstance(item, BaseException): + raise item + yield item + finally: + stop.set() + + +class KafkaSource(_AsyncSourceBase, ObservationSource): + """Consume measurements from a Kafka / Redpanda topic via aiokafka. + + Raises ``ImportError`` (with install hint) if aiokafka is not installed. + Sync iteration yields dicts ``{key, value, timestamp}``. + """ + + def __init__( + self, + bootstrap_servers: str, + topic: str, + *, + group_id: str = "aspc-stream-engine", + default_key: str = "default", + auto_offset_reset: str = "latest", + ): + try: + import aiokafka # noqa: F401 + except ImportError as exc: # pragma: no cover + raise ImportError( + "KafkaSource requires aiokafka. " + "Install with: pip install 'aspc[stream]' (or pip install aiokafka)" + ) from exc + self.bootstrap_servers = bootstrap_servers + self.topic = topic + self.group_id = group_id + self.default_key = default_key + self.auto_offset_reset = auto_offset_reset + self._consumer = None + + def __iter__(self) -> Iterator[float]: + for msg in self.iter_sync(): + yield float(msg["value"]) + + async def __aiter__(self) -> AsyncIterator[dict[str, Any]]: + from aiokafka import AIOKafkaConsumer + + backoff = 1.0 + while True: + consumer = AIOKafkaConsumer( + self.topic, + bootstrap_servers=self.bootstrap_servers, + group_id=self.group_id, + auto_offset_reset=self.auto_offset_reset, + enable_auto_commit=False, + ) + self._consumer = consumer + try: + await consumer.start() + backoff = 1.0 + async for msg in consumer: + key_hint = ( + msg.key.decode("utf-8") + if isinstance(msg.key, (bytes, bytearray)) + else (str(msg.key) if msg.key is not None else self.default_key) + ) + try: + obs = _parse_payload( + msg.value or b"", default_key=key_hint or self.default_key + ) + except Exception as poison_exc: + # Poison message: route to DLQ (best-effort) then commit past it. + dlq = os.getenv("ASPC_KAFKA_DLQ_TOPIC", f"{self.topic}.dlq") + try: + from aiokafka import AIOKafkaProducer + + prod = AIOKafkaProducer(bootstrap_servers=self.bootstrap_servers) + await prod.start() + try: + await prod.send_and_wait( + dlq, + value=msg.value or b"", + key=msg.key, + ) + finally: + await prod.stop() + logger.warning( + "Poison Kafka message sent to %s: %s", dlq, poison_exc + ) + except Exception: # noqa: BLE001 + logger.exception( + "Failed to publish poison message to DLQ %s", dlq + ) + await consumer.commit() + continue + yield obs.as_dict() + await consumer.commit() + except asyncio.CancelledError: + raise + except Exception: + await asyncio.sleep(backoff) + backoff = min(backoff * 2, 60.0) + finally: + try: + await consumer.stop() + except Exception: + pass + self._consumer = None + + +class MQTTSource(_AsyncSourceBase, ObservationSource): + """Subscribe to an MQTT topic via aiomqtt (or paho-mqtt sync fallback). + + Raises ``ImportError`` with install hint if neither client is available. + Sync iteration yields dicts ``{key, value, timestamp}``. + """ + + def __init__( + self, + host: str, + topic: str, + *, + port: int = 1883, + username: str | None = None, + password: str | None = None, + default_key: str | None = None, + ): + self._backend: str + try: + import aiomqtt # noqa: F401 + self._backend = "aiomqtt" + except ImportError: + try: + import paho.mqtt.client as mqtt # noqa: F401 + self._backend = "paho" + except ImportError as exc: # pragma: no cover + raise ImportError( + "MQTTSource requires aiomqtt or paho-mqtt. " + "Install with: pip install 'aspc[stream]' (or pip install aiomqtt)" + ) from exc + self.host = host + self.port = port + self.topic = topic + self.username = username + self.password = password + self.default_key = default_key + + def __iter__(self) -> Iterator[float]: + for msg in self.iter_sync(): + yield float(msg["value"]) + + def iter_sync(self, *, timeout: float | None = None) -> Iterator[dict[str, Any]]: + if self._backend == "paho": + yield from self._iter_paho(timeout=timeout) + return + yield from super().iter_sync(timeout=timeout) + + def _iter_paho(self, *, timeout: float | None = None) -> Iterator[dict[str, Any]]: + import time as _time + + import paho.mqtt.client as mqtt + + q: queue.Queue[dict[str, Any] | BaseException | None] = queue.Queue(maxsize=256) + stop = threading.Event() + + def _on_message(_client, _userdata, message) -> None: + try: + topic_str = str(message.topic) + default_key = self.default_key or topic_str + obs = _parse_payload(message.payload, default_key=default_key) + if obs.key == default_key and self.default_key is None: + obs = Observation(key=topic_str, ts=obs.ts, value=obs.value, raw=obs.raw) + try: + q.put(obs.as_dict(), timeout=5.0) + except queue.Full: + # Drop under backpressure rather than block the network thread, + # but surface the loss so Phase II ARL claims are not silently wrong. + logger.warning( + "MQTT backpressure: dropped observation key=%s topic=%s (queue full)", + obs.key, + topic_str, + ) + except BaseException as exc: # noqa: BLE001 + q.put(exc) + + def _on_disconnect(client, _userdata, _flags, reason_code, _properties=None): + if stop.is_set(): + return + # paho VERSION2 signature; reconnect with backoff in a helper thread + delay = 1.0 + while not stop.is_set(): + try: + client.reconnect() + client.subscribe(self.topic) + return + except Exception: + _time.sleep(delay) + delay = min(delay * 2, 60.0) + + try: + client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) + except AttributeError: + client = mqtt.Client() + if self.username is not None: + client.username_pw_set(self.username, self.password) + client.on_message = _on_message + try: + client.on_disconnect = _on_disconnect + except Exception: + pass + backoff = 1.0 + while True: + try: + client.connect(self.host, self.port) + client.subscribe(self.topic) + client.loop_start() + backoff = 1.0 + break + except Exception: + _time.sleep(backoff) + backoff = min(backoff * 2, 60.0) + try: + while True: + item = q.get(timeout=timeout) if timeout else q.get() + if isinstance(item, BaseException): + raise item + yield item + finally: + stop.set() + client.loop_stop() + try: + client.disconnect() + except Exception: + pass + + async def __aiter__(self) -> AsyncIterator[dict[str, Any]]: + if self._backend != "aiomqtt": + # Drive paho from a thread via iter_sync for async consumers. + loop = asyncio.get_running_loop() + q: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue(maxsize=256) + stop = threading.Event() + + def _runner() -> None: + try: + for msg in self._iter_paho(): + if stop.is_set(): + break + asyncio.run_coroutine_threadsafe(q.put(msg), loop).result() + finally: + asyncio.run_coroutine_threadsafe(q.put(None), loop).result() + + thread = threading.Thread(target=_runner, daemon=True) + thread.start() + try: + while True: + item = await q.get() + if item is None: + break + yield item + finally: + stop.set() + return + + import aiomqtt + + kwargs: dict[str, Any] = {"hostname": self.host, "port": self.port} + if self.username is not None: + kwargs["username"] = self.username + if self.password is not None: + kwargs["password"] = self.password + + backoff = 1.0 + while True: + try: + async with aiomqtt.Client(**kwargs) as client: + await client.subscribe(self.topic) + backoff = 1.0 + async for message in client.messages: + topic_str = str(message.topic) + default_key = self.default_key or topic_str + payload = message.payload + obs = _parse_payload(payload, default_key=default_key) + if obs.key == default_key and self.default_key is None: + obs = Observation( + key=topic_str, ts=obs.ts, value=obs.value, raw=obs.raw + ) + yield obs.as_dict() + except asyncio.CancelledError: + raise + except Exception: + await asyncio.sleep(backoff) + backoff = min(backoff * 2, 60.0) diff --git a/adapters/webhooks.py b/adapters/webhooks.py new file mode 100644 index 0000000..01e5313 --- /dev/null +++ b/adapters/webhooks.py @@ -0,0 +1,70 @@ +"""Outbound signed webhooks for OOC events.""" +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +import os +import urllib.error +import urllib.request +from typing import Any + +logger = logging.getLogger(__name__) + + +def _sign(body: bytes, secret: str) -> str: + return hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest() + + +def deliver_webhook( + url: str, + payload: dict[str, Any], + *, + secret: str | None = None, + timeout_s: float = 5.0, +) -> bool: + """POST JSON payload to ``url``. Returns True on 2xx. + + When ``secret`` is set, sends header ``X-ASPC-Signature: sha256=``. + Failures are logged; never raises to callers (stream eval must continue). + """ + if not url: + return False + body = json.dumps(payload, default=str).encode("utf-8") + headers = { + "Content-Type": "application/json", + "User-Agent": "ASPC-Webhook/1.0", + } + secret = secret or os.getenv("ASPC_WEBHOOK_SECRET") or "" + if secret: + headers["X-ASPC-Signature"] = f"sha256={_sign(body, secret)}" + req = urllib.request.Request(url, data=body, headers=headers, method="POST") + try: + with urllib.request.urlopen(req, timeout=timeout_s) as resp: # noqa: S310 — operator-configured URL + ok = 200 <= getattr(resp, "status", 200) < 300 + if not ok: + logger.warning("Webhook %s returned status %s", url, getattr(resp, "status", "?")) + return ok + except urllib.error.HTTPError as exc: + logger.warning("Webhook HTTP error %s for %s: %s", exc.code, url, exc.reason) + return False + except Exception: # noqa: BLE001 + logger.exception("Webhook delivery failed for %s", url) + return False + + +def resolve_webhook_url( + stream_meta: dict[str, Any] | None, + *, + global_url: str | None = None, +) -> str | None: + """Prefer per-stream ``meta.webhook_url``, else global config/env.""" + if stream_meta: + u = stream_meta.get("webhook_url") + if isinstance(u, str) and u.strip(): + return u.strip() + if global_url and str(global_url).strip(): + return str(global_url).strip() + env = os.getenv("ASPC_WEBHOOK_URL", "").strip() + return env or None diff --git a/agent_config/agent_prompts/__init__.py b/agent_config/agent_prompts/__init__.py deleted file mode 100644 index 3cb8ec8..0000000 --- a/agent_config/agent_prompts/__init__.py +++ /dev/null @@ -1,56 +0,0 @@ -""" -Agent Prompts Module -Centralized storage and loading of agent prompts from JSON configuration -""" -import json -from pathlib import Path - - -def load_prompts(): - """Load all agent prompts from prompts.json""" - prompts_file = Path(__file__).parent / 'prompts.json' - with open(prompts_file, 'r') as f: - return json.load(f) - - -def get_prompt(agent_name): - """ - Get prompt for a specific agent - - Args: - agent_name: One of 'control_chart_agent', 'msa_agent', 'capability_agent' - - Returns: - str: The prompt text for the specified agent - """ - prompts = load_prompts() - if agent_name not in prompts: - raise ValueError(f"Unknown agent: {agent_name}. Available: {list(prompts.keys())}") - return prompts[agent_name]['prompt'] - - -# Convenience exports -def get_control_chart_prompt(): - """Get Control Chart Agent prompt""" - return get_prompt('control_chart_agent') - - -def get_msa_prompt(): - """Get MSA Agent prompt""" - return get_prompt('msa_agent') - - -def get_capability_prompt(): - """Get Capability Agent prompt""" - return get_prompt('capability_agent') - - -# Export for easy import -__all__ = [ - 'load_prompts', - 'get_prompt', - 'get_control_chart_prompt', - 'get_msa_prompt', - 'get_capability_prompt' -] - diff --git a/agent_config/agent_prompts/prompts.json b/agent_config/agent_prompts/prompts.json deleted file mode 100644 index 8b92fcf..0000000 --- a/agent_config/agent_prompts/prompts.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "control_chart_agent": { - "name": "Control Chart Agent", - "description": "Statistical Process Control (SPC) expert specializing in Control Charts", - "prompt": "You are a Statistical Process Control (SPC) expert specializing in Control Charts.\n\nYOUR TOOL: You have ONE comprehensive tool - run_control_chart_analysis() - that performs complete control chart analysis.\n\nWORKFLOW:\n1. Call run_control_chart_analysis() with data_path (required) and optional parameters\n2. Tool auto-detects data type, selects appropriate chart (I-MR, Xbar-R, P, NP, C, U), calculates limits, detects out-of-control points, and generates HTML report\n3. Interpret the results for the user\n\nPARAMETERS:\n- data_path: REQUIRED - path to CSV file\n- value_col: Optional - specify if data has multiple numeric columns (e.g., 'measurement', 'defects', 'value')\n- subgroup_col: Optional - specify for subgrouped data (e.g., 'subgroup', 'batch', 'sample')\n- chart_type: Optional - override auto-selection if user specifies\n\nCOLUMN SELECTION GUIDANCE:\n- BE CAREFUL with auto-detection when CSV has multiple numeric columns\n- PREFER columns named 'measurement', 'value', 'defects' for value_col\n- If user mentions subgroups/batches, ALWAYS specify both value_col AND subgroup_col explicitly\n- For Xbar-R charts: MUST specify value_col (measurements) and subgroup_col (group identifiers)\n\nCRITICAL REMINDERS:\n- Always ask if MSA was performed first. Measurement variation can appear as process variation.\n- For out-of-control processes: recommend investigating 6M (Man, Machine, Material, Method, Measurement, Environment)\n- In-control processes show only natural/common cause variation - predictable and stable\n\nINTERPRETATION: Explain WHAT the results show, WHY it matters, and WHAT actions to take. Keep responses concise and actionable." - }, - - "msa_agent": { - "name": "MSA Agent", - "description": "Measurement System Analysis (MSA) expert validating measurement systems", - "prompt": "You are a Measurement System Analysis (MSA) expert. You validate measurement systems BEFORE any process analysis.\n\nYOUR TOOL: You have ONE comprehensive tool - run_msa_analysis() - that performs complete MSA.\n\nWORKFLOW:\n1. Call run_msa_analysis() with data_path (required) and optional parameters\n2. Tool auto-detects study type (Gage R&R, Bias, Linearity, Stability), validates data, runs analysis, and generates HTML report\n3. Interpret the results for the user\n\nPARAMETERS:\n- data_path: REQUIRED - path to CSV file\n- part_col, operator_col, measurement_col, trial_col, reference_col, date_col: Optional - auto-detected from column names\n- tolerance: Optional - for %Tolerance calculation in Gage R&R\n- method: 'anova' (default) or 'range' for Gage R&R\n- study_type: Optional - override auto-detection\n\nACCEPTANCE CRITERIA:\n- Gage R&R %GRR: <10% Excellent, 10-30% Acceptable, >30% Unacceptable\n- Bias: p-value < 0.05 indicates significant bias\n- Linearity: p-value < 0.05 indicates linearity issue\n- Stability: Out-of-control points indicate instability\n- NDC ≥ 5: Good discrimination capability\n\nCRITICAL: MSA is the FIRST step in quality analysis. Without validated measurements, SPC and capability analysis are meaningless.\n\nINTERPRETATION: Explain WHAT the measurement system quality is, WHY it matters, and WHAT actions to take. Keep responses concise and actionable." - }, - - "capability_agent": { - "name": "Capability Agent", - "description": "Process Capability expert assessing if processes meet customer specifications", - "prompt": "You are a Process Capability expert. You assess if stable processes can meet customer specifications.\n\nYOUR TOOL: You have ONE comprehensive tool - run_capability_analysis() - that performs complete capability analysis.\n\nWORKFLOW:\n1. Call run_capability_analysis() with data_path, USL, LSL (required) and optional parameters\n2. Tool checks normality, calculates Cp/Cpk/Pp/Ppk, analyzes yield (DPMO, Sigma level), checks centering, and generates HTML report\n3. Interpret the results for the user\n\nPARAMETERS:\n- data_path: REQUIRED - path to CSV file\n- usl: REQUIRED - Upper Specification Limit\n- lsl: REQUIRED - Lower Specification Limit\n- target: Optional - defaults to midpoint (USL+LSL)/2\n- measurement_col: Optional - auto-detected if not specified\n- subgroup_col: Optional - for distinguishing Cp (within) vs Pp (overall)\n\nACCEPTANCE CRITERIA:\n- Cpk ≥ 1.67 (5σ): World-class, <1 DPMO\n- Cpk ≥ 1.33 (4σ): Excellent, ~63 DPMO\n- Cpk ≥ 1.0 (3σ): Adequate, ~2700 DPMO\n- Cpk < 1.0: Unacceptable, high defect rate\n\nCRITICAL PREREQUISITES:\n1. MSA must be acceptable (GRR < 30%) - measurement error reduces apparent capability\n2. Process must be in statistical control first - use control charts to verify\n3. Data should be normally distributed (p > 0.05) for valid Cp/Cpk\n\nINTERPRETATION:\n- If Cp > Cpk: Process off-center, adjust mean to target\n- If Pp/Ppk < Cp/Cpk: Process has shifts over time, investigate root causes\n- Explain WHAT the capability is, WHY it matters (customer satisfaction, defect cost), WHAT actions to take.\n\nKeep responses concise and actionable." - } -} - diff --git a/agent_config/config.yaml b/agent_config/config.yaml deleted file mode 100644 index 086fffe..0000000 --- a/agent_config/config.yaml +++ /dev/null @@ -1,38 +0,0 @@ -# SPC & Quality Management System Configuration - -# LLM Provider Configuration -llm: - provider: "groq" # Options: groq, openai, anthropic, ollama, etc. - model: "llama-3.1-8b-instant" # Model name for the provider - # Full model string will be: "provider:model" (e.g., "groq:llama-3.1-8b-instant") - - # Provider-specific models (examples): - # Groq: llama-3.1-8b-instant, llama-3.1-70b-versatile, mixtral-8x7b-32768 - # OpenAI: gpt-4, gpt-4-turbo, gpt-3.5-turbo - # Anthropic: claude-3-opus, claude-3-sonnet, claude-3-haiku - # Ollama: llama2, mistral, codellama - -# Agent Configuration -agents: - temperature: 0.0 # 0.0 = deterministic, 1.0 = creative - max_tokens: null # null = provider default, or specify max tokens - timeout: 60 # Request timeout in seconds - -# API Configuration -api: - host: "0.0.0.0" - port: 8000 - cors_origins: ["*"] # List of allowed origins, or ["*"] for all - -# File Upload Configuration -uploads: - temp_directory: "temp_uploads" - max_file_size_mb: 10 # Maximum file size in MB - allowed_extensions: [".csv", ".xlsx", ".txt"] - -# Report Configuration -reports: - auto_generate: true # Auto-generate HTML reports - save_with_data: true # Save reports in same folder as data - include_plots: true # Include interactive plots in reports - diff --git a/agent_config/config_loader.py b/agent_config/config_loader.py deleted file mode 100644 index 02009ff..0000000 --- a/agent_config/config_loader.py +++ /dev/null @@ -1,151 +0,0 @@ -""" -Configuration Loader -Loads LLM and system configuration from config.yaml -""" -import yaml -import os -from pathlib import Path -from dotenv import load_dotenv - - -class Config: - """Configuration manager for SPC system""" - - def __init__(self, config_path=None): - """Load configuration from YAML file""" - if config_path is None: - # Default to config.yaml in the same directory as this file - config_path = Path(__file__).parent / "config.yaml" - self.config_path = Path(config_path) - - # Load environment variables from project root - env_path = Path(__file__).parent.parent / '.env' - load_dotenv(dotenv_path=env_path) - - # Load YAML configuration - if self.config_path.exists(): - with open(self.config_path, 'r') as f: - self.config = yaml.safe_load(f) - else: - # Default configuration if file doesn't exist - self.config = self._get_default_config() - - def _get_default_config(self): - """Default configuration if config.yaml doesn't exist""" - return { - 'llm': { - 'provider': 'groq', - 'model': 'llama-3.1-8b-instant' - }, - 'agents': { - 'temperature': 0.0, - 'max_tokens': None, - 'timeout': 60 - }, - 'api': { - 'host': '0.0.0.0', - 'port': 8000, - 'cors_origins': ['*'] - }, - 'uploads': { - 'temp_directory': 'temp_uploads', - 'max_file_size_mb': 10, - 'allowed_extensions': ['.csv', '.xlsx', '.txt'] - }, - 'reports': { - 'auto_generate': True, - 'save_with_data': True, - 'include_plots': True - } - } - - @property - def llm_provider(self): - """Get LLM provider (e.g., 'groq', 'openai')""" - return self.config.get('llm', {}).get('provider', 'groq') - - @property - def llm_model(self): - """Get LLM model name (e.g., 'llama-3.1-8b-instant')""" - return self.config.get('llm', {}).get('model', 'llama-3.1-8b-instant') - - @property - def llm_model_string(self): - """Get full LLM model string (e.g., 'groq:llama-3.1-8b-instant')""" - return f"{self.llm_provider}:{self.llm_model}" - - @property - def llm_api_key(self): - """Get API key for the configured LLM provider""" - provider = self.llm_provider.upper() - key_name = f"{provider}_API_KEY" - api_key = os.getenv(key_name) - - if not api_key: - raise ValueError( - f"API key not found for provider '{self.llm_provider}'. " - f"Please set {key_name} in your .env file. " - f"See env.example for instructions." - ) - - return api_key - - @property - def agent_temperature(self): - """Get agent temperature setting""" - return self.config.get('agents', {}).get('temperature', 0.0) - - @property - def agent_max_tokens(self): - """Get max tokens setting""" - return self.config.get('agents', {}).get('max_tokens', None) - - @property - def agent_timeout(self): - """Get request timeout""" - return self.config.get('agents', {}).get('timeout', 60) - - @property - def api_host(self): - """Get API host""" - return self.config.get('api', {}).get('host', '0.0.0.0') - - @property - def api_port(self): - """Get API port""" - return self.config.get('api', {}).get('port', 8000) - - @property - def cors_origins(self): - """Get CORS origins""" - return self.config.get('api', {}).get('cors_origins', ['*']) - - @property - def temp_upload_dir(self): - """Get temporary upload directory""" - return self.config.get('uploads', {}).get('temp_directory', 'temp_uploads') - - def __repr__(self): - """String representation of config""" - return f"Config(provider={self.llm_provider}, model={self.llm_model})" - - -# Global config instance -config = Config() - - -# Convenience functions -def get_llm_model_string(): - """Get the full LLM model string (provider:model)""" - return config.llm_model_string - - -def get_api_key(): - """Get API key for configured provider""" - return config.llm_api_key - - -def get_config(): - """Get the global config instance""" - return config - diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..a463b5d --- /dev/null +++ b/alembic.ini @@ -0,0 +1,47 @@ +# Alembic config for ASPC (script location = this directory's parent? → migrations/) +# Usage from repo root: +# alembic -c alembic.ini upgrade head +# ASPC_TIMESCALE_DSN=postgresql+psycopg://aspc:aspc@localhost:5432/aspc alembic upgrade head + +[alembic] +script_location = migrations +prepend_sys_path = . +version_path_separator = os + +sqlalchemy.url = postgresql+psycopg://aspc:aspc@localhost:5432/aspc + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/api/__init__.py b/api/__init__.py deleted file mode 100644 index aa1b6d5..0000000 --- a/api/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""FastAPI Agent Chat API for SPC & Quality Management System""" - diff --git a/api/main.py b/api/main.py deleted file mode 100644 index e3f42d2..0000000 --- a/api/main.py +++ /dev/null @@ -1,149 +0,0 @@ -""" -FastAPI Agent Chat API - Minimal & Clean - -Three conversational endpoints: -- POST /chat/msa - Measurement System Analysis -- POST /chat/control-charts - Statistical Process Control -- POST /chat/capability - Process Capability Analysis -""" - -from fastapi import FastAPI, UploadFile, File, Form, HTTPException -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse -from typing import Optional -from pathlib import Path -from langchain_core.messages import HumanMessage - -# Import agent graphs -from control_chart_system.control_chart_agent import control_chart_graph -from msa_system.msa_agent import msa_graph -from process_capability_system.capability_agent import capability_graph - -app = FastAPI( - title="SPC & Quality Management API", - description="AI Quality Consultants for MSA, SPC, and Capability Analysis", - version="1.0.0" -) - -# CORS configuration -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -# Agent graphs mapping -AGENT_GRAPHS = { - "msa": msa_graph, - "control-charts": control_chart_graph, - "capability": capability_graph -} - - -# ===== ENDPOINTS ===== - -@app.get("/") -async def root(): - """API root endpoint""" - return { - "message": "SPC & Quality Management API", - "version": "1.0.0", - "endpoints": { - "msa": "/chat/msa", - "control_charts": "/chat/control-charts", - "capability": "/chat/capability", - "health": "/health", - "docs": "/docs" - } - } - - -@app.post("/chat/{agent_id}") -async def chat( - agent_id: str, - message: str = Form(...), - thread_id: str = Form(...), - user_id: str = Form(...), - file: Optional[UploadFile] = File(None) -): - """ - Universal chat endpoint for all agents - - Args: - agent_id: Agent identifier (msa, control-charts, capability) - message: Your question or command - thread_id: Thread ID for conversation continuity - user_id: User ID for tracking - file: Optional CSV file with data - - Returns: - { - "status": "success", - "thread_id": "...", - "user_id": "...", - "response": "agent response text" - } - """ - # Validate agent exists - if agent_id not in AGENT_GRAPHS: - raise HTTPException(404, f"Agent not found. Available: {list(AGENT_GRAPHS.keys())}") - - try: - # Save uploaded file if provided - file_path = None - if file: - temp_dir = Path("temp_uploads") - temp_dir.mkdir(exist_ok=True) - file_path = temp_dir / file.filename - with open(file_path, "wb") as f: - f.write(file.file.read()) - file_path = str(file_path) - - # Prepend file path to message if provided - if file_path: - message = f"File: {file_path}\n\n{message}" - - # Create config for agent memory - config = { - "configurable": { - "thread_id": thread_id, - "user_id": user_id - } - } - - # Run agent - agent_response = await AGENT_GRAPHS[agent_id].ainvoke( - {"messages": [HumanMessage(content=message)]}, - config=config - ) - - # Extract response text - response_text = "No response from agent" - if "messages" in agent_response and len(agent_response["messages"]) > 0: - last_message = agent_response["messages"][-1] - if hasattr(last_message, 'content') and last_message.content: - response_text = last_message.content - - # Return response - return JSONResponse(content={ - "status": "success", - "thread_id": thread_id, - "user_id": user_id, - "response": response_text - }) - - except Exception as e: - raise HTTPException(500, f"Error: {str(e)}") - - -@app.get("/health") -async def health(): - """Health check endpoint""" - return {"status": "healthy"} - - -if __name__ == "__main__": - import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..803548c --- /dev/null +++ b/apps/__init__.py @@ -0,0 +1 @@ +# Apps package diff --git a/apps/api/__init__.py b/apps/api/__init__.py new file mode 100644 index 0000000..28b07ef --- /dev/null +++ b/apps/api/__init__.py @@ -0,0 +1 @@ +# API package diff --git a/apps/api/deps.py b/apps/api/deps.py new file mode 100644 index 0000000..434d7cb --- /dev/null +++ b/apps/api/deps.py @@ -0,0 +1,208 @@ +"""Shared FastAPI dependencies and app-level state for ASPC API routers.""" +from __future__ import annotations + +import os +from collections.abc import Callable +from datetime import UTC, datetime, timedelta +from typing import Any + +from fastapi import Depends, Header, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from adapters.factory import get_repository +from apps.config import get_config + +cfg = get_config() +repo = get_repository(cfg) + +_bearer = HTTPBearer(auto_error=False) + +try: + import bcrypt as _bcrypt +except ImportError as exc: # pragma: no cover + raise RuntimeError( + "bcrypt is required for ASPC auth. Install with: pip install 'aspc[apps]' " + "or: pip install bcrypt" + ) from exc + +_admin_password_hash: bytes | None = None +_user_password_hashes: dict[str, bytes] = {} + +VALID_ROLES = frozenset({"admin", "analyst", "operator"}) + + +def ensure_password_hash() -> bytes: + global _admin_password_hash + if _admin_password_hash is not None: + return _admin_password_hash + raw = cfg.admin_password + if raw.startswith(("$2a$", "$2b$", "$2y$")): + _admin_password_hash = raw.encode("utf-8") + else: + _admin_password_hash = _bcrypt.hashpw(raw.encode("utf-8"), _bcrypt.gensalt()) + return _admin_password_hash + + +def _hash_password(raw: str) -> bytes: + if raw.startswith(("$2a$", "$2b$", "$2y$")): + return raw.encode("utf-8") + return _bcrypt.hashpw(raw.encode("utf-8"), _bcrypt.gensalt()) + + +def verify_password(plain: str, *, username: str | None = None) -> bool: + """Verify against admin password or an optional demo user entry.""" + if username and username != cfg.admin_username: + user = find_user(username) + if user is None: + return False + stored = _user_password_hashes.get(username) + if stored is None: + stored = _hash_password(str(user.get("password") or "")) + _user_password_hashes[username] = stored + try: + return bool(_bcrypt.checkpw(plain.encode("utf-8"), stored)) + except Exception: + return False + stored = ensure_password_hash() + try: + return bool(_bcrypt.checkpw(plain.encode("utf-8"), stored)) + except Exception: + return False + + +def find_user(username: str) -> dict[str, Any] | None: + for u in cfg.auth_users: + if isinstance(u, dict) and u.get("username") == username: + return u + if username == cfg.admin_username: + return { + "username": cfg.admin_username, + "role": cfg.default_role or "admin", + "tenant_id": cfg.default_tenant_id, + } + return None + + +def create_access_token( + subject: str, + *, + role: str | None = None, + tenant_id: str | None = None, +) -> str: + try: + from jose import jwt + except ImportError as exc: # pragma: no cover + raise HTTPException(500, "python-jose required for JWT auth") from exc + expire = datetime.now(UTC) + timedelta(minutes=cfg.jwt_expire_minutes) + user = find_user(subject) or {} + role = role or str(user.get("role") or cfg.default_role or "admin") + if role not in VALID_ROLES: + role = "operator" + tid = tenant_id if tenant_id is not None else user.get("tenant_id", cfg.default_tenant_id) + payload: dict[str, Any] = {"sub": subject, "exp": expire, "role": role} + if tid: + payload["tenant_id"] = str(tid) + return jwt.encode(payload, cfg.jwt_secret, algorithm=cfg.jwt_algorithm) + + +def decode_token(token: str) -> dict[str, Any]: + try: + from jose import JWTError, jwt + except ImportError as exc: # pragma: no cover + raise HTTPException(500, "python-jose required for JWT auth") from exc + try: + data = jwt.decode(token, cfg.jwt_secret, algorithms=[cfg.jwt_algorithm]) + except JWTError as exc: + raise HTTPException(401, "Invalid or expired token") from exc + username = data.get("sub") + if not username: + raise HTTPException(401, "Invalid token") + role = data.get("role") or cfg.default_role or "admin" + if role not in VALID_ROLES: + role = "operator" + out: dict[str, Any] = {"username": username, "auth": "jwt", "role": role} + if data.get("tenant_id"): + out["tenant_id"] = data["tenant_id"] + elif cfg.default_tenant_id: + out["tenant_id"] = cfg.default_tenant_id + return out + + +def get_current_user( + credentials: HTTPAuthorizationCredentials | None = Depends(_bearer), +) -> dict[str, Any]: + if not cfg.auth_enabled: + return { + "username": "anonymous", + "auth": "disabled", + "role": "admin", + **({"tenant_id": cfg.default_tenant_id} if cfg.default_tenant_id else {}), + } + if credentials is None or not credentials.credentials: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + return decode_token(credentials.credentials) + + +def require_roles(*roles: str) -> Callable[..., dict[str, Any]]: + """Dependency factory: require JWT role in ``roles`` (admin always allowed).""" + allowed = frozenset(roles) | frozenset({"admin"}) + + def _dep(user: dict[str, Any] = Depends(get_current_user)) -> dict[str, Any]: + role = user.get("role") or "operator" + if role not in allowed: + raise HTTPException( + status.HTTP_403_FORBIDDEN, + f"Role '{role}' cannot perform this action (need one of {sorted(allowed)})", + ) + return user + + return _dep + + +def require_api_key(x_api_key: str | None = Header(None, alias="X-API-Key")) -> str: + keys = cfg.api_keys + if not keys: + env_keys = os.getenv("ASPC_API_KEYS", "") + keys = [k.strip() for k in env_keys.split(",") if k.strip()] + if not keys: + if cfg.dev_insecure: + return x_api_key or "dev" + raise HTTPException( + status.HTTP_401_UNAUTHORIZED, + "API keys not configured. Set ASPC_API_KEYS, or ASPC_DEV_INSECURE=1 for local dev.", + ) + if not x_api_key or x_api_key not in keys: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid or missing X-API-Key") + return x_api_key + + +def startup_security_checks() -> None: + ensure_password_hash() + if cfg.dev_insecure: + return + if cfg.jwt_secret == "change-me-in-production": + raise RuntimeError( + "ASPC_JWT_SECRET is still the default 'change-me-in-production'. " + "Set a strong secret, or set ASPC_DEV_INSECURE=1 for local development only." + ) + if cfg.admin_password == "admin": + raise RuntimeError( + "ASPC_ADMIN_PASSWORD is still the default 'admin'. " + "Set a strong password, or set ASPC_DEV_INSECURE=1 for local development only." + ) + if not cfg.api_keys: + raise RuntimeError( + "ASPC_API_KEYS is empty. Set at least one API key for stream mutations, " + "or set ASPC_DEV_INSECURE=1 for local development only." + ) + + +def reset_password_hash_cache() -> None: + """Test helper: force re-hash after cfg mutation.""" + global _admin_password_hash, _user_password_hashes + _admin_password_hash = None + _user_password_hashes = {} diff --git a/apps/api/main.py b/apps/api/main.py new file mode 100644 index 0000000..fc7b460 --- /dev/null +++ b/apps/api/main.py @@ -0,0 +1,1372 @@ +"""FastAPI application — typed batch endpoints over spc_core. + +Endpoints: + POST /auth/token + POST /analyze/control-chart | /capability | /msa + GET /runs/{run_id} | /runs | /reports/{run_id} + GET /health | /metrics + GET /stream/replay (SSE) + WS /ws/live/{stream_key} + POST /streams/register | /streams/{key}/go-live | /alerts/{id}/ack + GET /streams +""" +from __future__ import annotations + +import html +import json +import os +import re +import time +from concurrent.futures import ThreadPoolExecutor +from contextlib import asynccontextmanager +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +from fastapi import ( + Depends, + FastAPI, + File, + Form, + Header, + HTTPException, + Query, + Request, + UploadFile, + WebSocket, + WebSocketDisconnect, + status, +) +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import HTMLResponse, PlainTextResponse, StreamingResponse +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, OAuth2PasswordRequestForm +from pydantic import BaseModel + +from adapters.factory import get_repository +from adapters.io_files import FileReadError, load_columns, resolve_under, save_upload_stream +from adapters.protocols import StreamingOpsRepository +from adapters.render_plotly import ( + render_capability_html, + render_control_chart_html, + render_msa_html, + save_html, +) +from adapters.stream import FileReplaySource, stream_evaluate +from apps.config import get_config +from spc_core import ( + ChartType, + bias_study, + capability_analysis, + check_normality, + checklist_ready_for_golive, + establish, + gage_rr_anova, + gage_rr_range, + ingest, + linearity_study, + phase1_checklist, + stability_study, +) +from spc_core.models import ControlLimits, LimitSet +from spc_core.report import CapabilityReport, MSAReport, SPCReport + +cfg = get_config() +repo = get_repository(cfg) + +_ANALYZE_POOL = ThreadPoolExecutor(max_workers=4, thread_name_prefix="aspc-analyze") + +# Rate limiting (slowapi) — required when auth is enabled (checked at lifespan) +try: + from slowapi import Limiter, _rate_limit_exceeded_handler + from slowapi.errors import RateLimitExceeded + from slowapi.util import get_remote_address + + _RATE_LIMIT = True +except ImportError: # pragma: no cover + Limiter = None # type: ignore[misc, assignment] + _rate_limit_exceeded_handler = None # type: ignore[misc, assignment] + RateLimitExceeded = Exception # type: ignore[misc, assignment] + get_remote_address = None # type: ignore[misc, assignment] + _RATE_LIMIT = False + +# Password hashing — bcrypt is required (no plaintext fallback). +try: + import bcrypt as _bcrypt +except ImportError as exc: # pragma: no cover + raise RuntimeError( + "bcrypt is required for ASPC auth. Install with: pip install 'aspc[apps]' " + "or: pip install bcrypt" + ) from exc + +_admin_password_hash: bytes | None = None + + +def _ensure_password_hash() -> bytes: + global _admin_password_hash + if _admin_password_hash is not None: + return _admin_password_hash + raw = cfg.admin_password + if raw.startswith(("$2a$", "$2b$", "$2y$")): + _admin_password_hash = raw.encode("utf-8") + else: + _admin_password_hash = _bcrypt.hashpw(raw.encode("utf-8"), _bcrypt.gensalt()) + return _admin_password_hash + + +def _verify_password(plain: str) -> bool: + stored = _ensure_password_hash() + try: + return bool(_bcrypt.checkpw(plain.encode("utf-8"), stored)) + except Exception: + return False + + +def _startup_security_checks() -> None: + """Refuse insecure production defaults unless ASPC_DEV_INSECURE=1. + + Weak secrets are refused even when auth is disabled — only ``dev_insecure`` + bypasses these checks. + """ + _ensure_password_hash() + if cfg.dev_insecure: + return + if cfg.jwt_secret == "change-me-in-production": + raise RuntimeError( + "ASPC_JWT_SECRET is still the default 'change-me-in-production'. " + "Set a strong secret, or set ASPC_DEV_INSECURE=1 for local development only." + ) + if cfg.admin_password == "admin": + raise RuntimeError( + "ASPC_ADMIN_PASSWORD is still the default 'admin'. " + "Set a strong password, or set ASPC_DEV_INSECURE=1 for local development only." + ) + if not cfg.api_keys: + raise RuntimeError( + "ASPC_API_KEYS is empty. Set at least one API key for stream mutations, " + "or set ASPC_DEV_INSECURE=1 for local development only." + ) + + +@asynccontextmanager +async def _lifespan(_app: FastAPI): + _startup_security_checks() + if not _RATE_LIMIT and cfg.auth_enabled and not cfg.dev_insecure: + raise RuntimeError( + "slowapi is required when auth is enabled. Install with: pip install slowapi " + "or set ASPC_DEV_INSECURE=1 for local development only." + ) + yield + + +app = FastAPI( + title="ASPC — Statistical Process Control API", + description=( + "Correct, tested SPC core with batch analysis and Phase II streaming. " + "Optional JWT roles (admin/analyst/operator) and tenant_id for multi-user demos." + ), + version="2.0.0", + lifespan=_lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=cfg.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +_bearer = HTTPBearer(auto_error=False) + +limiter = None +if _RATE_LIMIT: + limiter = Limiter(key_func=get_remote_address, default_limits=[]) + app.state.limiter = limiter + app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + +# Prometheus metrics (optional dependency) +try: + from prometheus_client import CONTENT_TYPE_LATEST, Counter, Histogram, generate_latest + + REQUESTS = Counter("aspc_http_requests_total", "HTTP requests", ["method", "path", "status"]) + ANALYZE_LATENCY = Histogram("aspc_analyze_seconds", "Analyze endpoint latency", ["kind"]) + _PROM = True +except ImportError: # pragma: no cover + _PROM = False + + +# ---- auth helpers ------------------------------------------------------------- + +_VALID_ROLES = frozenset({"admin", "analyst", "operator"}) + + +def _create_access_token( + subject: str, + *, + role: str | None = None, + tenant_id: str | None = None, +) -> str: + try: + from jose import jwt + except ImportError as exc: # pragma: no cover + raise HTTPException(500, "python-jose required for JWT auth") from exc + expire = datetime.now(UTC) + timedelta(minutes=cfg.jwt_expire_minutes) + role = role or cfg.default_role or "admin" + if role not in _VALID_ROLES: + role = "operator" + payload: dict[str, Any] = {"sub": subject, "exp": expire, "role": role} + tid = tenant_id if tenant_id is not None else cfg.default_tenant_id + if tid: + payload["tenant_id"] = str(tid) + return jwt.encode(payload, cfg.jwt_secret, algorithm=cfg.jwt_algorithm) + + +def _decode_token(token: str) -> dict[str, Any]: + try: + from jose import JWTError, jwt + except ImportError as exc: # pragma: no cover + raise HTTPException(500, "python-jose required for JWT auth") from exc + try: + data = jwt.decode(token, cfg.jwt_secret, algorithms=[cfg.jwt_algorithm]) + except JWTError as exc: + raise HTTPException(401, "Invalid or expired token") from exc + username = data.get("sub") + if not username: + raise HTTPException(401, "Invalid token") + role = data.get("role") or cfg.default_role or "admin" + if role not in _VALID_ROLES: + role = "operator" + out: dict[str, Any] = {"username": username, "auth": "jwt", "role": role} + if data.get("tenant_id"): + out["tenant_id"] = data["tenant_id"] + elif cfg.default_tenant_id: + out["tenant_id"] = cfg.default_tenant_id + return out + + +def get_current_user( + credentials: HTTPAuthorizationCredentials | None = Depends(_bearer), +) -> dict[str, Any]: + """Validate Bearer JWT. When auth is disabled, return anonymous user.""" + if not cfg.auth_enabled: + out: dict[str, Any] = { + "username": "anonymous", + "auth": "disabled", + "role": "admin", + } + if cfg.default_tenant_id: + out["tenant_id"] = cfg.default_tenant_id + return out + if credentials is None or not credentials.credentials: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + return _decode_token(credentials.credentials) + + +def require_roles(*roles: str): + """Dependency factory: require JWT role in ``roles`` (admin always allowed).""" + allowed = frozenset(roles) | frozenset({"admin"}) + + def _dep(user: dict[str, Any] = Depends(get_current_user)) -> dict[str, Any]: + role = user.get("role") or "operator" + if role not in allowed: + raise HTTPException( + status.HTTP_403_FORBIDDEN, + f"Role '{role}' cannot perform this action (need one of {sorted(allowed)})", + ) + return user + + return _dep + + +def require_api_key(x_api_key: str | None = Header(None, alias="X-API-Key")) -> str: + """Require X-API-Key for ingest / stream mutation endpoints. + + Fail-closed when no keys are configured, unless ``ASPC_DEV_INSECURE=1``. + """ + keys = cfg.api_keys + if not keys: + env_keys = os.getenv("ASPC_API_KEYS", "") + keys = [k.strip() for k in env_keys.split(",") if k.strip()] + if not keys: + if cfg.dev_insecure: + return x_api_key or "dev" + raise HTTPException( + status.HTTP_401_UNAUTHORIZED, + "API keys not configured. Set ASPC_API_KEYS, or ASPC_DEV_INSECURE=1 for local dev.", + ) + if not x_api_key or x_api_key not in keys: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid or missing X-API-Key") + return x_api_key + + +_RUN_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$") + + +def _safe_run_id(run_id: str) -> str: + if not _RUN_ID_RE.match(run_id): + raise HTTPException(400, "Invalid run_id") + return run_id + + +# ---- response models ---------------------------------------------------------- + +class AnalyzeResponse(BaseModel): + status: str = "success" + run_id: str + analysis_type: str + report: dict[str, Any] + html_report: str | None = None + checklist: dict[str, Any] | None = None + + +class HealthResponse(BaseModel): + status: str + version: str = "2.0.0" + checks: dict[str, str] | None = None + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + + +class StreamRegisterRequest(BaseModel): + stream_key: str + topic: str | None = None + chart_type: str | None = None + ruleset: str = "nelson" + meta: dict[str, Any] | None = None + + +class GoLiveRequest(BaseModel): + limits_version: str + ruleset: str | None = None + + +# ---- helpers ------------------------------------------------------------------ + +def _save_file(file: UploadFile) -> Path: + try: + return save_upload_stream( + file.file, + cfg.temp_upload_dir, + file.filename or "upload.csv", + max_bytes=cfg.max_file_size_bytes, + allowed_extensions=cfg.allowed_extensions, + ) + except FileReadError as exc: + raise HTTPException(400, str(exc)) from exc + + +def _maybe_html(render_fn, report: Any, filename: str) -> str | None: + if not cfg.config["reports"]["auto_generate"]: + return None + try: + return str(save_html(render_fn(report), Path(cfg.report_dir) / filename)) + except (ImportError, OSError): + return None + + +def _load(path: Path) -> dict[str, list]: + try: + return load_columns(path) + except FileReadError as exc: + raise HTTPException(400, str(exc)) from exc + + +def _limits_from_stored(stored: dict[str, Any]) -> ControlLimits: + payload = stored["payload"] + components = { + name: LimitSet(**comp) for name, comp in payload["components"].items() + } + return ControlLimits( + chart_type=ChartType(payload["chart_type"]), + subgroup_size=payload["subgroup_size"], + components=components, + sigma=payload.get("sigma"), + source_n_points=payload.get("source_n_points"), + notes=payload.get("notes") or {}, + ) + + +# ---- endpoints ---------------------------------------------------------------- + +@app.get("/", response_model=dict) +async def root(): + return { + "message": "ASPC Statistical Process Control API", + "version": "2.0.0", + "endpoints": { + "auth": "POST /auth/token", + "control_chart": "POST /analyze/control-chart", + "capability": "POST /analyze/capability", + "msa": "POST /analyze/msa", + "runs": "GET /runs", + "report": "GET /reports/{run_id}", + "streams": "GET /streams", + "stream_register": "POST /streams/register", + "go_live": "POST /streams/{key}/go-live", + "ack_alert": "POST /alerts/{id}/ack", + "ws_live": "WS /ws/live/{stream_key}", + "stream_replay": "GET /stream/replay", + "metrics": "GET /metrics", + "health": "GET /health", + "docs": "/docs", + }, + } + + +@app.get("/health", response_model=HealthResponse) +async def health(): + checks: dict[str, str] = {"api": "ok"} + try: + if hasattr(repo, "list_runs"): + repo.list_runs(limit=1) + checks["persistence"] = "ok" + except Exception as exc: # noqa: BLE001 + checks["persistence"] = f"error: {exc}" + try: + import redis as redis_lib + + r = redis_lib.Redis.from_url(cfg.redis_url, socket_connect_timeout=1) + r.ping() + r.close() + checks["redis"] = "ok" + except Exception as exc: # noqa: BLE001 + checks["redis"] = f"unavailable: {exc}" + + degraded = any( + not v.startswith(("ok", "unavailable")) for k, v in checks.items() if k != "api" + ) + return HealthResponse(status="degraded" if degraded else "healthy", checks=checks) + + +@app.get("/metrics") +async def metrics(_user: dict = Depends(get_current_user)): + if not _PROM: + return PlainTextResponse( + "# prometheus-client not installed\n", media_type="text/plain" + ) + return PlainTextResponse(generate_latest().decode("utf-8"), media_type=CONTENT_TYPE_LATEST) + + +def _rate_limit(limit: str): + """Apply slowapi limit when available; otherwise no-op.""" + if _RATE_LIMIT and limiter is not None: + return limiter.limit(limit) + + def _noop(fn): + return fn + + return _noop + + +@app.post("/auth/token", response_model=TokenResponse) +@_rate_limit("10/minute") +async def login_for_access_token( + request: Request, + form: OAuth2PasswordRequestForm = Depends(), +): + """Issue a JWT for the admin user or an optional demo user from config.auth.users.""" + role = cfg.default_role or "admin" + tenant_id = cfg.default_tenant_id + if form.username == cfg.admin_username: + if not _verify_password(form.password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + else: + demo = next( + (u for u in cfg.auth_users if isinstance(u, dict) and u.get("username") == form.username), + None, + ) + if demo is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + raw = str(demo.get("password") or "") + ok = False + try: + if raw.startswith(("$2a$", "$2b$", "$2y$")): + ok = bool(_bcrypt.checkpw(form.password.encode("utf-8"), raw.encode("utf-8"))) + else: + ok = form.password == raw + except Exception: + ok = False + if not ok: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + role = str(demo.get("role") or "operator") + tenant_id = demo.get("tenant_id") + token = _create_access_token(form.username, role=role, tenant_id=tenant_id) + return TokenResponse(access_token=token) + + +@app.get("/auth/me") +async def auth_me(_user: dict = Depends(get_current_user)): + """Return current JWT identity (username, role, optional tenant_id).""" + return { + "username": _user.get("username"), + "role": _user.get("role"), + "tenant_id": _user.get("tenant_id"), + "auth": _user.get("auth"), + } + + +@app.post("/analyze/control-chart", response_model=AnalyzeResponse) +@_rate_limit("20/minute") +async def analyze_cc( + request: Request, + file: UploadFile = File(...), + value_col: str | None = Form(None), + subgroup_col: str | None = Form(None), + sample_size_col: str | None = Form(None), + opportunity_col: str | None = Form(None), + chart_type: str | None = Form(None), + ruleset: str | None = Form(None), + valid_range_min: float | None = Form(None), + valid_range_max: float | None = Form(None), + include_records: bool = Form(False), + msa_file: UploadFile | None = File(None), + msa_tolerance: float | None = Form(None), + _user: dict = Depends(get_current_user), +): + t0 = time.perf_counter() + try: + path = _save_file(file) + columns = _load(path) + frame = ingest( + columns, + value_col=value_col, + subgroup_col=subgroup_col, + sample_size_col=sample_size_col, + opportunity_col=opportunity_col, + ) + cmap = frame.column_map + if cmap.value_col is None: + raise HTTPException(400, "Could not detect measurement column") + + msa_kwargs: dict[str, Any] = {} + if msa_file is not None and msa_file.filename: + msa_path = _save_file(msa_file) + msa_cols = _load(msa_path) + msa_frame = ingest(msa_cols) + msa_map = msa_frame.column_map + if ( + msa_map.value_col is None + or msa_map.part_col is None + or msa_map.operator_col is None + ): + raise HTTPException( + 400, + "MSA file needs measurement, part, and operator columns", + ) + msa_kwargs = { + "msa_parts": msa_cols[msa_map.part_col], + "msa_operators": msa_cols[msa_map.operator_col], + "msa_measurements": msa_cols[msa_map.value_col], + "msa_tolerance": msa_tolerance, + } + + values = columns[cmap.value_col] + ct = ChartType(chart_type) if chart_type else None + valid_range = None + if valid_range_min is not None or valid_range_max is not None: + if valid_range_min is None or valid_range_max is None: + raise HTTPException(400, "Both valid_range_min and valid_range_max are required") + valid_range = (valid_range_min, valid_range_max) + + def _run_establish(): + return establish( + values, + subgroup_ids=columns.get(cmap.subgroup_col) if cmap.subgroup_col else None, + sample_sizes=columns.get(cmap.sample_size_col) if cmap.sample_size_col else None, + opportunities=columns.get(cmap.opportunity_col) if cmap.opportunity_col else None, + chart_type=ct, + ruleset=ruleset or cfg.ruleset, + acf_threshold=cfg.acf_threshold, + valid_range=valid_range, + **msa_kwargs, + ) + + import asyncio + + pipeline = await asyncio.get_running_loop().run_in_executor( + _ANALYZE_POOL, _run_establish + ) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + checklist = phase1_checklist( + pipeline, + min_subgroups=cfg.min_phase1_points, + phase2_enabled=False, + ) + result = pipeline.chart + report = SPCReport.from_chart_result( + result, + normality=pipeline.normality, + autocorrelation=pipeline.autocorrelation, + source_file=str(path), + gates=pipeline.gates, + checklist=checklist, + include_records=include_records, + ) + report_dict = report.model_dump(mode="json") + + limits_meta = { + "source_file": str(path), + "frozen": pipeline.frozen, + "stopped": pipeline.stopped, + # Exclude phase2_enabled — that item flips only after go-live itself. + "checklist_passed": checklist_ready_for_golive(checklist), + "limits_version": report.limits.version, + } + # Only persist freezeable limits — STOP / failed checklist must not produce + # a go-live-eligible limits version. + if pipeline.frozen: + repo.save_limits( + report_dict["limits"], report.limits.version, + report.chart_type.value, meta=limits_meta, + ) + run_id = repo.save_run( + "control_chart", report_dict, + limits_version=report.limits.version if pipeline.frozen else None, + source_file=str(path), + user_id=_user.get("username"), + ) + + html_path = None + if cfg.config["reports"]["auto_generate"]: + html_path = _maybe_html( + render_control_chart_html, report, f"{run_id}_control_chart.html" + ) + + if _PROM: + ANALYZE_LATENCY.labels(kind="control_chart").observe(time.perf_counter() - t0) + + return AnalyzeResponse( + run_id=run_id, + analysis_type="control_chart", + report=report_dict, + html_report=html_path, + checklist=checklist, + ) + + +@app.post("/analyze/capability", response_model=AnalyzeResponse) +@_rate_limit("20/minute") +async def analyze_cap( + request: Request, + file: UploadFile = File(...), + usl: float = Form(...), + lsl: float = Form(...), + target: float | None = Form(None), + value_col: str | None = Form(None), + subgroup_col: str | None = Form(None), + _user: dict = Depends(get_current_user), +): + if usl <= lsl: + raise HTTPException(400, f"USL ({usl}) must be greater than LSL ({lsl})") + + try: + path = _save_file(file) + columns = _load(path) + frame = ingest(columns, value_col=value_col, subgroup_col=subgroup_col) + cmap = frame.column_map + if cmap.value_col is None: + raise HTTPException(400, "Could not detect measurement column") + + values = [float(v) for v in columns[cmap.value_col] if v is not None] + subgroups = None + if cmap.subgroup_col and cmap.subgroup_col in columns: + from spc_core.limits import build_subgroups + subgroups = build_subgroups(values, columns[cmap.subgroup_col]) + + normality = check_normality(values) + result = capability_analysis(values, usl=usl, lsl=lsl, target=target, subgroups=subgroups) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + report = CapabilityReport.from_capability(result, normality=normality, source_file=str(path)) + report_dict = report.model_dump(mode="json") + + run_id = repo.save_run( + "capability", report_dict, source_file=str(path), + user_id=_user.get("username"), + ) + html_path = None + if cfg.config["reports"]["auto_generate"]: + html_path = _maybe_html(render_capability_html, report, f"{run_id}_capability.html") + + return AnalyzeResponse( + run_id=run_id, analysis_type="capability", + report=report_dict, html_report=html_path, + ) + + +@app.post("/analyze/msa", response_model=AnalyzeResponse) +@_rate_limit("20/minute") +async def analyze_msa( + request: Request, + file: UploadFile = File(...), + study_type: str | None = Form(None), + method: str = Form("anova"), + tolerance: float | None = Form(None), + part_col: str | None = Form(None), + operator_col: str | None = Form(None), + measurement_col: str | None = Form(None), + trial_col: str | None = Form(None), + reference_col: str | None = Form(None), + _user: dict = Depends(get_current_user), +): + try: + path = _save_file(file) + columns = _load(path) + frame = ingest( + columns, value_col=measurement_col, part_col=part_col, + operator_col=operator_col, trial_col=trial_col, reference_col=reference_col, + ) + cmap = frame.column_map + + st = study_type + if st is None: + if cmap.part_col and cmap.operator_col: + st = "gage_rr" + elif cmap.reference_col: + refs = set(columns[cmap.reference_col]) + st = "linearity" if len(refs) > 1 else "bias" + elif cmap.date_col: + st = "stability" + else: + st = "gage_rr" + + st_lower = st.lower().replace(" ", "_").replace("&", "") + if st_lower in ("gage_rr", "gage_r&r", "grr"): + if not cmap.part_col or not cmap.operator_col or not cmap.value_col: + raise HTTPException(400, "Gage R&R needs part, operator, and measurement columns") + fn = gage_rr_anova if method == "anova" else gage_rr_range + result = fn( + columns[cmap.part_col], columns[cmap.operator_col], + columns[cmap.value_col], tolerance=tolerance, + ) + report = MSAReport.from_gage_rr(result, source_file=str(path)) + elif st_lower == "bias": + if not cmap.reference_col or not cmap.value_col: + raise HTTPException(400, "Bias study needs reference and measurement columns") + result = bias_study(columns[cmap.value_col], columns[cmap.reference_col]) + report = MSAReport.from_bias(result, source_file=str(path)) + elif st_lower == "linearity": + if not cmap.reference_col or not cmap.value_col: + raise HTTPException(400, "Linearity study needs reference and measurement columns") + result = linearity_study(columns[cmap.value_col], columns[cmap.reference_col]) + report = MSAReport.from_linearity(result, source_file=str(path)) + elif st_lower == "stability": + if not cmap.value_col: + raise HTTPException(400, "Stability study needs a measurement column") + result = stability_study(columns[cmap.value_col]) + report = MSAReport.from_stability(result, source_file=str(path)) + else: + raise HTTPException(400, f"Unknown study_type: {study_type}") + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + + report_dict = report.model_dump(mode="json") + run_id = repo.save_run( + "msa", report_dict, source_file=str(path), + user_id=_user.get("username"), + ) + html_path = None + if cfg.config["reports"]["auto_generate"]: + html_path = _maybe_html(render_msa_html, report, f"{run_id}_msa.html") + + return AnalyzeResponse( + run_id=run_id, analysis_type="msa", + report=report_dict, html_report=html_path, + ) + + +class ContinuousMSARequest(BaseModel): + measured: list[float] + reference: list[float] + tolerance: float = 1.0 + alpha: float = 0.2 + + +@app.post("/analyze/msa-continuous") +@_rate_limit("20/minute") +async def analyze_msa_continuous( + request: Request, + body: ContinuousMSARequest, + _user: dict = Depends(get_current_user), +): + """Evaluate continuous MSA drift (EWMA bias + rolling R) on paired reference injections.""" + from spc_core.msa_stream import ContinuousMSA + + if len(body.measured) != len(body.reference): + raise HTTPException(400, "measured and reference must have the same length") + if not body.measured: + raise HTTPException(400, "measured/reference must be non-empty") + try: + monitor = ContinuousMSA(tolerance=body.tolerance, alpha=body.alpha) + for m, r in zip(body.measured, body.reference, strict=True): + monitor.observe_reference(m, r) + summary = monitor.summary() + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + return {"status": "success", "summary": summary} + + +@app.get("/runs") +async def list_runs( + analysis_type: str | None = None, + limit: int = Query(50, le=500), + _user: dict = Depends(get_current_user), +): + return {"runs": repo.list_runs(analysis_type=analysis_type, limit=limit)} + + +@app.get("/runs/{run_id}") +async def get_run(run_id: str, _user: dict = Depends(get_current_user)): + run = repo.get_run(run_id) + if not run: + raise HTTPException(404, f"Run not found: {run_id}") + return run + + +@app.get("/reports/{run_id}") +async def get_report_html(run_id: str, _user: dict = Depends(get_current_user)): + """Serve a previously generated HTML report, or rebuild from stored JSON.""" + safe_id = _safe_run_id(run_id) + report_root = Path(cfg.report_dir).resolve() + report_root.mkdir(parents=True, exist_ok=True) + for suffix in ("_control_chart.html", "_capability.html", "_msa.html"): + try: + candidate = resolve_under(report_root, f"{safe_id}{suffix}") + except FileReadError as exc: + raise HTTPException(400, str(exc)) from exc + if candidate.exists() and candidate.is_file(): + return HTMLResponse(candidate.read_text(encoding="utf-8")) + + run = repo.get_run(safe_id) + if not run: + raise HTTPException(404, f"Run not found: {safe_id}") + report = run["report"] + safe_json = html.escape(json.dumps(report, indent=2, default=str)) + safe_type = html.escape(str(run["analysis_type"])) + safe_title = html.escape(safe_id) + html_body = f"""Run {safe_title} +

    {safe_type}

    +
    {safe_json}
    """ + return HTMLResponse(html_body) + + +@app.post("/streams/register") +async def register_stream( + body: StreamRegisterRequest, + _key: str = Depends(require_api_key), + _user: dict = Depends(require_roles("analyst", "admin")), +): + if not isinstance(repo, StreamingOpsRepository): + raise HTTPException( + 501, + "Stream registry requires TimescaleDB backend (persistence.backend=timescale)", + ) + meta = dict(body.meta or {}) + if _user.get("tenant_id") and "tenant_id" not in meta: + meta["tenant_id"] = _user["tenant_id"] + key = repo.register_stream( + body.stream_key, + topic=body.topic, + chart_type=body.chart_type, + ruleset=body.ruleset, + active=False, + meta=meta or None, + ) + # Persist tenant_id column when supported + if _user.get("tenant_id") and hasattr(repo, "set_stream_tenant"): + try: + repo.set_stream_tenant(key, _user["tenant_id"]) # type: ignore[attr-defined] + except Exception: # noqa: BLE001 + pass + repo.save_audit( + "stream_register", + {"stream_key": key, "topic": body.topic, "tenant_id": _user.get("tenant_id")}, + user_id=_user.get("username"), + ) + return {"stream_key": key, "active": False, "tenant_id": _user.get("tenant_id")} + + +@app.post("/streams/{stream_key}/go-live") +async def go_live( + stream_key: str, + body: GoLiveRequest, + _key: str = Depends(require_api_key), + _user: dict = Depends(require_roles("analyst", "admin")), +): + if not isinstance(repo, StreamingOpsRepository): + raise HTTPException(501, "Stream registry requires TimescaleDB backend") + stored = repo.get_limits(body.limits_version) + if not stored: + raise HTTPException(404, f"Limits version not found: {body.limits_version}") + meta = stored.get("meta") or {} + if meta.get("frozen") is False or meta.get("stopped") is True: + raise HTTPException( + 409, + "Cannot go-live: Phase I limits were not frozen (STOP gate fired). " + "Re-run Phase I after resolving stop conditions.", + ) + if meta.get("checklist_passed") is False: + raise HTTPException( + 409, + "Cannot go-live: Phase I checklist did not pass. " + "Resolve checklist items before enabling Phase II.", + ) + ruleset = body.ruleset or cfg.ruleset + stream_meta: dict[str, Any] = {} + if _user.get("tenant_id"): + stream_meta["tenant_id"] = _user["tenant_id"] + if cfg.webhook_url: + stream_meta.setdefault("webhook_url", cfg.webhook_url) + repo.register_stream( + stream_key, + limits_version=body.limits_version, + chart_type=stored.get("chart_type"), + ruleset=ruleset, + active=True, + meta=stream_meta or None, + ) + repo.save_audit( + "stream_go_live", + {"stream_key": stream_key, "limits_version": body.limits_version}, + user_id=_user.get("username"), + ) + return { + "stream_key": stream_key, + "limits_version": body.limits_version, + "active": True, + "ruleset": ruleset, + "tenant_id": _user.get("tenant_id"), + } + + +@app.get("/streams") +async def list_streams( + active_only: bool = False, + _user: dict = Depends(get_current_user), +): + if not isinstance(repo, StreamingOpsRepository): + return {"streams": []} + streams = repo.list_streams(active_only=active_only) + tid = _user.get("tenant_id") + if tid: + streams = [ + s + for s in streams + if (s.get("tenant_id") == tid) + or ((s.get("meta") or {}).get("tenant_id") == tid) + or (s.get("tenant_id") is None and not (s.get("meta") or {}).get("tenant_id")) + ] + return {"streams": streams} + + +@app.post("/alerts/{event_id}/ack") +async def ack_alert( + event_id: int, + _key: str = Depends(require_api_key), + _user: dict = Depends(get_current_user), +): + if not isinstance(repo, StreamingOpsRepository): + raise HTTPException(501, "Alert ack requires TimescaleDB backend") + updated = repo.ack_alert(event_id, acked_by=_user.get("username")) + if updated is None: + raise HTTPException(404, f"Alert not found: {event_id}") + return updated + + +@app.websocket("/ws/live/{stream_key}") +async def ws_live(websocket: WebSocket, stream_key: str): + """Subscribe to Redis channel ``spc:live:{stream_key}`` and forward messages. + + When auth is enabled, the client must send a first JSON message + ``{"type": "auth", "token": ""}`` after the handshake (no token in the URL). + """ + await websocket.accept() + user: dict[str, Any] = {"role": "admin"} + if cfg.auth_enabled: + try: + raw = await websocket.receive_text() + payload = json.loads(raw) + if not isinstance(payload, dict) or payload.get("type") != "auth": + await websocket.close(code=1008, reason="Expected auth message") + return + token = payload.get("token") + if not token or not isinstance(token, str): + await websocket.close(code=1008, reason="Missing token") + return + user = _decode_token(token) + except (WebSocketDisconnect, json.JSONDecodeError, HTTPException): + await websocket.close(code=1008, reason="Invalid or expired token") + return + + tid = user.get("tenant_id") if (cfg.redis_tenant_prefix or user.get("tenant_id")) else None + if tid: + channel = f"spc:live:{tid}:{stream_key}" + else: + channel = f"spc:live:{stream_key}" + try: + import asyncio + + import redis.asyncio as aioredis + except ImportError: + await websocket.send_json( + {"error": "redis package required for live websocket; pip install redis"} + ) + await websocket.close() + return + + client = aioredis.from_url(cfg.redis_url, decode_responses=True) + pubsub = client.pubsub() + try: + await pubsub.subscribe(channel) + await websocket.send_json({"event": "subscribed", "channel": channel}) + while True: + msg = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0) + if msg and msg.get("type") == "message": + data = msg["data"] + try: + payload = json.loads(data) if isinstance(data, str) else data + except json.JSONDecodeError: + payload = {"raw": data} + await websocket.send_json(payload) + else: + try: + await asyncio.wait_for(websocket.receive_text(), timeout=0.01) + except TimeoutError: + pass + except WebSocketDisconnect: + break + except WebSocketDisconnect: + pass + finally: + await pubsub.unsubscribe(channel) + await pubsub.close() + await client.close() + + +@app.get("/stream/replay") +async def stream_replay( + file_path: str = Query(..., description="CSV filename under the upload directory"), + value_col: str = Query("measurement"), + limits_version: str = Query(..., description="Frozen Phase I limits version"), + _user: dict = Depends(get_current_user), +): + """SSE stream of OOC signals while replaying a CSV against frozen limits. + + ``file_path`` must resolve inside the configured upload directory. + """ + stored = repo.get_limits(limits_version) + if not stored: + raise HTTPException(404, f"Limits version not found: {limits_version}") + + limits = _limits_from_stored(stored) + + try: + resolved = resolve_under(cfg.temp_upload_dir, file_path) + except FileReadError as exc: + raise HTTPException(400, str(exc)) from exc + if not resolved.exists(): + raise HTTPException(404, f"File not found: {file_path}") + + def event_gen(): + source = FileReplaySource(str(resolved), value_col=value_col, delay_s=0.0) + signals = stream_evaluate(source, limits, ruleset=cfg.ruleset) + for s in signals: + yield f"data: {s.model_dump_json()}\n\n" + yield 'data: {"event": "done"}\n\n' + + return StreamingResponse(event_gen(), media_type="text/event-stream") + + +# ---- onboarding / explain / lab / excel / ops --------------------------------- + +class ExplainRequest(BaseModel): + signal: dict[str, Any] + limits_version: str | None = None + gates: list[dict[str, Any]] | None = None + checklist: dict[str, Any] | None = None + + +class CounterfactualRequest(BaseModel): + """Sandbox re-establish without mutating stored frozen limits.""" + values: list[float] + ruleset: str = "nelson" + chart_type: str | None = None + valid_range_min: float | None = None + valid_range_max: float | None = None + + +@app.get("/onboarding/sample") +async def onboarding_sample( + dataset: str = Query("spc_individual_in_control"), + _user: dict = Depends(get_current_user), +): + """Return a sample CSV body from ``sample_data`` for the onboarding wizard.""" + import csv + import io + + from sample_data import DATASET_CATALOG, get_dataset + + known = sorted( + { + "spc_individual_in_control", + "spc_individual_out_of_control", + "spc_subgroup_data", + "spc_c_chart_data", + "spc_p_chart_data", + "spc_np_chart_data", + "spc_u_chart_data", + "msa_gage_rr_excellent", + "msa_gage_rr_poor", + "msa_bias_study", + "msa_linearity_study", + "msa_stability_study", + "capability_excellent", + "capability_skewed_data", + "capability_off_center", + "capability_high_variation", + } + | set(DATASET_CATALOG.keys() if isinstance(DATASET_CATALOG, dict) else []) + ) + try: + cols = get_dataset(dataset) + except KeyError as exc: + raise HTTPException(404, f"Unknown dataset: {dataset}. Known: {known}") from exc + buf = io.StringIO() + keys = list(cols.keys()) + writer = csv.DictWriter(buf, fieldnames=keys) + writer.writeheader() + n = len(next(iter(cols.values()))) + for i in range(n): + writer.writerow({k: cols[k][i] for k in keys}) + return { + "dataset": dataset, + "filename": f"{dataset}.csv", + "csv": buf.getvalue(), + "catalog": known, + } + + +@app.post("/onboarding/demo-stream") +async def onboarding_demo_stream( + limits_version: str = Query(...), + stream_key: str = Query("demo-line-1"), + _key: str = Depends(require_api_key), + _user: dict = Depends(require_roles("analyst", "admin")), +): + """Register + go-live a demo stream against a frozen limits version (Timescale only).""" + body = GoLiveRequest(limits_version=limits_version) + await register_stream( + StreamRegisterRequest(stream_key=stream_key, meta={"demo": True}), + _key=_key, + _user=_user, + ) + return await go_live(stream_key, body, _key=_key, _user=_user) + + +@app.post("/analyze/explain") +async def analyze_explain( + body: ExplainRequest, + _user: dict = Depends(get_current_user), +): + """Deterministic Explainable SPC Copilot — structured why for one signal.""" + from spc_core.explain import explain_signal + + limits = None + if body.limits_version: + stored = repo.get_limits(body.limits_version) + if stored: + limits = stored.get("payload") or stored + return explain_signal( + body.signal, + limits_version=body.limits_version, + limits=limits, + gates=body.gates, + checklist=body.checklist, + ) + + +@app.post("/analyze/counterfactual") +async def analyze_counterfactual( + body: CounterfactualRequest, + _user: dict = Depends(get_current_user), +): + """Sandbox establish — never writes limits to the repository.""" + from spc_core import ChartType, establish, phase1_checklist + from spc_core.explain import explain_signals + + kwargs: dict[str, Any] = {"ruleset": body.ruleset} + if body.chart_type: + kwargs["chart_type"] = ChartType(body.chart_type) + if body.valid_range_min is not None and body.valid_range_max is not None: + kwargs["valid_range"] = (body.valid_range_min, body.valid_range_max) + pipe = establish(body.values, **kwargs) + checklist = phase1_checklist(pipe) + report = pipe.chart + signals = list(report.signals or []) + return { + "frozen": pipe.frozen, + "limits_version": report.limits.version if report.limits else None, + "gates": [ + g.model_dump() if hasattr(g, "model_dump") else {"step": g.step, "status": g.status, "reason": g.reason} + for g in (pipe.gates or []) + ], + "checklist": checklist, + "explanations": explain_signals( + signals, + limits_version=report.limits.version if report.limits else None, + limits=report.limits, + ), + "note": "Counterfactual only — live frozen limits were not modified.", + } + + +@app.get("/limits/{version_a}/diff/{version_b}") +async def limits_diff( + version_a: str, + version_b: str, + _user: dict = Depends(get_current_user), +): + from spc_core.explain import diff_limits + + a = repo.get_limits(version_a) + b = repo.get_limits(version_b) + if not a: + raise HTTPException(404, f"Limits version not found: {version_a}") + if not b: + raise HTTPException(404, f"Limits version not found: {version_b}") + pa = a.get("payload") or a + pb = b.get("payload") or b + if "version" not in pa: + pa = {**pa, "version": version_a} + if "version" not in pb: + pb = {**pb, "version": version_b} + return diff_limits(pa, pb) + + +@app.get("/lab/cases") +async def lab_cases(_user: dict = Depends(get_current_user)): + from resilience_data import load_manifest + + cases = load_manifest() + return { + "cases": [ + { + "id": c.get("id"), + "category": c.get("category"), + "entry": c.get("entry"), + "description": c.get("description") or c.get("title"), + } + for c in cases + ] + } + + +@app.post("/lab/cases/{case_id}/run") +async def lab_run_case(case_id: str, _user: dict = Depends(require_roles("analyst", "admin"))): + from resilience_data import load_manifest + from resilience_data.runner import run_case + + cases = {c["id"]: c for c in load_manifest()} + if case_id not in cases: + raise HTTPException(404, f"Unknown case: {case_id}") + return run_case(cases[case_id]) + + +@app.get("/runs/{run_id}/export.xlsx") +async def export_run_xlsx(run_id: str, _user: dict = Depends(get_current_user)): + from fastapi.responses import Response + + from adapters.excel import spc_report_to_xlsx_bytes + + safe = _safe_run_id(run_id) + detail = repo.get_run(safe) + if not detail: + raise HTTPException(404, f"Run not found: {run_id}") + report = detail.get("report") if isinstance(detail, dict) else None + if not isinstance(report, dict): + raise HTTPException(400, "Run has no SPC report to export") + try: + data = spc_report_to_xlsx_bytes(report) + except ImportError as exc: + raise HTTPException(501, str(exc)) from exc + return Response( + content=data, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f'attachment; filename="{safe}.xlsx"'}, + ) + + +@app.get("/ops/summary") +async def ops_summary(_user: dict = Depends(get_current_user)): + """Multi-stream ops snapshot for the overview dashboard.""" + streams: list[dict[str, Any]] = [] + if isinstance(repo, StreamingOpsRepository): + streams = repo.list_streams(active_only=False) + tid = _user.get("tenant_id") + if tid: + streams = [ + s + for s in streams + if s.get("tenant_id") == tid + or (s.get("meta") or {}).get("tenant_id") == tid + or s.get("tenant_id") is None + ] + active = [s for s in streams if s.get("active")] + recent = repo.list_runs(limit=20) if hasattr(repo, "list_runs") else [] + checklist_debt = 0 + for r in recent: + # heuristic: control-chart runs without limits_version + if r.get("analysis_type") == "control_chart" and not r.get("limits_version"): + checklist_debt += 1 + return { + "streams_total": len(streams), + "streams_active": len(active), + "streams": [ + { + "stream_key": s.get("stream_key"), + "active": s.get("active"), + "limits_version": s.get("limits_version"), + "chart_type": s.get("chart_type"), + "tenant_id": s.get("tenant_id") or (s.get("meta") or {}).get("tenant_id"), + } + for s in streams + ], + "recent_runs": len(recent), + "checklist_debt": checklist_debt, + } + + +def run() -> None: + """Entry point for the ``aspc-api`` console script.""" + import uvicorn + + uvicorn.run( + "apps.api.main:app", + host=cfg.api_host, + port=cfg.api_port, + reload=False, + ) + + +if __name__ == "__main__": + run() diff --git a/apps/api/routers/__init__.py b/apps/api/routers/__init__.py new file mode 100644 index 0000000..2c6f579 --- /dev/null +++ b/apps/api/routers/__init__.py @@ -0,0 +1,6 @@ +"""API routers package. + +Route handlers currently live in ``apps.api.main`` for a single FastAPI app +entry point. Shared auth/config lives in ``apps.api.deps``; streaming capability +checks use ``adapters.protocols.StreamingOpsRepository``. +""" diff --git a/apps/cli/__init__.py b/apps/cli/__init__.py new file mode 100644 index 0000000..1a44842 --- /dev/null +++ b/apps/cli/__init__.py @@ -0,0 +1 @@ +# CLI package diff --git a/apps/cli/main.py b/apps/cli/main.py new file mode 100644 index 0000000..7b10f5f --- /dev/null +++ b/apps/cli/main.py @@ -0,0 +1,358 @@ +"""ASPC CLI — thin consumer of spc_core (no LLM).""" +from __future__ import annotations + +import argparse +import json +import sys + +from adapters.io_files import FileReadError, load_columns +from adapters.persistence import SQLiteRepository +from adapters.render_plotly import ( + render_capability_html, + render_control_chart_html, + render_msa_html, + save_html, +) +from apps.config import get_config +from spc_core import ( + ChartType, + bias_study, + capability_analysis, + check_normality, + gage_rr_anova, + gage_rr_range, + ingest, + linearity_study, + stability_study, +) +from spc_core.report import CapabilityReport, MSAReport, SPCReport + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="aspc", + description="ASPC — Statistical Process Control (core library CLI)", + ) + sub = parser.add_subparsers(dest="cmd", required=True) + + # control-chart + cc = sub.add_parser("control-chart", help="Run control chart analysis") + cc.add_argument("-f", "--file", required=True) + cc.add_argument("--value-col") + cc.add_argument("--subgroup-col") + cc.add_argument("--sample-size-col") + cc.add_argument("--opportunity-col") + cc.add_argument("--chart-type", choices=[c.value for c in ChartType]) + cc.add_argument("--ruleset", default=None) + cc.add_argument("--html", help="Write HTML report to this path") + cc.add_argument("--json", action="store_true", help="Print JSON report") + + # capability + cap = sub.add_parser("capability", help="Run process capability analysis") + cap.add_argument("-f", "--file", required=True) + cap.add_argument("--usl", type=float, required=True) + cap.add_argument("--lsl", type=float, required=True) + cap.add_argument("--target", type=float) + cap.add_argument("--value-col") + cap.add_argument("--subgroup-col") + cap.add_argument("--html") + cap.add_argument("--json", action="store_true") + + # msa + msa = sub.add_parser("msa", help="Run MSA / Gage R&R / bias / linearity / stability") + msa.add_argument("-f", "--file", required=True) + msa.add_argument("--study-type", choices=["gage_rr", "bias", "linearity", "stability"]) + msa.add_argument("--method", choices=["anova", "range"], default="anova") + msa.add_argument("--tolerance", type=float) + msa.add_argument("--part-col") + msa.add_argument("--operator-col") + msa.add_argument("--measurement-col") + msa.add_argument("--reference-col") + msa.add_argument("--html") + msa.add_argument("--json", action="store_true") + + # serve + srv = sub.add_parser("serve", help="Start the FastAPI server") + srv.add_argument("--host", default=None) + srv.add_argument("--port", type=int, default=None) + + # doctor / demo / resilience + sub.add_parser("doctor", help="Check local ASPC config and dependencies") + demo = sub.add_parser("demo", help="Demo helpers") + demo_sub = demo.add_subparsers(dest="demo_cmd", required=True) + demo_sub.add_parser("up", help="Print one-command Compose + onboarding hints") + res = sub.add_parser("resilience", help="Run resilience catalog case(s)") + res.add_argument("--case", default=None, help="Case id (default: all via report script)") + res.add_argument("--report", action="store_true", help="Write JUDGMENT.md") + + args = parser.parse_args(argv) + cfg = get_config() + + if args.cmd == "serve": + import uvicorn + + from apps.api.main import app + uvicorn.run( + app, + host=args.host or cfg.api_host, + port=args.port or cfg.api_port, + ) + return 0 + + if args.cmd == "doctor": + return _run_doctor(cfg) + + if args.cmd == "demo": + return _run_demo_up() + + if args.cmd == "resilience": + return _run_resilience(args) + + try: + columns = load_columns(args.file) + except FileReadError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + repo = SQLiteRepository(cfg.sqlite_path) + + if args.cmd == "control-chart": + return _run_control_chart(args, columns, cfg, repo) + if args.cmd == "capability": + return _run_capability(args, columns, cfg, repo) + if args.cmd == "msa": + return _run_msa(args, columns, cfg, repo) + return 1 + + +def _run_control_chart(args, columns, cfg, repo) -> int: + from spc_core import establish, phase1_checklist + + frame = ingest( + columns, + value_col=args.value_col, + subgroup_col=args.subgroup_col, + sample_size_col=args.sample_size_col, + opportunity_col=args.opportunity_col, + ) + cmap = frame.column_map + if cmap.value_col is None: + print("Error: could not detect measurement column", file=sys.stderr) + return 1 + + ct = ChartType(args.chart_type) if args.chart_type else None + pipeline = establish( + columns[cmap.value_col], + subgroup_ids=columns.get(cmap.subgroup_col) if cmap.subgroup_col else None, + sample_sizes=columns.get(cmap.sample_size_col) if cmap.sample_size_col else None, + opportunities=columns.get(cmap.opportunity_col) if cmap.opportunity_col else None, + chart_type=ct, + ruleset=args.ruleset or cfg.ruleset, + acf_threshold=cfg.acf_threshold, + ) + checklist = phase1_checklist(pipeline, min_subgroups=cfg.min_phase1_points) + result = pipeline.chart + report = SPCReport.from_chart_result( + result, + normality=pipeline.normality, + autocorrelation=pipeline.autocorrelation, + source_file=args.file, + gates=pipeline.gates, + checklist=checklist, + ) + report_dict = report.model_dump(mode="json") + repo.save_limits(report_dict["limits"], report.limits.version, report.chart_type.value) + run_id = repo.save_run("control_chart", report_dict, limits_version=report.limits.version, + source_file=args.file) + + if args.html: + save_html(render_control_chart_html(report), args.html) + print(f"HTML report: {args.html}") + + if args.json: + print(json.dumps({"run_id": run_id, **report_dict}, indent=2, default=str)) + else: + print(f"Chart: {result.chart_type.value}") + print(f"Limits version: {result.limits.version}") + print(f"Points: {len(result.plotted_values)}") + print(f"Signals: {result.out_of_control_count}") + print(f"Run ID: {run_id}") + print(f"Checklist passed: {checklist.get('passed')}") + for s in result.signals[:10]: + print(f" [{s.rule_id}] point {s.index}: {s.description}") + return 0 + + +def _run_capability(args, columns, cfg, repo) -> int: + frame = ingest(columns, value_col=args.value_col, subgroup_col=args.subgroup_col) + cmap = frame.column_map + if cmap.value_col is None: + print("Error: could not detect measurement column", file=sys.stderr) + return 1 + values = [float(v) for v in columns[cmap.value_col] if v is not None] + subgroups = None + if cmap.subgroup_col and cmap.subgroup_col in columns: + from spc_core.limits import build_subgroups + subgroups = build_subgroups(values, columns[cmap.subgroup_col]) + + normality = check_normality(values) + result = capability_analysis( + values, usl=args.usl, lsl=args.lsl, target=args.target, subgroups=subgroups + ) + report = CapabilityReport.from_capability(result, normality=normality, source_file=args.file) + report_dict = report.model_dump(mode="json") + run_id = repo.save_run("capability", report_dict, source_file=args.file) + + if args.html: + save_html(render_capability_html(report), args.html) + print(f"HTML report: {args.html}") + + if args.json: + print(json.dumps({"run_id": run_id, **report_dict}, indent=2, default=str)) + else: + print(f"Method: {result.method}") + print(f"Cpk: {result.cpk} Ppk: {result.ppk}") + print(f"DPMO: {result.observed_dpmo:.1f} Sigma level: {result.sigma_level}") + print(f"Rating: {result.rating}") + print(f"Normality: is_normal={normality.is_normal} shapiro_p={normality.shapiro_p}") + print(f"Run ID: {run_id}") + return 0 + + +def _run_msa(args, columns, cfg, repo) -> int: + frame = ingest( + columns, + value_col=args.measurement_col, + part_col=args.part_col, + operator_col=args.operator_col, + reference_col=args.reference_col, + ) + cmap = frame.column_map + st = args.study_type + if st is None: + if cmap.part_col and cmap.operator_col: + st = "gage_rr" + elif cmap.reference_col: + refs = set(columns[cmap.reference_col]) + st = "linearity" if len(refs) > 1 else "bias" + else: + st = "stability" + + if st == "gage_rr": + fn = gage_rr_anova if args.method == "anova" else gage_rr_range + result = fn(columns[cmap.part_col], columns[cmap.operator_col], + columns[cmap.value_col], tolerance=args.tolerance) + report = MSAReport.from_gage_rr(result, source_file=args.file) + summary = f"GRR%={result.grr_percent:.1f} NDC={result.ndc} {result.acceptability}" + elif st == "bias": + result = bias_study(columns[cmap.value_col], columns[cmap.reference_col]) + report = MSAReport.from_bias(result, source_file=args.file) + summary = f"bias={result.mean_bias:.4f} p={result.p_value:.4f}" + elif st == "linearity": + result = linearity_study(columns[cmap.value_col], columns[cmap.reference_col]) + report = MSAReport.from_linearity(result, source_file=args.file) + summary = f"slope={result.slope:.4f} R²={result.r_squared:.3f}" + else: + result = stability_study(columns[cmap.value_col]) + report = MSAReport.from_stability(result, source_file=args.file) + summary = f"stable={result.is_stable} OOC={result.out_of_control_points}" + + report_dict = report.model_dump(mode="json") + run_id = repo.save_run("msa", report_dict, source_file=args.file) + + if args.html: + save_html(render_msa_html(report), args.html) + print(f"HTML report: {args.html}") + + if args.json: + print(json.dumps({"run_id": run_id, **report_dict}, indent=2, default=str)) + else: + print(f"Study: {report.study_type}") + print(summary) + # Always expose grr_percent for gage_rr (fixes the legacy key mismatch) + if st == "gage_rr": + print(f"grr_percent: {result.grr_percent}") + print(f"Run ID: {run_id}") + return 0 + + +def _run_doctor(cfg) -> int: + import os + import shutil + from pathlib import Path + + ok = True + root = Path(__file__).resolve().parents[2] + print("ASPC doctor") + print(f" config: {cfg.config_path}") + print(f" auth_enabled: {cfg.auth_enabled}") + print(f" dev_insecure: {cfg.dev_insecure}") + print(f" persistence: {cfg.persistence_backend}") + if cfg.jwt_secret == "change-me-in-production" and not cfg.dev_insecure: + print(" FAIL: ASPC_JWT_SECRET still default — set a secret or ASPC_DEV_INSECURE=1") + ok = False + else: + print(" ok: jwt secret") + if cfg.admin_password == "admin" and not cfg.dev_insecure: + print(" FAIL: ASPC_ADMIN_PASSWORD still default") + ok = False + else: + print(" ok: admin password") + if not cfg.api_keys and not cfg.dev_insecure: + print(" FAIL: ASPC_API_KEYS empty") + ok = False + else: + print(" ok: api keys / insecure") + print(f" redis_url: {cfg.redis_url}") + print(f" webhook_url: {cfg.webhook_url or '(none)'}") + print(f" docker: {'yes' if shutil.which('docker') else 'no'}") + compose = (root / "docker-compose.yml").exists() or (root / "compose.yaml").exists() + print(f" compose file: {'yes' if compose else 'no'}") + print(f" ASPC_TENANT_ID: {os.getenv('ASPC_TENANT_ID') or '(none)'}") + return 0 if ok else 1 + + +def _run_demo_up() -> int: + print("ASPC demo up") + print("Full stack (Compose starts API + UI + streaming infra):") + print( + " 1. cp deploy/compose/.env.example deploy/compose/.env # set secrets;" + " ASPC_CORS_ORIGINS should include http://localhost:3000 and http://127.0.0.1:3000" + ) + print( + " 2. docker compose -f deploy/compose/docker-compose.yml" + " --env-file deploy/compose/.env up -d --build" + ) + print(" 3. Open http://localhost:3000/onboarding (log in with ASPC_ADMIN_* from .env)") + print(" Tip: do not also run `aspc serve` — Compose already exposes the API on :8000") + print("") + print("Minimal (batch only, no Live streaming):") + print(" 1. uv pip install -e '.[dev]' && aspc serve --port 8000") + print(" 2. cd frontend && npm run dev # UI on :3000") + print(" Tip: python -m sample_data --out examples/data") + return 0 + + +def _run_resilience(args) -> int: + if args.report or not args.case: + import runpy + from pathlib import Path + + report = Path(__file__).resolve().parents[2] / "scripts" / "resilience_report.py" + runpy.run_path(str(report), run_name="__main__") + return 0 + from resilience_data import load_manifest + from resilience_data.runner import run_case + + cases = {c["id"]: c for c in load_manifest()} + if args.case not in cases: + print(f"Unknown case: {args.case}", file=sys.stderr) + print("Known:", ", ".join(sorted(cases)), file=sys.stderr) + return 1 + result = run_case(cases[args.case]) + print(json.dumps(result, indent=2, default=str)) + return 0 if result.get("status") in ("PASS", "XFAIL") else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/config.py b/apps/config.py new file mode 100644 index 0000000..5cf552e --- /dev/null +++ b/apps/config.py @@ -0,0 +1,305 @@ +"""Application config — YAML + env overrides.""" +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +try: + import yaml +except ImportError: # pragma: no cover + yaml = None + +from dotenv import load_dotenv + +DEFAULTS: dict[str, Any] = { + "api": { + "host": "0.0.0.0", + "port": 8000, + "cors_origins": ["http://localhost:3000", "http://127.0.0.1:3000"], + }, + "auth": { + "enabled": True, + "jwt_secret": "change-me-in-production", + "jwt_algorithm": "HS256", + "jwt_expire_minutes": 60, + "admin_username": "admin", + "admin_password": "admin", + "api_keys": [], + # Optional demo users: [{username, password, role, tenant_id}] + "users": [], + "default_role": "admin", + "default_tenant_id": None, + }, + "webhooks": { + "url": None, + "secret": None, + }, + "uploads": { + "temp_directory": "var/uploads", + "max_file_size_mb": 10, + "allowed_extensions": [".csv", ".parquet", ".pq"], + }, + "reports": { + "auto_generate": True, + "output_directory": "var/reports", + "include_plots": True, + }, + "persistence": { + "backend": "sqlite", + "sqlite_path": "aspc.db", + "timescale_dsn": None, + }, + "redis": { + "url": "redis://localhost:6379/0", + }, + "kafka": { + "bootstrap": "localhost:9092", + "topic": "spc.measurements", + }, + "spc": { + "ruleset": "nelson", + "min_phase1_points": 25, + "acf_threshold": 0.2, + }, +} + + +class Config: + def __init__(self, config_path: str | Path | None = None): + root = Path(__file__).resolve().parents[1] # repo root (apps/ -> ASPC/) + load_dotenv(root / ".env") + + if config_path is None: + candidates = [ + Path(__file__).parent / "config.yaml", + root / "config" / "config.yaml", + ] + config_path = next((p for p in candidates if p.exists()), candidates[0]) + + self.config_path = Path(config_path) + self.config = self._deep_merge(DEFAULTS, self._load_yaml()) + self._apply_env_overrides() + + def _load_yaml(self) -> dict: + if not self.config_path.exists() or yaml is None: + return {} + with self.config_path.open() as f: + data = yaml.safe_load(f) or {} + return data + + def _apply_env_overrides(self) -> None: + """Environment variables win over YAML for deployment wiring.""" + p = self.config.setdefault("persistence", {}) + if os.getenv("ASPC_PERSISTENCE_BACKEND"): + p["backend"] = os.environ["ASPC_PERSISTENCE_BACKEND"] + if os.getenv("ASPC_SQLITE_PATH"): + p["sqlite_path"] = os.environ["ASPC_SQLITE_PATH"] + if os.getenv("ASPC_TIMESCALE_DSN") or os.getenv("DATABASE_URL"): + p["timescale_dsn"] = os.getenv("ASPC_TIMESCALE_DSN") or os.getenv("DATABASE_URL") + + api = self.config.setdefault("api", {}) + if os.getenv("ASPC_CORS_ORIGINS"): + raw = os.environ["ASPC_CORS_ORIGINS"] + api["cors_origins"] = [o.strip() for o in raw.split(",") if o.strip()] + if os.getenv("ASPC_API_HOST"): + api["host"] = os.environ["ASPC_API_HOST"] + if os.getenv("ASPC_API_PORT"): + api["port"] = int(os.environ["ASPC_API_PORT"]) + + auth = self.config.setdefault("auth", {}) + if os.getenv("ASPC_JWT_SECRET"): + auth["jwt_secret"] = os.environ["ASPC_JWT_SECRET"] + if os.getenv("ASPC_ADMIN_USERNAME"): + auth["admin_username"] = os.environ["ASPC_ADMIN_USERNAME"] + if os.getenv("ASPC_ADMIN_PASSWORD"): + auth["admin_password"] = os.environ["ASPC_ADMIN_PASSWORD"] + if os.getenv("ASPC_API_KEYS"): + auth["api_keys"] = [k.strip() for k in os.environ["ASPC_API_KEYS"].split(",") if k.strip()] + if os.getenv("ASPC_AUTH_ENABLED") is not None: + auth["enabled"] = os.environ["ASPC_AUTH_ENABLED"].lower() in ("1", "true", "yes") + if os.getenv("ASPC_DEV_INSECURE") is not None: + auth["dev_insecure"] = os.environ["ASPC_DEV_INSECURE"].lower() in ("1", "true", "yes") + if os.getenv("ASPC_DEFAULT_ROLE"): + auth["default_role"] = os.environ["ASPC_DEFAULT_ROLE"] + if os.getenv("ASPC_TENANT_ID"): + auth["default_tenant_id"] = os.environ["ASPC_TENANT_ID"] + + webhooks = self.config.setdefault("webhooks", {}) + if os.getenv("ASPC_WEBHOOK_URL"): + webhooks["url"] = os.environ["ASPC_WEBHOOK_URL"] + if os.getenv("ASPC_WEBHOOK_SECRET"): + webhooks["secret"] = os.environ["ASPC_WEBHOOK_SECRET"] + + redis = self.config.setdefault("redis", {}) + if os.getenv("ASPC_REDIS_URL"): + redis["url"] = os.environ["ASPC_REDIS_URL"] + if os.getenv("ASPC_REDIS_TENANT_PREFIX") is not None: + redis["tenant_prefix"] = os.environ["ASPC_REDIS_TENANT_PREFIX"].lower() in ( + "1", + "true", + "yes", + ) + + kafka = self.config.setdefault("kafka", {}) + if os.getenv("ASPC_KAFKA_BOOTSTRAP"): + kafka["bootstrap"] = os.environ["ASPC_KAFKA_BOOTSTRAP"] + if os.getenv("ASPC_KAFKA_TOPIC"): + kafka["topic"] = os.environ["ASPC_KAFKA_TOPIC"] + + # Serverless (Vercel) filesystems are read-only except /tmp. + uploads = self.config.setdefault("uploads", {}) + if os.getenv("ASPC_UPLOAD_DIR"): + uploads["temp_directory"] = os.environ["ASPC_UPLOAD_DIR"] + elif os.getenv("VERCEL"): + uploads["temp_directory"] = "/tmp/aspc-uploads" + + reports = self.config.setdefault("reports", {}) + if os.getenv("ASPC_REPORT_DIR"): + reports["output_directory"] = os.environ["ASPC_REPORT_DIR"] + elif os.getenv("VERCEL"): + reports["output_directory"] = "/tmp/aspc-reports" + + @staticmethod + def _deep_merge(base: dict, override: dict) -> dict: + out = dict(base) + for k, v in override.items(): + if isinstance(v, dict) and isinstance(out.get(k), dict): + out[k] = Config._deep_merge(out[k], v) + else: + out[k] = v + return out + + @property + def api_host(self) -> str: + return self.config["api"]["host"] + + @property + def api_port(self) -> int: + return int(self.config["api"]["port"]) + + @property + def cors_origins(self) -> list[str]: + return list(self.config["api"]["cors_origins"]) + + @property + def temp_upload_dir(self) -> str: + return self.config["uploads"]["temp_directory"] + + @property + def max_file_size_bytes(self) -> int: + return int(self.config["uploads"]["max_file_size_mb"]) * 1024 * 1024 + + @property + def allowed_extensions(self) -> list[str]: + return list(self.config["uploads"]["allowed_extensions"]) + + @property + def report_dir(self) -> str: + return self.config["reports"]["output_directory"] + + @property + def persistence_backend(self) -> str: + return str(self.config["persistence"]["backend"]) + + @property + def sqlite_path(self) -> str: + return self.config["persistence"]["sqlite_path"] + + @property + def timescale_dsn(self) -> str | None: + return self.config["persistence"].get("timescale_dsn") + + @property + def redis_url(self) -> str: + return str(self.config["redis"]["url"]) + + @property + def kafka_bootstrap(self) -> str: + return str(self.config["kafka"]["bootstrap"]) + + @property + def kafka_topic(self) -> str: + return str(self.config["kafka"].get("topic") or "spc.measurements") + + @property + def jwt_secret(self) -> str: + return str(self.config["auth"]["jwt_secret"]) + + @property + def jwt_algorithm(self) -> str: + return str(self.config["auth"].get("jwt_algorithm") or "HS256") + + @property + def jwt_expire_minutes(self) -> int: + return int(self.config["auth"].get("jwt_expire_minutes") or 60) + + @property + def admin_username(self) -> str: + return str(self.config["auth"].get("admin_username") or "admin") + + @property + def admin_password(self) -> str: + return str(self.config["auth"].get("admin_password") or "admin") + + @property + def api_keys(self) -> list[str]: + return list(self.config["auth"].get("api_keys") or []) + + @property + def auth_enabled(self) -> bool: + return bool(self.config["auth"].get("enabled", True)) + + @property + def dev_insecure(self) -> bool: + """Explicit opt-out for local/dev insecure auth shortcuts.""" + return bool(self.config["auth"].get("dev_insecure", False)) + + @property + def auth_users(self) -> list[dict[str, Any]]: + return list(self.config["auth"].get("users") or []) + + @property + def default_role(self) -> str: + return str(self.config["auth"].get("default_role") or "admin") + + @property + def default_tenant_id(self) -> str | None: + tid = self.config["auth"].get("default_tenant_id") + return str(tid) if tid else None + + @property + def webhook_url(self) -> str | None: + u = (self.config.get("webhooks") or {}).get("url") + return str(u) if u else None + + @property + def webhook_secret(self) -> str | None: + s = (self.config.get("webhooks") or {}).get("secret") + return str(s) if s else None + + @property + def redis_tenant_prefix(self) -> bool: + return bool((self.config.get("redis") or {}).get("tenant_prefix", False)) + + @property + def ruleset(self) -> str: + return self.config["spc"]["ruleset"] + + @property + def acf_threshold(self) -> float: + return float(self.config["spc"]["acf_threshold"]) + + @property + def min_phase1_points(self) -> int: + return int(self.config["spc"].get("min_phase1_points") or 25) + + +_config: Config | None = None + + +def get_config() -> Config: + global _config + if _config is None: + _config = Config() + return _config diff --git a/apps/config.yaml b/apps/config.yaml new file mode 100644 index 0000000..2fb9732 --- /dev/null +++ b/apps/config.yaml @@ -0,0 +1,50 @@ +# ASPC application configuration +# Insecure placeholders below are refused at API startup unless ASPC_DEV_INSECURE=1. +# Prefer env overrides (see env.example) for real deployments. + +api: + host: "0.0.0.0" + port: 8000 + cors_origins: ["http://localhost:3000", "http://127.0.0.1:3000"] + +auth: + enabled: true + jwt_secret: "change-me-in-production" + jwt_algorithm: "HS256" + jwt_expire_minutes: 60 + admin_password: "admin" + api_keys: [] + +webhooks: + url: null + secret: null + +uploads: + temp_directory: "var/uploads" + max_file_size_mb: 10 + allowed_extensions: [".csv", ".parquet", ".pq"] + +reports: + auto_generate: true + output_directory: "var/reports" + include_plots: true + +persistence: + backend: "sqlite" + sqlite_path: "aspc.db" + timescale_dsn: null + +redis: + url: "redis://localhost:6379/0" + # When true (or ASPC_TENANT_ID set on the stream worker), channels are + # spc:live:{tenant_id}:{stream_key}. Document sticky Kafka consumers for scale-out. + tenant_prefix: false + +kafka: + bootstrap: "localhost:9092" + topic: "spc.measurements" + +spc: + ruleset: "nelson" + min_phase1_points: 25 + acf_threshold: 0.2 diff --git a/combinatorial/__init__.py b/combinatorial/__init__.py new file mode 100644 index 0000000..fbba785 --- /dev/null +++ b/combinatorial/__init__.py @@ -0,0 +1,13 @@ +"""Combinatorial dual-mode test package for spc_core.""" +from __future__ import annotations + +from combinatorial.matrix import CaseSpec, build_matrix, load_config +from combinatorial.schema_catalog import SchemaCatalog, load_catalog + +__all__ = [ + "CaseSpec", + "SchemaCatalog", + "build_matrix", + "load_catalog", + "load_config", +] diff --git a/combinatorial/__main__.py b/combinatorial/__main__.py new file mode 100644 index 0000000..e92e26d --- /dev/null +++ b/combinatorial/__main__.py @@ -0,0 +1,60 @@ +"""CLI: python -m combinatorial generate|run|report.""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +# Ensure repo root on path when run as script +_ROOT = Path(__file__).resolve().parents[1] +if str(_ROOT) not in sys.path: + sys.path.insert(0, str(_ROOT)) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="combinatorial", description="spc_core combinatorial matrix") + sub = parser.add_subparsers(dest="cmd", required=True) + + for name in ("generate", "run", "report"): + p = sub.add_parser(name) + p.add_argument("--mode", choices=["sparse", "exhaustive"], default=None) + p.add_argument("--seed", type=int, default=None) + p.add_argument("--max-sparse", type=int, default=None) + + args = parser.parse_args(argv) + + from combinatorial.dual_runner import run_matrix + from combinatorial.matrix import build_matrix, load_config + from combinatorial.report import full_report + from combinatorial.static_pipeline import generate_static + + cfg = load_config() + mode = args.mode or cfg.get("mode", "sparse") + seed = args.seed if args.seed is not None else int(cfg.get("seed", 42)) + max_sparse = args.max_sparse if args.max_sparse is not None else int(cfg.get("max_sparse_cases", 80)) + + if args.cmd == "generate": + cases = build_matrix(mode=mode, seed=seed, max_sparse_cases=max_sparse) + paths = generate_static(cases) + print(f"Wrote {len(paths)} fixtures under combinatorial/out/static") + return 0 + + if args.cmd == "run": + summary = run_matrix(mode=mode, seed=seed, max_sparse_cases=max_sparse) + print(json.dumps({"counts": summary["counts"], "n_cases": summary["n_cases"]}, indent=2)) + fails = [r for r in summary["results"] if r["status"] != "PASS"] + return 1 if fails else 0 + + if args.cmd == "report": + out = full_report(mode=mode) + print(f"Wrote reports under {out['out']}") + print(json.dumps(out["coverage"], indent=2)[:2000]) + fails = [r for r in out["summary"]["results"] if r["status"] != "PASS"] + return 1 if fails else 0 + + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/combinatorial/batch_runner.py b/combinatorial/batch_runner.py new file mode 100644 index 0000000..1ad6ad1 --- /dev/null +++ b/combinatorial/batch_runner.py @@ -0,0 +1,152 @@ +"""Write static case fixtures and run batch entries against spc_core.""" +from __future__ import annotations + +import json +import traceback +from pathlib import Path +from typing import Any + +from combinatorial.generators.series import build_series, sanitize_for_json, values_from_columns +from combinatorial.matrix import CaseSpec +from spc_core import ChartType, analyze_control_chart, capability_analysis, establish +from spc_core.pipeline import phase1_checklist + + +def write_static_fixture(case: CaseSpec, out_dir: Path, *, seed: int = 0) -> Path: + out_dir.mkdir(parents=True, exist_ok=True) + cols = build_series(case.series_kind, seed=seed) + payload = { + "id": case.id, + "case": case.to_dict(), + "columns": sanitize_for_json(cols), + } + path = out_dir / f"{case.id}.json" + path.write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8") + return path + + +def _chart_type(params: dict[str, Any]) -> ChartType | None: + raw = params.get("chart_type") + if not raw: + return None + return ChartType(raw) + + +def run_batch_case(case: CaseSpec, *, seed: int = 0) -> dict[str, Any]: + """Execute one batch-oriented case; return judgment dict.""" + observed: dict[str, Any] = {"id": case.id, "entry": case.entry} + status = "PASS" + mismatches: list[str] = [] + exc_info: dict[str, Any] | None = None + + try: + cols = build_series(case.series_kind, seed=seed) + values, extra = values_from_columns(cols, case.series_kind) + params = dict(case.params) + ct = _chart_type(params) + ruleset = params.get("ruleset", "nelson") + + if case.entry == "establish": + pipe = establish( + values, + chart_type=ct, + ruleset=ruleset, + **{k: v for k, v in extra.items()}, + ) + checklist = phase1_checklist(pipe) + gates = [{"step": g.step, "status": g.status, "reason": g.reason} for g in pipe.gates] + observed.update( + { + "frozen": pipe.frozen, + "stopped": pipe.stopped, + "gates": gates, + "checklist_passed": checklist.get("passed"), + "limits_version": pipe.limits_version if pipe.frozen else None, + "chart_type": pipe.chart.chart_type.value if pipe.chart else None, + "rule_ids": [s.rule_id for s in (pipe.chart.signals or [])], + } + ) + status = _judge_establish(case, observed, mismatches) + + elif case.entry == "analyze_control_chart": + result = analyze_control_chart( + values, + chart_type=ct, + ruleset=ruleset, + **{k: v for k, v in extra.items()}, + ) + observed.update( + { + "frozen": True, + "chart_type": result.chart_type.value, + "limits_version": result.limits.version, + "rule_ids": [s.rule_id for s in (result.signals or [])], + "n_plotted": len(result.plotted_values), + } + ) + if case.expect_class == "raises": + mismatches.append("expected raise but analyze succeeded") + status = "FAIL" + else: + status = "PASS" + + elif case.entry == "capability": + usl = float(params["usl"]) + lsl = float(params["lsl"]) + # coerce numeric values only + nums = [float(v) for v in values if isinstance(v, (int, float))] + capability_analysis(nums, usl=usl, lsl=lsl) + if case.expect_class == "raises": + mismatches.append("expected raise but capability succeeded") + status = "FAIL" + + else: + observed["skipped_batch"] = True + status = "PASS" + + except Exception as exc: # noqa: BLE001 — capture for judgment + exc_info = { + "type": type(exc).__name__, + "message": str(exc), + "traceback": traceback.format_exc(limit=4), + } + observed["exception"] = exc_info + if case.expect_class == "raises": + status = "PASS" + else: + status = "ERROR" + mismatches.append(f"unexpected {type(exc).__name__}: {exc}") + + return { + "id": case.id, + "status": status, + "expect_class": case.expect_class, + "mismatches": mismatches, + "observed": observed, + "adversarial": case.adversarial, + "modality": case.modality, + } + + +def _judge_establish(case: CaseSpec, observed: dict[str, Any], mismatches: list[str]) -> str: + if case.expect_class == "raises": + mismatches.append("expected raise but establish succeeded") + return "FAIL" + if case.expect_class == "stop_unfrozen": + if observed.get("frozen") is True: + # multimodal may not always stop depending on dip — soft-pass with note + stops = [g for g in observed.get("gates") or [] if g.get("status") == "stop"] + if not stops and not observed.get("stopped"): + mismatches.append("expected STOP/unfrozen but frozen=True with no stop gate") + return "FAIL" + if observed.get("frozen") and stops: + mismatches.append("stop gate present but frozen=True (unexpected)") + return "FAIL" + return "PASS" + if case.expect_class == "ok_freeze": + if not observed.get("frozen"): + # constant / sentinel may still freeze; if not, record soft fail + mismatches.append("expected freeze but frozen=False") + return "FAIL" + return "PASS" + return "PASS" diff --git a/combinatorial/config.yaml b/combinatorial/config.yaml new file mode 100644 index 0000000..5a25816 --- /dev/null +++ b/combinatorial/config.yaml @@ -0,0 +1,6 @@ +# Combinatorial dual-mode test matrix for spc_core +mode: sparse # sparse | exhaustive +seed: 42 +max_sparse_cases: 80 +adversarial: true +output_dir: combinatorial/out diff --git a/combinatorial/coverage.py b/combinatorial/coverage.py new file mode 100644 index 0000000..a259d52 --- /dev/null +++ b/combinatorial/coverage.py @@ -0,0 +1,65 @@ +"""Coverage metrics over schema axes vs executed cases.""" +from __future__ import annotations + +from typing import Any + +from combinatorial.matrix import CaseSpec +from combinatorial.schema_catalog import axis_coverage_targets, load_catalog + + +def compute_coverage(cases: list[CaseSpec], results: list[dict[str, Any]] | None = None) -> dict[str, Any]: + catalog = load_catalog() + targets = axis_coverage_targets(catalog) + + hit: dict[str, set[str]] = {k: set() for k in targets} + for c in cases: + hit["series_kinds"].add(c.series_kind) + hit["expect_classes"].add(c.expect_class) + hit["adversarial_kinds"].add(c.adversarial or "none") + rs = c.params.get("ruleset") + if rs: + hit["rulesets"].add(str(rs)) + ct = c.params.get("chart_type") + if ct: + hit["chart_types"].add(str(ct)) + + if results: + for r in results: + obs = r.get("observed") or {} + # flatten nested + blobs = [obs] + if "batch" in obs and isinstance(obs["batch"], dict): + blobs.append(obs["batch"]) + if "stream" in obs and isinstance(obs["stream"], dict): + blobs.append(obs["stream"]) + for blob in blobs: + for g in blob.get("gates") or []: + if isinstance(g, dict) and g.get("step"): + hit["gate_steps"].add(str(g["step"])) + for rid in blob.get("rule_ids") or []: + if str(rid) in targets["nelson_ids"]: + hit["nelson_ids"].add(str(rid)) + if str(rid) in targets["we_ids"]: + hit["we_ids"].add(str(rid)) + for pair in blob.get("batch_rule_ids") or []: + rid = pair[0] if isinstance(pair, (list, tuple)) else pair + if str(rid) in targets["nelson_ids"]: + hit["nelson_ids"].add(str(rid)) + for pair in blob.get("stream_rule_ids") or []: + rid = pair[0] if isinstance(pair, (list, tuple)) else pair + if str(rid) in targets["nelson_ids"]: + hit["nelson_ids"].add(str(rid)) + + axes: dict[str, Any] = {} + for name, target in targets.items(): + got = hit[name] & target + pct = (100.0 * len(got) / len(target)) if target else 100.0 + axes[name] = { + "target": len(target), + "hit": len(got), + "percent": round(pct, 1), + "missing": sorted(target - got), + } + + overall = sum(a["percent"] for a in axes.values()) / max(1, len(axes)) + return {"overall_percent": round(overall, 1), "axes": axes, "n_cases": len(cases)} diff --git a/combinatorial/dual_runner.py b/combinatorial/dual_runner.py new file mode 100644 index 0000000..3fccf42 --- /dev/null +++ b/combinatorial/dual_runner.py @@ -0,0 +1,72 @@ +"""Dual runner: batch + stream matrix execution.""" +from __future__ import annotations + +import json +from collections import Counter +from pathlib import Path +from typing import Any + +from combinatorial.batch_runner import run_batch_case, write_static_fixture +from combinatorial.matrix import CaseSpec, build_matrix, load_config +from combinatorial.stream_runner import run_stream_case + + +def run_case(case: CaseSpec, *, seed: int = 0) -> dict[str, Any]: + if case.modality == "batch": + return run_batch_case(case, seed=seed) + if case.modality == "stream": + return run_stream_case(case, seed=seed) + # both: run stream path (includes establish + parity); also attach batch establish summary + stream = run_stream_case(case, seed=seed) + if case.entry == "phase2_parity": + return stream + batch = run_batch_case(case, seed=seed) + return { + "id": case.id, + "status": "PASS" + if batch["status"] == "PASS" and stream["status"] == "PASS" + else "FAIL" + if "FAIL" in (batch["status"], stream["status"]) + else "ERROR", + "expect_class": case.expect_class, + "mismatches": list(batch.get("mismatches") or []) + list(stream.get("mismatches") or []), + "observed": {"batch": batch.get("observed"), "stream": stream.get("observed")}, + "adversarial": case.adversarial, + "modality": case.modality, + } + + +def run_matrix( + *, + mode: str | None = None, + seed: int | None = None, + max_sparse_cases: int | None = None, + adversarial: bool | None = None, + output_dir: str | Path | None = None, + write_fixtures: bool = True, +) -> dict[str, Any]: + cfg = load_config() + mode = mode or cfg.get("mode", "sparse") + seed = int(seed if seed is not None else cfg.get("seed", 42)) + max_sparse = int(max_sparse_cases if max_sparse_cases is not None else cfg.get("max_sparse_cases", 80)) + adv = bool(cfg.get("adversarial", True) if adversarial is None else adversarial) + out = Path(output_dir or cfg["output_dir"]) + out.mkdir(parents=True, exist_ok=True) + + cases = build_matrix(mode=mode, seed=seed, max_sparse_cases=max_sparse, adversarial=adv) + if write_fixtures: + static_dir = out / "static" + for i, c in enumerate(cases): + write_static_fixture(c, static_dir, seed=i) + + results = [run_case(c, seed=i) for i, c in enumerate(cases)] + counts = Counter(r["status"] for r in results) + summary = { + "mode": mode, + "seed": seed, + "n_cases": len(results), + "counts": dict(counts), + "results": results, + } + (out / "results.json").write_text(json.dumps(summary, indent=2, default=str), encoding="utf-8") + return summary diff --git a/combinatorial/generators/__init__.py b/combinatorial/generators/__init__.py new file mode 100644 index 0000000..3490c6a --- /dev/null +++ b/combinatorial/generators/__init__.py @@ -0,0 +1,4 @@ +"""Generators package.""" +from combinatorial.generators.series import build_series, values_from_columns + +__all__ = ["build_series", "values_from_columns"] diff --git a/combinatorial/generators/series.py b/combinatorial/generators/series.py new file mode 100644 index 0000000..a6816b1 --- /dev/null +++ b/combinatorial/generators/series.py @@ -0,0 +1,190 @@ +"""Seeded series builders for combinatorial cases.""" +from __future__ import annotations + +import math +from typing import Any + +import numpy as np + +Columns = dict[str, list[Any]] + + +def _rng(seed: int) -> np.random.Generator: + return np.random.default_rng(seed) + + +def build_series(kind: str, *, seed: int = 0) -> Columns: + """Return column dict for ``kind`` (resilience-style empty = None elsewhere).""" + if kind == "imr_in_control": + try: + from resilience_data.generators.spc import imr_in_control + + return imr_in_control(n=40, seed=100 + seed) + except ImportError: + rng = _rng(100 + seed) + return {"measurement": [float(v) for v in 100.0 + rng.normal(0, 1, 40)]} + + if kind == "imr_mean_shift": + try: + from resilience_data.generators.spc import imr_mean_shift + + return imr_mean_shift(n=50, seed=110 + seed) + except ImportError: + rng = _rng(110 + seed) + vals = 100.0 + rng.normal(0, 1, 50) + vals[25:] += 4.0 + return {"measurement": [float(v) for v in vals]} + + if kind == "imr_trend": + try: + from resilience_data.generators.spc import imr_trend + + return imr_trend(n=50, seed=113 + seed) + except ImportError: + rng = _rng(113 + seed) + vals = 100.0 + rng.normal(0, 0.5, 50) + np.linspace(0, 8, 50) + return {"measurement": [float(v) for v in vals]} + + if kind == "imr_alternating": + try: + from resilience_data.generators.spc import imr_alternating_nelson4 + + return imr_alternating_nelson4(n=40, seed=142 + seed) + except ImportError: + swing = 2.0 + return {"measurement": [100.0 + ((-1) ** i) * swing for i in range(40)]} + + if kind == "imr_constant": + return {"measurement": [100.0] * 30} + + if kind == "imr_empty": + return {"measurement": []} + + if kind == "imr_n1": + return {"measurement": [100.0]} + + if kind == "imr_n2": + return {"measurement": [100.0, 101.0]} + + if kind == "imr_nan_inf": + return {"measurement": [100.0, float("nan"), float("inf"), 101.0] + [100.0] * 20} + + if kind == "imr_sentinel": + vals = [100.0 + i * 0.01 for i in range(30)] + vals[5] = 999.0 + vals[6] = -999.0 + return {"measurement": vals} + + if kind == "imr_overflow": + return {"measurement": [1e308, -1e308] + [100.0] * 28} + + if kind == "xbar_r": + try: + from resilience_data.generators.spc import xbar_r + + return xbar_r(n_subgroups=25, size=5, seed=120 + seed) + except ImportError: + rng = _rng(120 + seed) + m, s = [], [] + for sid in range(1, 26): + for v in 50.0 + rng.normal(0, 1.2, 5): + m.append(float(v)) + s.append(sid) + return {"measurement": m, "subgroup": s} + + if kind == "attribute_p": + try: + from resilience_data.generators.spc import attribute_p + + return attribute_p(n=25, seed=130 + seed) + except ImportError: + return {"defective": [2] * 25, "inspected": [100] * 25} + + if kind == "attribute_p_zero_n": + return {"defective": [0, 1], "inspected": [0, 100]} + + if kind == "attribute_np": + try: + from resilience_data.generators.spc import attribute_np + + return attribute_np(n=25, seed=131 + seed) + except ImportError: + return {"defectives": [3] * 25, "sample_size": [100] * 25} + + if kind == "attribute_c": + try: + from resilience_data.generators.spc import attribute_c + + return attribute_c(n=25, seed=132 + seed) + except ImportError: + return {"defects": [2] * 25} + + if kind == "attribute_u": + try: + from resilience_data.generators.spc import attribute_u + + return attribute_u(n=25, seed=133 + seed) + except ImportError: + return {"defects": [3] * 25, "units": [10] * 25} + + if kind == "attribute_u_zero_opp": + return {"defects": [1, 2], "units": [0, 10]} + + if kind == "multimodal": + try: + from resilience_data.generators.spc import multimodal_stop + + return multimodal_stop(seed=140 + seed) + except ImportError: + rng = _rng(140 + seed) + a = rng.normal(90, 0.5, 40) + b = rng.normal(110, 0.5, 40) + return {"measurement": [float(v) for v in np.concatenate([a, b])]} + + if kind == "heavy_tail": + try: + from resilience_data.generators.spc import heavy_tail_wheeler + + return heavy_tail_wheeler(seed=150 + seed) + except ImportError: + rng = _rng(150 + seed) + return {"measurement": [float(v) for v in 100.0 + rng.standard_t(2.5, 50)]} + + if kind == "type_mismatch": + return {"measurement": ["not-a-number", "also-bad", 1, 2, 3]} + + raise KeyError(f"Unknown series kind: {kind}") + + +def values_from_columns(cols: Columns, kind: str) -> tuple[list[Any], dict[str, Any]]: + """Extract establish/analyze kwargs from columns.""" + extra: dict[str, Any] = {} + if "subgroup" in cols: + return list(cols["measurement"]), {"subgroup_ids": list(cols["subgroup"])} + if kind.startswith("attribute_p") or ("defective" in cols and "inspected" in cols): + return list(cols["defective"]), {"sample_sizes": list(cols["inspected"])} + if "defectives" in cols and "sample_size" in cols: + return list(cols["defectives"]), {"sample_sizes": list(cols["sample_size"])} + if kind.startswith("attribute_u") or ("units" in cols and "defects" in cols and "inspected" not in cols): + if "units" in cols: + return list(cols["defects"]), {"opportunities": list(cols["units"])} + if "defects" in cols and "measurement" not in cols: + return list(cols["defects"]), {} + if "measurement" in cols: + return list(cols["measurement"]), extra + # fallback first column + key = next(iter(cols)) + return list(cols[key]), extra + + +def sanitize_for_json(cols: Columns) -> dict[str, list[Any]]: + out: dict[str, list[Any]] = {} + for k, vals in cols.items(): + row = [] + for v in vals: + if isinstance(v, float) and (math.isnan(v) or math.isinf(v)): + row.append(str(v)) + else: + row.append(v) + out[k] = row + return out diff --git a/combinatorial/matrix.py b/combinatorial/matrix.py new file mode 100644 index 0000000..ef948d5 --- /dev/null +++ b/combinatorial/matrix.py @@ -0,0 +1,393 @@ +"""CaseSpec definitions and matrix builders (exhaustive / sparse).""" +from __future__ import annotations + +import random +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any, Literal + +from combinatorial.schema_catalog import RULESETS, SchemaCatalog, load_catalog + +Modality = Literal["batch", "stream", "both"] +ExpectClass = Literal[ + "ok_freeze", + "stop_unfrozen", + "raises", + "phase2_signals", + "phase2_rejects", + "adversarial_behavior", +] + + +@dataclass +class CaseSpec: + id: str + modality: Modality + entry: str # establish | analyze_control_chart | phase2_parity | phase2_reject | capability + series_kind: str + params: dict[str, Any] = field(default_factory=dict) + expect_class: ExpectClass = "ok_freeze" + adversarial: str = "none" + notes: str = "" + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +def _critical_cases() -> list[CaseSpec]: + """Always-included critical probes.""" + return [ + CaseSpec( + id="crit_establish_empty", + modality="batch", + entry="establish", + series_kind="imr_empty", + expect_class="raises", + notes="n=0 ValueError", + ), + CaseSpec( + id="crit_establish_n1", + modality="batch", + entry="establish", + series_kind="imr_n1", + expect_class="raises", + notes="n=1 ValueError", + ), + CaseSpec( + id="crit_establish_n2_freeze", + modality="batch", + entry="establish", + series_kind="imr_n2", + params={"ruleset": "nelson"}, + expect_class="ok_freeze", + notes="minimum establish points", + ), + CaseSpec( + id="crit_p_zero_n", + modality="batch", + entry="analyze_control_chart", + series_kind="attribute_p_zero_n", + params={"chart_type": "P"}, + expect_class="raises", + ), + CaseSpec( + id="crit_u_zero_opp", + modality="batch", + entry="analyze_control_chart", + series_kind="attribute_u_zero_opp", + params={"chart_type": "U"}, + expect_class="raises", + ), + CaseSpec( + id="crit_imr_in_control_nelson", + modality="both", + entry="phase2_parity", + series_kind="imr_in_control", + params={"ruleset": "nelson", "chart_type": "I-MR"}, + expect_class="phase2_signals", + adversarial="none", + ), + CaseSpec( + id="crit_imr_mean_shift_parity", + modality="both", + entry="phase2_parity", + series_kind="imr_mean_shift", + params={"ruleset": "nelson", "chart_type": "I-MR"}, + expect_class="phase2_signals", + adversarial="none", + ), + CaseSpec( + id="crit_xbar_subgroup_reject_scalar", + modality="stream", + entry="phase2_reject", + series_kind="xbar_r", + params={"chart_type": "Xbar-R", "ruleset": "nelson", "wrong_api": "observe"}, + expect_class="phase2_rejects", + ), + CaseSpec( + id="crit_imr_reject_subgroup", + modality="stream", + entry="phase2_reject", + series_kind="imr_in_control", + params={"chart_type": "I-MR", "ruleset": "nelson", "wrong_api": "observe_subgroup"}, + expect_class="phase2_rejects", + ), + CaseSpec( + id="crit_multimodal_stop", + modality="batch", + entry="establish", + series_kind="multimodal", + params={"ruleset": "nelson"}, + expect_class="stop_unfrozen", + ), + CaseSpec( + id="crit_constant_series", + modality="batch", + entry="establish", + series_kind="imr_constant", + params={"ruleset": "nelson"}, + expect_class="raises", + notes="constant series → Box-Cox ValueError in establish transform", + ), + CaseSpec( + id="crit_sentinel", + modality="batch", + entry="establish", + series_kind="imr_sentinel", + params={"ruleset": "nelson"}, + expect_class="ok_freeze", + ), + CaseSpec( + id="crit_nan_inf", + modality="batch", + entry="establish", + series_kind="imr_nan_inf", + params={"ruleset": "nelson"}, + expect_class="raises", + notes="non-finite often fails usable-points check", + ), + CaseSpec( + id="crit_type_mismatch", + modality="batch", + entry="establish", + series_kind="type_mismatch", + expect_class="raises", + ), + CaseSpec( + id="crit_capability_bad_specs", + modality="batch", + entry="capability", + series_kind="imr_in_control", + params={"usl": 90.0, "lsl": 110.0}, + expect_class="raises", + ), + CaseSpec( + id="crit_adv_ooo", + modality="stream", + entry="phase2_parity", + series_kind="imr_mean_shift", + params={"ruleset": "nelson", "chart_type": "I-MR"}, + expect_class="adversarial_behavior", + adversarial="out_of_order", + notes="order_sensitive", + ), + CaseSpec( + id="crit_adv_dup", + modality="stream", + entry="phase2_parity", + series_kind="imr_in_control", + params={"ruleset": "nelson", "chart_type": "I-MR"}, + expect_class="adversarial_behavior", + adversarial="duplicate_index", + ), + CaseSpec( + id="crit_adv_jitter", + modality="stream", + entry="phase2_parity", + series_kind="imr_in_control", + params={"ruleset": "nelson", "chart_type": "I-MR"}, + expect_class="adversarial_behavior", + adversarial="jitter_delay", + notes="timing no-op for state", + ), + CaseSpec( + id="crit_adv_watermark", + modality="stream", + entry="phase2_parity", + series_kind="imr_in_control", + params={"ruleset": "nelson", "chart_type": "I-MR"}, + expect_class="adversarial_behavior", + adversarial="watermark_jump", + ), + CaseSpec( + id="crit_adv_split_brain", + modality="stream", + entry="phase2_parity", + series_kind="imr_in_control", + params={"ruleset": "nelson", "chart_type": "I-MR"}, + expect_class="adversarial_behavior", + adversarial="split_brain_seed", + ), + ] + + +def _cartesian_batch(catalog: SchemaCatalog) -> list[CaseSpec]: + """Finite Cartesian over ruleset × core series kinds (not every ChartType combo).""" + cases: list[CaseSpec] = [] + continuous_kinds = ( + "imr_in_control", + "imr_mean_shift", + "imr_constant", + "heavy_tail", + "imr_overflow", + ) + for ruleset in RULESETS: + for kind in continuous_kinds: + # Constant → Box-Cox ValueError; overflow → multimodal histogram ValueError + expect: ExpectClass = ( + "raises" if kind in ("imr_constant", "imr_overflow") else "ok_freeze" + ) + cases.append( + CaseSpec( + id=f"batch_{kind}_{ruleset}", + modality="batch", + entry="establish", + series_kind=kind, + params={"ruleset": ruleset, "chart_type": "I-MR"}, + expect_class=expect, + ) + ) + for kind, ct in ( + ("xbar_r", "Xbar-R"), + ("attribute_p", "P"), + ("attribute_np", "NP"), + ("attribute_c", "C"), + ("attribute_u", "U"), + ): + cases.append( + CaseSpec( + id=f"batch_{kind}_nelson", + modality="batch", + entry="establish", + series_kind=kind, + params={"ruleset": "nelson", "chart_type": ct}, + expect_class="ok_freeze", + ) + ) + # Phase2 parity for each ruleset on I-MR in-control + for ruleset in RULESETS: + cases.append( + CaseSpec( + id=f"parity_imr_{ruleset}", + modality="both", + entry="phase2_parity", + series_kind="imr_in_control", + params={"ruleset": ruleset, "chart_type": "I-MR"}, + expect_class="phase2_signals", + adversarial="none", + ) + ) + cases.append( + CaseSpec( + id="parity_xbar_r_nelson", + modality="both", + entry="phase2_parity", + series_kind="xbar_r", + params={"ruleset": "nelson", "chart_type": "Xbar-R"}, + expect_class="phase2_signals", + adversarial="none", + ) + ) + # Fire Western Electric + Nelson pattern rules for coverage + cases.append( + CaseSpec( + id="rules_we_mean_shift", + modality="batch", + entry="analyze_control_chart", + series_kind="imr_mean_shift", + params={"ruleset": "western_electric", "chart_type": "I-MR"}, + expect_class="ok_freeze", + ) + ) + cases.append( + CaseSpec( + id="rules_nelson_trend", + modality="batch", + entry="analyze_control_chart", + series_kind="imr_trend", + params={"ruleset": "nelson", "chart_type": "I-MR"}, + expect_class="ok_freeze", + ) + ) + cases.append( + CaseSpec( + id="rules_nelson_alternating", + modality="batch", + entry="analyze_control_chart", + series_kind="imr_alternating", + params={"ruleset": "nelson", "chart_type": "I-MR"}, + expect_class="ok_freeze", + ) + ) + # ChartType coverage via analyze + for ct in catalog.chart_types: + kind = { + "I-MR": "imr_in_control", + "Xbar-R": "xbar_r", + "Xbar-S": "xbar_r", + "P": "attribute_p", + "NP": "attribute_np", + "C": "attribute_c", + "U": "attribute_u", + "EWMA": "imr_in_control", + "CUSUM": "imr_in_control", + }.get(ct, "imr_in_control") + cases.append( + CaseSpec( + id=f"chartcov_{ct.replace('-', '_').replace('/', '_')}", + modality="batch", + entry="analyze_control_chart", + series_kind=kind, + params={"chart_type": ct, "ruleset": "nelson"}, + expect_class="ok_freeze", + ) + ) + return cases + + +def build_matrix( + *, + mode: str = "sparse", + seed: int = 42, + max_sparse_cases: int = 80, + adversarial: bool = True, + catalog: SchemaCatalog | None = None, +) -> list[CaseSpec]: + catalog = catalog or load_catalog() + critical = _critical_cases() + if not adversarial: + critical = [c for c in critical if c.adversarial == "none"] + full = critical + _cartesian_batch(catalog) + # de-dupe by id + by_id: dict[str, CaseSpec] = {} + for c in full: + by_id[c.id] = c + cases = list(by_id.values()) + + if mode == "exhaustive": + return sorted(cases, key=lambda c: c.id) + + # sparse: keep all critical, sample the rest + crit_ids = {c.id for c in critical} + rest = [c for c in cases if c.id not in crit_ids] + rng = random.Random(seed) + budget = max(0, max_sparse_cases - len(critical)) + picked = rng.sample(rest, k=min(budget, len(rest))) if rest else [] + out = critical + picked + return sorted(out, key=lambda c: c.id) + + +def load_config(path: str | Path | None = None) -> dict[str, Any]: + root = Path(__file__).resolve().parent + cfg_path = Path(path) if path else root / "config.yaml" + defaults: dict[str, Any] = { + "mode": "sparse", + "seed": 42, + "max_sparse_cases": 80, + "adversarial": True, + "output_dir": str(root / "out"), + } + if not cfg_path.exists(): + return defaults + try: + import yaml + except ImportError: + return defaults + with cfg_path.open() as f: + data = yaml.safe_load(f) or {} + defaults.update(data) + out = Path(str(defaults["output_dir"])) + if not out.is_absolute(): + # Resolve relative to repo root (parent of combinatorial/) + defaults["output_dir"] = str((root.parent / out).resolve()) + return defaults diff --git a/combinatorial/out/COVERAGE.json b/combinatorial/out/COVERAGE.json new file mode 100644 index 0000000..4edb1c5 --- /dev/null +++ b/combinatorial/out/COVERAGE.json @@ -0,0 +1,72 @@ +{ + "overall_percent": 85.6, + "axes": { + "chart_types": { + "target": 9, + "hit": 9, + "percent": 100.0, + "missing": [] + }, + "rulesets": { + "target": 3, + "hit": 3, + "percent": 100.0, + "missing": [] + }, + "quality_flags": { + "target": 7, + "hit": 0, + "percent": 0.0, + "missing": [ + "EXCLUDED_INCOMPLETE", + "EXCLUDED_MAINTENANCE", + "IMPUTED_LOCF", + "MISSING_HUMAN", + "MISSING_SENSOR", + "ORIGINAL", + "RESTORED_FROM_BACKUP" + ] + }, + "nelson_ids": { + "target": 8, + "hit": 8, + "percent": 100.0, + "missing": [] + }, + "we_ids": { + "target": 4, + "hit": 4, + "percent": 100.0, + "missing": [] + }, + "gate_steps": { + "target": 10, + "hit": 7, + "percent": 70.0, + "missing": [ + "gage_resolution", + "transform", + "valid_range" + ] + }, + "expect_classes": { + "target": 6, + "hit": 6, + "percent": 100.0, + "missing": [] + }, + "series_kinds": { + "target": 21, + "hit": 21, + "percent": 100.0, + "missing": [] + }, + "adversarial_kinds": { + "target": 6, + "hit": 6, + "percent": 100.0, + "missing": [] + } + }, + "n_cases": 56 +} \ No newline at end of file diff --git a/combinatorial/out/ENGINE_BEHAVIOR_REPORT.md b/combinatorial/out/ENGINE_BEHAVIOR_REPORT.md new file mode 100644 index 0000000..f6a8cde --- /dev/null +++ b/combinatorial/out/ENGINE_BEHAVIOR_REPORT.md @@ -0,0 +1,103 @@ +# SPC Core Engine Behavior Report + +Generated: 2026-08-12T16:04:59.339917+00:00 +Matrix mode: `exhaustive` · cases=56 · PASS=56 FAIL=0 ERROR=0 + +This report is produced by the combinatorial dual-mode pipeline (`python -m combinatorial report`). It summarizes how `spc_core` handles Phase I establishment, Phase II evaluation, and adversarial streams. + +## 1. Phase I gate order and freeze semantics + +The `establish()` pipeline runs gated checks (MSA → range → missing → ACF → multimodal/normality → chart → freeze). Any gate with status `stop` sets `frozen=False`; warnings still allow freeze when no STOP fired. + +Observed gate status counts in this run: + +- `autocorrelation:ok`: 9 +- `autocorrelation:warn`: 8 +- `chart:ok`: 17 +- `freeze:blocked`: 1 +- `freeze:ok`: 16 +- `missing:ok`: 17 +- `msa:warn`: 17 +- `multimodal:ok`: 6 +- `multimodal:stop`: 1 +- `normality:ok`: 9 +- `normality:warn`: 2 + +STOP/unfrozen expect class: 1 cases (1 PASS). + +## 2. Chart auto-selection vs forced ChartType + +Forced `chart_type` in matrix params exercises each `ChartType` enum value via `analyze_control_chart` / `establish`. Attribute charts require `sample_sizes` / `opportunities`; subgroup charts require `subgroup_ids`. + +Chart-type axis coverage: 100.0%. + +## 3. Ruleset differences + +- `nelson`: full Nelson 1–8 pattern rules on Shewhart charts. +- `western_electric`: WE1–WE4 subset. +- `wheeler`: beyond-limits only (used on non-normal / forced Wheeler paths). + +Ruleset coverage: 100.0%. + +## 4. Phase II: frozen limits and batch↔stream parity + +`Phase2Evaluator` never recomputes limits. For `adversarial=none` parity cases, `evaluate_batch` (or sequential `observe_subgroup`) must match streamed observes on the **rule_id multiset**. + +Parity cases: 6/6 PASS. + +- `crit_imr_in_control_nelson`: PASS parity=True batch=[] stream=[] +- `crit_imr_mean_shift_parity`: PASS parity=True batch=[('EWMA1', 39)] stream=[('EWMA1', 39)] +- `parity_imr_nelson`: PASS parity=True batch=[] stream=[] +- `parity_imr_western_electric`: PASS parity=True batch=[('WE2', 1), ('WE4', 1)] stream=[('WE2', 1), ('WE4', 1)] +- `parity_imr_wheeler`: PASS parity=True batch=[] stream=[] +- `parity_xbar_r_nelson`: PASS parity=True batch=[] stream=[] + +## 5. Cleaning / capability / malformed edges + +Raise-expect cases: 14/14 PASS. + +- `batch_imr_constant_nelson`: PASS → ValueError: Data must not be constant. +- `batch_imr_constant_western_electric`: PASS → ValueError: Data must not be constant. +- `batch_imr_constant_wheeler`: PASS → ValueError: Data must not be constant. +- `batch_imr_overflow_nelson`: PASS → ValueError: Too many bins for data range. Cannot create 6 finite-sized bins. +- `batch_imr_overflow_western_electric`: PASS → ValueError: Too many bins for data range. Cannot create 6 finite-sized bins. +- `batch_imr_overflow_wheeler`: PASS → ValueError: Too many bins for data range. Cannot create 6 finite-sized bins. +- `crit_capability_bad_specs`: PASS → ValueError: Valid USL > LSL required for capability analysis +- `crit_constant_series`: PASS → ValueError: Data must not be constant. +- `crit_establish_empty`: PASS → ValueError: establish() requires at least 2 observations to compute control limits; received 0. +- `crit_establish_n1`: PASS → ValueError: establish() requires at least 2 observations to compute control limits; received 1. +- `crit_nan_inf`: PASS → ValueError: autodetected range of [100.0, inf] is not finite +- `crit_p_zero_n`: PASS → ValueError: P chart sample sizes must be > 0 +- `crit_type_mismatch`: PASS → ValueError: could not convert string to float: 'not-a-number' +- `crit_u_zero_opp`: PASS → ValueError: U chart opportunities must be > 0 + +## 6. Adversarial streaming behavior + +| Case | Adversarial | Behavior tag | Status | Notes | +|------|-------------|--------------|--------|-------| +| `crit_adv_dup` | duplicate_index | duplicate_observe | PASS | parity=True | +| `crit_adv_jitter` | jitter_delay | timing_noop | PASS | parity=True | +| `crit_adv_ooo` | out_of_order | order_sensitive | PASS | parity=False | +| `crit_adv_split_brain` | split_brain_seed | split_brain_seed | PASS | parity=True | +| `crit_adv_watermark` | watermark_jump | watermark_jump | PASS | parity=True | + +Interpretation: + +- `jitter_delay`: timing only — state must match canonical parity. +- `out_of_order`: Phase II is order-sensitive; divergence is expected and recorded. +- `duplicate_index` / `watermark_jump` / `split_brain_seed`: probe restart and idempotency-adjacent behavior; parity not required. + +## 7. Coverage and blind spots + +Overall axis coverage: **85.6%**. + +- `quality_flags` missing: `EXCLUDED_INCOMPLETE`, `EXCLUDED_MAINTENANCE`, `IMPUTED_LOCF`, `MISSING_HUMAN`, `MISSING_SENSOR`, `ORIGINAL`, `RESTORED_FROM_BACKUP` +- `gate_steps` missing: `gage_resolution`, `transform`, `valid_range` + +## Regenerate + +```bash +PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python -m combinatorial report --mode sparse +PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python -m combinatorial report --mode exhaustive +``` + diff --git a/combinatorial/out/JUDGMENT.md b/combinatorial/out/JUDGMENT.md new file mode 100644 index 0000000..1855f03 --- /dev/null +++ b/combinatorial/out/JUDGMENT.md @@ -0,0 +1,85 @@ +# Combinatorial judgment report + +Generated: 2026-08-12T16:04:59.339527+00:00 +Mode: `exhaustive` · seed=42 · cases=56 + +| Status | Count | +|--------|------:| +| PASS | 56 | +| FAIL | 0 | +| ERROR | 0 | + +## Coverage (overall 85.6%) + +| Axis | Hit | Target | % | Missing | +|------|----:|-------:|--:|---------| +| chart_types | 9 | 9 | 100.0 | — | +| rulesets | 3 | 3 | 100.0 | — | +| quality_flags | 0 | 7 | 0.0 | `EXCLUDED_INCOMPLETE`, `EXCLUDED_MAINTENANCE`, `IMPUTED_LOCF`, `MISSING_HUMAN`, `MISSING_SENSOR`, `ORIGINAL`, `RESTORED_FROM_BACKUP` | +| nelson_ids | 8 | 8 | 100.0 | — | +| we_ids | 4 | 4 | 100.0 | — | +| gate_steps | 7 | 10 | 70.0 | `gage_resolution`, `transform`, `valid_range` | +| expect_classes | 6 | 6 | 100.0 | — | +| series_kinds | 21 | 21 | 100.0 | — | +| adversarial_kinds | 6 | 6 | 100.0 | — | + +## Per-case results + +| id | status | expect | adversarial | notes | +|----|--------|--------|-------------|-------| +| `batch_attribute_c_nelson` | PASS | ok_freeze | none | | +| `batch_attribute_np_nelson` | PASS | ok_freeze | none | | +| `batch_attribute_p_nelson` | PASS | ok_freeze | none | | +| `batch_attribute_u_nelson` | PASS | ok_freeze | none | | +| `batch_heavy_tail_nelson` | PASS | ok_freeze | none | | +| `batch_heavy_tail_western_electric` | PASS | ok_freeze | none | | +| `batch_heavy_tail_wheeler` | PASS | ok_freeze | none | | +| `batch_imr_constant_nelson` | PASS | raises | none | | +| `batch_imr_constant_western_electric` | PASS | raises | none | | +| `batch_imr_constant_wheeler` | PASS | raises | none | | +| `batch_imr_in_control_nelson` | PASS | ok_freeze | none | | +| `batch_imr_in_control_western_electric` | PASS | ok_freeze | none | | +| `batch_imr_in_control_wheeler` | PASS | ok_freeze | none | | +| `batch_imr_mean_shift_nelson` | PASS | ok_freeze | none | | +| `batch_imr_mean_shift_western_electric` | PASS | ok_freeze | none | | +| `batch_imr_mean_shift_wheeler` | PASS | ok_freeze | none | | +| `batch_imr_overflow_nelson` | PASS | raises | none | | +| `batch_imr_overflow_western_electric` | PASS | raises | none | | +| `batch_imr_overflow_wheeler` | PASS | raises | none | | +| `batch_xbar_r_nelson` | PASS | ok_freeze | none | | +| `chartcov_C` | PASS | ok_freeze | none | | +| `chartcov_CUSUM` | PASS | ok_freeze | none | | +| `chartcov_EWMA` | PASS | ok_freeze | none | | +| `chartcov_I_MR` | PASS | ok_freeze | none | | +| `chartcov_NP` | PASS | ok_freeze | none | | +| `chartcov_P` | PASS | ok_freeze | none | | +| `chartcov_U` | PASS | ok_freeze | none | | +| `chartcov_Xbar_R` | PASS | ok_freeze | none | | +| `chartcov_Xbar_S` | PASS | ok_freeze | none | | +| `crit_adv_dup` | PASS | adversarial_behavior | duplicate_index | | +| `crit_adv_jitter` | PASS | adversarial_behavior | jitter_delay | | +| `crit_adv_ooo` | PASS | adversarial_behavior | out_of_order | | +| `crit_adv_split_brain` | PASS | adversarial_behavior | split_brain_seed | | +| `crit_adv_watermark` | PASS | adversarial_behavior | watermark_jump | | +| `crit_capability_bad_specs` | PASS | raises | none | | +| `crit_constant_series` | PASS | raises | none | | +| `crit_establish_empty` | PASS | raises | none | | +| `crit_establish_n1` | PASS | raises | none | | +| `crit_establish_n2_freeze` | PASS | ok_freeze | none | | +| `crit_imr_in_control_nelson` | PASS | phase2_signals | none | | +| `crit_imr_mean_shift_parity` | PASS | phase2_signals | none | | +| `crit_imr_reject_subgroup` | PASS | phase2_rejects | none | | +| `crit_multimodal_stop` | PASS | stop_unfrozen | none | | +| `crit_nan_inf` | PASS | raises | none | | +| `crit_p_zero_n` | PASS | raises | none | | +| `crit_sentinel` | PASS | ok_freeze | none | | +| `crit_type_mismatch` | PASS | raises | none | | +| `crit_u_zero_opp` | PASS | raises | none | | +| `crit_xbar_subgroup_reject_scalar` | PASS | phase2_rejects | none | | +| `parity_imr_nelson` | PASS | phase2_signals | none | | +| `parity_imr_western_electric` | PASS | phase2_signals | none | | +| `parity_imr_wheeler` | PASS | phase2_signals | none | | +| `parity_xbar_r_nelson` | PASS | phase2_signals | none | | +| `rules_nelson_alternating` | PASS | ok_freeze | none | | +| `rules_nelson_trend` | PASS | ok_freeze | none | | +| `rules_we_mean_shift` | PASS | ok_freeze | none | | diff --git a/combinatorial/parity.py b/combinatorial/parity.py new file mode 100644 index 0000000..10b9321 --- /dev/null +++ b/combinatorial/parity.py @@ -0,0 +1,19 @@ +"""Parity helpers for batch vs stream Phase II signals.""" +from __future__ import annotations + +from collections import Counter +from typing import Any + + +def signal_multiset(signals: list[Any]) -> list[tuple[str, int]]: + """Sorted (rule_id, count) pairs for comparison.""" + c: Counter[str] = Counter() + for s in signals: + rid = getattr(s, "rule_id", None) or (s.get("rule_id") if isinstance(s, dict) else None) + if rid is not None: + c[str(rid)] += 1 + return sorted(c.items()) + + +def signals_equal(a: list[Any], b: list[Any]) -> bool: + return signal_multiset(a) == signal_multiset(b) diff --git a/combinatorial/report.py b/combinatorial/report.py new file mode 100644 index 0000000..b42eb2d --- /dev/null +++ b/combinatorial/report.py @@ -0,0 +1,229 @@ +"""Judgment markdown + engine behavior report writers.""" +from __future__ import annotations + +import json +from collections import Counter, defaultdict +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from combinatorial.coverage import compute_coverage +from combinatorial.dual_runner import run_matrix +from combinatorial.matrix import build_matrix, load_config + + +def write_judgment(summary: dict[str, Any], coverage: dict[str, Any], out_dir: Path) -> Path: + counts = summary.get("counts") or {} + lines = [ + "# Combinatorial judgment report", + "", + f"Generated: {datetime.now(UTC).isoformat()}", + f"Mode: `{summary.get('mode')}` · seed={summary.get('seed')} · cases={summary.get('n_cases')}", + "", + "| Status | Count |", + "|--------|------:|", + ] + for st in ("PASS", "FAIL", "ERROR"): + lines.append(f"| {st} | {counts.get(st, 0)} |") + lines += [ + "", + f"## Coverage (overall {coverage.get('overall_percent')}%)", + "", + "| Axis | Hit | Target | % | Missing |", + "|------|----:|-------:|--:|---------|", + ] + for name, ax in (coverage.get("axes") or {}).items(): + missing = ", ".join(f"`{m}`" for m in (ax.get("missing") or [])[:8]) + if len(ax.get("missing") or []) > 8: + missing += ", …" + lines.append( + f"| {name} | {ax['hit']} | {ax['target']} | {ax['percent']} | {missing or '—'} |" + ) + + lines += ["", "## Per-case results", "", "| id | status | expect | adversarial | notes |", "|----|--------|--------|-------------|-------|"] + for r in summary.get("results") or []: + notes = "; ".join(r.get("mismatches") or [])[:100] + lines.append( + f"| `{r['id']}` | {r['status']} | {r.get('expect_class')} | {r.get('adversarial')} | {notes} |" + ) + + fails = [r for r in summary.get("results") or [] if r["status"] in ("FAIL", "ERROR")] + if fails: + lines += ["", "## Failures detail", ""] + for r in fails: + lines.append(f"### `{r['id']}` — {r['status']}") + for m in r.get("mismatches") or []: + lines.append(f"- {m}") + exc = (r.get("observed") or {}).get("exception") + if exc: + lines.append(f"- exception: `{exc.get('type')}`: {exc.get('message')}") + lines.append("") + + path = out_dir / "JUDGMENT.md" + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + (out_dir / "COVERAGE.json").write_text(json.dumps(coverage, indent=2), encoding="utf-8") + return path + + +def write_engine_report(summary: dict[str, Any], coverage: dict[str, Any], out_dir: Path) -> Path: + """Narrative report of how spc_core behaves under the matrix.""" + results = summary.get("results") or [] + by_expect: dict[str, list] = defaultdict(list) + for r in results: + by_expect[str(r.get("expect_class"))].append(r) + + parity = [r for r in results if r.get("expect_class") == "phase2_signals"] + parity_ok = sum(1 for r in parity if r["status"] == "PASS") + adv = [r for r in results if r.get("expect_class") == "adversarial_behavior"] + raises = [r for r in results if r.get("expect_class") == "raises"] + stops = [r for r in results if r.get("expect_class") == "stop_unfrozen"] + + gate_counter: Counter[str] = Counter() + for r in results: + obs = r.get("observed") or {} + for blob in (obs, obs.get("batch") or {}, obs.get("stream") or {}): + if not isinstance(blob, dict): + continue + for g in blob.get("gates") or []: + if isinstance(g, dict): + gate_counter[f"{g.get('step')}:{g.get('status')}"] += 1 + + lines = [ + "# SPC Core Engine Behavior Report", + "", + f"Generated: {datetime.now(UTC).isoformat()}", + f"Matrix mode: `{summary.get('mode')}` · cases={summary.get('n_cases')} · " + f"PASS={summary.get('counts', {}).get('PASS', 0)} " + f"FAIL={summary.get('counts', {}).get('FAIL', 0)} " + f"ERROR={summary.get('counts', {}).get('ERROR', 0)}", + "", + "This report is produced by the combinatorial dual-mode pipeline " + "(`python -m combinatorial report`). It summarizes how `spc_core` " + "handles Phase I establishment, Phase II evaluation, and adversarial streams.", + "", + "## 1. Phase I gate order and freeze semantics", + "", + "The `establish()` pipeline runs gated checks (MSA → range → missing → ACF → " + "multimodal/normality → chart → freeze). Any gate with status `stop` sets " + "`frozen=False`; warnings still allow freeze when no STOP fired.", + "", + "Observed gate status counts in this run:", + "", + ] + for k, v in sorted(gate_counter.items()): + lines.append(f"- `{k}`: {v}") + if not gate_counter: + lines.append("- (no gate payloads recorded in this slice)") + + lines += [ + "", + f"STOP/unfrozen expect class: {len(stops)} cases " + f"({sum(1 for r in stops if r['status']=='PASS')} PASS).", + "", + "## 2. Chart auto-selection vs forced ChartType", + "", + "Forced `chart_type` in matrix params exercises each `ChartType` enum value via " + "`analyze_control_chart` / `establish`. Attribute charts require " + "`sample_sizes` / `opportunities`; subgroup charts require `subgroup_ids`.", + "", + f"Chart-type axis coverage: " + f"{coverage.get('axes', {}).get('chart_types', {}).get('percent', '?')}%.", + "", + "## 3. Ruleset differences", + "", + "- `nelson`: full Nelson 1–8 pattern rules on Shewhart charts.", + "- `western_electric`: WE1–WE4 subset.", + "- `wheeler`: beyond-limits only (used on non-normal / forced Wheeler paths).", + "", + f"Ruleset coverage: {coverage.get('axes', {}).get('rulesets', {}).get('percent', '?')}%.", + "", + "## 4. Phase II: frozen limits and batch↔stream parity", + "", + "`Phase2Evaluator` never recomputes limits. For `adversarial=none` parity cases, " + "`evaluate_batch` (or sequential `observe_subgroup`) must match streamed observes " + "on the **rule_id multiset**.", + "", + f"Parity cases: {parity_ok}/{len(parity)} PASS.", + "", + ] + for r in parity: + obs = r.get("observed") or {} + lines.append( + f"- `{r['id']}`: {r['status']} parity={obs.get('parity')} " + f"batch={obs.get('batch_rule_ids')} stream={obs.get('stream_rule_ids')}" + ) + + lines += [ + "", + "## 5. Cleaning / capability / malformed edges", + "", + f"Raise-expect cases: {sum(1 for r in raises if r['status']=='PASS')}/{len(raises)} PASS.", + "", + ] + for r in raises: + exc = (r.get("observed") or {}).get("exception") or {} + lines.append( + f"- `{r['id']}`: {r['status']} → {exc.get('type', '—')}: {exc.get('message', '')}" + ) + + lines += [ + "", + "## 6. Adversarial streaming behavior", + "", + "| Case | Adversarial | Behavior tag | Status | Notes |", + "|------|-------------|--------------|--------|-------|", + ] + for r in adv: + obs = r.get("observed") or {} + lines.append( + f"| `{r['id']}` | {r.get('adversarial')} | {obs.get('behavior') or r.get('behavior')} | " + f"{r['status']} | parity={obs.get('parity')} |" + ) + lines += [ + "", + "Interpretation:", + "", + "- `jitter_delay`: timing only — state must match canonical parity.", + "- `out_of_order`: Phase II is order-sensitive; divergence is expected and recorded.", + "- `duplicate_index` / `watermark_jump` / `split_brain_seed`: probe restart and " + "idempotency-adjacent behavior; parity not required.", + "", + "## 7. Coverage and blind spots", + "", + f"Overall axis coverage: **{coverage.get('overall_percent')}%**.", + "", + ] + for name, ax in (coverage.get("axes") or {}).items(): + miss = ax.get("missing") or [] + if miss: + lines.append(f"- `{name}` missing: {', '.join(f'`{m}`' for m in miss[:12])}") + lines += [ + "", + "## Regenerate", + "", + "```bash", + "PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python -m combinatorial report --mode sparse", + "PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python -m combinatorial report --mode exhaustive", + "```", + "", + ] + path = out_dir / "ENGINE_BEHAVIOR_REPORT.md" + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return path + + +def full_report(*, mode: str | None = None) -> dict[str, Any]: + cfg = load_config() + mode = mode or cfg.get("mode", "sparse") + summary = run_matrix(mode=mode, write_fixtures=True) + cases = build_matrix( + mode=mode, + seed=int(summary["seed"]), + max_sparse_cases=int(cfg.get("max_sparse_cases", 80)), + adversarial=bool(cfg.get("adversarial", True)), + ) + coverage = compute_coverage(cases, summary.get("results")) + out = Path(cfg["output_dir"]) + write_judgment(summary, coverage, out) + write_engine_report(summary, coverage, out) + return {"summary": summary, "coverage": coverage, "out": str(out)} diff --git a/combinatorial/schema_catalog.py b/combinatorial/schema_catalog.py new file mode 100644 index 0000000..ef24a89 --- /dev/null +++ b/combinatorial/schema_catalog.py @@ -0,0 +1,107 @@ +"""Schema catalog for combinatorial probing of spc_core contracts.""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from spc_core.models import ChartType, QualityFlag +from spc_core.rules import NELSON, WESTERN_ELECTRIC + +RULESETS = ("nelson", "western_electric", "wheeler") + +GATE_STEPS = ( + "msa", + "gage_resolution", + "valid_range", + "missing", + "autocorrelation", + "multimodal", + "normality", + "transform", + "chart", + "freeze", +) + +EXPECT_CLASSES = ( + "ok_freeze", + "stop_unfrozen", + "raises", + "phase2_signals", + "phase2_rejects", + "adversarial_behavior", +) + +# Documented hard raises / STOP edges (kept in sync via meta-tests). +DOCUMENTED_EDGES: list[dict[str, Any]] = [ + {"id": "establish_n0", "raises": "ValueError", "note": "<2 points"}, + {"id": "establish_n1", "raises": "ValueError", "note": "single point"}, + {"id": "p_zero_n", "raises": "ValueError", "note": "P chart n<=0"}, + {"id": "u_zero_opp", "raises": "ValueError", "note": "U opportunities<=0"}, + {"id": "phase2_scalar_on_xbar", "raises": "ValueError", "note": "observe on Xbar"}, + {"id": "phase2_subgroup_on_imr", "raises": "ValueError", "note": "observe_subgroup on I-MR"}, + {"id": "msa_stop", "expect_class": "stop_unfrozen", "note": "GRR>30 or NDC fail"}, + {"id": "multimodal_stop", "expect_class": "stop_unfrozen", "note": "Hartigan dip"}, + {"id": "usl_le_lsl", "raises": "ValueError", "note": "capability specs"}, +] + + +@dataclass(frozen=True) +class SchemaCatalog: + chart_types: tuple[str, ...] = tuple(c.value for c in ChartType) + quality_flags: tuple[str, ...] = tuple(q.value for q in QualityFlag) + rulesets: tuple[str, ...] = RULESETS + nelson_ids: tuple[str, ...] = tuple(NELSON.keys()) + we_ids: tuple[str, ...] = tuple(WESTERN_ELECTRIC.keys()) + gate_steps: tuple[str, ...] = GATE_STEPS + expect_classes: tuple[str, ...] = EXPECT_CLASSES + documented_edges: tuple[dict[str, Any], ...] = tuple(DOCUMENTED_EDGES) + series_kinds: tuple[str, ...] = ( + "imr_in_control", + "imr_mean_shift", + "imr_trend", + "imr_alternating", + "imr_constant", + "imr_empty", + "imr_n1", + "imr_n2", + "imr_nan_inf", + "imr_sentinel", + "imr_overflow", + "xbar_r", + "attribute_p", + "attribute_p_zero_n", + "attribute_np", + "attribute_c", + "attribute_u", + "attribute_u_zero_opp", + "multimodal", + "heavy_tail", + "type_mismatch", + ) + adversarial_kinds: tuple[str, ...] = ( + "none", + "out_of_order", + "jitter_delay", + "duplicate_index", + "split_brain_seed", + "watermark_jump", + ) + + +def load_catalog() -> SchemaCatalog: + return SchemaCatalog() + + +def axis_coverage_targets(catalog: SchemaCatalog | None = None) -> dict[str, set[str]]: + c = catalog or load_catalog() + return { + "chart_types": set(c.chart_types), + "rulesets": set(c.rulesets), + "quality_flags": set(c.quality_flags), + "nelson_ids": set(c.nelson_ids), + "we_ids": set(c.we_ids), + "gate_steps": set(c.gate_steps), + "expect_classes": set(c.expect_classes), + "series_kinds": set(c.series_kinds), + "adversarial_kinds": set(c.adversarial_kinds), + } diff --git a/combinatorial/static_pipeline.py b/combinatorial/static_pipeline.py new file mode 100644 index 0000000..6db3e24 --- /dev/null +++ b/combinatorial/static_pipeline.py @@ -0,0 +1,20 @@ +"""Persist generated static fixtures for the matrix.""" +from __future__ import annotations + +from pathlib import Path + +from combinatorial.batch_runner import write_static_fixture +from combinatorial.matrix import CaseSpec, build_matrix, load_config + + +def generate_static(cases: list[CaseSpec] | None = None, *, output_dir: str | Path | None = None) -> list[Path]: + cfg = load_config() + out = Path(output_dir or cfg["output_dir"]) / "static" + if cases is None: + cases = build_matrix( + mode=cfg.get("mode", "sparse"), + seed=int(cfg.get("seed", 42)), + max_sparse_cases=int(cfg.get("max_sparse_cases", 80)), + adversarial=bool(cfg.get("adversarial", True)), + ) + return [write_static_fixture(c, out, seed=i) for i, c in enumerate(cases)] diff --git a/combinatorial/stream_emitter.py b/combinatorial/stream_emitter.py new file mode 100644 index 0000000..1b03357 --- /dev/null +++ b/combinatorial/stream_emitter.py @@ -0,0 +1,99 @@ +"""Adversarial streaming event emitter for Phase2Evaluator.""" +from __future__ import annotations + +import random +import time +from dataclasses import dataclass +from typing import Any, Literal + +EventKind = Literal["observe", "observe_subgroup", "seed"] + + +@dataclass +class StreamEvent: + kind: EventKind + value: float | list[float] | None = None + index_hint: int | None = None + seed_index: int | None = None + seed_values: list[float] | None = None + delay_s: float = 0.0 + + +def events_from_plotted( + plotted: list[float], + *, + subgroup_size: int | None = None, + raw_subgroups: list[list[float]] | None = None, +) -> list[StreamEvent]: + """Build canonical in-order observe events.""" + if raw_subgroups: + return [StreamEvent(kind="observe_subgroup", value=list(g), index_hint=i) for i, g in enumerate(raw_subgroups)] + return [StreamEvent(kind="observe", value=float(v), index_hint=i) for i, v in enumerate(plotted)] + + +def apply_adversarial( + events: list[StreamEvent], + adversarial: str, + *, + seed: int = 0, +) -> tuple[list[StreamEvent], str]: + """Return transformed events + behavior tag.""" + if adversarial in ("none", "", None): + return events, "canonical" + + rng = random.Random(seed) + tag = adversarial + + if adversarial == "out_of_order": + out = list(events) + if len(out) > 3: + rng.shuffle(out) + return out, "order_sensitive" + + if adversarial == "jitter_delay": + out = [] + for e in events: + out.append( + StreamEvent( + kind=e.kind, + value=e.value, + index_hint=e.index_hint, + delay_s=rng.uniform(0.0, 0.002), + ) + ) + return out, "timing_noop" + + if adversarial == "duplicate_index": + out = list(events) + if out: + mid = len(out) // 2 + out.insert(mid, StreamEvent(kind=out[mid].kind, value=out[mid].value, index_hint=out[mid].index_hint)) + return out, "duplicate_observe" + + if adversarial == "watermark_jump": + seed_ev = StreamEvent(kind="seed", seed_index=10_000, seed_values=[]) + return [seed_ev, *events], "watermark_jump" + + if adversarial == "split_brain_seed": + warm = [float(e.value) for e in events[:5] if isinstance(e.value, (int, float))] + # Conflicting warm path: seed with reversed warm values at wrong index + seed_ev = StreamEvent(kind="seed", seed_index=4, seed_values=list(reversed(warm)) if warm else [0.0]) + return [seed_ev, *events], "split_brain_seed" + + return events, tag + + +def play_events(evaluator: Any, events: list[StreamEvent], *, apply_delays: bool = True) -> list[Any]: + """Drive Phase2Evaluator with events; return flat Signal list.""" + signals: list[Any] = [] + for ev in events: + if apply_delays and ev.delay_s > 0: + time.sleep(ev.delay_s) + if ev.kind == "seed": + evaluator.seed_state(index=ev.seed_index if ev.seed_index is not None else -1, values=ev.seed_values) + continue + if ev.kind == "observe_subgroup": + signals.extend(evaluator.observe_subgroup(ev.value)) + else: + signals.extend(evaluator.observe(float(ev.value))) # type: ignore[arg-type] + return signals diff --git a/combinatorial/stream_runner.py b/combinatorial/stream_runner.py new file mode 100644 index 0000000..f33b7fe --- /dev/null +++ b/combinatorial/stream_runner.py @@ -0,0 +1,154 @@ +"""Streaming Phase2 runs + parity against evaluate_batch.""" +from __future__ import annotations + +import traceback +from typing import Any + +from combinatorial.generators.series import build_series, values_from_columns +from combinatorial.matrix import CaseSpec +from combinatorial.parity import signal_multiset, signals_equal +from combinatorial.stream_emitter import apply_adversarial, events_from_plotted, play_events +from spc_core import ChartType, establish +from spc_core.evaluator import Phase2Evaluator, evaluate_batch +from spc_core.limits import build_subgroups + + +def _chart_type(params: dict[str, Any]) -> ChartType | None: + raw = params.get("chart_type") + return ChartType(raw) if raw else None + + +def _subgroup_lists(values: list[Any], subgroup_ids: list[Any] | None) -> list[list[float]] | None: + if not subgroup_ids: + return None + groups = build_subgroups([float(v) for v in values], subgroup_ids) + return [[float(x) for x in g] for g in groups] + + +def run_stream_case(case: CaseSpec, *, seed: int = 0) -> dict[str, Any]: + observed: dict[str, Any] = {"id": case.id, "entry": case.entry} + mismatches: list[str] = [] + status = "PASS" + behavior = "canonical" + + try: + cols = build_series(case.series_kind, seed=seed) + values, extra = values_from_columns(cols, case.series_kind) + params = dict(case.params) + ct = _chart_type(params) + ruleset = params.get("ruleset", "nelson") + + # Establish Phase I limits (must freeze for Phase II) + pipe = establish( + values, + chart_type=ct, + ruleset=ruleset, + **{k: v for k, v in extra.items()}, + ) + observed["phase1_frozen"] = pipe.frozen + if not pipe.frozen: + if case.expect_class == "phase2_rejects": + # still try reject paths below if wrong_api with unfrozen — skip + pass + elif case.expect_class in ("phase2_signals", "adversarial_behavior"): + # Use chart limits anyway for diagnostic observe if chart exists + if pipe.stopped and case.expect_class == "adversarial_behavior": + status = "PASS" + observed["skipped"] = "unfrozen" + return _result(case, status, mismatches, observed, behavior) + mismatches.append("Phase I not frozen; cannot run Phase II parity") + status = "FAIL" + return _result(case, status, mismatches, observed, behavior) + + limits = pipe.chart.limits + plotted = list(pipe.chart.plotted_values) + subgroups = _subgroup_lists(values, extra.get("subgroup_ids")) + + if case.entry == "phase2_reject": + ev = Phase2Evaluator(limits, ruleset=ruleset) + wrong = params.get("wrong_api") + try: + if wrong == "observe": + ev.observe(float(plotted[0]) if plotted else 0.0) + elif wrong == "observe_subgroup": + ev.observe_subgroup([1.0, 2.0, 3.0]) + else: + raise RuntimeError("wrong_api not set") + mismatches.append("expected Phase2 ValueError but call succeeded") + status = "FAIL" + except ValueError as exc: + observed["exception"] = {"type": "ValueError", "message": str(exc)} + status = "PASS" + return _result(case, status, mismatches, observed, behavior) + + # Parity / adversarial + batch_signals = evaluate_batch(limits, plotted, ruleset=ruleset) if not subgroups else [] + if subgroups: + # batch evaluate via sequential observe_subgroup + ev_batch = Phase2Evaluator(limits, ruleset=ruleset) + for g in subgroups: + batch_signals.extend(ev_batch.observe_subgroup(g)) + + events = events_from_plotted(plotted, raw_subgroups=subgroups) + events, behavior = apply_adversarial(events, case.adversarial, seed=seed) + observed["behavior"] = behavior + + ev = Phase2Evaluator(limits, ruleset=ruleset) + # For watermark/split_brain, seed is inside events + stream_signals = play_events(ev, events, apply_delays=(case.adversarial == "jitter_delay")) + + batch_ids = signal_multiset(batch_signals) + stream_ids = signal_multiset(stream_signals) + observed["batch_rule_ids"] = batch_ids + observed["stream_rule_ids"] = stream_ids + observed["parity"] = signals_equal(batch_signals, stream_signals) + + if case.expect_class == "adversarial_behavior": + if behavior == "timing_noop": + if not observed["parity"]: + mismatches.append("jitter should preserve parity") + status = "FAIL" + else: + status = "PASS" + elif behavior == "order_sensitive": + # Document divergence; PASS if we recorded behavior (parity may fail) + observed["parity_expected"] = False + status = "PASS" + else: + # duplicate / watermark / split_brain: record outcome; do not require parity + observed["parity_expected"] = False + status = "PASS" + else: + # strict parity + if not observed["parity"]: + mismatches.append( + f"parity mismatch batch={batch_ids} stream={stream_ids}" + ) + status = "FAIL" + + except Exception as exc: # noqa: BLE001 + observed["exception"] = { + "type": type(exc).__name__, + "message": str(exc), + "traceback": traceback.format_exc(limit=4), + } + if case.expect_class == "raises": + status = "PASS" + else: + status = "ERROR" + mismatches.append(f"unexpected {type(exc).__name__}: {exc}") + + return _result(case, status, mismatches, observed, behavior) + + +def _result(case: CaseSpec, status: str, mismatches: list[str], observed: dict, behavior: str) -> dict[str, Any]: + return { + "id": case.id, + "status": status, + "expect_class": case.expect_class, + "mismatches": mismatches, + "observed": observed, + "adversarial": case.adversarial, + "behavior": behavior, + "modality": case.modality, + } diff --git a/control_chart_system/__init__.py b/control_chart_system/__init__.py deleted file mode 100644 index 8b850eb..0000000 --- a/control_chart_system/__init__.py +++ /dev/null @@ -1,24 +0,0 @@ -""" -Control Chart System -Statistical Process Control (SPC) AI Agent with LangGraph - -This package contains: -- ControlChartPipeline: Core SPC analysis engine -- Control Chart Tool: Single comprehensive LangGraph tool -- Control Chart Agent: Conversational AI interface - -Supported Charts: -- Continuous: I-MR, Xbar-R, Xbar-S -- Attribute: P, NP, C, U -""" - -from .control_chart_pipeline import ControlChartPipeline -from .control_chart_tools import run_control_chart_analysis - -__version__ = "2.0.0" -__author__ = "Control Chart AI Team" - -__all__ = [ - "ControlChartPipeline", - "run_control_chart_analysis" -] diff --git a/control_chart_system/control_chart_agent.py b/control_chart_system/control_chart_agent.py deleted file mode 100644 index d2675d1..0000000 --- a/control_chart_system/control_chart_agent.py +++ /dev/null @@ -1,32 +0,0 @@ -import sys -from pathlib import Path -from langchain.agents import create_agent -from langgraph.checkpoint.memory import InMemorySaver -from langgraph.store.memory import InMemoryStore - -# Add parent directory to path for imports -parent_dir = Path(__file__).parent.parent -sys.path.insert(0, str(parent_dir)) - -from control_chart_system.control_chart_tools import run_control_chart_analysis -from agent_config.agent_prompts import get_control_chart_prompt -from agent_config.config_loader import get_llm_model_string - -# Load the agent prompt from JSON configuration -Control_chart_agent = get_control_chart_prompt() - -# Initialize shared memory components -checkpointer = InMemorySaver() -store = InMemoryStore() - -# Create the agent graph (exported for use in API) -control_chart_graph = create_agent( - model=get_llm_model_string(), # Load from config.yaml - tools=[run_control_chart_analysis], # Single comprehensive tool - checkpointer=checkpointer, - store=store, - system_prompt=Control_chart_agent, - name="control charts ai generator", - -) - diff --git a/control_chart_system/control_chart_pipeline.py b/control_chart_system/control_chart_pipeline.py deleted file mode 100644 index 90b6cfe..0000000 --- a/control_chart_system/control_chart_pipeline.py +++ /dev/null @@ -1,623 +0,0 @@ -import pandas as pd -import numpy as np -import plotly.graph_objects as go -from plotly.subplots import make_subplots -import plotly.express as px -from scipy import stats -import warnings - -class ControlChartPipeline: - def __init__(self, data, subgroup_col=None, value_col=None, date_col=None, - sample_size_col=None, opportunity_col=None): - """ - Initialize the control chart pipeline - - Parameters: - data: DataFrame or array-like - subgroup_col: column name for subgroup identifiers - value_col: column name for measurement values - date_col: column name for time sequence - sample_size_col: column name for sample sizes (for P/NP charts) - opportunity_col: column name for opportunities/area of inspection (for C/U charts) - """ - self.data = pd.DataFrame(data) if not isinstance(data, pd.DataFrame) else data - self.subgroup_col = subgroup_col - self.value_col = value_col - self.date_col = date_col - self.sample_size_col = sample_size_col - self.opportunity_col = opportunity_col - self.chart_type = None - self.control_limits = {} - self.report = {} - self.data_quality_issues = [] - - def validate_data_quality(self): - """ - Validate data quality before analysis - Returns list of issues found - """ - issues = [] - - # Auto-detect value column if not specified (SMART DETECTION) - if self.value_col is None: - numeric_cols = self.data.select_dtypes(include=[np.number]).columns.tolist() - if len(numeric_cols) == 0: - issues.append("ERROR: No numeric columns found for analysis") - return issues - - # Skip columns that are likely identifiers/grouping variables - skip_patterns = ['id', 'subgroup', 'batch', 'sample', 'group', 'lot', 'serial', 'number'] - filtered_cols = [c for c in numeric_cols if not any(pattern in c.lower() for pattern in skip_patterns)] - - # If we have columns after filtering, use those - if filtered_cols: - numeric_cols = filtered_cols - - # Prefer columns with measurement-related names - priority_names = ['measurement', 'value', 'measure', 'data', 'reading', 'result', 'defect', 'count'] - for priority in priority_names: - matching = [c for c in numeric_cols if priority in c.lower()] - if matching: - self.value_col = matching[0] - break - else: - # Fallback to first remaining numeric column - self.value_col = numeric_cols[0] - - # Check if value column exists - if self.value_col not in self.data.columns: - issues.append(f"ERROR: Column '{self.value_col}' not found in data. Available columns: {list(self.data.columns)}") - return issues - - values = self.data[self.value_col] - - # Check for missing values - missing_count = values.isna().sum() - if missing_count > 0: - missing_rows = values[values.isna()].index.tolist() - issues.append(f"WARNING: {missing_count} missing values found in rows: {missing_rows[:10]}{'...' if len(missing_rows) > 10 else ''}") - - # Check for non-numeric values - try: - numeric_values = pd.to_numeric(values, errors='coerce') - non_numeric = numeric_values.isna().sum() - missing_count - if non_numeric > 0: - issues.append(f"ERROR: {non_numeric} non-numeric values found in '{self.value_col}' column") - except: - pass - - # Check for suspicious values - values_clean = values.dropna() - if len(values_clean) > 0: - # Check for error codes (999, 9999, -999, etc.) - error_codes = values_clean[values_clean.isin([999, 9999, -999, -9999])] - if len(error_codes) > 0: - issues.append(f"WARNING: Potential error codes detected (999, -999) in rows: {error_codes.index.tolist()}") - - # Check for unreasonable negative values in count data - if values_clean.min() < 0: - negative_rows = values_clean[values_clean < 0].index.tolist() - issues.append(f"WARNING: Negative values found (rows: {negative_rows}). If measuring counts/dimensions, negative values are impossible") - - # Check sample size - if len(values_clean) < 25: - issues.append(f"WARNING: Only {len(values_clean)} data points. Minimum 25 recommended for reliable control limits") - - # Check if all values are identical - if len(values_clean) > 0 and values_clean.nunique() == 1: - issues.append(f"WARNING: All values are identical ({values_clean.iloc[0]}). No variation to analyze!") - - # Check if variation is suspiciously high - if len(values_clean) > 0: - std = values_clean.std() - mean = values_clean.mean() - if mean != 0 and (std / abs(mean)) > 1.0: # CV > 100% - issues.append(f"WARNING: Very high variation detected (CV = {std/abs(mean)*100:.1f}%). Could this be measurement error rather than process variation?") - - self.data_quality_issues = issues - return issues - - def detect_data_type(self): - """Detect if data is continuous or attribute""" - if self.value_col is None: - # Smart detection - prefer measurement columns, skip identifiers - numeric_cols = self.data.select_dtypes(include=[np.number]).columns.tolist() - if len(numeric_cols) == 0: - raise ValueError("No numeric columns found for analysis") - - # Skip ID/grouping columns - skip_patterns = ['id', 'subgroup', 'batch', 'sample', 'group', 'lot', 'serial', 'number'] - filtered_cols = [c for c in numeric_cols if not any(p in c.lower() for p in skip_patterns)] - if filtered_cols: - numeric_cols = filtered_cols - - # Prefer measurement columns - priority_names = ['measurement', 'value', 'measure', 'data', 'reading', 'result', 'defect', 'count'] - for priority in priority_names: - matching = [c for c in numeric_cols if priority in c.lower()] - if matching: - self.value_col = matching[0] - break - else: - self.value_col = numeric_cols[0] - - values = self.data[self.value_col] - - # Strong indicators of attribute data - has_sample_size = self.sample_size_col is not None - has_opportunity = self.opportunity_col is not None - is_binary = values.isin([0, 1]).all() - is_integer = np.all(values == values.astype(int)) - is_non_negative = np.all(values >= 0) - max_value = values.max() - - # Check if data is attribute (count/defect data) or continuous - if has_sample_size or has_opportunity or is_binary: - # If sample_size or opportunity columns exist, it's attribute data - self.data_type = "attribute" - elif is_integer and is_non_negative and max_value < 50: - # Small non-negative integers are likely counts (attribute data) - self.data_type = "attribute" - else: - # If data contains decimals/floats, it's continuous (measurements) - if not is_integer: - self.data_type = "continuous" - else: - # For integers, check unique ratio - unique_ratio = len(values.unique()) / len(values) - if unique_ratio > 0.3 and pd.api.types.is_numeric_dtype(values): - self.data_type = "continuous" - else: - self.data_type = "attribute" - - # Set additional attributes based on data type - if self.data_type == "continuous": - # Detect subgroup size for continuous data - if self.subgroup_col: - subgroup_sizes = self.data.groupby(self.subgroup_col).size() - self.subgroup_size = subgroup_sizes.iloc[0] - if not all(subgroup_sizes == self.subgroup_size): - warnings.warn("Variable subgroup sizes detected") - else: - self.subgroup_size = 1 - else: - # For attribute data, determine if defectives or defects - if is_binary or has_sample_size: - self.attribute_type = "defectives" # Binary or proportion data - else: - self.attribute_type = "defects" # Count data - - return self.data_type - - def select_control_chart(self): - """Select appropriate control chart based on data characteristics""" - self.detect_data_type() - - if self.data_type == "continuous": - if self.subgroup_size == 1: - self.chart_type = "I-MR" - elif self.subgroup_size <= 9: - self.chart_type = "Xbar-R" - else: - self.chart_type = "Xbar-S" - - else: # attribute data - if self.attribute_type == "defectives": - # Check if constant sample size - if hasattr(self, 'sample_size_col') and self.sample_size_col: - sample_sizes = self.data[self.sample_size_col] - if sample_sizes.nunique() == 1: - self.chart_type = "NP" # Constant sample size → NP chart - else: - self.chart_type = "P" # Variable sample size → P chart - else: - # Assume constant sample size if not specified - self.chart_type = "NP" - else: # defects - if hasattr(self, 'opportunity_col') and self.opportunity_col: - opportunities = self.data[self.opportunity_col] - if opportunities.nunique() == 1: - self.chart_type = "C" # Constant opportunity → C chart - else: - self.chart_type = "U" # Variable opportunity → U chart - else: - # Assume constant opportunity if not specified - self.chart_type = "C" - - print(f"Selected control chart: {self.chart_type}") - return self.chart_type - - def calculate_control_limits(self): - """Calculate control limits based on selected chart type""" - if self.chart_type is None: - self.select_control_chart() - - values = self.data[self.value_col] - - if self.chart_type == "I-MR": - # Individuals and Moving Range chart - individuals = values - moving_ranges = np.abs(individuals.diff().dropna()) - - MR_bar = moving_ranges.mean() - X_bar = individuals.mean() - - self.control_limits = { - 'individuals': { - 'UCL': X_bar + 2.66 * MR_bar, - 'LCL': X_bar - 2.66 * MR_bar, - 'center': X_bar - }, - 'moving_range': { - 'UCL': 3.27 * MR_bar, - 'LCL': 0, - 'center': MR_bar - } - } - - elif self.chart_type == "Xbar-R": - # Xbar and R chart - subgroups = self.data.groupby(self.subgroup_col)[self.value_col] - subgroup_means = subgroups.mean() - subgroup_ranges = subgroups.apply(lambda x: x.max() - x.min()) - - X_bar = subgroup_means.mean() - R_bar = subgroup_ranges.mean() - - # Constants for Xbar-R chart (for subgroup size) - A2 = {2: 1.88, 3: 1.02, 4: 0.73, 5: 0.58, 6: 0.48, - 7: 0.42, 8: 0.37, 9: 0.34}.get(self.subgroup_size, 0.31) - - D3 = {2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0.08, 8: 0.14, 9: 0.18} - D4 = {2: 3.27, 3: 2.57, 4: 2.28, 5: 2.11, 6: 2.00, - 7: 1.92, 8: 1.86, 9: 1.82} - - self.control_limits = { - 'xbar': { - 'UCL': X_bar + A2 * R_bar, - 'LCL': max(X_bar - A2 * R_bar, 0), - 'center': X_bar - }, - 'range': { - 'UCL': D4.get(self.subgroup_size, 1.78) * R_bar, - 'LCL': D3.get(self.subgroup_size, 0) * R_bar, - 'center': R_bar - } - } - - elif self.chart_type == "P": - # P chart for proportion defective (variable sample size) - if self.sample_size_col and self.sample_size_col in self.data.columns: - sample_sizes = self.data[self.sample_size_col] - else: - # Assume all samples have same size, use total count - sample_sizes = pd.Series([len(self.data)] * len(values)) - - p_bar = values.sum() / sample_sizes.sum() - - # Calculate control limits for each point - self.control_limits = { - 'proportion': { - 'UCL': [p_bar + 3 * np.sqrt(p_bar * (1 - p_bar) / n) for n in sample_sizes], - 'LCL': [max(p_bar - 3 * np.sqrt(p_bar * (1 - p_bar) / n), 0) for n in sample_sizes], - 'center': p_bar - } - } - - elif self.chart_type == "NP": - # NP chart for number of defectives (constant sample size) - if self.sample_size_col and self.sample_size_col in self.data.columns: - n = self.data[self.sample_size_col].iloc[0] - else: - # Assume constant sample size - n = len(self.data) - - np_bar = values.mean() - p_bar = np_bar / n - - self.control_limits = { - 'np': { - 'UCL': np_bar + 3 * np.sqrt(np_bar * (1 - p_bar)), - 'LCL': max(np_bar - 3 * np.sqrt(np_bar * (1 - p_bar)), 0), - 'center': np_bar - } - } - - elif self.chart_type == "C": - # C chart for count of defects (constant opportunity) - c_bar = values.mean() - - self.control_limits = { - 'defects': { - 'UCL': c_bar + 3 * np.sqrt(c_bar), - 'LCL': max(c_bar - 3 * np.sqrt(c_bar), 0), - 'center': c_bar - } - } - - elif self.chart_type == "U": - # U chart for defects per unit (variable opportunity) - if self.opportunity_col and self.opportunity_col in self.data.columns: - opportunities = self.data[self.opportunity_col] - else: - # Default to assuming opportunities = 1 for each sample - opportunities = pd.Series([1] * len(values)) - - u_bar = values.sum() / opportunities.sum() - - # Calculate control limits for each point - self.control_limits = { - 'defects_per_unit': { - 'UCL': [u_bar + 3 * np.sqrt(u_bar / n) for n in opportunities], - 'LCL': [max(u_bar - 3 * np.sqrt(u_bar / n), 0) for n in opportunities], - 'center': u_bar - } - } - - return self.control_limits - - def generate_plot(self): - """Generate interactive control chart plot""" - if not self.control_limits: - self.calculate_control_limits() - - values = self.data[self.value_col].tolist() - - # Fix: Convert range to list for Plotly - if self.date_col and self.date_col in self.data.columns: - sequence = self.data[self.date_col].tolist() - else: - sequence = list(range(1, len(values) + 1)) # Convert range to list - - # Create appropriate subplot structure based on chart type - if self.chart_type == "I-MR": - fig = make_subplots( - rows=2, cols=1, - subplot_titles=['Individuals Chart', 'Moving Range Chart'], - vertical_spacing=0.15 - ) - - # Individuals chart - fig.add_trace( - go.Scatter(x=sequence, y=values, mode='lines+markers', - name='Individuals', line=dict(color='blue')), - row=1, col=1 - ) - - # Moving Range chart - moving_ranges = np.abs(pd.Series(values).diff().dropna()).tolist() - mr_sequence = sequence[1:] # Adjust sequence for moving ranges - - fig.add_trace( - go.Scatter(x=mr_sequence, y=moving_ranges, mode='lines+markers', - name='Moving Range', line=dict(color='green')), - row=2, col=1 - ) - - # Add control limits for individuals - limits_ind = self.control_limits['individuals'] - fig.add_trace( - go.Scatter(x=sequence, y=[limits_ind['UCL']] * len(sequence), - mode='lines', name='UCL', line=dict(color='red', dash='dash')), - row=1, col=1 - ) - fig.add_trace( - go.Scatter(x=sequence, y=[limits_ind['LCL']] * len(sequence), - mode='lines', name='LCL', line=dict(color='red', dash='dash')), - row=1, col=1 - ) - fig.add_trace( - go.Scatter(x=sequence, y=[limits_ind['center']] * len(sequence), - mode='lines', name='Center', line=dict(color='green')), - row=1, col=1 - ) - - # Add control limits for moving range - limits_mr = self.control_limits['moving_range'] - fig.add_trace( - go.Scatter(x=mr_sequence, y=[limits_mr['UCL']] * len(mr_sequence), - mode='lines', name='MR UCL', line=dict(color='red', dash='dash')), - row=2, col=1 - ) - fig.add_trace( - go.Scatter(x=mr_sequence, y=[limits_mr['LCL']] * len(mr_sequence), - mode='lines', name='MR LCL', line=dict(color='red', dash='dash')), - row=2, col=1 - ) - fig.add_trace( - go.Scatter(x=mr_sequence, y=[limits_mr['center']] * len(mr_sequence), - mode='lines', name='MR Center', line=dict(color='green')), - row=2, col=1 - ) - - else: - # For other chart types, use single plot for simplicity - fig = make_subplots(rows=1, cols=1) - - fig.add_trace( - go.Scatter(x=sequence, y=values, mode='lines+markers', - name='Values', line=dict(color='blue')), - row=1, col=1 - ) - - # Add control limits based on chart type - if self.chart_type == "Xbar-R": - limits = self.control_limits['xbar'] - elif self.chart_type == "P": - limits = self.control_limits['proportion'] - elif self.chart_type == "NP": - limits = self.control_limits['np'] - elif self.chart_type == "C": - limits = self.control_limits['defects'] - elif self.chart_type == "U": - limits = self.control_limits['defects_per_unit'] - else: - # Default to simple limits for other chart types - mean_val = np.mean(values) - std_val = np.std(values) - limits = {'UCL': mean_val + 3*std_val, 'LCL': mean_val - 3*std_val, 'center': mean_val} - - # Handle variable control limits for P chart - if isinstance(limits['UCL'], list): - fig.add_trace( - go.Scatter(x=sequence, y=limits['UCL'], mode='lines', - name='UCL', line=dict(color='red', dash='dash')), - row=1, col=1 - ) - fig.add_trace( - go.Scatter(x=sequence, y=limits['LCL'], mode='lines', - name='LCL', line=dict(color='red', dash='dash')), - row=1, col=1 - ) - else: - fig.add_trace( - go.Scatter(x=sequence, y=[limits['UCL']] * len(sequence), mode='lines', - name='UCL', line=dict(color='red', dash='dash')), - row=1, col=1 - ) - fig.add_trace( - go.Scatter(x=sequence, y=[limits['LCL']] * len(sequence), mode='lines', - name='LCL', line=dict(color='red', dash='dash')), - row=1, col=1 - ) - - fig.add_trace( - go.Scatter(x=sequence, y=[limits['center']] * len(sequence), mode='lines', - name='Center', line=dict(color='green')), - row=1, col=1 - ) - - # Update layout - fig.update_layout( - height=600, - title_text=f"Control Chart Analysis - {self.chart_type}", - showlegend=True - ) - - return fig - - def generate_report(self): - """Generate comprehensive analysis report""" - if not self.control_limits: - self.calculate_control_limits() - - values = self.data[self.value_col] - - # Basic statistics - report = { - 'chart_type': self.chart_type, - 'data_type': self.data_type, - 'sample_size': len(values), - 'mean': float(values.mean()), - 'std_dev': float(values.std()), - 'control_limits': self.control_limits, - 'out_of_control_points': self._detect_out_of_control() - } - - self.report = report - return report - - def _detect_out_of_control(self): - """Detect points outside control limits""" - values = self.data[self.value_col].tolist() - out_of_control = [] - - if self.chart_type == "I-MR": - limits = self.control_limits['individuals'] - for i, val in enumerate(values): - if val > limits['UCL'] or val < limits['LCL']: - out_of_control.append({'point': i+1, 'value': val, 'reason': 'Outside control limits'}) - - elif self.chart_type == "Xbar-R": - subgroups = self.data.groupby(self.subgroup_col)[self.value_col] - subgroup_means = subgroups.mean() - limits = self.control_limits['xbar'] - - for i, (subgroup, mean_val) in enumerate(subgroup_means.items()): - if mean_val > limits['UCL'] or mean_val < limits['LCL']: - out_of_control.append({'point': subgroup, 'value': float(mean_val), 'reason': 'Outside control limits'}) - - elif self.chart_type in ["P", "U"]: - # Variable control limits - check each point against its own limits - limits_key = 'proportion' if self.chart_type == "P" else 'defects_per_unit' - limits = self.control_limits[limits_key] - - for i, val in enumerate(values): - ucl = limits['UCL'][i] if isinstance(limits['UCL'], list) else limits['UCL'] - lcl = limits['LCL'][i] if isinstance(limits['LCL'], list) else limits['LCL'] - if val > ucl or val < lcl: - out_of_control.append({'point': i+1, 'value': val, 'reason': 'Outside control limits'}) - - elif self.chart_type in ["NP", "C"]: - # Constant control limits - limits_key = 'np' if self.chart_type == "NP" else 'defects' - limits = self.control_limits[limits_key] - - for i, val in enumerate(values): - if val > limits['UCL'] or val < limits['LCL']: - out_of_control.append({'point': i+1, 'value': val, 'reason': 'Outside control limits'}) - - return out_of_control - - def save_report(self, filename="control_chart_report.html"): - """Save interactive report as HTML file""" - import plotly.io as pio - - fig = self.generate_plot() - report = self.generate_report() - - # Create comprehensive HTML report - html_content = f""" - - - Control Chart Analysis Report - - - -

    Control Chart Analysis Report

    - -
    -

    Summary

    - - - - - - -
    Chart Type{report['chart_type']}
    Data Type{report['data_type']}
    Sample Size{report['sample_size']}
    Mean{report['mean']:.4f}
    Standard Deviation{report['std_dev']:.4f}
    -
    - -
    -

    Control Chart

    - {pio.to_html(fig, include_plotlyjs='cdn')} -
    - -
    -

    Control Limits

    -
    {str(report['control_limits'])}
    -
    - -
    -

    Out of Control Points

    -

    Number of out-of-control points: {len(report['out_of_control_points'])}

    - {"".join([f'

    Point {p["point"]}: Value {p["value"]:.4f} - {p["reason"]}

    ' for p in report['out_of_control_points']]) if report['out_of_control_points'] else '

    No out-of-control points detected

    '} -
    - - - """ - - with open(filename, 'w') as f: - f.write(html_content) - - print(f"Report saved as: {filename}") - return filename - diff --git a/control_chart_system/control_chart_tools.py b/control_chart_system/control_chart_tools.py deleted file mode 100644 index 801c220..0000000 --- a/control_chart_system/control_chart_tools.py +++ /dev/null @@ -1,279 +0,0 @@ -""" -Control Chart Tool for LangGraph Agent -Single comprehensive tool that wraps ControlChartPipeline functionality. -""" -import pandas as pd -import numpy as np -import json -from typing import Optional -from langchain.tools import tool -from .control_chart_pipeline import ControlChartPipeline - - -@tool -def run_control_chart_analysis( - data_path: str, - value_col: Optional[str] = None, - subgroup_col: Optional[str] = None, - date_col: Optional[str] = None, - sample_size_col: Optional[str] = None, - opportunity_col: Optional[str] = None, - chart_type: Optional[str] = None -) -> str: - """ - Complete control chart analysis with automatic validation, chart selection, and report generation. - - This tool performs the entire SPC workflow: - 1. Validates data file and schema - 2. Auto-detects columns if not specified - 3. Detects data type (variable/attribute) - 4. Selects appropriate chart type (I-MR, Xbar-R, P, NP, C, U) - 5. Calculates control limits (UCL, CL, LCL) - 6. Detects out-of-control points - 7. Generates comprehensive HTML report - - Args: - data_path: Path to CSV file containing process data - value_col: Column with measurement values (auto-detected if None) - subgroup_col: Column for subgroup identifiers (optional) - date_col: Column for time sequence (optional) - sample_size_col: Column for sample sizes - for P/NP charts (optional) - opportunity_col: Column for opportunities/area - for U charts (optional) - chart_type: Override auto-selection with specific chart type (optional) - Options: 'I-MR', 'Xbar-R', 'P', 'NP', 'C', 'U' - - Returns: - JSON string with complete analysis results including: - - Data validation status - - Chart type selected - - Control limits (UCL, CL, LCL) - - Out-of-control points - - Statistical summary - - HTML report path - - Recommendations - """ - try: - # ===== STEP 1: LOAD AND VALIDATE DATA ===== - try: - data = pd.read_csv(data_path) - except FileNotFoundError: - return json.dumps({ - "status": "error", - "error_type": "file_not_found", - "message": f"File not found: {data_path}", - "suggestion": "Please check the file path and try again." - }) - except pd.errors.EmptyDataError: - return json.dumps({ - "status": "error", - "error_type": "empty_file", - "message": "File is empty", - "suggestion": "Please provide a CSV file with data." - }) - except Exception as e: - return json.dumps({ - "status": "error", - "error_type": "read_error", - "message": f"Error reading file: {str(e)}", - "suggestion": "Make sure it's a valid CSV file." - }) - - # ===== STEP 2: SCHEMA VALIDATION ===== - if data.empty: - return json.dumps({ - "status": "error", - "error_type": "no_data", - "message": "CSV file contains no data rows", - "suggestion": "Ensure the file has data rows, not just headers." - }) - - # List available columns for troubleshooting - available_columns = list(data.columns) - - # Auto-detect value column if not specified (SMART DETECTION) - if value_col is None: - numeric_cols = data.select_dtypes(include=[np.number]).columns.tolist() - if len(numeric_cols) == 0: - return json.dumps({ - "status": "error", - "error_type": "no_numeric_columns", - "message": "No numeric columns found in data", - "available_columns": available_columns, - "suggestion": "Ensure your CSV has at least one numeric column for measurements." - }) - - # Skip ID/grouping columns - skip_patterns = ['id', 'subgroup', 'batch', 'sample', 'group', 'lot', 'serial', 'number'] - filtered_cols = [c for c in numeric_cols if not any(p in c.lower() for p in skip_patterns)] - if filtered_cols: - numeric_cols = filtered_cols - - # Prefer measurement-related names - priority_names = ['measurement', 'value', 'measure', 'data', 'reading', 'result', 'defect', 'count'] - for priority in priority_names: - matching = [c for c in numeric_cols if priority in c.lower()] - if matching: - value_col = matching[0] - break - else: - value_col = numeric_cols[0] - - # Validate specified columns exist - if value_col not in data.columns: - return json.dumps({ - "status": "error", - "error_type": "column_not_found", - "message": f"Column '{value_col}' not found in data", - "available_columns": available_columns, - "suggestion": f"Use one of the available columns or let the tool auto-detect (value_col=None)." - }) - - if subgroup_col and subgroup_col not in data.columns: - return json.dumps({ - "status": "error", - "error_type": "column_not_found", - "message": f"Subgroup column '{subgroup_col}' not found in data", - "available_columns": available_columns, - "suggestion": "Check column name or set subgroup_col=None for individual measurements." - }) - - # ===== STEP 3: CREATE PIPELINE AND VALIDATE DATA QUALITY ===== - pipeline = ControlChartPipeline( - data=data, - value_col=value_col, - subgroup_col=subgroup_col, - date_col=date_col, - sample_size_col=sample_size_col, - opportunity_col=opportunity_col - ) - - # Validate data quality - quality_issues = pipeline.validate_data_quality() - - # Check for critical errors - has_critical_errors = any('ERROR' in issue for issue in quality_issues) - if has_critical_errors: - error_messages = [issue for issue in quality_issues if 'ERROR' in issue] - warning_messages = [issue for issue in quality_issues if 'WARNING' in issue] - return json.dumps({ - "status": "error", - "error_type": "data_quality_error", - "message": "Data quality issues detected", - "errors": error_messages, - "warnings": warning_messages, - "suggestion": "Fix the errors in your data and try again." - }) - - # ===== STEP 4: DETECT DATA TYPE ===== - data_type = pipeline.detect_data_type() - - # ===== STEP 5: SELECT CHART TYPE ===== - if chart_type: - # User override - pipeline.chart_type = chart_type - selected_chart = chart_type - else: - # Auto-select - selected_chart = pipeline.select_control_chart() - - # ===== STEP 6: CALCULATE CONTROL LIMITS ===== - try: - limits = pipeline.calculate_control_limits() - except Exception as e: - return json.dumps({ - "status": "error", - "error_type": "calculation_error", - "message": f"Error calculating control limits: {str(e)}", - "suggestion": "Check if your data is appropriate for the selected chart type." - }) - - # ===== STEP 7: GENERATE REPORT ===== - try: - report = pipeline.generate_report() - except Exception as e: - return json.dumps({ - "status": "error", - "error_type": "report_generation_error", - "message": f"Error generating report: {str(e)}", - "suggestion": "Analysis completed but report generation failed." - }) - - # ===== STEP 8: GENERATE HTML REPORT ===== - try: - html_report_path = pipeline.save_report(filename=data_path.replace('.csv', '_control_chart_report.html')) - except Exception as e: - html_report_path = None - html_error = str(e) - - # ===== STEP 9: GENERATE PLOT ===== - try: - plot_fig = pipeline.generate_plot() - plot_generated = True - except Exception as e: - plot_generated = False - plot_error = str(e) - - # ===== STEP 10: PREPARE RESPONSE ===== - # Determine process status - out_of_control_count = len(report.get('out_of_control_points', [])) - if out_of_control_count == 0: - status = "IN CONTROL" - recommendation = "Process is stable and predictable. Continue monitoring. Natural variation only." - else: - status = "OUT OF CONTROL" - recommendation = f"Process has {out_of_control_count} out-of-control points. Investigate special causes using 6M analysis (Man, Machine, Material, Method, Measurement, Environment)." - - # Compile warnings - warnings = [issue for issue in quality_issues if 'WARNING' in issue] - - # Build comprehensive response - response = { - "status": "success", - "analysis_complete": True, - "data_info": { - "file": data_path, - "data_type": data_type, - "sample_size": report.get('sample_size', len(data)), - "columns_used": { - "value_col": value_col, - "subgroup_col": subgroup_col, - "date_col": date_col - } - }, - "chart_info": { - "chart_type": selected_chart, - "description": f"{selected_chart} chart selected for {data_type} data" - }, - "control_limits": { - "UCL": round(float(limits.get('UCL', 0)), 4), - "CL": round(float(limits.get('CL', 0)), 4), - "LCL": round(float(limits.get('LCL', 0)), 4) - }, - "statistical_summary": { - "mean": round(float(report.get('mean', 0)), 4), - "std_dev": round(float(report.get('std_dev', 0)), 4), - "range": round(float(report.get('range', 0)), 4) if 'range' in report else None - }, - "process_status": { - "status": status, - "out_of_control_points": out_of_control_count, - "out_of_control_indices": report.get('out_of_control_points', []) - }, - "recommendations": recommendation, - "html_report": html_report_path if html_report_path else "Report generation failed", - "plot_generated": plot_generated - } - - # Add warnings if any - if warnings: - response["warnings"] = warnings - - return json.dumps(response, indent=2) - - except Exception as e: - return json.dumps({ - "status": "error", - "error_type": "unexpected_error", - "message": f"Unexpected error during analysis: {str(e)}", - "suggestion": "Please check your data format and try again." - }) diff --git a/data_samples/capability_excellent.csv b/data_samples/capability_excellent.csv deleted file mode 100644 index 35990d2..0000000 --- a/data_samples/capability_excellent.csv +++ /dev/null @@ -1,52 +0,0 @@ -measurement -10.05 -10.08 -10.02 -10.06 -10.04 -10.07 -10.03 -10.05 -10.06 -10.04 -10.08 -10.02 -10.05 -10.07 -10.03 -10.06 -10.04 -10.05 -10.07 -10.03 -10.05 -10.06 -10.04 -10.08 -10.02 -10.05 -10.07 -10.03 -10.06 -10.04 -10.05 -10.07 -10.03 -10.05 -10.06 -10.04 -10.08 -10.02 -10.05 -10.07 -10.03 -10.06 -10.04 -10.05 -10.07 -10.03 -10.05 -10.06 -10.04 -10.05 - diff --git a/data_samples/capability_high_variation.csv b/data_samples/capability_high_variation.csv deleted file mode 100644 index cb4a0e0..0000000 --- a/data_samples/capability_high_variation.csv +++ /dev/null @@ -1,52 +0,0 @@ -measurement -9.85 -10.45 -9.62 -10.58 -9.94 -10.32 -9.75 -10.48 -9.88 -10.25 -9.95 -10.38 -9.72 -10.52 -9.90 -10.28 -9.78 -10.42 -9.85 -10.35 -9.92 -10.30 -9.68 -10.55 -9.88 -10.32 -9.82 -10.45 -9.90 -10.28 -9.75 -10.48 -9.88 -10.35 -9.95 -10.25 -9.70 -10.50 -9.85 -10.38 -9.92 -10.30 -9.78 -10.45 -9.88 -10.32 -9.85 -10.35 -9.95 -10.25 - diff --git a/data_samples/capability_off_center.csv b/data_samples/capability_off_center.csv deleted file mode 100644 index c408dff..0000000 --- a/data_samples/capability_off_center.csv +++ /dev/null @@ -1,52 +0,0 @@ -measurement -10.42 -10.45 -10.38 -10.43 -10.40 -10.44 -10.39 -10.42 -10.43 -10.40 -10.45 -10.38 -10.42 -10.44 -10.39 -10.43 -10.40 -10.42 -10.44 -10.39 -10.42 -10.43 -10.40 -10.45 -10.38 -10.42 -10.44 -10.39 -10.43 -10.40 -10.42 -10.44 -10.39 -10.42 -10.43 -10.40 -10.45 -10.38 -10.42 -10.44 -10.39 -10.43 -10.40 -10.42 -10.44 -10.39 -10.42 -10.43 -10.40 -10.42 - diff --git a/data_samples/capability_skewed_data.csv b/data_samples/capability_skewed_data.csv deleted file mode 100644 index 884c985..0000000 --- a/data_samples/capability_skewed_data.csv +++ /dev/null @@ -1,32 +0,0 @@ -measurement -10.01 -10.02 -10.03 -10.04 -10.05 -10.06 -10.07 -10.08 -10.09 -10.10 -10.11 -10.12 -10.13 -10.14 -10.15 -10.18 -10.22 -10.28 -10.35 -10.45 -10.58 -10.75 -10.95 -11.20 -11.50 -11.85 -12.25 -12.70 -13.20 -13.75 - diff --git a/data_samples/msa_bias_study.csv b/data_samples/msa_bias_study.csv deleted file mode 100644 index b3b03a9..0000000 --- a/data_samples/msa_bias_study.csv +++ /dev/null @@ -1,12 +0,0 @@ -Reference,Measurement -10.0,10.15 -10.0,10.12 -10.0,10.18 -10.0,10.14 -10.0,10.16 -10.0,10.11 -10.0,10.19 -10.0,10.13 -10.0,10.17 -10.0,10.15 - diff --git a/data_samples/msa_gage_rr_excellent.csv b/data_samples/msa_gage_rr_excellent.csv deleted file mode 100644 index beaef67..0000000 --- a/data_samples/msa_gage_rr_excellent.csv +++ /dev/null @@ -1,62 +0,0 @@ -Part,Operator,Trial,Measurement -1,A,1,10.12 -1,A,2,10.15 -1,B,1,10.08 -1,B,2,10.14 -1,C,1,10.18 -1,C,2,10.16 -2,A,1,9.95 -2,A,2,9.98 -2,B,1,9.92 -2,B,2,9.96 -2,C,1,10.02 -2,C,2,10.00 -3,A,1,11.25 -3,A,2,11.28 -3,B,1,11.22 -3,B,2,11.27 -3,C,1,11.30 -3,C,2,11.28 -4,A,1,10.55 -4,A,2,10.58 -4,B,1,10.52 -4,B,2,10.56 -4,C,1,10.60 -4,C,2,10.58 -5,A,1,9.35 -5,A,2,9.38 -5,B,1,9.32 -5,B,2,9.36 -5,C,1,9.40 -5,C,2,9.38 -6,A,1,10.75 -6,A,2,10.78 -6,B,1,10.72 -6,B,2,10.76 -6,C,1,10.80 -6,C,2,10.78 -7,A,1,11.45 -7,A,2,11.48 -7,B,1,11.42 -7,B,2,11.46 -7,C,1,11.50 -7,C,2,11.48 -8,A,1,9.65 -8,A,2,9.68 -8,B,1,9.62 -8,B,2,9.66 -8,C,1,9.70 -8,C,2,9.68 -9,A,1,10.85 -9,A,2,10.88 -9,B,1,10.82 -9,B,2,10.86 -9,C,1,10.90 -9,C,2,10.88 -10,A,1,11.15 -10,A,2,11.18 -10,B,1,11.12 -10,B,2,11.16 -10,C,1,11.20 -10,C,2,11.18 - diff --git a/data_samples/msa_gage_rr_poor.csv b/data_samples/msa_gage_rr_poor.csv deleted file mode 100644 index b2273ae..0000000 --- a/data_samples/msa_gage_rr_poor.csv +++ /dev/null @@ -1,62 +0,0 @@ -Part,Operator,Trial,Measurement -1,A,1,10.12 -1,A,2,10.85 -1,B,1,9.45 -1,B,2,10.92 -1,C,1,11.35 -1,C,2,9.88 -2,A,1,9.95 -2,A,2,10.68 -2,B,1,9.22 -2,B,2,10.75 -2,C,1,11.12 -2,C,2,9.65 -3,A,1,11.25 -3,A,2,11.98 -3,B,1,10.55 -3,B,2,12.05 -3,C,1,12.48 -3,C,2,10.98 -4,A,1,10.55 -4,A,2,11.28 -4,B,1,9.85 -4,B,2,11.35 -4,C,1,11.78 -4,C,2,10.28 -5,A,1,9.35 -5,A,2,10.08 -5,B,1,8.65 -5,B,2,10.15 -5,C,1,10.58 -5,C,2,9.08 -6,A,1,10.75 -6,A,2,11.48 -6,B,1,10.05 -6,B,2,11.55 -6,C,1,11.98 -6,C,2,10.48 -7,A,1,11.45 -7,A,2,12.18 -7,B,1,10.75 -7,B,2,12.25 -7,C,1,12.68 -7,C,2,11.18 -8,A,1,9.65 -8,A,2,10.38 -8,B,1,8.95 -8,B,2,10.45 -8,C,1,10.88 -8,C,2,9.38 -9,A,1,10.85 -9,A,2,11.58 -9,B,1,10.15 -9,B,2,11.65 -9,C,1,12.08 -9,C,2,10.58 -10,A,1,11.15 -10,A,2,11.88 -10,B,1,10.45 -10,B,2,11.95 -10,C,1,12.38 -10,C,2,10.88 - diff --git a/data_samples/msa_linearity_study.csv b/data_samples/msa_linearity_study.csv deleted file mode 100644 index 688be9d..0000000 --- a/data_samples/msa_linearity_study.csv +++ /dev/null @@ -1,17 +0,0 @@ -Reference,Measurement -5.0,5.12 -5.0,5.08 -5.0,5.15 -10.0,10.18 -10.0,10.14 -10.0,10.22 -15.0,15.28 -15.0,15.24 -15.0,15.32 -20.0,20.38 -20.0,20.34 -20.0,20.42 -25.0,25.48 -25.0,25.44 -25.0,25.52 - diff --git a/data_samples/msa_stability_study.csv b/data_samples/msa_stability_study.csv deleted file mode 100644 index 4b5220f..0000000 --- a/data_samples/msa_stability_study.csv +++ /dev/null @@ -1,22 +0,0 @@ -Date,Measurement -2024-01-01,10.12 -2024-01-02,10.15 -2024-01-03,10.08 -2024-01-04,10.14 -2024-01-05,10.18 -2024-01-06,10.16 -2024-01-07,10.11 -2024-01-08,10.19 -2024-01-09,10.13 -2024-01-10,10.17 -2024-01-11,10.15 -2024-01-12,10.12 -2024-01-13,10.20 -2024-01-14,10.14 -2024-01-15,10.18 -2024-01-16,10.16 -2024-01-17,10.11 -2024-01-18,10.22 -2024-01-19,10.13 -2024-01-20,10.17 - diff --git a/data_samples/spc_c_chart_data.csv b/data_samples/spc_c_chart_data.csv deleted file mode 100644 index bb4eadc..0000000 --- a/data_samples/spc_c_chart_data.csv +++ /dev/null @@ -1,22 +0,0 @@ -sample,defects -1,5 -2,8 -3,6 -4,7 -5,9 -6,5 -7,4 -8,6 -9,8 -10,7 -11,10 -12,6 -13,5 -14,7 -15,8 -16,6 -17,5 -18,9 -19,7 -20,6 - diff --git a/data_samples/spc_individual_in_control.csv b/data_samples/spc_individual_in_control.csv deleted file mode 100644 index a1d7fae..0000000 --- a/data_samples/spc_individual_in_control.csv +++ /dev/null @@ -1,42 +0,0 @@ -measurement -100.12 -99.85 -100.23 -99.92 -100.15 -99.88 -100.05 -100.18 -99.95 -100.08 -100.22 -99.78 -100.12 -100.05 -99.92 -100.18 -99.85 -100.15 -100.02 -99.95 -100.08 -100.12 -99.88 -100.05 -99.98 -100.15 -100.22 -99.85 -100.08 -99.95 -100.12 -100.05 -99.92 -100.18 -99.88 -100.15 -100.02 -99.95 -100.08 -100.12 - diff --git a/data_samples/spc_individual_out_of_control.csv b/data_samples/spc_individual_out_of_control.csv deleted file mode 100644 index 8271f24..0000000 --- a/data_samples/spc_individual_out_of_control.csv +++ /dev/null @@ -1,32 +0,0 @@ -measurement -100.12 -99.85 -100.23 -99.92 -100.15 -99.88 -100.05 -100.18 -99.95 -100.08 -100.22 -99.78 -100.12 -100.05 -99.92 -105.50 -106.20 -105.85 -106.10 -105.95 -100.08 -100.12 -99.88 -100.05 -99.98 -100.15 -100.22 -99.85 -100.08 -99.95 - diff --git a/data_samples/spc_np_chart_data.csv b/data_samples/spc_np_chart_data.csv deleted file mode 100644 index 975c62d..0000000 --- a/data_samples/spc_np_chart_data.csv +++ /dev/null @@ -1,22 +0,0 @@ -sample,sample_size,defectives -1,100,3 -2,100,5 -3,100,2 -4,100,4 -5,100,3 -6,100,6 -7,100,4 -8,100,2 -9,100,5 -10,100,3 -11,100,7 -12,100,4 -13,100,3 -14,100,5 -15,100,2 -16,100,4 -17,100,3 -18,100,6 -19,100,4 -20,100,3 - diff --git a/data_samples/spc_p_chart_data.csv b/data_samples/spc_p_chart_data.csv deleted file mode 100644 index f48d781..0000000 --- a/data_samples/spc_p_chart_data.csv +++ /dev/null @@ -1,22 +0,0 @@ -sample,inspected,defective -1,100,5 -2,100,8 -3,100,6 -4,100,7 -5,100,9 -6,100,5 -7,100,4 -8,100,6 -9,100,8 -10,100,7 -11,100,10 -12,100,6 -13,100,5 -14,100,7 -15,100,8 -16,100,6 -17,100,5 -18,100,9 -19,100,7 -20,100,6 - diff --git a/data_samples/spc_subgroup_data.csv b/data_samples/spc_subgroup_data.csv deleted file mode 100644 index ad449de..0000000 --- a/data_samples/spc_subgroup_data.csv +++ /dev/null @@ -1,52 +0,0 @@ -subgroup,measurement -1,100.12 -1,100.15 -1,100.08 -1,100.14 -1,100.11 -2,99.95 -2,99.98 -2,99.92 -2,99.96 -2,99.94 -3,100.25 -3,100.28 -3,100.22 -3,100.27 -3,100.24 -4,100.05 -4,100.08 -4,100.02 -4,100.06 -4,100.04 -5,99.85 -5,99.88 -5,99.82 -5,99.86 -5,99.84 -6,100.15 -6,100.18 -6,100.12 -6,100.16 -6,100.14 -7,100.35 -7,100.38 -7,100.32 -7,100.36 -7,100.34 -8,99.95 -8,99.98 -8,99.92 -8,99.96 -8,99.94 -9,100.15 -9,100.18 -9,100.12 -9,100.16 -9,100.14 -10,100.05 -10,100.08 -10,100.02 -10,100.06 -10,100.04 - diff --git a/data_samples/spc_subgroup_stable.csv b/data_samples/spc_subgroup_stable.csv deleted file mode 100644 index 96b2398..0000000 --- a/data_samples/spc_subgroup_stable.csv +++ /dev/null @@ -1,52 +0,0 @@ -subgroup,measurement -1,10.1 -1,10.2 -1,9.9 -1,10.0 -1,10.1 -2,10.0 -2,9.8 -2,10.2 -2,10.1 -2,10.0 -3,10.1 -3,10.0 -3,9.9 -3,10.1 -3,10.2 -4,9.9 -4,10.0 -4,10.1 -4,10.0 -4,9.8 -5,10.2 -5,10.1 -5,10.0 -5,9.9 -5,10.1 -6,10.0 -6,10.1 -6,9.9 -6,10.0 -6,10.2 -7,10.1 -7,10.0 -7,10.1 -7,9.9 -7,10.0 -8,9.8 -8,10.0 -8,10.1 -8,10.2 -8,10.0 -9,10.0 -9,10.1 -9,9.9 -9,10.0 -9,10.1 -10,10.2 -10,10.0 -10,10.1 -10,9.9 -10,10.0 - diff --git a/data_samples/spc_u_chart_data.csv b/data_samples/spc_u_chart_data.csv deleted file mode 100644 index 346c8ae..0000000 --- a/data_samples/spc_u_chart_data.csv +++ /dev/null @@ -1,22 +0,0 @@ -sample,units,defects -1,10,5 -2,12,8 -3,15,12 -4,10,6 -5,20,15 -6,10,7 -7,15,10 -8,12,9 -9,10,5 -10,18,14 -11,10,8 -12,15,11 -13,12,7 -14,10,4 -15,20,16 -16,15,12 -17,10,6 -18,12,8 -19,15,10 -20,10,5 - diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example new file mode 100644 index 0000000..4b2943b --- /dev/null +++ b/deploy/compose/.env.example @@ -0,0 +1,14 @@ +# Copy to deploy/compose/.env and fill in real secrets before `docker compose up`. +# cp deploy/compose/.env.example deploy/compose/.env + +POSTGRES_USER=aspc +POSTGRES_PASSWORD=change-me-db-password +POSTGRES_DB=aspc + +ASPC_JWT_SECRET=change-me-to-a-long-random-string +ASPC_API_KEYS=change-me-api-key +ASPC_ADMIN_USERNAME=admin +ASPC_ADMIN_PASSWORD=change-me-admin-password +ASPC_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 +ASPC_AUTH_ENABLED=true +# Never set ASPC_DEV_INSECURE=1 in production diff --git a/deploy/compose/docker-compose.yml b/deploy/compose/docker-compose.yml new file mode 100644 index 0000000..0d731ec --- /dev/null +++ b/deploy/compose/docker-compose.yml @@ -0,0 +1,194 @@ +# ASPC production stack — Redpanda, Mosquitto, TimescaleDB, Redis, API, stream engine, frontend. +# Usage: +# cp deploy/compose/.env.example deploy/compose/.env # then edit secrets +# docker compose -f deploy/compose/docker-compose.yml --env-file deploy/compose/.env up -d + +services: + redpanda: + image: docker.redpanda.com/redpandadata/redpanda:v24.2.4 + restart: "no" + command: + - redpanda + - start + - --overprovisioned + - --smp + - "1" + - --memory + - 512M + - --reserve-memory + - 0M + - --node-id + - "0" + - --check=false + - --kafka-addr + - INTERNAL://0.0.0.0:9092,EXTERNAL://0.0.0.0:19092 + - --advertise-kafka-addr + - INTERNAL://redpanda:9092,EXTERNAL://localhost:19092 + # Bind to loopback only — infra must not be reachable from the LAN. + ports: + - "127.0.0.1:19092:19092" + - "127.0.0.1:9644:9644" + healthcheck: + test: ["CMD-SHELL", "rpk cluster health | grep -q 'Healthy'"] + interval: 10s + timeout: 5s + retries: 10 + + mosquitto: + image: eclipse-mosquitto:2.0 + restart: "no" + ports: + - "127.0.0.1:1883:1883" + volumes: + - ../mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro + + timescaledb: + image: timescale/timescaledb:latest-pg16 + restart: "no" + env_file: + - .env + environment: + POSTGRES_USER: ${POSTGRES_USER:-aspc} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in deploy/compose/.env} + POSTGRES_DB: ${POSTGRES_DB:-aspc} + ports: + - "127.0.0.1:5433:5432" + volumes: + - tsdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-aspc} -d $${POSTGRES_DB:-aspc}"] + interval: 5s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + restart: "no" + ports: + - "127.0.0.1:6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + + migrate: + build: + context: ../.. + dockerfile: deploy/docker/Dockerfile.api + env_file: + - .env + environment: + ASPC_PERSISTENCE_BACKEND: timescale + ASPC_TIMESCALE_DSN: postgresql+psycopg://${POSTGRES_USER:-aspc}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-aspc} + command: ["alembic", "upgrade", "head"] + depends_on: + timescaledb: + condition: service_healthy + restart: "no" + + api: + build: + context: ../.. + dockerfile: deploy/docker/Dockerfile.api + restart: "no" + env_file: + - .env + environment: + ASPC_PERSISTENCE_BACKEND: timescale + ASPC_TIMESCALE_DSN: postgresql+asyncpg://${POSTGRES_USER:-aspc}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-aspc} + ASPC_REDIS_URL: redis://redis:6379/0 + ASPC_KAFKA_BOOTSTRAP: redpanda:9092 + ASPC_CORS_ORIGINS: ${ASPC_CORS_ORIGINS:-http://localhost:3000} + ASPC_JWT_SECRET: ${ASPC_JWT_SECRET:?Set ASPC_JWT_SECRET in deploy/compose/.env} + ASPC_API_KEYS: ${ASPC_API_KEYS:?Set ASPC_API_KEYS in deploy/compose/.env} + ASPC_ADMIN_USERNAME: ${ASPC_ADMIN_USERNAME:-admin} + ASPC_ADMIN_PASSWORD: ${ASPC_ADMIN_PASSWORD:?Set ASPC_ADMIN_PASSWORD in deploy/compose/.env} + ASPC_AUTH_ENABLED: ${ASPC_AUTH_ENABLED:-true} + ASPC_TSDB_INIT: "0" + PYTHONPATH: /app + ports: + - "8000:8000" + depends_on: + migrate: + condition: service_completed_successfully + timescaledb: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')\""] + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s + + stream-engine: + build: + context: ../.. + dockerfile: deploy/docker/Dockerfile.stream_engine + restart: "no" + env_file: + - .env + environment: + ASPC_TIMESCALE_DSN: postgresql+asyncpg://${POSTGRES_USER:-aspc}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-aspc} + ASPC_REDIS_URL: redis://redis:6379/0 + ASPC_KAFKA_BOOTSTRAP: redpanda:9092 + ASPC_KAFKA_TOPIC: spc.measurements + ASPC_TSDB_INIT: "0" + depends_on: + migrate: + condition: service_completed_successfully + redpanda: + condition: service_healthy + timescaledb: + condition: service_healthy + redis: + condition: service_healthy + + mqtt-bridge: + build: + context: ../.. + dockerfile: deploy/docker/Dockerfile.mqtt_bridge + restart: "no" + environment: + ASPC_MQTT_HOST: mosquitto + ASPC_MQTT_PORT: "1883" + ASPC_MQTT_TOPIC: sensors/# + ASPC_KAFKA_BOOTSTRAP: redpanda:9092 + ASPC_KAFKA_TOPIC: spc.measurements + depends_on: + mosquitto: + condition: service_started + redpanda: + condition: service_healthy + + frontend: + build: + context: ../.. + dockerfile: deploy/docker/Dockerfile.frontend + # NEXT_PUBLIC_* and proxy target are baked at build time. + args: + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-/backend} + NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-ws://localhost:8000} + ASPC_API_PROXY_TARGET: ${ASPC_API_PROXY_TARGET:-http://api:8000} + restart: "no" + ports: + - "3000:3000" + depends_on: + api: + condition: service_healthy + + grafana: + image: grafana/grafana:11.1.0 + profiles: ["ops"] + restart: "no" + ports: + - "127.0.0.1:3001:3000" + volumes: + - ../grafana/provisioning:/etc/grafana/provisioning:ro + depends_on: + - timescaledb + +volumes: + tsdata: diff --git a/deploy/docker/Dockerfile.api b/deploy/docker/Dockerfile.api new file mode 100644 index 0000000..6e13af5 --- /dev/null +++ b/deploy/docker/Dockerfile.api @@ -0,0 +1,32 @@ +# ASPC API service +FROM python:3.12-slim AS base + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv + +COPY pyproject.toml README.md requirements.txt ./ +COPY spc_core ./spc_core +COPY adapters ./adapters +COPY apps ./apps +COPY services ./services +COPY sample_data ./sample_data +COPY resilience_data ./resilience_data +COPY migrations ./migrations +COPY alembic.ini ./ + +RUN uv pip install --system --no-cache -e ".[apps,data,render,tsdb,stream]" + +ENV PYTHONUNBUFFERED=1 +ENV ASPC_API_HOST=0.0.0.0 +ENV ASPC_API_PORT=8000 +ENV PYTHONPATH=/app + +EXPOSE 8000 + +CMD ["aspc-api"] diff --git a/deploy/docker/Dockerfile.frontend b/deploy/docker/Dockerfile.frontend new file mode 100644 index 0000000..ba7d6b9 --- /dev/null +++ b/deploy/docker/Dockerfile.frontend @@ -0,0 +1,38 @@ +# ASPC Next.js frontend — multi-stage Node Alpine build +FROM node:20-alpine AS deps +WORKDIR /app +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci + +FROM node:20-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY frontend/ ./ +RUN mkdir -p public +ENV NEXT_TELEMETRY_DISABLED=1 +# Same-origin /backend proxy (see next.config.js) — browser never cross-origin to :8000 +ARG NEXT_PUBLIC_API_URL=/backend +ARG NEXT_PUBLIC_WS_URL=ws://localhost:8000 +ARG ASPC_API_PROXY_TARGET=http://api:8000 +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL +ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL +ENV ASPC_API_PROXY_TARGET=$ASPC_API_PROXY_TARGET +RUN npm run build + +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 nextjs + +RUN mkdir -p ./public +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 +CMD ["node", "server.js"] diff --git a/deploy/docker/Dockerfile.mqtt_bridge b/deploy/docker/Dockerfile.mqtt_bridge new file mode 100644 index 0000000..94034c3 --- /dev/null +++ b/deploy/docker/Dockerfile.mqtt_bridge @@ -0,0 +1,25 @@ +# ASPC MQTT → Kafka bridge +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv + +COPY pyproject.toml README.md requirements.txt ./ +COPY spc_core ./spc_core +COPY adapters ./adapters +COPY apps ./apps +COPY services ./services +COPY sample_data ./sample_data +COPY resilience_data ./resilience_data + +RUN uv pip install --system --no-cache -e ".[apps,stream]" \ + && (uv pip install --system --no-cache kafka-python-ng || uv pip install --system --no-cache kafka-python) + +ENV PYTHONUNBUFFERED=1 + +CMD ["aspc-mqtt-bridge"] diff --git a/deploy/docker/Dockerfile.stream_engine b/deploy/docker/Dockerfile.stream_engine new file mode 100644 index 0000000..20a2003 --- /dev/null +++ b/deploy/docker/Dockerfile.stream_engine @@ -0,0 +1,27 @@ +# ASPC Phase II stream engine +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv + +COPY pyproject.toml README.md requirements.txt ./ +COPY spc_core ./spc_core +COPY adapters ./adapters +COPY apps ./apps +COPY services ./services +COPY sample_data ./sample_data +COPY resilience_data ./resilience_data +COPY migrations ./migrations +COPY alembic.ini ./ + +RUN uv pip install --system --no-cache -e ".[apps,data,tsdb,stream]" + +ENV PYTHONUNBUFFERED=1 + +CMD ["aspc-stream-engine"] diff --git a/deploy/grafana/provisioning/datasources/datasources.yml b/deploy/grafana/provisioning/datasources/datasources.yml new file mode 100644 index 0000000..a687300 --- /dev/null +++ b/deploy/grafana/provisioning/datasources/datasources.yml @@ -0,0 +1,19 @@ +# Minimal Grafana provisioning so `--profile ops` mounts succeed. +# Replace / extend with real Timescale dashboards as needed. +apiVersion: 1 + +datasources: + - name: ASPC TimescaleDB + type: postgres + access: proxy + url: timescaledb:5432 + user: aspc + database: aspc + isDefault: true + editable: true + jsonData: + sslmode: disable + postgresVersion: 1600 + timescaledb: true + secureJsonData: + password: ${POSTGRES_PASSWORD} diff --git a/deploy/mosquitto/mosquitto.conf b/deploy/mosquitto/mosquitto.conf new file mode 100644 index 0000000..6f87ffa --- /dev/null +++ b/deploy/mosquitto/mosquitto.conf @@ -0,0 +1,7 @@ +# Local compose only. Anonymous publish is intentional for demos. +# Before any non-local / production use: disable anonymous access, add a +# password_file (or TLS client certs), and prefer an internal Docker network +# without publishing 1883 to the host. +listener 1883 +allow_anonymous true +persistence false diff --git a/deploy/vercel/.env.example b/deploy/vercel/.env.example new file mode 100644 index 0000000..4b1a1b5 --- /dev/null +++ b/deploy/vercel/.env.example @@ -0,0 +1,19 @@ +# Copy values into the Vercel API project — do not commit real secrets. + +ASPC_AUTH_ENABLED=true +ASPC_JWT_SECRET=some-long-random-string +ASPC_ADMIN_USERNAME=admin +ASPC_ADMIN_PASSWORD=admin +ASPC_API_KEYS=demokey +ASPC_CORS_ORIGINS=https://aspc-web.vercel.app +ASPC_PERSISTENCE_BACKEND=sqlite +ASPC_SQLITE_PATH=/tmp/aspc.db +ASPC_UPLOAD_DIR=/tmp/aspc-uploads +ASPC_REPORT_DIR=/tmp/aspc-reports +# Needed when using admin/admin or default-looking secrets on a demo: +ASPC_DEV_INSECURE=1 +VERCEL_SUPPORT_LARGE_FUNCTIONS=1 + +# Frontend project (Root Directory = frontend): +# NEXT_PUBLIC_API_URL=https://aspc-plum.vercel.app +# NEXT_PUBLIC_WS_URL=wss://aspc-plum.vercel.app diff --git a/deploy/vercel/Dockerfile.api b/deploy/vercel/Dockerfile.api new file mode 100644 index 0000000..82fa4c4 --- /dev/null +++ b/deploy/vercel/Dockerfile.api @@ -0,0 +1,37 @@ +# Slim ASPC API image for Vercel (HTTP only) — UI + API, nothing else. +# Build from the REPO ROOT (not from this folder): +# docker build -f deploy/vercel/Dockerfile.api -t aspc-api:slim . +# +# No MQTT / Kafka / stream-engine / external DB. Batch Analyze works. +# SQLite at ASPC_SQLITE_PATH (default /tmp/aspc.db) — ephemeral on Vercel. + +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv + +COPY pyproject.toml README.md requirements.txt ./ +COPY spc_core ./spc_core +COPY adapters ./adapters +COPY apps ./apps +COPY services ./services +COPY sample_data ./sample_data + +# No tsdb/stream extras — SQLite + apps only (CSV Analyze; no polars/plotly) +RUN uv pip install --system --no-cache -e ".[apps]" + +ENV PYTHONUNBUFFERED=1 +ENV ASPC_API_HOST=0.0.0.0 +ENV ASPC_API_PORT=8000 +ENV ASPC_PERSISTENCE_BACKEND=sqlite +ENV ASPC_SQLITE_PATH=/tmp/aspc.db + +EXPOSE 8000 + +# Map $PORT → ASPC_API_PORT when Vercel provides it. +CMD ["sh", "-c", "export ASPC_API_PORT=\"${PORT:-${ASPC_API_PORT:-8000}}\" && exec aspc-api"] diff --git a/deploy/vercel/README.md b/deploy/vercel/README.md new file mode 100644 index 0000000..4a7ee16 --- /dev/null +++ b/deploy/vercel/README.md @@ -0,0 +1,62 @@ +# Slim deploy: frontend + API only (Vercel) + +Production Git branch for both Vercel projects is **`frefact`**. + +This folder is **additive** — it does not change Compose or the streaming stack. + +**Only two things:** UI (like `:3000`) and API (like `:8000`). No MQTT/Kafka/Redis/DB service. + +| Included | Not included | +|----------|----------------| +| Next.js UI (`frontend/`) | Mosquitto / Kafka / Redis | +| FastAPI SPC core (Analyze, MSA, capability, auth) | stream-engine, Live + sim | +| SQLite inside the API (`/tmp/aspc.db`, ephemeral) | | + +## API project (`aspc` → aspc-plum.vercel.app) + +1. Framework: **FastAPI** +2. Root Directory: **empty / `.`** (never set this to a Dockerfile path) +3. Install Command: `pip install -e ".[apps]"` then uninstall serverless-unused wheels (`uvloop`, `watchfiles`, `httptools`, `openpyxl`, `redis`, `prometheus-client`) so numpy/scipy fit under 225 MB. Skip `render`/plotly. Analyze JSON still works; xlsx export / Redis metrics are Compose-only. +4. Env (at least): + +```text +ASPC_AUTH_ENABLED=true +ASPC_JWT_SECRET=some-long-random-string +ASPC_ADMIN_USERNAME=admin +ASPC_ADMIN_PASSWORD=admin +ASPC_API_KEYS=demokey +ASPC_PERSISTENCE_BACKEND=sqlite +ASPC_SQLITE_PATH=/tmp/aspc.db +ASPC_UPLOAD_DIR=/tmp/aspc-uploads +ASPC_REPORT_DIR=/tmp/aspc-reports +ASPC_CORS_ORIGINS=https://aspc-web.vercel.app +ASPC_DEV_INSECURE=1 +VERCEL_SUPPORT_LARGE_FUNCTIONS=1 +``` + +Uploads/reports default to `/tmp/...` automatically when `VERCEL=1` is present. + +`VERCEL_SUPPORT_LARGE_FUNCTIONS=1` is required so numpy/scipy fit past the default ~225 MB function limit. + +Do **not** put a root `Dockerfile.vercel` unless Container Images are enabled for the team — otherwise deploys can become empty 2s “Ready” no-ops. + +5. Check: + +```bash +curl -sS https://aspc-plum.vercel.app/health +``` + +## Frontend project (`aspc-web`) + +Root Directory: **`frontend`**. Env: + +```text +NEXT_PUBLIC_API_URL=https://aspc-plum.vercel.app +NEXT_PUBLIC_WS_URL=wss://aspc-plum.vercel.app +``` + +## Local slim image (optional) + +```bash +docker build -f deploy/vercel/Dockerfile.api -t aspc-api:slim . +``` diff --git a/deploy/vercel/vercel.json b/deploy/vercel/vercel.json new file mode 100644 index 0000000..3cda8cd --- /dev/null +++ b/deploy/vercel/vercel.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "fastapi", + "installCommand": "pip install -e \".[apps]\" && pip uninstall -y uvloop watchfiles httptools openpyxl et-xmlfile redis prometheus-client" +} diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..624ddbd --- /dev/null +++ b/docs/api.md @@ -0,0 +1,185 @@ +# HTTP API reference + +Source: [`apps/api/main.py`](../apps/api/main.py). Interactive docs: `GET /docs` (Swagger) when the server is running. + +```bash +aspc serve --port 8000 +# or: aspc-api +``` + +## Authentication + +### JWT (`Authorization: Bearer …`) + +Most analyze / history endpoints depend on `get_current_user`: + +- If `auth.enabled` / `ASPC_AUTH_ENABLED` is **false**, requests proceed as anonymous. +- If enabled, a valid Bearer JWT is required. + +Obtain a token (password must match `ASPC_ADMIN_PASSWORD`; username must match +`ASPC_ADMIN_USERNAME`, default `admin`). Startup refuses the default password +`admin` and an empty API-key list unless `ASPC_DEV_INSECURE=1`: + +```bash +curl -s -X POST http://localhost:8000/auth/token \ + -d 'username=admin&password=$ASPC_ADMIN_PASSWORD' +# → {"access_token":"…","token_type":"bearer"} +``` + +### API key (`X-API-Key`) + +Required for stream mutations and alert ack (`require_api_key`): + +- Keys from config `auth.api_keys` or `ASPC_API_KEYS` (comma-separated) +- If **no keys configured**, requests fail closed unless `ASPC_DEV_INSECURE=1` + +```bash +curl -H "X-API-Key: $ASPC_API_KEY" -H "Authorization: Bearer $TOKEN" … +``` + +## Common response shape + +Analyze endpoints return `AnalyzeResponse`: + +```json +{ + "status": "success", + "run_id": "…", + "analysis_type": "control_chart", + "report": { }, + "html_report": "var/reports/_control_chart.html", + "checklist": { "passed": false, "items": [ ] } +} +``` + +`checklist` is present for control-chart analysis (Phase I go-live items). + +## Endpoints + +### Health and metrics + +| Method | Path | Auth | Notes | +|--------|------|------|-------| +| `GET` | `/` | no | Endpoint map | +| `GET` | `/health` | no | `{status, version, checks}` | +| `GET` | `/metrics` | JWT | Prometheus text (if `prometheus-client` installed) | + +### Auth + +| Method | Path | Body | +|--------|------|------| +| `POST` | `/auth/token` | OAuth2 password form: `username`, `password` | +| `GET` | `/auth/me` | JWT — `{username, role, tenant_id}` | + +### Analyze (multipart form + file) + +All require JWT when auth is enabled. Upload CSV/Parquet (`file`). + +#### `POST /analyze/control-chart` + +Form fields: `value_col`, `subgroup_col`, `sample_size_col`, `opportunity_col`, `chart_type`, `ruleset`, `valid_range_min` / `valid_range_max`, optional `msa_file` + `msa_tolerance`, `include_records` (bool). Identity comes from the JWT, not a spoofable Form `user_id`. + +Runs `establish` + `phase1_checklist`, saves limits and run, optional HTML under `var/reports/`. + +```bash +TOKEN=$(curl -s -X POST http://localhost:8000/auth/token \ + -d 'username=op&password=admin' | python3 -c 'import sys,json; print(json.load(sys.stdin)["access_token"])') + +curl -s -X POST http://localhost:8000/analyze/control-chart \ + -H "Authorization: Bearer $TOKEN" \ + -F "file=@examples/data/spc_individual_in_control.csv" \ + -F "include_records=false" +``` + +#### `POST /analyze/capability` + +Form: `usl` (required), `lsl` (required), `target`, `value_col`, `subgroup_col`, `user_id`. Rejects `usl <= lsl`. + +#### `POST /analyze/msa` + +Form: `study_type`, `method` (`anova`\|`range`), `tolerance`, `part_col`, `operator_col`, `measurement_col`, `trial_col`, `reference_col`. + +#### `POST /analyze/msa-continuous` + +JSON body for streaming MSA drift (`spc_core.msa_stream`). JWT. Used by the MSA page “Continuous MSA” panel. + +#### `POST /analyze/explain` + +JSON: one `Signal` (or list) plus optional `limits_version` / limits snapshot. Returns structured `operator_summary` from [`spc_core.explain`](../spc_core/explain.py) — no LLM. + +#### `POST /analyze/counterfactual` + +Sandbox re-`establish` on posted values without mutating stored frozen limits. + +### Onboarding, Lab, ops + +| Method | Path | Auth | Notes | +|--------|------|------|-------| +| `GET` | `/onboarding/sample` | JWT | Query `dataset=` — CSV body from `sample_data` | +| `POST` | `/onboarding/demo-stream` | JWT + API key | Register + go-live helper for the wizard | +| `GET` | `/lab/cases` | JWT | Resilience catalog ids | +| `POST` | `/lab/cases/{case_id}/run` | JWT (analyst/admin) | Run one judgment case | +| `GET` | `/ops/summary` | JWT | Compact ops counts for Overview | +| `GET` | `/limits/{version_a}/diff/{version_b}` | JWT | Limit-version diff | +| `GET` | `/runs/{run_id}/export.xlsx` | JWT | Excel export of a stored run | + +Compose API sets `PYTHONPATH=/app` so `/lab/cases` can import `resilience_data`. + +### History + +| Method | Path | Notes | +|--------|------|-------| +| `GET` | `/runs` | Query: `analysis_type`, `limit` (≤500) | +| `GET` | `/runs/{run_id}` | Full stored run | +| `GET` | `/reports/{run_id}` | HTML if generated, else JSON fallback (JWT) | + +### Streams (TimescaleDB backend) + +These call repository methods available on the Timescale adapter. With SQLite they return **501** (register / go-live / ack) or an empty list (list). + +| Method | Path | Auth | Body / notes | +|--------|------|------|----------------| +| `POST` | `/streams/register` | JWT + API key | JSON: `stream_key`, optional `topic`, `chart_type`, `ruleset`, `meta` | +| `POST` | `/streams/{stream_key}/go-live` | JWT + API key | JSON: `limits_version`, optional `ruleset` — freezes go-live against stored limits | +| `GET` | `/streams` | JWT | Query: `active_only` | +| `POST` | `/alerts/{event_id}/ack` | JWT + API key | Acknowledge OOC event | + +```bash +curl -X POST http://localhost:8000/streams/register \ + -H "Authorization: Bearer $TOKEN" -H "X-API-Key: demokey" \ + -H "Content-Type: application/json" \ + -d '{"stream_key":"line-a","chart_type":"I-MR","ruleset":"nelson"}' + +curl -X POST http://localhost:8000/streams/line-a/go-live \ + -H "Authorization: Bearer $TOKEN" -H "X-API-Key: demokey" \ + -H "Content-Type: application/json" \ + -d '{"limits_version":"<16-char-hash>"}' +``` + +### Live WebSocket + +`WS /ws/live/{stream_key}` + +Subscribes to Redis channel `spc:live:{stream_key}` and forwards JSON messages. Requires `redis` package and a reachable `ASPC_REDIS_URL`. + +When auth is enabled, after the handshake the client must send a first text frame: + +```json +{"type": "auth", "token": ""} +``` + +Do **not** put the JWT in the query string. First server message after auth: `{"event":"subscribed","channel":"…"}`. + +### SSE replay + +`GET /stream/replay?file_path=…&value_col=measurement&limits_version=…` + +Server-side CSV replay against frozen limits; emits SSE `data:` lines of `Signal` JSON, then `{"event":"done"}`. + +## CORS + +Configured via `api.cors_origins` / `ASPC_CORS_ORIGINS` (comma-separated). Compose `.env.example` allows both `http://localhost:3000` and `http://127.0.0.1:3000`. + +The operator UI talks to the API via same-origin **`/backend/*`** (Next.js rewrite to the API). Browsers on `:3000` do not need a cross-origin call to `:8000`. Direct `curl` to `:8000` still works. + +See [configuration.md](configuration.md) and [deployment.md](deployment.md). diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..5b04e23 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,267 @@ +# ASPC structural & architectural scan + +ASPC is a **hexagonal / clean-layered modular monolith** for production Statistical Process Control. Pure math lives in `spc_core/`; I/O and persistence in `adapters/`; HTTP/CLI in `apps/`; long-running stream workers in `services/`. Deployable as library + CLI, or as a Compose stack (API, stream-engine, mqtt-bridge + Redpanda, Mosquitto, TimescaleDB, Redis, Next.js UI). + +**Primary pattern:** hexagonal / ports-and-adapters (not classic MVC microservices). One shared Python package (`aspc`), three optional long-running processes, shared domain library. + +**Domain contract:** Phase I (`establish`) gates and freezes versioned limits → Phase II (`Phase2Evaluator`) evaluates new points against frozen limits and never recomputes them. + +--- + +## High-level system architecture + +```mermaid +flowchart TB + subgraph clients [Clients] + UI[Next.js Dashboard] + CLI[aspc CLI] + Sensors[Sensors / MES / MQTT] + end + + subgraph appsLayer [Application Layer] + API[FastAPI apps/api] + end + + subgraph domain [Domain - spc_core] + Pipeline[establish Phase I] + Charts[charts / limits / rules] + Eval[Phase2Evaluator] + MSA[msa / capability] + end + + subgraph adaptersLayer [Adapters] + Repo[Repository SQLite or Timescale] + SE[StreamEngine] + Plotly[Plotly HTML] + Sources[KafkaSource / MQTTSource] + end + + subgraph servicesLayer [Deployable Services] + Bridge[mqtt_bridge] + EngineProc[stream_engine] + end + + subgraph infra [Infrastructure] + RP[Redpanda Kafka] + MQTT[Mosquitto] + TSDB[TimescaleDB] + Redis[Redis pub/sub] + end + + UI -->|REST JWT| API + UI -->|WebSocket| API + CLI --> Pipeline + API --> Pipeline + API --> Charts + API --> MSA + API --> Repo + API --> Plotly + API -->|subscribe| Redis + + Sensors --> MQTT + MQTT --> Bridge + Bridge --> RP + RP --> EngineProc + EngineProc --> SE + SE --> Eval + SE --> Repo + SE --> Redis + + Repo --> TSDB +``` + +--- + +## Key folders and responsibilities + +| Path | Responsibility | +|------|----------------| +| [`spc_core/`](../spc_core/) | Pure statistics: ingest, cleaning, normality/multimodal gates, Shewhart/EWMA/CUSUM, rules, MSA, capability, `establish()` pipeline, `Phase2Evaluator`, report models | +| [`adapters/`](../adapters/) | Side effects: file I/O, SQLite/Timescale repos, Plotly, Kafka/MQTT sources, `StreamEngine`, SQLAlchemy models | +| [`apps/api/`](../apps/api/main.py) | FastAPI: auth, batch analyze, runs/reports, stream registry/go-live, WS live, SSE replay | +| [`apps/cli/`](../apps/cli/main.py) | `aspc` CLI (`control-chart`, `capability`, `msa`, `serve`, `doctor`, `demo`, `resilience`) | +| [`apps/config.yaml`](../apps/config.yaml) + [`apps/config.py`](../apps/config.py) | YAML defaults + `ASPC_*` env overrides | +| [`services/stream_engine/`](../services/stream_engine/main.py) | Kafka consumer process → Phase II → DB + Redis | +| [`services/mqtt_bridge/`](../services/mqtt_bridge/main.py) | MQTT → Redpanda producer | +| [`frontend/`](../frontend/) | Next.js 14 operator UI (onboarding, analyze, MSA, capability, live, lab, runs) | +| [`deploy/`](../deploy/) | Compose, Dockerfiles, Mosquitto, Grafana, Vercel helpers | +| [`migrations/`](../migrations/) | Alembic (Timescale hypertables + analysis tables) | +| [`combinatorial/`](../combinatorial/) | Finite batch + in-process Phase II matrix + engine behavior report | +| [`tests/`](../tests/) | unit / integration / resilience / combinatorial / load | +| [`resilience_data/`](../resilience_data/) | Standards-mapped CSV corpus + MANIFEST expects | +| [`docs/`](./) | Product overview, API, config, deploy, pipeline concepts | +| [`sample_data/`](../sample_data/), [`examples/`](../examples/), [`benchmarks/`](../benchmarks/), [`scripts/`](../scripts/) | Demo data, accuracy/perf benches, live sims, resilience tooling | + +--- + +## Critical dependencies and configuration + +### Tech stack + +- **Language:** Python 3.11+ (core/services); TypeScript/React 18 (UI) +- **Stats:** NumPy, SciPy, Pydantic, diptest; optional Polars, Plotly +- **API:** FastAPI, Uvicorn, JWT (`python-jose`), API keys, SlowAPI, Prometheus, Structlog +- **Frontend:** Next.js 14 App Router, TanStack Query, Plotly, Tailwind +- **Persistence:** SQLite (local default) or TimescaleDB/Postgres (streaming/prod) via SQLAlchemy async + Alembic +- **Streaming:** Redpanda (Kafka API), Mosquitto MQTT, Redis pub/sub +- **Packaging:** [`pyproject.toml`](../pyproject.toml) + [`uv.lock`](../uv.lock); console scripts `aspc`, `aspc-api`, `aspc-stream-engine`, `aspc-mqtt-bridge` + +### Core config surfaces + +- [`apps/config.yaml`](../apps/config.yaml) — API/CORS, auth, uploads, persistence backend, Redis, Kafka, SPC ruleset / Phase I thresholds +- [`env.example`](../env.example) — `ASPC_JWT_SECRET`, admin, API keys, `ASPC_DEV_INSECURE`, DSN, Redis, Kafka +- [`frontend/.env.example`](../frontend/.env.example) — `NEXT_PUBLIC_API_URL`, `NEXT_PUBLIC_WS_URL` +- [`deploy/compose/docker-compose.yml`](../deploy/compose/docker-compose.yml) — full stack orchestration + +### Persistence models ([`adapters/db_models.py`](../adapters/db_models.py)) + +- Batch: `control_limits`, `analysis_runs`, `audit_log`, `capability_history` +- Streaming: `raw_measurements` (hypertable), `ooc_events`, `stream_registry` + +Streaming path requires Timescale (`save_raw_measurement`); SQLite is batch/prototype only. + +--- + +## Domain modules (`spc_core`) + +- **Models:** [`models.py`](../spc_core/models.py) — `ChartType`, `ControlLimits`, `LimitSet`, `Signal`, `SPCRecord` +- **Pipeline:** [`pipeline.py`](../spc_core/pipeline.py) — `Gate`, `establish`, `phase1_checklist`, `checklist_ready_for_golive` +- **Charts / limits / rules:** [`charts.py`](../spc_core/charts.py), [`limits.py`](../spc_core/limits.py), [`rules.py`](../spc_core/rules.py) +- **Phase II:** [`evaluator.py`](../spc_core/evaluator.py) — `Phase2Evaluator`, `evaluate_batch` +- **Explain:** [`explain.py`](../spc_core/explain.py) — `explain_signal` (deterministic operator text) +- **MSA / capability:** [`msa.py`](../spc_core/msa.py), [`capability.py`](../spc_core/capability.py) +- **Ingest / cleaning / gates:** [`ingest.py`](../spc_core/ingest.py), [`cleaning.py`](../spc_core/cleaning.py), [`normality.py`](../spc_core/normality.py), [`multimodal.py`](../spc_core/multimodal.py) + +--- + +## API surface ([`apps/api/main.py`](../apps/api/main.py)) + +| Area | Endpoints | +|------|-----------| +| Ops | `GET /`, `/health`, `/metrics` (JWT), `/ops/summary` | +| Auth | `POST /auth/token`, `GET /auth/me` | +| Batch analyze | `POST /analyze/control-chart`, `/analyze/capability`, `/analyze/msa`, `/analyze/explain` | +| Onboarding / lab | `GET /onboarding/sample`, `POST /onboarding/demo-stream`, `GET /lab/cases`, `POST /lab/cases/{id}/run` | +| History | `GET /runs`, `/runs/{id}`, `/reports/{id}` | +| Live control | `POST /streams/register`, `/streams/{key}/go-live`, `GET /streams`, `POST /alerts/{id}/ack` | +| Live data | `WS /ws/live/{stream_key}` (first-message JWT), `GET /stream/replay` (SSE) | + +**Auth:** JWT Bearer for analyze/UI; API keys (`X-API-Key`) for stream register/go-live/ack; WebSocket auth is the first JSON frame `{"type":"auth","token"}` (not a query param). Startup refuses insecure defaults unless `ASPC_DEV_INSECURE=1`. + +See also [api.md](api.md) for request/response detail. + +--- + +## Main data / request flows + +### A. Batch Phase I (HTTP) + +```mermaid +sequenceDiagram + participant UI as Frontend_or_CLI + participant API as FastAPI + participant Core as spc_core + participant DB as Repository + + UI->>API: POST /analyze/control-chart + API->>Core: ingest + establish + Core-->>API: PipelineResult gates + chart + alt limits frozen + API->>DB: save_limits + save_run + end + API-->>UI: AnalyzeResponse + optional Plotly HTML +``` + +1. Upload CSV → `load_columns` / `ingest` +2. `establish(...)` runs gate sequence (ok/warn/stop) + chart +3. If frozen: persist versioned limits + run metadata +4. Optional Plotly report; checklist for go-live readiness + +Capability/MSA follow the same app path with `capability_analysis` / Gage R&R entry points. + +### B. Go-live → Phase II registration + +1. `POST /streams/register` then `POST /streams/{key}/go-live` (JWT + API key) +2. Validates frozen limits / checklist → `repo.register_stream(active=True)` +3. `stream_engine` loads limits via `StreamEngine.load_limits` + +### C. Live streaming (MQTT → UI) + +```mermaid +flowchart LR + Sensors --> Mosquitto + Mosquitto --> mqtt_bridge + mqtt_bridge --> Redpanda + Redpanda --> stream_engine + stream_engine --> Phase2Evaluator + Phase2Evaluator --> TimescaleDB + Phase2Evaluator --> Redis + Redis --> FastAPI_WS + FastAPI_WS --> NextJS_Live +``` + +1. MQTT message → [`mqtt_bridge`](../services/mqtt_bridge/main.py) normalizes → Kafka topic +2. [`stream_engine`](../services/stream_engine/main.py) → `StreamEngine.handle_message` → `Phase2Evaluator.observe` +3. Persist Tier-1 raw + Tier-2 OOC events; publish Redis `spc:live:{stream_key}` +4. API `ws_live` subscribes and fans out to the Live page + +### D. SSE replay (demo / file-based Phase II) + +`GET /stream/replay` → `FileReplaySource` + `stream_evaluate` (no Kafka). + +--- + +## Frontend map ([`frontend/src/app/`](../frontend/src/app/)) + +| Route | Role | +|-------|------| +| `/` | Overview: health, runs, streams | +| `/login` | JWT login | +| `/onboarding` | Sample → establish → go-live wizard | +| `/analyze`, `/capability`, `/msa` | Batch upload workflows | +| `/live` | Phase II WebSocket + alerts + go-live | +| `/lab` | Resilience catalog browse/run | +| `/runs`, `/runs/[run_id]` | Run history + report | + +REST from the browser uses same-origin **`/backend/*`**, rewritten by Next.js to the API (`ASPC_API_PROXY_TARGET`, Compose default `http://api:8000`). Clients: [`frontend/src/lib/api.ts`](../frontend/src/lib/api.ts), [`ws.ts`](../frontend/src/lib/ws.ts). + +--- + +## Deploy topology (Compose) + +| Service | Port | Role | +|---------|------|------| +| `api` | 8000 | FastAPI | +| `frontend` | 3000 | Next.js | +| `redpanda` | 19092 | Kafka bus | +| `mosquitto` | 1883 | MQTT | +| `timescaledb` | 5433 | Persistence | +| `redis` | 6379 | Live fan-out | +| `stream-engine` | — | Phase II worker | +| `mqtt-bridge` | — | MQTT → Kafka | +| `migrate` | — | Alembic once | +| `grafana` (ops profile) | 3001 | Timescale dashboards | + +Full ops notes: [deployment.md](deployment.md). + +--- + +## Tests & quality evidence + +- **Unit:** [`tests/unit/`](../tests/unit/) — core math, pipeline, API, security, stream +- **Integration:** [`tests/integration/`](../tests/integration/) — batch e2e + realtime (gated on Timescale/Redis) +- **Resilience:** [`tests/resilience/`](../tests/resilience/) + [`resilience_data/`](../resilience_data/) — parametrized catalog asserting gates/rules +- **Combinatorial:** [`combinatorial/`](../combinatorial/) + [`tests/combinatorial/`](../tests/combinatorial/) — batch/stream matrix, sparse in CI +- **Load:** Locust in [`tests/load/`](../tests/load/) +- **Frontend:** Vitest + Playwright (`frontend/e2e/`, stack-required except `home.spec.ts`) +- **CI:** [`.github/workflows/ci.yml`](../.github/workflows/ci.yml) — ruff, mypy, pytest, resilience + sparse combinatorial, benches, frontend build, Docker images + +Contributor workflow: [development.md](development.md). + +--- + +## Summary + +ASPC separates **correct SPC math** (`spc_core`) from **I/O and ops** (`adapters` / `apps` / `services`). Batch analysis is a classic request → domain → repository flow; real-time monitoring is an event pipeline (MQTT → Kafka → evaluator → Timescale/Redis → WebSocket). The architectural spine is the Phase I freeze contract: limits are established under gates, versioned, then evaluated in Phase II without recomputation. + +Health insights, quick wins, and enterprise roadmap: [overview/health-and-roadmap.md](overview/health-and-roadmap.md). diff --git a/docs/aspc_system_doc.html b/docs/aspc_system_doc.html new file mode 100644 index 0000000..e8a7165 --- /dev/null +++ b/docs/aspc_system_doc.html @@ -0,0 +1,1197 @@ + + + + + +ASPC — As-Built System Document v2.0.0 + + + + + +
    + + +
    +
    AS-BUILT SYSTEM DOCUMENT · v2.0.0
    +

    ASPC — Production
    Statistical Process
    Control Platform

    +

    + What the product actually does today: a pure spc_core library, gated Phase I → freeze → Phase II contract, + TimescaleDB-backed streaming, FastAPI/CLI surfaces, Next.js operator dashboard, and the security/ops hardening + that landed through Phases 1–4. +

    +
    +
    STATUS
    Production / As-Built
    +
    VERSION
    spc_core 2.0.0 · API 2.0.0
    +
    DATE
    July 2026
    +
    STACK
    Python · FastAPI · TimescaleDB · Kafka · Redis · Next.js
    +
    STANDARDS
    AIAG MSA-4 · AIAG SPC · ISO 7870 · Six Sigma DMAIC
    +
    +
    + + + + + +
    +
    +
    01
    +
    +
    System Overview & Layered Architecture
    +
    Pure core library, adapters for I/O and streaming, thin application surfaces
    +
    +
    + +
    +
    +
    The pure-core rule. spc_core/ has no I/O, no pandas, no polars, no FastAPI, no Redis. It accepts NumPy arrays and plain Python types and returns Pydantic models. Everything else — files, databases, brokers, HTML, WebSockets — lives in adapters/, apps/, or services/.
    +
    + +
    +
    FIG 1.1 LAYERED ARCHITECTURE
    +
    +flowchart TB + FE[Next.js Dashboard
    Live · Analyze · Capability · MSA · Runs] + API[FastAPI apps/api
    JWT · API keys · WebSocket] + CLI[CLI apps/cli
    control-chart · capability · msa · serve] + SE[stream-engine
    Kafka consumer · Phase II] + MB[mqtt-bridge
    MQTT → Kafka] + + FE --> API + CLI --> CORE + API --> ADAPTERS + SE --> ADAPTERS + MB --> Kafka + + subgraph apps_layer [Applications and Services] + API + CLI + SE + MB + end + + subgraph adapters_layer [Adapters] + ADAPTERS[io_files · persistence · persistence_tsdb
    stream_engine · stream_sources
    render_plotly · archive · factory] + end + + subgraph core_layer [spc_core — Pure Computation] + CORE[pipeline · charts · limits · rules
    evaluator · msa · capability
    normality · cleaning · models] + end + + ADAPTERS --> CORE + Kafka[(Redpanda / Kafka)] + Redis[(Redis pub/sub)] + TSDB[(TimescaleDB)] + MQTT[(Mosquitto MQTT)] + + MB --> MQTT + SE --> Kafka + SE --> Redis + SE --> TSDB + API --> Redis + API --> TSDB + + style CORE fill:#081218,stroke:#00D4FF,color:#00D4FF + style ADAPTERS fill:#0d0818,stroke:#B57AFF,color:#C9A8FF + style FE fill:#081a0d,stroke:#39FF6E,color:#8FD9A8 +
    +
    + +
    CORE MODULES — spc_core/
    +
    +
    pipeline.py
    Gated Phase I master pipeline: establish(), phase1_checklist(), freeze/stop contract.
    +
    charts.py
    Chart selection matrix and batch orchestrator analyze_control_chart().
    +
    limits.py
    Phase I limit math: I-MR, Xbar-R/S, P/NP/C/U.
    +
    rules.py
    Stateful Nelson / Western Electric / Wheeler run-rule engine.
    +
    evaluator.py
    Phase II Phase2Evaluator — fixed, variable-n, EWMA, CUSUM.
    +
    ewma.py / cusum.py
    EWMA with time-varying limits; tabular two-sided CUSUM.
    +
    msa.py
    Gage R&R ANOVA/range, bias, linearity, stability, NDC, resolution gates.
    +
    msa_stream.py
    Continuous MSA: reference injection and bias-drift alerts.
    +
    capability.py
    Cp/Cpk/Pp/Ppk, DPMO ↔ sigma, parametric/transformed/nonparametric.
    +
    normality.py
    Shapiro-Wilk / AD / KS, ACF gate, Box-Cox / log / Yeo-Johnson.
    +
    multimodal.py
    Hartigan dip test — stratification STOP gate.
    +
    cleaning.py
    SPC missing-value classification — no silent imputation.
    +
    ingest.py
    Column auto-detection and frame validation (column-dict input).
    +
    models.py
    Pydantic contracts: ChartType, ControlLimits, SPCRecord, Gate, …
    +
    constants.py
    Shewhart factors as functions of subgroup size n.
    +
    report.py
    Pure report models (SPC / MSA / Capability) — no HTML/I/O.
    +
    +
    + + +
    +
    +
    02
    +
    +
    The Gated Phase I Pipeline
    +
    establish() — real gate order, freeze contract, go-live checklist
    +
    +
    + +
    + Phase I is not “run a chart.” It is a decisional pipeline that validates the measurement system, + classifies missing data, checks independence and distribution, selects a chart/ruleset, then either + freezes versioned control limits or blocks freeze when a STOP gate fires. + Phase II only evaluates against limits that survived this contract. +
    + +
    +
    FIG 2.1 establish() — ACTUAL GATE ORDER
    +
    +flowchart TD + A([Raw Phase I sample]) --> B[msa gate
    %GRR / NDC] + B --> C{gage_resolution
    provided?} + C -->|yes| D[gage_resolution gate
    10:1 rule] + C -->|no| E[missing gate
    classify_missing] + D --> E + E --> F[autocorrelation gate
    ACF lag-1] + F --> G{Autocorrelated?} + G -->|YES| H[Route EWMA or CUSUM
    skip normality + multimodal] + G -->|NO| I[multimodal gate
    Hartigan dip] + I --> J{Multimodal?} + J -->|STOP| K[stop — stratify] + J -->|ok| L[normality gate
    transform or Wheeler] + H --> M[chart gate
    analyze_control_chart] + L --> M + K --> M + M --> N{Any STOP?} + N -->|YES| O[freeze: blocked
    frozen = false] + N -->|NO| P[freeze: ok
    frozen = true
    version hash] + O --> Q([PipelineResult
    diagnostic only]) + P --> R([PipelineResult
    limits ready for Phase II]) + + style A fill:#1a0808,stroke:#FF6B35,color:#FF9060 + style K fill:#1a0808,stroke:#FF4EA3,color:#FF9AAD + style O fill:#1a0808,stroke:#FF6B35,color:#FF6B35 + style P fill:#081a0d,stroke:#39FF6E,color:#39FF6E + style R fill:#081a0d,stroke:#39FF6E,color:#8FD9A8 + style H fill:#0d0818,stroke:#B57AFF,color:#C9A8FF +
    +
    + +
    GATE STEPS
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    STEPWHEN EMITTEDSTATUSESNOTES
    msaAlwaysok warn stop%GRR <10 ok; 10–30 warn; >30 stop. Warn if MSA inputs omitted.
    gage_resolutionOnly if resolution + tolerance providedok stop10:1 resolution gate.
    missingAlwaysok warnSPC classify — never silent drop/impute.
    autocorrelationAlwaysok warnWarn + route EWMA/CUSUM when ACF > threshold (default 0.2).
    multimodalShewhart path onlyok stopSkipped on EWMA/CUSUM route. STOP → stratify.
    normalityShewhart path onlyok warnTransform if possible; else Wheeler ruleset. Skipped on EWMA/CUSUM.
    chartAlwaysokRuns analyze_control_chart with selected type/ruleset.
    freezeAlwaysok blockedfrozen = not stopped. Blocked lists stop_steps in detail.
    +
    + +
    +
    +
    Enforcement chain. A STOP gate sets PipelineResult.frozen = False. The API skips save_limits for unfrozen runs. POST /streams/{key}/go-live rejects registration when stored meta has frozen: false, stopped: true, or checklist_passed: false. Diagnostic charts may still render — they must not go live.
    +
    + +
    phase1_checklist() — GO-LIVE ITEMS
    +
    +
      +
    • msa_grr_ndc — MSA %GRR and NDC acceptable
    • +
    • normality_path — distribution path chosen and recorded
    • +
    • autocorrelation — ACF checked; EWMA/CUSUM if needed
    • +
    • outliers_investigated — caller-supplied (default true)
    • +
    • missing_classified — every gap flagged; no silent imputation
    • +
    • min_subgroups — default ≥ 25 subgroups / points
    • +
    • limits_frozen — freeze gate ok; version hash present
    • +
    • transform_documented — transform method recorded when used
    • +
    • gage_calibration — caller-supplied (default true)
    • +
    • phase2_enabled — caller-supplied (default false until go-live)
    • +
    • stratificationconditional: only when multimodal gate is STOP
    • +
    +
    +
    + + +
    +
    +
    03
    +
    +
    Chart Selection & Run Rules
    +
    Nine ChartType members, three rulesets, normality → transform → Wheeler branch
    +
    +
    + +
    CHART TYPES
    +
    + + + + + + + + + + + + + + + +
    ChartTypeVALUEDATA SHAPELIMITS
    I_MRI-MRIndividuals (no subgroup)Fixed UCL/CL/LCL + MR secondary
    XBAR_RXbar-RSubgroups, n ≤ ~10Fixed or variable-n; R secondary
    XBAR_SXbar-SSubgroups, larger nFixed or variable-n; S secondary
    PPDefectives / sample sizeVariable limits; rejects n ≤ 0
    NPNPDefectives, constant nFixed limits
    CCDefect counts, equal areaFixed limits
    UUDefects / opportunitiesVariable limits; rejects o ≤ 0
    EWMAEWMAIndividuals, ACF routeTime-varying limits; stateful z
    CUSUMCUSUMIndividuals, ACF routeC+/C− tabular; k, h params
    +
    + +
    RULESETS
    +
    + + + + + + + + + + + + + + + + + + + + + +
    RULESETRULE IDsWHEN SELECTED
    nelson1–8 (beyond 3σ, runs, trends, zones, mixture…)Default for normal / successfully transformed Shewhart charts
    western_electricWE1–WE4Explicit config; classic Western Electric zone rules
    wheelerRule 1 only — points beyond 3σAuto when non-normal and transform fails; skips zone/run tests
    +
    + +
    +
    FIG 3.1 NORMALITY → TRANSFORM → WHEELER BRANCH
    +
    +flowchart TD + A([Shewhart path — ACF ok]) --> B[check_normality] + B --> C{is_normal?} + C -->|YES| D[ruleset = nelson
    dist = NORMAL] + C -->|NO| E[apply_transform method=auto
    Box-Cox / log / Yeo-Johnson] + E --> F{became_normal?} + F -->|YES| G[use transformed values
    ruleset = nelson
    dist = TRANSFORMED] + F -->|NO| H[Wheeler robust path
    ruleset = wheeler
    dist = NON_NORMAL_RAW] + H --> I{subgroup_ids present?} + I -->|YES| J[Keep Xbar-R / Xbar-S
    only switch ruleset] + I -->|NO| K[I-MR with Wheeler rules] + + style D fill:#081a0d,stroke:#39FF6E,color:#8FD9A8 + style G fill:#081a0d,stroke:#39FF6E,color:#8FD9A8 + style H fill:#0d0818,stroke:#B57AFF,color:#C9A8FF + style J fill:#081218,stroke:#00D4FF,color:#7ADCF7 +
    +
    + +
    +
    +
    Hardening Phase 1. The Wheeler path no longer flattens subgrouped data into I-MR. When subgroup_ids are present, the chart type is preserved and only the ruleset switches to wheeler. The old force_wheeler or True unconditional branch is gone.
    +
    +
    + + +
    +
    +
    04
    +
    +
    Measurement System Analysis
    +
    Static studies, continuous MSA, and pipeline gate thresholds
    +
    +
    + +
    + MSA runs before process charts in Phase I. A measurement system that consumes most of the tolerance + will manufacture false OOC signals no matter how good the chart math is. +
    + +
    STATIC STUDIES — spc_core/msa.py
    +
    +
    gage_rr_anova
    Crossed ANOVA Gage R&R. Unbalanced designs fall back to range method automatically.
    +
    gage_rr_range
    Range method Gage R&R — used directly or as ANOVA fallback.
    +
    bias_study
    Bias vs reference standard with significance test.
    +
    linearity_study
    Bias across the measurement range.
    +
    stability_study
    Measurement stability over time (control-chart style).
    +
    ndc_gate / gage_resolution_gate
    NDC ≥ 5 and 10:1 resolution vs tolerance STOP/OK gates.
    +
    + +
    %GRR THRESHOLDS IN THE PIPELINE
    +
    + + + + + + + + + +
    %GRR OF TOLERANCEGATE STATUSACTION
    < 10%okMeasurement system acceptable — proceed
    10% – 30%warnConditional — document risk, consult QE
    > 30%stopFix the gage before freezing limits
    +
    + +
    CONTINUOUS MSA — spc_core/msa_stream.py
    +
    + ContinuousMSA supports reference-standard injection on a live stream, tracks EWMA bias drift, + and emits CalibrationAlert events. The library API is complete; it is not yet wired into + StreamEngine as an automatic Phase II sidecar (see §11). +
    +
    + + +
    +
    +
    05
    +
    +
    Capability & Six Sigma
    +
    Parametric, transformed, and nonparametric capability with DPMO ↔ sigma
    +
    +
    + +
    +
    FIG 5.1 CAPABILITY ROUTING
    +
    +flowchart TD + A([Measurements + USL/LSL]) --> B[check_normality] + B --> C{Normal?} + C -->|YES| D[parametric_capability
    Cp Cpk Pp Ppk Cpm] + C -->|NO| E[apply_transform] + E --> F{Became normal?} + F -->|YES| G[parametric on transformed
    back-transform interpretation] + F -->|NO| H[nonparametric_capability
    percentile / Cnpk path] + D --> I[dpmo_to_sigma] + G --> I + H --> I + I --> J([CapabilityResult
    + sigma level]) + + style D fill:#081a0d,stroke:#39FF6E,color:#8FD9A8 + style G fill:#081218,stroke:#00D4FF,color:#7ADCF7 + style H fill:#0d0818,stroke:#B57AFF,color:#C9A8FF +
    +
    + +
    + + + + + + + + + + + +
    INDEXUSESNOTES
    Cp / CpkWithin-subgroup σShort-term capability; Cpk = min(CPU, CPL)
    Pp / PpkOverall σLong-term performance
    CpmTarget-centeredTaguchi-style when target ≠ midpoint
    CnpkPercentile-basedNonparametric path for stubborn non-normal data
    DPMO → σdpmo_to_sigma / sigma_to_dpmoSix Sigma conversion helpers
    +
    +
    + + +
    +
    +
    06
    +
    +
    Data Quality & the SPC Record
    +
    SPC-specific cleaning rules and the per-point output schema
    +
    +
    + +
    +
    +
    SPC cleaning ≠ ML cleaning. No mean imputation. No silent drops. Every gap gets a QualityFlag. LOCF is capped at 3 consecutive points. Control-chart points that were imputed are still flagged so Phase II can treat them cautiously.
    +
    + +
    QUALITY & DISTRIBUTION FLAGS
    +
    + + + + + + + + + + + + + + + + + + + + + +
    ENUMMEMBERSPURPOSE
    QualityFlagORIGINAL · IMPUTED_LOCF · EXCLUDED · MISSING · OUT_OF_RANGE · …Per-point data-quality classification from classify_missing() / range checks
    DistributionFlagNORMAL · TRANSFORMED · NON_NORMAL_RAWWhich distribution path the pipeline took
    PhasePHASE_I · PHASE_IIWhether limits were being established or applied
    +
    + +
    SPCRecord — PER-POINT OUTPUT SCHEMA
    +
    + + + + + + + + + + + + + + + + +
    FIELDTYPEREQUIREDPURPOSE
    timestampdatetime UTCIF PROVIDEDTime order; passed through from ingest when present
    subgroup_idint / nullIF SUBGROUPEDRational subgroup linkage
    valuefloatALWAYSOriginal or transformed measurement
    quality_flagQualityFlagALWAYSORIGINAL / IMPUTED_LOCF / EXCLUDED / …
    distribution_flagDistributionFlagALWAYSNORMAL / TRANSFORMED / NON_NORMAL_RAW
    transform_appliedstring / nullIF TRANSFORMEDe.g. BOXCOX(λ=…), LOG
    phasePhaseALWAYSPHASE_I or PHASE_II
    ucl / lcl / centerfloatPHASE IIFrom frozen ControlLimits — must not drift
    gage_idstring / nullOPTIONALMSA / calibration linkage
    machine_idstring / nullOPTIONALStratification and traceability
    +
    +
    + + +
    +
    +
    07
    +
    +
    Real-Time Architecture
    +
    MQTT → Kafka → StreamEngine → Timescale + Redis → WebSocket → dashboard
    +
    +
    + +
    +
    FIG 7.1 LIVE DATA PATH
    +
    +flowchart LR + S[Sensors / PLCs] --> M[Mosquitto MQTT
    sensors/#] + M --> B[mqtt-bridge] + B --> K[Redpanda / Kafka
    spc.measurements] + K --> E[stream-engine
    StreamEngine] + E --> T[(TimescaleDB
    raw + ooc)] + E --> R[(Redis
    spc:live:key)] + R --> A[FastAPI
    /ws/live/key] + A --> UI[Next.js /live] + + style E fill:#081218,stroke:#00D4FF,color:#00D4FF + style T fill:#0d0818,stroke:#B57AFF,color:#C9A8FF + style UI fill:#081a0d,stroke:#39FF6E,color:#8FD9A8 +
    +
    + +
    + Contract: StreamEngine only evaluates against frozen Phase I limits + registered via /streams/register + /streams/{key}/go-live. + Each observation is written to Tier-1 raw_measurements, evaluated by a keyed + Phase2Evaluator (Shewhart fixed, variable-n, EWMA, or CUSUM), and any OOC signal + is persisted to ooc_events and published on Redis for the live dashboard. +
    + +
    DURABILITY & RELIABILITY (HARDENING PHASE 3)
    +
    + + + + + + + + + + + + +
    MECHANISMBEHAVIOR
    Evaluator state restoreOn re-register, seed index + rule buffer / EWMA z / CUSUM C± from recent raw measurements
    LRU stream evictionBounded in-memory evaluator map; inactive streams dropped under pressure
    Deterministic raw IDsHash(stream_key, ts, value) + ON CONFLICT DO NOTHING — Kafka replay is idempotent
    Manual Kafka commitsenable_auto_commit=False; commit after successful handle; poison messages committed past
    Kafka / MQTT reconnectExponential backoff on disconnect for both sources
    SQLite rejectionrequire_streaming_repository() raises if repo lacks save_raw_measurement
    +
    +
    + + +
    +
    +
    08
    +
    +
    Storage Architecture
    +
    SQLite for batch; TimescaleDB for streaming; seven schema tables
    +
    +
    + +
    +
    +
    Backend selection. adapters/factory.py picks SQLite (default) or TimescaleDB from config. Streaming services call require_streaming_repository(), which hard-rejects SQLite because it has no Tier-1 raw hypertable path.
    +
    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    TABLETIERKEY COLUMNSPURPOSE
    raw_measurementsTier-1id, ts, stream_key, value, quality_flag, machine_id, gage_id, limits_versionHypertable; ~90-day retention; idempotent inserts
    ooc_eventsTier-2stream_key, limits_version, index, value, rule_id, ts, acked…Unique (stream_key, ts, rule_id); ack workflow
    analysis_runsTier-2run_id, analysis_type, limits_version, report JSONBatch Phase I / capability / MSA run archive
    capability_historyTier-2run_id, cpk, ppk, sigma_levelCapability trend over runs
    control_limitsControl planeversion (PK), chart_type, payload, metaFrozen limits + version content hash
    stream_registryControl planestream_key, topic, limits_version, chart_type, ruleset, activeWhich streams are live and which limits they use
    audit_logControl planeevent_id, event, detail, user_idSecurity and operational audit trail
    +
    + +
    + Cold export: adapters/archive.py can dump raw measurements to Parquet/CSV. + There is no automated hot→cold archival job yet — retention is Timescale policy only. + Schema changes ship via Alembic; compose runs a migrate service before API and stream-engine start + (ASPC_TSDB_INIT=0 disables runtime create_all). +
    +
    + + +
    +
    +
    09
    +
    +
    Interfaces
    +
    HTTP API, CLI, Python library entry points, and configuration
    +
    +
    + +
    HTTP API — apps/api/main.py
    +
    + + + + + + + + + + + + + + + + + + + + + +
    METHODPATHAUTH
    GET/none
    GET/healthnone — probes persistence + Redis
    GET/metricsnone
    POST/auth/tokennone — rate limit 10/min
    POST/analyze/control-chartJWT
    POST/analyze/capabilityJWT
    POST/analyze/msaJWT
    GET/runs · /runs/{run_id}JWT
    GET/reports/{run_id}JWT + path sanitize
    POST/streams/registerJWT + X-API-Key
    POST/streams/{key}/go-liveJWT + X-API-Key
    GET/streamsJWT
    POST/alerts/{event_id}/ackJWT + X-API-Key
    WS/ws/live/{stream_key}JWT ?token=
    GET/stream/replayJWT + upload-dir containment
    +
    + +
    CLI — aspc
    +
    +
    aspc control-chart
    Phase I via establish(); persist run + limits when frozen.
    +
    aspc capability
    Process capability analysis with USL/LSL.
    +
    aspc msa
    gage_rr / bias / linearity / stability studies.
    +
    aspc serve
    Start FastAPI via uvicorn.
    +
    + +
    PYTHON — key entry points
    +
    + establish() · phase1_checklist() · analyze_control_chart() · + Phase2Evaluator · capability_analysis() · + gage_rr_anova() / gage_rr_range() · + ewma_chart() / cusum_chart() · + classify_missing() · ingest() +
    + +
    CONFIGURATION — ASPC_* OVERRIDES
    +
    + + + + + + + + + + + + + + + + + + +
    ENV VARTARGETDEFAULT / NOTES
    ASPC_PERSISTENCE_BACKENDpersistence.backendsqlite or timescale
    ASPC_SQLITE_PATHpersistence.sqlite_pathaspc.db
    ASPC_TIMESCALE_DSN / DATABASE_URLpersistence.timescale_dsnrequired for streaming
    ASPC_JWT_SECRETauth.jwt_secretrefused if default in production
    ASPC_ADMIN_USERNAME / PASSWORDauth.admin_*bcrypt-hashed at runtime
    ASPC_API_KEYSauth.api_keyscomma-separated; fail-closed if empty
    ASPC_AUTH_ENABLEDauth.enableddefault true
    ASPC_DEV_INSECUREauth.dev_insecurelocal escape hatch only
    ASPC_CORS_ORIGINSapi.cors_originsdefault localhost:3000 only
    ASPC_REDIS_URLredis.urlredis://localhost:6379/0
    ASPC_KAFKA_BOOTSTRAP / TOPICkafka.*stream-engine + bridge
    ASPC_TSDB_INIT(runtime)0 in compose — Alembic owns schema
    +
    +
    + + +
    +
    +
    10
    +
    +
    Security, Operations & Quality Gates
    +
    Auth controls, compose stack, CI enforcement, test inventory
    +
    +
    + +
    SECURITY CONTROLS
    +
    + + + + + + + + + + + + + + + + +
    CONTROLSTATUSDETAIL
    bcrypt admin loginyesDirect bcrypt; username must match admin
    JWT BeareryesHS256; expire minutes configurable
    Default JWT secret refusalyesStartup check unless ASPC_DEV_INSECURE=1
    Fail-closed API keysyesEmpty key list → 401 (unless dev_insecure)
    WebSocket JWTyes?token= validated; close 1008 on fail
    Path containmentyes_safe_run_id + resolve_under for reports/replay
    Streamed uploadsyesChunked write + incremental max-bytes check
    Login rate limitpartialslowapi 10/min on /auth/token only
    CORS defaultsyeslocalhost:3000 / 127.0.0.1:3000 — not *
    Go-live gate enforcementyesRejects unfrozen / failed checklist
    +
    + +
    DOCKER COMPOSE SERVICES
    +
    + + + + + + + + + + + + + + + + +
    SERVICEHOST PORTSROLE
    redpanda19092, 9644Kafka-compatible broker
    mosquitto1883MQTT broker
    timescaledb5433 → 5432Primary streaming DB
    redis6379Live OOC pub/sub
    migrateAlembic upgrade head (one-shot)
    api8000FastAPI
    stream-enginePhase II Kafka consumer
    mqtt-bridgeMQTT → Kafka forwarder
    frontend3000Next.js operator UI
    grafana3001Ops profile only
    +
    + +
    CI JOBS — .github/workflows/ci.yml
    +
    + + + + + + + + + + +
    JOBGATESENFORCED
    pythonruff · mypy spc_core · pytest (not integration) · acceptance · Py 3.11+3.12blocking
    integrationTimescaleDB + Redis services · ASPC_INTEGRATION=1blocking
    frontendlint · test · buildblocking
    dockerBuild API + frontend imagesblocking
    +
    + +
    + Tests: 106 pytest functions across 20 files (unit + integration), plus frontend Vitest and a Playwright e2e smoke. + Sample data: 16 datasets in DATASET_CATALOG covering in/out-of-control individuals, subgroups, + attribute charts, Gage R&R excellent/poor, bias/linearity/stability, and capability scenarios. + Sample data is excluded from the published wheel. +
    +
    + + +
    +
    +
    11
    +
    +
    Spec Coverage & Known Gaps
    +
    Honest scoring of the original MVP document against what shipped
    +
    +
    + +
    +
    +
    The original MVP HTML (refactoring elements/spc_mvp_doc.html) was a design target. This section maps each of its eight sections to the as-built product — no marketing gloss.
    +
    + +
    MVP SECTION VERDICTS
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    MVP SECTIONVERDICTEVIDENCE / GAP
    01 Master Pipeline Overviewpartialestablish() implements the decisional flow; step order differs slightly (missing before ACF); no dedicated outlier-classification step
    02 Normality & Distribution TreepartialACF → EWMA/CUSUM, transform, Wheeler, multimodal STOP all real; no ARIMA-residuals route; no visual QQ pipeline
    03 Ingestion Mode ArchitecturepartialCSV/Parquet batch + Kafka/MQTT streaming work; no NATS / OPC-UA / Modbus adapters; no QuestDB
    04 Pandas vs Polars Decisionnot implementedCore is deliberately dataframe-free (NumPy + column dicts). Polars is optional Parquet I/O only — by design, not a miss
    05 MSA & Gage R&R FlowpartialStatic studies + pipeline gates complete; ContinuousMSA library exists but is not wired into StreamEngine
    06 Missing Value Classificationpartialclassify_missing() + QualityFlag real; no incomplete-subgroup reduced-n, no maintenance-restart tree, no KNN-for-Cpk-only path
    07 Storage ArchitecturepartialSQLite + Timescale two-tier real; no QuestDB; no automated hot→cold archival job
    08 SPC-Ready Output SchemaimplementedSPCRecord, to_records(), versioned ControlLimits, 10-item checklist — core complete
    +
    + +
    KNOWN GAPS (AS-BUILT)
    +
    +
    No dataframe routing layer
    Intentional: spc_core stays NumPy-pure. Polars is adapter-only for Parquet.
    +
    No ARIMA residual route
    Autocorrelated data routes to EWMA/CUSUM only — not ARIMA-then-Shewhart-on-residuals.
    +
    No OPC-UA / Modbus / NATS
    Live ingress is MQTT + Kafka. Industrial protocol adapters are future work.
    +
    No QuestDB path
    TimescaleDB is the streaming store; QuestDB from the MVP spec was not adopted.
    +
    Outlier classification placeholder
    Checklist item outliers_investigated is caller-supplied; no automated special-vs-common cause step.
    +
    ContinuousMSA not in StreamEngine
    Library ready; live auto-injection of reference standards into the stream path is not hooked up.
    +
    Rate limit = login only
    slowapi is wired; analyze/upload endpoints are not rate-limited yet.
    +
    No automated cold archive job
    archive.py helper exists; retention is Timescale policy, not a scheduled Parquet dump.
    +
    docs/development.md CI section
    Slightly stale vs Phase 4 — omits mypy and the integration job (code/CI are correct).
    +
    + +
    +
    +
    Bottom line. ASPC v2.0.0 is a production Phase I/II SPC platform with real chart math, enforced freeze gates, Timescale-backed streaming durability, and hardened auth. The gaps above are known product boundaries — not hidden stubs behind the advertised ChartType surface.
    +
    +
    + +
    + + +
    +
    ASPC AS-BUILT SYSTEM DOCUMENT v2.0.0
    +
    Standards: AIAG MSA-4 · AIAG SPC · ISO 7870 · Six Sigma DMAIC
    +
    Stack: Python · FastAPI · TimescaleDB · Kafka · Redis · Next.js
    +
    + +
    + + + + diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..7187200 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,146 @@ +# CLI reference (`aspc`) + +Entry point: [`apps/cli/main.py`](../apps/cli/main.py) → console script `aspc`. + +```bash +uv venv && source .venv/bin/activate +uv pip install -e ".[dev]" +uv run python -m sample_data --out examples/data +``` + +## Commands + +### `aspc control-chart` + +Runs the gated Phase I pipeline (`establish`) and persists limits + run to SQLite. + +| Flag | Required | Description | +|------|----------|-------------| +| `-f` / `--file` | yes | CSV or Parquet path | +| `--value-col` | no | Measurement column (auto-detected if omitted) | +| `--subgroup-col` | no | Subgroup id column | +| `--sample-size-col` | no | For P / NP charts | +| `--opportunity-col` | no | For U charts | +| `--chart-type` | no | One of `ChartType` values (`I-MR`, `Xbar-R`, …) | +| `--ruleset` | no | Default from config (`nelson`) | +| `--html PATH` | no | Write Plotly HTML report | +| `--json` | no | Print full JSON report to stdout | + +Example: + +```bash +aspc control-chart -f examples/data/spc_individual_out_of_control.csv --json +aspc control-chart -f examples/data/spc_subgroup_data.csv --html /tmp/xbar.html +``` + +Human output includes chart type, limits version, signal count, checklist pass flag, and up to 10 signals. + +### `aspc capability` + +| Flag | Required | Description | +|------|----------|-------------| +| `-f` / `--file` | yes | Input file | +| `--usl` | yes | Upper spec limit | +| `--lsl` | yes | Lower spec limit | +| `--target` | no | Target for Cpm | +| `--value-col` | no | Measurement column | +| `--subgroup-col` | no | Optional subgroups for short-term sigma | +| `--html` | no | HTML report path | +| `--json` | no | JSON report | + +```bash +aspc capability -f examples/data/capability_excellent.csv --usl 10.5 --lsl 9.5 +``` + +### `aspc msa` + +| Flag | Required | Description | +|------|----------|-------------| +| `-f` / `--file` | yes | Input file | +| `--study-type` | no | `gage_rr` \| `bias` \| `linearity` \| `stability` (auto if omitted) | +| `--method` | no | `anova` (default) or `range` for Gage R&R | +| `--tolerance` | no | Spec tolerance for %GRR of tolerance | +| `--part-col` | no | Part column | +| `--operator-col` | no | Operator column | +| `--measurement-col` | no | Measurement column | +| `--reference-col` | no | Reference for bias / linearity | +| `--html` | no | HTML report | +| `--json` | no | JSON report | + +Auto study selection: + +- Part + operator columns → `gage_rr` +- Reference column with >1 unique value → `linearity`; else `bias` +- Otherwise → `stability` + +```bash +aspc msa -f examples/data/msa_gage_rr_excellent.csv --study-type gage_rr +aspc msa -f examples/data/msa_bias_study.csv --study-type bias +``` + +### `aspc serve` + +Starts the FastAPI app via uvicorn. + +| Flag | Default | +|------|---------| +| `--host` | config `api.host` (`0.0.0.0`) | +| `--port` | config `api.port` (`8000`) | + +```bash +aspc serve --port 8000 +# equivalent entry point: +aspc-api +``` + +`aspc serve` is the **minimal** path (SQLite, no Live streaming). Full Compose already starts the API — do not also run `aspc serve` on port 8000. + +### `aspc doctor` + +Prints a local health checklist: config load, persistence backend, Redis/Kafka URLs, Compose file present, webhook URL, `ASPC_TENANT_ID`. Exit `1` if required pieces are missing. + +```bash +aspc doctor +``` + +### `aspc demo up` + +Prints how to start a demo. It does **not** start Docker by itself. + +```bash +aspc demo up +``` + +Full stack (Compose starts API + UI + streaming): + +```bash +cp deploy/compose/.env.example deploy/compose/.env +docker compose -f deploy/compose/docker-compose.yml --env-file deploy/compose/.env up -d --build +# Open http://localhost:3000/onboarding +``` + +### `aspc resilience` + +Runs the [`resilience_data/`](../resilience_data/) judgment catalog. + +| Flag | Description | +|------|-------------| +| `--report` | Write `resilience_data/JUDGMENT.md` (default if `--case` omitted) | +| `--case ID` | Run a single catalog case and print JSON | + +```bash +aspc resilience +aspc resilience --case imr_in_control_n50 +``` + +See [resilience_data/README.md](../resilience_data/README.md). Combinatorial matrix (batch + in-process Phase II): `python -m combinatorial report --mode sparse`. + +## Column auto-detection + +[`spc_core.ingest`](../spc_core/ingest.py) maps common header names (`measurement`, `Part`, `Operator`, `subgroup`, `defective`, `inspected`, …). Pass explicit `--*-col` flags when headers are nonstandard. + +## Persistence + +CLI writes to the configured SQLite path (`ASPC_SQLITE_PATH` / `persistence.sqlite_path`, default `aspc.db`): frozen limits and analysis runs. Reports HTML goes to `--html` if provided. + +See [configuration.md](configuration.md) and [api.md](api.md). diff --git a/docs/concepts.md b/docs/concepts.md new file mode 100644 index 0000000..e044e8d --- /dev/null +++ b/docs/concepts.md @@ -0,0 +1,105 @@ +# SPC concepts (as implemented) + +This page describes the statistics ASPC actually computes. Source of truth: `spc_core/`. + +## Chart types + +`ChartType` in [`spc_core/models.py`](../spc_core/models.py): + +| Value | Use when | +|-------|----------| +| `I-MR` | Continuous data, subgroup size 1 (individuals + moving range) | +| `Xbar-R` | Continuous, subgroup size 2–8 | +| `Xbar-S` | Continuous, subgroup size ≥ 9 | +| `NP` | Defectives, constant sample size | +| `P` | Defectives, variable sample size | +| `C` | Defects (counts), constant opportunity | +| `U` | Defects, variable opportunity | +| `EWMA` | Small shifts / autocorrelated series (λ, L) | +| `CUSUM` | Small persistent shifts (tabular two-sided C+/C−) | + +Auto-selection (`select_chart_type` / `analyze_control_chart`): + +- Continuous + no subgroups → `I-MR` +- Continuous + median subgroup size ≤ 8 → `Xbar-R`; else `Xbar-S` +- Attribute with `sample_sizes` → `P` if sizes vary, else `NP` +- Attribute with `opportunities` → `U` if sizes vary, else `C` + +You can override with an explicit `chart_type`. + +## Run rules + +[`spc_core/rules.py`](../spc_core/rules.py) — `RuleEngine(ruleset=...)`: + +| Ruleset | Behavior | +|---------|----------| +| `nelson` (default) | Nelson rules 1–8 (zones use inclusive boundaries) | +| `western_electric` | Western Electric subset | +| `wheeler` | Points-outside-limits only (robust path for non-normal data) | + +Primary and secondary panels (R / MR / S) are both evaluated when present (`secondary_signals` on `ControlChartResult`). + +## Measurement System Analysis (MSA) + +[`spc_core/msa.py`](../spc_core/msa.py) and continuous tracking in [`spc_core/msa_stream.py`](../spc_core/msa_stream.py). + +| Study | Function | Key outputs | +|-------|----------|-------------| +| Gage R&R (ANOVA) | `gage_rr_anova` | `grr_percent`, `ndc`, `acceptability` | +| Gage R&R (Range) | `gage_rr_range` | Same flat `grr_percent` contract | +| Bias | `bias_study` | mean bias, t-test, `is_significant` | +| Linearity | `linearity_study` | slope, R², `is_linear` | +| Stability | `stability_study` | I-MR style limits, `is_stable` | + +Acceptability (AIAG MSA-4 style): + +- `%GRR < 10` → Excellent (also requires NDC ≥ 5) +- `10 ≤ %GRR < 30` → Acceptable / conditional +- `%GRR ≥ 30` or NDC < 5 → Unacceptable + +Gates: + +- `ndc_gate(ndc, minimum=5)` — discrimination for SPC +- `gage_resolution_gate(resolution, tolerance, ratio=10.0)` — 10:1 rule + +Continuous MSA (`ContinuousMSA`) tracks EWMA bias drift and a rolling R chart on reference injections; emits `CalibrationAlert` when bias exceeds threshold. + +## Process capability + +[`spc_core/capability.py`](../spc_core/capability.py): + +- Parametric: Cp, Cpk, Pp, Ppk, Cpm (when target given), sigma level, DPMO +- Nonparametric: percentile-based Ppk when normality fails and transform does not restore it +- Transformed path: Box-Cox / Yeo-Johnson / log when transform restores normality; specs are transformed consistently +- Helpers: `dpmo_to_sigma`, `sigma_to_dpmo` (analytic via `norm.ppf`, not bucket tables) + +`capability_analysis(..., method="auto")` routes by normality and transform success. + +## Normality, transforms, multimodality + +[`spc_core/normality.py`](../spc_core/normality.py), [`spc_core/multimodal.py`](../spc_core/multimodal.py): + +- Normality: Anderson-Darling, Shapiro-Wilk, Kolmogorov-Smirnov (Lilliefors when `statsmodels` is available), skewness / kurtosis +- Autocorrelation: lag-1 ACF vs configurable threshold (default 0.2); recommends EWMA / CUSUM when elevated +- Transforms: `apply_transform(..., method="auto")` +- Multimodal: Hartigan dip test (+ histogram peak heuristic); recommendation to **stratify** (pipeline STOP) + +## Quality and distribution flags + +From [`spc_core/models.py`](../spc_core/models.py): + +**`QualityFlag`** (per measurement provenance — no silent imputation): + +`ORIGINAL`, `IMPUTED_LOCF`, `MISSING_SENSOR`, `EXCLUDED_MAINTENANCE`, `EXCLUDED_INCOMPLETE`, `RESTORED_FROM_BACKUP`, `MISSING_HUMAN` + +**`DistributionFlag`**: + +`NORMAL`, `TRANSFORMED`, `NON_NORMAL_RAW` (Wheeler path) + +**`Phase`**: `PHASE_I` | `PHASE_II` + +## Limits and versioning + +`ControlLimits` is a frozen Pydantic model. `version` is a 16-character SHA-256 content hash of chart type, subgroup size, components, and sigma. Phase II and the stream registry must reference this version explicitly. + +See [pipeline.md](pipeline.md) for how these pieces are orchestrated. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..a27312b --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,111 @@ +# Configuration + +Application config is YAML defaults merged with environment overrides. Sources: + +- [`apps/config.yaml`](../apps/config.yaml) — checked-in defaults +- [`apps/config.py`](../apps/config.py) — load + merge + `ASPC_*` overrides +- [`env.example`](../env.example) — copy to `.env` at repo root + +**Precedence:** environment variables win over YAML. + +## YAML sections + +### `api` + +| Key | Default | Meaning | +|-----|---------|---------| +| `host` | `0.0.0.0` | Bind address | +| `port` | `8000` | HTTP port | +| `cors_origins` | `["http://localhost:3000", "http://127.0.0.1:3000"]` | CORS allow list | + +### `auth` + +| Key | Default | Meaning | +|-----|---------|---------| +| `enabled` | `true` | Require JWT on protected routes | +| `jwt_secret` | `change-me-in-production` (refused at startup unless `ASPC_DEV_INSECURE=1`) | HS256 secret | +| `jwt_algorithm` | `HS256` | JWT algorithm | +| `jwt_expire_minutes` | `60` | Token lifetime | +| `admin_password` | `admin` (refused at startup unless `ASPC_DEV_INSECURE=1`) | Password accepted by `/auth/token` | +| `api_keys` | `[]` (refused at startup unless `ASPC_DEV_INSECURE=1`) | Allowed `X-API-Key` values | + +### `webhooks` + +| Key | Default | Meaning | +|-----|---------|---------| +| `url` | `null` | Optional signed OOC webhook URL (copied onto stream `meta` at go-live) | +| `secret` | `null` | HMAC secret for webhook payloads | + +Env: `ASPC_WEBHOOK_URL`, `ASPC_WEBHOOK_SECRET`. + +### `uploads` / `reports` + +| Key | Default | +|-----|---------| +| `uploads.temp_directory` | `var/uploads` | +| `uploads.max_file_size_mb` | `10` | +| `uploads.allowed_extensions` | `.csv`, `.parquet`, `.pq` | +| `reports.auto_generate` | `true` | +| `reports.output_directory` | `var/reports` | +| `reports.include_plots` | `true` | + +Directories are created on write. Both under `var/` are gitignored. + +### `persistence` + +| Key | Default | Meaning | +|-----|---------|---------| +| `backend` | `sqlite` | `sqlite` or `timescale` | +| `sqlite_path` | `aspc.db` | Local DB file | +| `timescale_dsn` | `null` | SQLAlchemy/asyncpg DSN | + +Factory: [`adapters/factory.py`](../adapters/factory.py) → `SQLiteRepository` or `TimescaleDBRepository`. + +### `redis` / `kafka` + +| Key | Default | +|-----|---------| +| `redis.url` | `redis://localhost:6379/0` | +| `kafka.bootstrap` | `localhost:9092` | +| `kafka.topic` | `spc.measurements` | + +### `spc` + +| Key | Default | Meaning | +|-----|---------|---------| +| `ruleset` | `nelson` | Default run-rule set | +| `min_phase1_points` | `25` | Checklist minimum plotted points | +| `acf_threshold` | `0.2` | Lag-1 ACF gate | + +## Environment overrides + +| Variable | Maps to | +|----------|---------| +| `ASPC_AUTH_ENABLED` | `auth.enabled` (`1`/`true`/`yes`) | +| `ASPC_JWT_SECRET` | `auth.jwt_secret` | +| `ASPC_ADMIN_PASSWORD` | `auth.admin_password` | +| `ASPC_API_KEYS` | `auth.api_keys` (comma-separated) | +| `ASPC_API_HOST` | `api.host` | +| `ASPC_API_PORT` | `api.port` | +| `ASPC_CORS_ORIGINS` | `api.cors_origins` (comma-separated) | +| `ASPC_PERSISTENCE_BACKEND` | `persistence.backend` | +| `ASPC_SQLITE_PATH` | `persistence.sqlite_path` | +| `ASPC_TIMESCALE_DSN` / `DATABASE_URL` | `persistence.timescale_dsn` | +| `ASPC_REDIS_URL` | `redis.url` | +| `ASPC_KAFKA_BOOTSTRAP` | `kafka.bootstrap` | +| `ASPC_KAFKA_TOPIC` | `kafka.topic` | +| `ASPC_WEBHOOK_URL` | `webhooks.url` | +| `ASPC_WEBHOOK_SECRET` | `webhooks.secret` | + +MQTT bridge also reads `ASPC_MQTT_HOST`, `ASPC_MQTT_PORT`, `ASPC_MQTT_TOPIC` (service-level, not in `Config`). + +## Local setup + +```bash +cp env.example .env +# edit secrets +uv pip install -e ".[dev]" +aspc serve +``` + +For the full stack, prefer Compose env (see [deployment.md](deployment.md)) over a local `.env`. diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..e97d425 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,121 @@ +# Deployment + +## Docker Compose (full stack) + +Compose file: [`deploy/compose/docker-compose.yml`](../deploy/compose/docker-compose.yml). + +```bash +cp deploy/compose/.env.example deploy/compose/.env # edit secrets +docker compose -f deploy/compose/docker-compose.yml --env-file deploy/compose/.env up -d --build +``` + +Containers use `restart: "no"` (do not auto-start on Docker/daemon reboot). Start explicitly with `compose up`; stop with `compose down`. + +| Service | Role | Host port | +|---------|------|-----------| +| `migrate` | `alembic upgrade head` (runs once before api/stream-engine) | — | +| `api` | FastAPI | 8000 | +| `frontend` | Next.js dashboard | 3000 | +| `redpanda` | Kafka-compatible broker | `127.0.0.1:19092`, `127.0.0.1:9644` | +| `mosquitto` | MQTT broker | `127.0.0.1:1883` | +| `timescaledb` | Tier-1/Tier-2 persistence | `127.0.0.1:5433` → 5432 | +| `redis` | Live alert pub/sub | `127.0.0.1:6379` | +| `stream-engine` | Kafka → Phase II eval → DB + Redis | — | +| `mqtt-bridge` | MQTT → Redpanda | — | +| `grafana` | Ops dashboards (`--profile ops`) | `127.0.0.1:3001` | + +Infra ports (Redpanda, Mosquitto, Timescale, Redis, Grafana) bind to **loopback only** so they are not reachable from the LAN. API and frontend remain on `0.0.0.0` for local browser access. + +Secrets come from `deploy/compose/.env` (never commit real values): + +- `POSTGRES_PASSWORD`, `ASPC_JWT_SECRET`, `ASPC_API_KEYS`, `ASPC_ADMIN_PASSWORD` +- `ASPC_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000` +- `ASPC_TSDB_INIT=0` on api/stream-engine so only the migrate service applies schema +- `PYTHONPATH=/app` on the API container so `/lab/cases` can import `resilience_data` + +**MQTT auth (required before non-local use):** Compose ships Mosquitto with `allow_anonymous true` for local demos only ([`deploy/mosquitto/mosquitto.conf`](../deploy/mosquitto/mosquitto.conf)). Before exposing the stack beyond localhost, add a Mosquitto password file (or TLS client certs), set `allow_anonymous false`, and prefer keeping `1883` off the host network entirely. + +Other deferred ops: continuous aggregates, image digest pins. + +**Kafka DLQ:** Unparseable measurement payloads are forwarded to +`ASPC_KAFKA_DLQ_TOPIC` (default `{topic}.dlq`, e.g. `spc.measurements.dlq`) instead of +crashing the stream engine. Monitor that topic in production. + +**Multi-tenant scaffolding:** Alembic revision `002_tenant_watermark` adds nullable +`tenant_id` columns and a `stream_registry.measurement_count` watermark. Filtering / +RBAC is not wired yet — columns are reserved for enterprise tenancy. + +Frontend `NEXT_PUBLIC_API_URL` / `NEXT_PUBLIC_WS_URL` / `ASPC_API_PROXY_TARGET` are **Docker build-args** (Next.js bakes them at build time). Defaults: API via same-origin `/backend` (Next rewrites to `http://api:8000` inside Compose), WebSocket `ws://localhost:8000`. Override via compose `.env` and rebuild the frontend image. + +Ops profile: + +```bash +docker compose -f deploy/compose/docker-compose.yml --env-file deploy/compose/.env --profile ops up -d +``` + +## Docker images + +Python images use [uv](https://docs.astral.sh/uv/) (`COPY --from=ghcr.io/astral-sh/uv:latest`): + +- [`deploy/docker/Dockerfile.api`](../deploy/docker/Dockerfile.api) → `aspc-api` +- [`deploy/docker/Dockerfile.stream_engine`](../deploy/docker/Dockerfile.stream_engine) → `aspc-stream-engine` +- [`deploy/docker/Dockerfile.mqtt_bridge`](../deploy/docker/Dockerfile.mqtt_bridge) → `aspc-mqtt-bridge` +- [`deploy/docker/Dockerfile.frontend`](../deploy/docker/Dockerfile.frontend) → Next.js (npm) + +## Data path (real-time) + +```mermaid +flowchart LR + edge[Sensors / MQTT] --> mosquitto + mosquitto --> bridge[mqtt-bridge] + bridge --> redpanda + redpanda --> engine[stream-engine] + engine --> tsdb[TimescaleDB Tier1+Tier2] + engine --> redis + redis --> api[API WS fan-out] + api --> ui[Next.js Live page] +``` + +1. Edge publishes to Mosquitto (`sensors/#` by default). +2. `mqtt-bridge` forwards to Kafka topic `spc.measurements`. +3. `stream-engine` evaluates each keyed stream against **frozen** limits, writes raw + OOC events, publishes to Redis `spc:live:{stream_key}` (or `spc:live:{tenant_id}:{stream_key}` when `ASPC_TENANT_ID` / `ASPC_REDIS_TENANT_PREFIX` is set). Optional signed OOC webhooks use `ASPC_WEBHOOK_URL` / stream `meta.webhook_url`. +4. For multi-replica stream-engine demos, pin Kafka consumers with sticky assignment per `stream_key` partition so Phase II evaluator state stays local. +4. API WebSocket `/ws/live/{stream_key}` fans out to the dashboard. + +Go-live requires Timescale stream registry: Phase I analysis → `limits_version` → `POST /streams/{key}/go-live`. + +## Migrations (TimescaleDB) + +Alembic: + +- Config: [`alembic.ini`](../alembic.ini) +- Env: [`migrations/env.py`](../migrations/env.py) +- Initial: [`migrations/versions/001_initial.py`](../migrations/versions/001_initial.py) (hypertables + analysis tables) + +Point `ASPC_TIMESCALE_DSN` / Alembic URL at the database and run: + +```bash +uv pip install -e ".[tsdb]" +alembic upgrade head +``` + +(Compose may rely on repository bootstrap depending on your ops practice; prefer explicit migrations in production.) + +## Observability + +- `GET /metrics` — Prometheus counters/histograms when `prometheus-client` is installed (JWT required) +- Grafana optional profile on port 3001 +- Structured logging via `structlog` in the apps extras + +## Minimal local (no Compose) + +SQLite + API + optional frontend: + +```bash +uv pip install -e ".[dev]" +aspc serve --port 8000 +# another shell: +cd frontend && cp .env.example .env.local && npm install && npm run dev +``` + +Streaming features need Redis / Kafka / Timescale as above. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..e0f2962 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,131 @@ +# Development + +## Tooling + +- Python ≥ 3.11 +- [uv](https://docs.astral.sh/uv/) for installs and lockfile (`uv.lock`) +- Node 20+ for `frontend/` +- Optional: Docker for integration / Compose + +## Setup + +```bash +uv venv && source .venv/bin/activate +uv pip install -e ".[dev]" +cp env.example .env +``` + +Regenerate lock after dependency changes: + +```bash +uv lock +``` + +## Layout + +| Path | Role | +|------|------| +| `spc_core/` | Pure statistics | +| `adapters/` | I/O, persistence, Plotly, stream sources | +| `apps/` | FastAPI + CLI + config | +| `services/` | stream-engine, mqtt-bridge | +| `sample_data/` | Deterministic synthetic datasets | +| `resilience_data/` | Standards-mapped sad/happy path corpus | +| `combinatorial/` | Finite batch + in-process Phase II matrix | +| `benchmarks/` | Performance + accuracy harnesses | +| `frontend/` | Next.js operator UI | +| `tests/` | unit + integration + resilience + combinatorial | +| `docs/` | This documentation | +| `deploy/` | Docker + Compose | +| `migrations/` | Alembic | + +## Tests + +```bash +# Prefer disabling ROS/launch pytest plugins if present on the machine +PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 pytest -q -m "not integration" + +# Combinatorial matrix (sparse; exhaustive is local) +python -m combinatorial run --mode sparse + +# Frontend unit tests +cd frontend && npm test + +# Playwright operator-console e2e (Compose UI+API must be up) +cd frontend && E2E_USERNAME=admin E2E_PASSWORD='…' npm run test:e2e +``` + +Notes: + +- Unit tests use in-memory `sample_data` via `dataset` / `write_dataset` fixtures ([`tests/conftest.py`](../tests/conftest.py)) — **no committed CSVs**. +- Hypothesis property tests live under `tests/unit/test_hypothesis_limits.py`. +- Integration tests that need Timescale/Redis skip unless `ASPC_TIMESCALE_DSN` / `ASPC_REDIS_URL` are set (`tests/integration/test_realtime_gated.py`). +- Combinatorial reports: `combinatorial/out/JUDGMENT.md`, `COVERAGE.json`, `ENGINE_BEHAVIOR_REPORT.md`. +- Playwright setup logs in once (`e2e/auth.setup.ts`); `e2e/.auth/` is gitignored. `home.spec.ts` still skips if `:3000` is down; other e2e specs fail if the stack is unreachable. + +Acceptance-style smoke (also in CI): + +```bash +python - <<'PY' +from spc_core import ChartType, analyze_control_chart, establish +from spc_core.ewma import ewma_chart +from spc_core.cusum import cusum_chart +import numpy as np +x = np.random.default_rng(0).normal(10, 1, 40) +assert analyze_control_chart(x).chart_type == ChartType.I_MR +assert ewma_chart(x).limits.chart_type == ChartType.EWMA +assert cusum_chart(x).limits.chart_type == ChartType.CUSUM +pipe = establish(x) +assert len(pipe.chart.to_records()) == len(pipe.chart.plotted_values) +print("ok") +PY +``` + +## Lint / types + +Configured in [`pyproject.toml`](../pyproject.toml): + +```bash +ruff check spc_core adapters apps services sample_data resilience_data combinatorial scripts tests benchmarks +mypy spc_core # tool.mypy.packages = ["spc_core"] only +``` + +CI runs **ruff as a blocking check** and **mypy on `spc_core` only** (adapters/apps/services are not type-gated yet). + +## Benchmarks + +In-process performance and formula accuracy (see [overview/benchmarking.md](overview/benchmarking.md)): + +```bash +.venv/bin/python benchmarks/performance.py +.venv/bin/python benchmarks/accuracy.py # exit 1 if any check fails +``` + +## Synthetic data (`sample_data`) + +Package: [`sample_data/`](../sample_data/). Generators are numpy-seeded and honor column-name contracts used by ingest/tests. + +```bash +# Write demo CSVs (gitignored under examples/data/) +uv run python -m sample_data --out examples/data + +# Or import in Python / tests +from sample_data import spc_individual, msa_gage_rr, capability, get_dataset, write_csv +``` + +Catalog names include `spc_individual_in_control`, `msa_gage_rr_excellent`, `capability_excellent`, attribute chart sets, etc. See `DATASET_CATALOG` / `get_dataset` in the package. + +## CI + +[`.github/workflows/ci.yml`](../.github/workflows/ci.yml): + +1. **python** (3.11 / 3.12) — `uv pip install -e ".[dev]"`, blocking ruff (includes `combinatorial`), mypy `spc_core`, pytest (`not integration`, includes `tests/resilience` + `tests/combinatorial`), resilience report, sparse `python -m combinatorial run` (`continue-on-error`), `benchmarks/accuracy.py`, acceptance script +2. **integration** — Timescale + Redis services; `tests/integration` +3. **frontend** — npm ci/lint/test/build +4. **docker** — build API + frontend images + +## Conventions + +- Do not put I/O or LLM imports in `spc_core`. +- Prefer frozen `ControlLimits` + `limits.version` over recomputing Phase I for live data. +- No `NotImplementedError` stubs for advertised features — optional deps should raise `ImportError` with an install hint. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..de5d64a --- /dev/null +++ b/docs/index.md @@ -0,0 +1,49 @@ +# ASPC documentation + +ASPC is a production Statistical Process Control platform: a pure statistics core (`spc_core`), thin adapters for I/O and persistence, a FastAPI + CLI surface, real-time streaming (Redpanda / MQTT / Redis), and a Next.js operator dashboard. + +Every advertised chart type has real math. Phase I establishes and **freezes** versioned control limits; Phase II evaluates new data against those frozen limits—never recomputes them. + +## Mental model: Phase I → freeze → Phase II + +```mermaid +flowchart LR + raw[Raw CSV / stream] --> establish["establish() gated Phase I"] + establish --> gates["Gates: ok / warn / stop"] + establish --> freeze["Frozen versioned limits"] + freeze --> phase2["Phase II evaluate against frozen limits"] + phase2 --> records["SPCRecord + Signals"] +``` + +1. **Phase I** — Run the gated pipeline (`establish`) on historical / baseline data. MSA, missing-value classification, autocorrelation, normality / multimodal checks, and chart selection produce gates with status `ok`, `warn`, or `stop`. +2. **Freeze** — Control limits are content-hashed (`limits.version`). That version is the contract Phase II must consume. +3. **Phase II** — New observations are scored against the frozen limits (batch `evaluate_batch` / `stream_evaluate`, or the live stream engine). Out-of-control conditions become `Signal`s on per-point `SPCRecord`s. + +Go-live is gated by `phase1_checklist()` (10 items). Streams register and activate via the API (`/streams/register` → `/streams/{key}/go-live`) when using the TimescaleDB backend. + +## Doc map + +| Doc | Audience | Contents | +|-----|----------|----------| +| [overview/problem-and-solution.md](overview/problem-and-solution.md) | Decision-makers & practitioners | Problem, solution, architecture diagrams | +| [architecture.md](architecture.md) | Developers & architects | Structural scan: layers, folders, config, request flows | +| [overview/health-and-roadmap.md](overview/health-and-roadmap.md) | Technical leads & product | Health insights, quick wins, 10x / enterprise roadmap | +| [overview/capabilities.md](overview/capabilities.md) | Practitioners & developers | Statistical + operational capabilities | +| [overview/use-cases.md](overview/use-cases.md) | Integrators | Stamping / pharma / molding patterns | +| [overview/benchmarking.md](overview/benchmarking.md) | Technical leads | Performance, accuracy, resilience evidence | +| [concepts.md](concepts.md) | Everyone | Chart types, run rules, MSA, capability, normality / flags | +| [pipeline.md](pipeline.md) | Operators & developers | Gated `establish()`, gates, checklist, `SPCRecord` | +| [cli.md](cli.md) | Operators & developers | `aspc` commands and flags | +| [api.md](api.md) | Integrators | REST, JWT / API-key auth, WebSocket, SSE | +| [python-api.md](python-api.md) | Developers | Library imports and snippets | +| [configuration.md](configuration.md) | Operators & DevOps | YAML + `ASPC_*` env overrides | +| [deployment.md](deployment.md) | DevOps | Docker Compose, migrations, services | +| [development.md](development.md) | Contributors | uv, tests, CI, `sample_data`, combinatorial, Playwright | +| [resilience_data/README.md](../resilience_data/README.md) | Contributors | Standards-mapped judgment corpus | +| [combinatorial/out/ENGINE_BEHAVIOR_REPORT.md](../combinatorial/out/ENGINE_BEHAVIOR_REPORT.md) | Contributors | Observed `spc_core` behavior from the matrix | + +## Quick links + +- Root [README](../README.md) — install and one-command quickstart +- Interactive OpenAPI — `http://localhost:8000/docs` when the API is running +- Standards — AIAG MSA-4 · AIAG SPC · ISO 7870 · Six Sigma DMAIC diff --git a/docs/overview/benchmarking.md b/docs/overview/benchmarking.md new file mode 100644 index 0000000..1b81fad --- /dev/null +++ b/docs/overview/benchmarking.md @@ -0,0 +1,131 @@ +# Benchmarking + +Quantitative evidence for ASPC: **performance** (in-process), **statistical accuracy** (formula / synthetic truth), and **robustness** (resilience catalog). Reproduce with [benchmarks/README.md](../../benchmarks/README.md). + +Product narrative: [problem and solution](problem-and-solution.md). Feature map: [capabilities](capabilities.md). + +**CI:** `benchmarks/accuracy.py` is a blocking CI step. Performance is **not** CI-gated (host noise); re-run locally and refresh [RESULTS.md](../../benchmarks/RESULTS.md) before citing numbers in a proposal. + +## How to read these numbers + +| Layer | What it proves | What it does not prove | +|-------|----------------|-------------------------| +| Performance | Core math + Phase II eval speed on one host | Full-stack MQTT→UI latency; multi-process / multi-host load | +| Accuracy | Published SPC formulas and rule engines on known inputs | AIAG MSA-4 worked examples; every Minitab/JMP dialog option; Box-Cox Cpk analytic parity | +| Resilience | Sad-path / gate / rule-id behavior under a fixed corpus | Your plant’s sensor network or MES quirks | + +## Standards alignment + +| Standard | What ASPC encodes | Where proven | +|----------|-------------------|--------------| +| AIAG SPC | ≥25 Phase I points/subgroups (checklist); chart matrix; Nelson 1–8, Western Electric WE1–WE4, Wheeler | [resilience_data/](../../resilience_data/), `spc_core/rules.py` | +| AIAG MSA-4 | %GRR bands (<10 / 10–30 / ≥30), NDC ≥ 5, bias / linearity / stability, resolution 10:1 | Resilience MSA cases; pipeline STOP when study inputs supplied; **not** AIAG Table 6.1 byte-match | +| ISO 7870 | Variable + attribute chart coverage | Resilience + `ChartType` surface | +| Wheeler | Non-normal robust path (beyond-3σ only; subgroups preserved) | Resilience `heavy_tail_wheeler*`; `ruleset_applied: wheeler` | +| Six Sigma | Cp/Cpk/Pp/Ppk; transformed / nonparametric routing | Resilience capability cases; `capability_analysis` | + +## A. Performance + +Command: + +```bash +.venv/bin/python benchmarks/performance.py +``` + +Committed snapshot (exact table, host, UTC date): **[benchmarks/RESULTS.md](../../benchmarks/RESULTS.md)**. Re-run on your hardware before proposals — do not treat the snapshot as an SLA. + +### Interpretation + +- **Phase I** (`establish`) includes optional MSA path, missing classification, ACF, normality / multimodal (`diptest`), chart, and freeze logic. +- **Phase II** (`Phase2Evaluator.observe`) is the in-process hot path. Network and broker overhead dominate in production; budget those separately. +- Not measured: Compose-stack end-to-end alert latency. + +## B. Formula and synthetic-truth checks (28) + +Command (also run in CI): + +```bash +.venv/bin/python benchmarks/accuracy.py +``` + +**28/28 checks** in the current suite cover: + +| Area | What is checked | +|------|-----------------| +| Constants | `d2`, `E2`, `A2`, `D3`, `D4` vs handbook targets | +| I-MR | Center / UCL / LCL / MR UCL vs hand formulas | +| Xbar-R | `X̄̄ ± A2·R̄`, `D3/D4·R̄` on constructed subgroups | +| P chart | `p̄ ± 3√(p̄(1-p̄)/n)` | +| Run rules | Nelson 1, 2, 3 and Western Electric WE1 on constructed series | +| MSA | Synthetic excellent gage %GRR < 10; poor gage %GRR > 30; ordering | +| Capability | Large normal sample Cp near (USL−LSL)/(6σ); centered Cpk ≈ Cp | +| Guards | Empty `establish([])` raises domain `ValueError` | + +### Explicitly out of scope for this suite + +- AIAG MSA-4 Table 6.1 (or other published worked-example) numerical parity +- JMP / Minitab dialog option bit-identity +- Box-Cox / Yeo-Johnson Cpk vs a closed-form analytic reference on transformed specs +- Nelson 4–8 / full WE set (those are covered in the **resilience** catalog, not `accuracy.py`) + +This is the right bar for an open engine: **formula fidelity and directional MSA/capability behavior**, not “identical to Minitab.” + +## C. Robustness (resilience catalog) + +Source of truth: [`resilience_data/`](../../resilience_data/) (55 MANIFEST cases) and `tests/resilience/`. + +```bash +PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 .venv/bin/python -m pytest tests/resilience -q +.venv/bin/python scripts/resilience_report.py # writes resilience_data/JUDGMENT.md (gitignored) +``` + +### Coverage (asserted, not merely present) + +- All nine `ChartType` values +- Quality flags used in cleaning (LOCF, sensor, maintenance, human, backup, incomplete, …) +- Nelson 1–8 and Western Electric WE1–WE4 via `rule_ids` / `includes` / `subset_of` +- Rulesets `nelson`, `western_electric`, `wheeler` (Wheeler asserts zone rules stay suppressed) +- Gates including MSA, autocorrelation, multimodal, normality, **range** (when `valid_range` set), freeze +- Raises with `raises_match` (message, not only exception type) + +### Highlighted outcomes from hardening + +| Topic | Result | +|-------|--------| +| Multimodal false positives (pure normal, with `diptest`) | ~0.25% in a large sweep; clear 5σ mixtures detected | +| Attribute (count) data | Dip / Gaussian normality skipped — in-control P/NP/C/U no longer false-STOP | +| Sensor sentinels | With `valid_range`, `-999` does not fire Nelson 1 as process OOC | +| Empty / all-NaN | Clear domain errors (no SciPy unpack crash) | +| R-chart variance shift | Asserted on `secondary_rule_ids`, not a vacuous `n_signals ≥ 0` | + +Mutation checks (breaking R secondary signals, Wheeler→Nelson leak, removing input guards, ignoring `valid_range`) make the corresponding cases **FAIL**. + +## D. Comparison matrix (feature posture) + +Architecture comparison — not a declaration that ASPC wins every statistician’s preferred dialog. Commercial seat pricing changes often; verify before procurement. + +| Concern | ASPC | Typical desktop SPC (e.g. Minitab / JMP) | Typical minimal Python chart snippet | R `qcc`-class packages | +|---------|------|------------------------------------------|--------------------------------------|-------------------------| +| Streaming Phase II | Yes (Kafka/MQTT path) | Desktop / project workflow | No | No | +| Frozen versioned limits | Yes | Analyst-managed | Rare | Rare | +| MSA STOP/WARN | When MSA inputs provided; else warn only | Separate study UI | Usually absent | Partial | +| REST + WebSocket | Yes | No (desktop) | No | No | +| License | MIT | Commercial seats | Often MIT | Often GPL | +| Open formula benches | `benchmarks/` + resilience | Proprietary | Usually none | Varies | +| Multi-tenant API | **No** (single deployment; JWT / API-key auth only) | N/A | N/A | N/A | + +## Reproducing for a report + +1. Record host (`uname -a`, Python version) and UTC time. +2. Run `benchmarks/performance.py`; update or attach [RESULTS.md](../../benchmarks/RESULTS.md). +3. Run `benchmarks/accuracy.py` (must exit 0; also CI). +4. Run resilience pytest / `resilience_report.py`; attach PASS counts. +5. State clearly if Compose e2e latency was or was not measured. + +## Related docs + +- [benchmarks/README.md](../../benchmarks/README.md) +- [benchmarks/RESULTS.md](../../benchmarks/RESULTS.md) +- [resilience_data/README.md](../../resilience_data/README.md) +- [development.md](../development.md) +- [capabilities.md](capabilities.md) — Known limitations diff --git a/docs/overview/capabilities.md b/docs/overview/capabilities.md new file mode 100644 index 0000000..25b8786 --- /dev/null +++ b/docs/overview/capabilities.md @@ -0,0 +1,121 @@ +# Capabilities + +What ASPC actually does, mapped to manufacturing work — not a feature checklist alone. Math detail: [concepts](../concepts.md). Pipeline detail: [pipeline](../pipeline.md). + +## Statistical capabilities + +### Variable charts + +| Chart | When | Notes | +|-------|------|--------| +| **I-MR** | Continuous, one-at-a-time | Individuals + moving range; secondary MR panel also gets run rules | +| **Xbar-R** | Subgroup size 2–8 | Mean chart + range chart; dispersion shifts show on **R**, not only on Xbar | +| **Xbar-S** | Subgroup size ≥ 9 | Mean + standard deviation | + +Rulesets on Shewhart charts: + +- `nelson` — Nelson 1–8 (default) +- `western_electric` — WE1–WE4 +- `wheeler` — beyond 3σ only (non-normal robust path) + +A sustained **sub-sigma** mean shift that never breaches 3σ is caught by run rules (e.g. Nelson 2 / 6) when the series stays on the Shewhart route. Large steps and long drifts often trip autocorrelation and correctly divert to EWMA — those patterns are still asserted at the chart layer in the resilience catalog. + +### Attribute charts + +| Chart | Data | +|-------|------| +| **NP** | Defectives, fixed sample size | +| **P** | Defectives, variable sample size | +| **C** | Defect counts, fixed opportunity | +| **U** | Defect counts, variable opportunity | + +Limits use binomial / Poisson formulas. Distribution gates that assume continuous data are **skipped** so in-control count processes are not false-STOPped by Hartigan’s dip test. + +### Small-shift / autocorrelated series + +| Chart | Role | +|-------|------| +| **EWMA** | λ and L configurable; time-varying limits | +| **CUSUM** | Tabular two-sided C+ / C− | + +Selected automatically when Phase I detects significant lag-1 autocorrelation on continuous data (`establish(..., autocorrelated_chart="EWMA"|"CUSUM")`). + +### Measurement System Analysis + +| Study | Purpose | +|-------|---------| +| Gage R&R ANOVA | %GRR, NDC, acceptability; range fallback when design is unbalanced | +| Bias | Mean bias vs reference, significance | +| Linearity | Slope / R² across reference range | +| Stability | Time stability of the measurement system | +| Resolution gate | 10:1 rule vs tolerance | +| Continuous MSA | Streaming bias / R tracking with calibration alerts | + +AIAG MSA-4 style bands on the **computed** Gage R&R when study inputs are passed into `establish` (or called directly): %GRR < 10 excellent (with NDC ≥ 5), 10–30 conditional, ≥ 30 or NDC < 5 unacceptable — and that can **STOP** Phase I. If MSA inputs are omitted, the MSA gate is `warn` and limits may still freeze. + +### Process capability + +`capability_analysis` routes: + +- Parametric Cp / Cpk / Pp / Ppk (and Cpm with target) +- Transformed path (Box-Cox / Yeo-Johnson / log) when normality fails and transform restores it +- Nonparametric percentile-based indices when transform fails + +DPMO and sigma level helpers are analytic, not lookup tables. + +## Operational capabilities + +### Batch + +- **Python library** — `from spc_core import establish, analyze_control_chart, …` +- **CLI** — `aspc control-chart`, capability, MSA commands ([cli](../cli.md)) +- **CSV / frames** — ingest helpers with column detection + +### Streaming + +- Kafka / Redpanda consumer in `services/stream_engine` +- MQTT → Redpanda bridge +- Per-stream keys, register → go-live after Phase I freeze +- Redis pub/sub for live alert fan-out +- WebSocket + SSE on the API ([api](../api.md)) + +### Persistence and apps + +| Layer | Options | +|-------|---------| +| Storage | SQLite (embedded) or TimescaleDB (hypertables + analysis tables) | +| Auth | JWT for users, API keys for services | +| UI | Next.js operator dashboard (charts, signals, stream status) | +| Ops | Docker Compose, Alembic migrations, Grafana | + +## Data quality capabilities + +SPC fails silently when dirty data looks like process signals. ASPC treats measurement failure as a first-class concern: + +| Mechanism | Behavior | +|-----------|----------| +| **classify_missing** | Short gaps ≤ LOCF max may be forward-filled and flagged `IMPUTED_LOCF`; longer gaps become `MISSING_SENSOR` and are excluded from chart math | +| **Reason codes** | maintenance / human / incomplete / backup map to explicit `QualityFlag`s | +| **valid_range on establish** | When `valid_range` is passed: out-of-physical-range readings (e.g. `-999`) are blanked as measurement failures — they must **not** fire Nelson rule 1 as “OOC process.” Opt-in; see Known limitations. | +| **Multimodal STOP** | Hartigan dip test (`diptest`); clear mixtures block go-live so you stratify first | +| **Input guards** | Empty / all-missing series raise clear `ValueError`s, not SciPy unpack crashes | +| **Incomplete subgroups** | Optional exclude of ragged last subgroups on Xbar charts | + +## Manufacturing patterns + +See [use-cases](use-cases.md) for stamping (streaming I-MR), tablet weight (batch Xbar-R + capability), and vision flash (P chart) integration patterns. + +## Known limitations + +Black-belt / practitioner caveats — not marketing footnotes: + +| Topic | Behavior | +|-------|----------| +| **MSA opt-in** | MSA is **not** required to freeze. Absence → `warn`. STOP only when study data is supplied and %GRR / NDC / resolution fail. | +| **`valid_range` opt-in** | Without `valid_range`, sensor sentinels can still enter the chart and fire rule 1 as if they were process signals. | +| **Autocorrelation routing** | Continuous series with \|lag-1 ACF\| above the threshold (including **negative** ACF) route to EWMA/CUSUM. Oscillation / operator tampering often needs Shewhart run rules (e.g. Nelson 4 / 7), not smoothing — assert those at the chart layer or raise the ACF threshold deliberately. | +| **Multimodal sensitivity** | Hartigan dip (`diptest`) plus a conservative histogram fallback. Clear, well-separated mixtures STOP; subtle ~2–3σ mixtures may be missed. | +| **Not a CSV / Part 11 pack** | Reports are HTML/JSON via CLI/API. Regulated computer-system validation is out of product scope. | +| **Not a substitute for MSA planning** | Parts × operators × trials design remains an engineering decision. | + +Also: not every plant IT stack is turnkey; not LLM-driven SPC — the engine is deterministic statistics. Measured limits: [benchmarking](benchmarking.md). diff --git a/docs/overview/health-and-roadmap.md b/docs/overview/health-and-roadmap.md new file mode 100644 index 0000000..69aca9a --- /dev/null +++ b/docs/overview/health-and-roadmap.md @@ -0,0 +1,171 @@ +# ASPC deep analysis: health, optimizations, roadmap + +Evidence-based review of the hexagonal Phase I → freeze → Phase II platform. Strengths first: `spc_core` math is real (not stubbed), freeze contract is clear, resilience catalog + CI are unusually strong for an SPC product. Gaps concentrate in auth flatness, streaming durability, API monolith size, Timescale write amplification, and incomplete variable-limit Phase II zone rules. + +**Superseded vs current tree:** the [Implementation status](#implementation-status-roadmap) list at the bottom is the source of truth for work already landed. It supersedes several §1 findings: operator go-live UI (Live register / go-live / ack, `/onboarding`), WebSocket first-message JWT (no query-string token), JWT-protected `/metrics`, and JWT-only analyze identity. Leave the remaining rows in §1 as open (write amp, variable-limit Phase II, RBAC/tenancy, Kafka at-most-once, MQTT drop-on-full). + +Companion structural map: [architecture.md](../architecture.md). + +--- + +## 1. Codebase health and deep insights + +### Architectural bottlenecks + +- **Monolithic API surface:** [`apps/api/main.py`](../../apps/api/main.py) (~900 lines) owns auth, uploads, three analyze pipelines, runs/reports, stream registry, WebSocket, SSE, health, and metrics. Module-level singletons `cfg` / `repo` make tests mutate globals and complicate multi-worker ASGI. +- **Single sync stream consumer:** [`services/stream_engine/main.py`](../../services/stream_engine/main.py) iterates `source.iter_sync()` in one process. Compose runs one `stream-engine` replica. Hot path is sync despite async-capable [`KafkaSource`](../../adapters/stream_sources.py) / [`MQTTSource`](../../adapters/stream_sources.py). +- **Write amplification on the live path:** [`TimescaleDBRepository.save_raw_measurement`](../../adapters/persistence_tsdb.py) / `save_ooc_event` open a new SQLAlchemy `Session` + `commit` per row. One observation ≈ 1 raw insert + N OOC inserts + Redis publish — not batched. +- **MQTT bridge flush-per-message:** [`services/mqtt_bridge/main.py`](../../services/mqtt_bridge/main.py) `_produce_kafka` calls `producer.flush()` (or `send_and_wait`) after every send — hard throughput ceiling. +- **In-memory evaluator ownership:** [`StreamEngine`](../../adapters/stream_engine.py) keeps up to `max_streams=10_000` `Phase2Evaluator`s in an OrderedDict with LRU eviction. Horizontal scale without sticky partition ownership duplicates state or cold-restores incorrectly. +- **Redis pub/sub fan-out:** Engine publishes `spc:live:{key}`; each [`ws_live`](../../apps/api/main.py) client opens its own Redis subscription. Cost scales with streams × clients; no durable replay buffer (history is Timescale-only → live/history split-brain if Redis fails after DB write). + +### Technical debt and brittle patterns + +- **Duck-typed streaming repository:** API uses `hasattr(repo, "register_stream")` etc. instead of a typed `StreamRepository` protocol — SQLite vs Timescale silently changes which routes return 501. +- **Go-live policy split:** Freeze rules live in [`establish`](../../spc_core/pipeline.py); HTTP [`go_live`](../../apps/api/main.py) re-checks checklist/frozen/stopped from `meta` written by `analyze_cc`. Easy to drift. +- **Checklist vs freeze asymmetry:** MSA absence **warns** the MSA gate (can still freeze) but **fails** `phase1_checklist()` item `msa_grr_ndc` — operators can freeze while go-live is blocked. +- **Variable-limit Phase II incomplete:** [`Phase2Evaluator._observe_variable`](../../spc_core/evaluator.py) only applies beyond-limits + run-of-9 style logic — not full Nelson 3–8 / Western Electric zone rules that use-cases imply for streaming P charts. +- **Transform / subgroup alignment risk:** `establish()` can set `working = transform.values` while still passing original `subgroup_ids` into `analyze_control_chart()` — length mismatch if NaNs dropped then transform ran on cleaned data. +- **Frontend (go-live shipped; polish remains):** Live no longer only lists streams — it registers, go-lives, and acks against the API. `/onboarding` and `/lab` exist. Remaining UI gaps are smaller than this section originally claimed (continuous MSA evaluate is wired; Analyze Phase I knobs are in the form). + +### Security vulnerabilities + +| Severity | Finding | Where | +|----------|---------|--------| +| High | `ASPC_AUTH_ENABLED=false` skips `_startup_security_checks` and opens analyze/history | [`_startup_security_checks`](../../apps/api/main.py), [`get_current_user`](../../apps/api/main.py) | +| High | ~~Plaintext password fallback if `bcrypt` missing~~ **Closed:** bcrypt required at import | `_ensure_password_hash` / `_verify_password` | +| High | ~~JWT in WebSocket query string (`?token=`)~~ **Closed:** first-message `{"type":"auth","token"}` | `ws_live` | +| High | Single shared admin; no RBAC/tenancy; any JWT sees all runs/streams | token payload is only `sub`+`exp`; `list_runs` has no ownership filter | +| Med | ~~Client-spoofable Form `user_id` on analyze~~ **Closed:** identity from JWT | `analyze_cc` / capability / MSA | +| Med | Rate limit still soft-fails if SlowAPI missing; analyze endpoints are limited when SlowAPI is installed | `_rate_limit` | +| Med | Unauthenticated `/health` (exception strings); ~~`/metrics`~~ **Closed:** JWT | health / metrics handlers | +| Med | ~~Upload overwrite without UUID~~ **Closed:** UUID-prefix in `save_upload_stream` | [`adapters/io_files.py`](../../adapters/io_files.py) | +| Med | ~~HTML report fallback unescaped~~ **Closed:** HTML-escape in report fallback | `get_report_html` | +| Med | Docs/deploy examples with `ASPC_DEV_INSECURE=1` / `admin` password | [`deploy/vercel/README.md`](../../deploy/vercel/README.md) | + +Positive: path allowlists (`safe_filename`, `resolve_under`, `_RUN_ID_RE`), go-live gates on frozen limits, Compose secret requirements, solid [`tests/unit/test_security.py`](../../tests/unit/test_security.py) for several cases. + +### Hidden dependencies and performance anti-patterns + +- **Streaming hard-requires Timescale:** [`StreamEngine.__init__`](../../adapters/stream_engine.py) and [`require_streaming_repository`](../../adapters/factory.py) refuse SQLite; Tier-1/2 + `stream_registry` only on Timescale. +- **Kafka commit before process success:** `_AsyncSourceBase.iter_sync` commits after yield/enqueue, not after `handle_message` — crash after commit ⇒ observation loss (at-most-once); redelivery ⇒ Redis duplicate live points (DB is mostly idempotent via `_measurement_id` + OOC unique constraint). +- **No DLQ:** Poison JSON in Kafka can kill the consumer process (docs explicitly defer DLQ). +- **MQTT drops under backpressure:** paho path `queue.Full` drops observations after 5s timeout — Phase II continuity broken silently. +- **Restore tax:** `_restore_evaluator` runs `count_raw_measurements` (`COUNT(*)`) + recent rows per stream register. +- **`ooc_events` not a hypertable;** no index on `acked` despite `list_ooc_events(unacked_only=True)`. +- **Subgroup MQTT gap:** `_normalise` forces `float(value)` — Xbar subgroup lists supported in `StreamEngine` / `Observation` but not through the bridge. + +```mermaid +flowchart LR + mqttDrop[MQTT queue Full drops] + kafkaCommit[Commit before handle_message] + rowWrite[Session per row] + redisFan[Per WS Redis subscribe] + mqttDrop --> continuityLoss[Phase II gaps] + kafkaCommit --> atMostOnce[Lost eval on crash] + rowWrite --> walPressure[WAL commit ceiling] + redisFan --> fanoutCost[Clients times streams] +``` + +--- + +## 2. Immediate optimization opportunities + +### Quick wins (days, high ROI) + +1. **Fail closed on auth crypto:** Remove plaintext password path; require `bcrypt`. Never skip secret checks solely because auth is off (tie to explicit `ASPC_DEV_INSECURE` only). +2. **Derive identity from JWT only:** Drop Form `user_id`; stop spoofable audit fields. +3. **UUID-prefix uploads** in `save_upload_stream`; HTML-escape report fallback. +4. **Typed `StreamRepository` protocol** in adapters; replace `hasattr` branches in API. +5. **Batch Kafka produce:** Remove per-message `flush()` in mqtt_bridge; batch or linger. +6. **Batch DB writes:** Buffer raw/OOC inserts (multi-row / COPY) in `StreamEngine` with periodic flush; reuse one session per batch. +7. **Rate-limit analyze endpoints** (and fail closed if SlowAPI missing when auth is on). +8. **Move WS auth off query string** (first-message or Sec-WebSocket-Protocol); keep JWT out of access logs. +9. **Split `main.py`** into routers: `auth`, `analyze`, `runs`, `streams`, `live` — same behavior, testable seams. +10. **Expose existing API knobs in Analyze UI:** `ruleset`, `valid_range`, optional `msa_file` — zero new backend work. + +### Performance / resource + +- Commit Kafka offsets **after** successful `handle_message` (or transactional outbox). +- Poison → DLQ topic instead of process crash. +- Cache stream watermarks on `stream_registry` to avoid `COUNT(*)` on every restore. +- Index `ooc_events.acked`; consider hypertable/retention for OOC. +- Shared Redis multiplexer (or Redis Streams) for live fan-out instead of N connections. +- Soften `_DEFAULT_MAX_STREAMS` / document sticky consumer assignment by `stream_key` partition. + +### DevEx and testing + +- Map domain `ValueError` → HTTP 400 in analyze handlers (today many become 500). +- Add security tests for: WS token missing/invalid, auth-disabled startup skip, `user_id` spoof, analyze rate limit. +- Wire Playwright smoke into CI (today optional / skip-if-down); add one MQTT→engine→Redis contract test beyond Timescale/Redis integration smoke. +- Replace deprecated `@app.on_event("startup")` with lifespan. +- Document single-tenant explicitly in API OpenAPI description (benchmarking already notes it). + +--- + +## 3. Product extension and feature roadmap + +Natural extensions stay inside the freeze contract: **never silently recompute limits**. + +### Near-term product (fits current architecture) + +| Priority | Feature | Why / leverage | +|----------|---------|----------------| +| P0 | **Operator go-live console** | **Shipped** on `/live` and `/onboarding` (register / go-live / ack + checklist). | +| P0 | **Phase I controls on Analyze** | **Shipped** (`ruleset`, `valid_range`, MSA upload on the Analyze form). | +| P1 | **Full Nelson/WE on variable-limit Phase II** | Extend `_observe_variable` so streaming P/U matches batch rule depth. | +| P1 | **Secondary live panels** | MR / R / S alongside primary (stamping use-case). | +| P1 | **Finish continuous MSA UI** | **Shipped** evaluate API/UI; remaining work is live-panel depth against Redis. | +| P2 | **SSE replay operator view** | Demo/audit without Kafka (`GET /stream/replay`). | +| P2 | **MES/webhook alert sinks** | Extend `/alerts/{id}/ack` outward (“when, not why”). | + +Avoid as primary path: auto-recalculating limits, LLM-as-SPC, Part 11 pack bolted onto core without a separate compliance layer. + +### Scaling to 10x / enterprise + +**Product** + +- Org → site → line hierarchy; RBAC (analyst / operator / admin). +- Scoped API keys; SSO (OIDC); queryable per-tenant audit (extend `audit_log`). +- Per-tenant retention, report branding, stream-key namespaces. + +**Architecture (around the same Phase I/II spine)** + +```mermaid +flowchart TB + tenants[tenant_id on limits runs streams ooc] + kafkaShard[Kafka partitions by stream_key] + workers[Sticky stream_engine workers] + state[Restore evaluators from Timescale] + apiScale[Analyze worker pool off event loop] + readScale[Read replicas for runs reports] + tenants --> kafkaShard + kafkaShard --> workers + workers --> state + tenants --> apiScale + tenants --> readScale +``` + +Concrete steps: + +1. Add `tenant_id` to [`db_models`](../../adapters/db_models.py) (`control_limits`, `analysis_runs`, `stream_registry`, `raw_measurements`, `ooc_events`); enforce in repository + FastAPI deps. +2. Shard consumers by partition; each worker owns a subset of keys; keep restore via `_restore_evaluator` as cold-start path. +3. Offload `establish` / heavy analyze to a process/thread pool so FastAPI event loop stays for WS. +4. Timescale continuous aggregates + compression (already deferred in [deployment.md](../deployment.md)); space-aware hypertables if multi-stream query load grows. +5. Durable live channel (Redis Streams / NATS) with consumer groups for dashboards. +6. Keep `ControlLimits.version` as the scaling unit of truth — multi-tenant is isolation and ops, not a rewrite of [`establish`](../../spc_core/pipeline.py) / [`Phase2Evaluator`](../../spc_core/evaluator.py). + +### Strategic takeaway + +ASPC’s durable asset is **correct gated Phase I + immutable Phase II evaluation**. Invest next in (1) closing security fail-open paths, (2) making the live path durable and batched, (3) finishing the operator go-live UX that the docs already sell, then (4) tenancy/RBAC as the enterprise unlock — without diluting the freeze contract. + +Contract probes: [`resilience_data/`](../../resilience_data/) (CSV judgment catalog) and [`combinatorial/`](../../combinatorial/) (finite batch + in-process Phase II dual-mode matrix; `python -m combinatorial report --mode sparse`). + +### Implementation status (roadmap) + +Phased work from the roadmap plan is in progress in-tree: + +- Security fail-closed (bcrypt required, startup checks, JWT-only identity, UUID uploads, HTML escape, analyze rate limits, metrics auth, WS first-message auth) +- Streaming: MQTT batch produce, Kafka DLQ, measurement_count watermark, `ooc_events.acked` index, `tenant_id` schema scaffolding +- Product: Analyze Phase I knobs, Live go-live console, continuous MSA evaluate API/UI +- Core: variable-limit Phase II zone rules, transform/subgroup alignment, analyze thread pool diff --git a/docs/overview/problem-and-solution.md b/docs/overview/problem-and-solution.md new file mode 100644 index 0000000..29694e9 --- /dev/null +++ b/docs/overview/problem-and-solution.md @@ -0,0 +1,126 @@ +# Problem and solution + +ASPC is a production Statistical Process Control platform for **batch and real-time** quality work. This page is for decision-makers and practitioners who need the *why* before the API reference. + +Deep technical detail lives in [concepts](../concepts.md), [pipeline](../pipeline.md), and [deployment](../deployment.md). Quantitative evidence lives in [benchmarking](benchmarking.md). + +## The problem + +### Variation becomes cost + +Every manufacturing process has variation. When that variation is *common-cause* (inherent to the process), you leave it alone. When it is *special-cause* (a shift, a broken sensor, a bad lot, an operator over-adjusting), you act — before scrap piles up, before a recall, before an FDA 483. + +SPC is the discipline that separates the two. The chart is not a decoration; it is a decision tool with a defined false-alarm rate and a defined detection delay. + +### What goes wrong with traditional tooling + +| Pain | Why it hurts | +|------|----------------| +| **Batch-only analysis** | A mean shift that starts at 09:12 and is found in the 14:00 Excel export has already made hours of bad parts. | +| **Desktop-seat economics** | Proprietary SPC packages are excellent for an engineer at a desk and poor as a service your MES, PLC, or vision system can call. | +| **Incomplete “libraries”** | Many open-source chart snippets compute limits incorrectly, skip MSA, ignore missing data, or treat a disconnected sensor (`-999`) as a process signal. | +| **No freeze contract** | Recomputing limits every shift silently absorbs the shift into the “new normal.” Phase I and Phase II must be distinct. | +| **No go-live gate** | Shipping a chart live without MSA, normality, or multimodal checks is how you get pretty plots of stratified garbage. | + +### The gap + +Factories need SPC that is: + +1. **Statistically correct** — AIAG SPC / MSA-4, ISO 7870, Wheeler where non-normal, Six Sigma capability routing. +2. **Operationally real-time** — stream observations against *frozen* limits, emit signals as they happen. +3. **Open and embeddable** — library, CLI, REST, WebSocket; MIT license; Docker Compose stack. +4. **Honest about bad data** — missing values classified, sentinels blanked **when `valid_range` is set**, multimodal STOP, empty input fails with a domain error. + +That is the gap ASPC is built to fill. + +## The solution + +### Correct core + +[`spc_core/`](../../spc_core/) is a pure statistics engine: Shewhart (I-MR, Xbar-R, Xbar-S, P, NP, C, U), EWMA, CUSUM, Nelson / Western Electric / Wheeler rulesets, Gage R&R, bias / linearity / stability, Cp/Cpk/Pp/Ppk with transform and nonparametric paths. + +Every advertised `ChartType` has real math — no stubs. Correctness is exercised by a [55-case resilience catalog](../../resilience_data/) that asserts gate statuses and, where the scenario pins a mechanism, *which* rule fired (not only a vacuous signal count). + +### Gated Phase I before go-live + +[`establish()`](../../spc_core/pipeline.py) runs a fixed gate sequence. Each gate returns `ok`, `warn`, or `stop`. A STOP (unacceptable MSA **when study data is supplied**, clear multimodality) blocks freezing limits for production use. A 10-item [`phase1_checklist()`](../../spc_core/pipeline.py) is the explicit go-live contract. + +### Frozen, versioned limits for Phase II + +Phase I produces content-hashed limit versions. Phase II ([`Phase2Evaluator`](../../spc_core/evaluator.py)) **never recomputes** those limits — batch replay and live streaming use the same object. That is the difference between “monitoring the process” and “absorbing the failure into the chart.” + +### Real-time path + +```mermaid +flowchart LR + sensors[Sensors / MES / MQTT] --> bridge[mqtt_bridge] + bridge --> bus[Redpanda / Kafka] + bus --> engine[stream_engine] + engine --> eval[Phase2Evaluator] + eval --> redis[Redis pub/sub] + redis --> api[FastAPI WebSocket] + api --> ui[Next.js dashboard] + engine --> tsdb[(TimescaleDB)] +``` + +Compose stack, JWT / API-key auth, SSE replay, and Grafana ops are documented in [deployment](../deployment.md) and [api](../api.md). + +### Open platform + +MIT-licensed. Integrate as a Python library, CLI (`aspc`), or HTTP service. Persist with SQLite for embedded use or TimescaleDB for production streaming. The dashboard is a client — not the only way to consume signals. + +## How it works + +### Phase I: establish and freeze + +```mermaid +flowchart TD + raw[Baseline measurements] --> msa["MSA gate (if study data provided)"] + msa --> range["Range gate (only if valid_range set)"] + range --> miss[Missing-value classification] + miss --> acf[Autocorrelation] + acf --> dist[Normality / multimodal] + dist --> chart[Control chart + run rules] + chart --> freeze[Freeze versioned limits] + freeze --> check[phase1_checklist] +``` + +Gates that look “always on” in the diagram are **not** all mandatory: + +- **MSA** — runs when parts / operators / measurements are passed into `establish`. If omitted, the gate is `warn` and Phase I can still freeze. +- **Range** — runs only when `valid_range=(low, high)` is set. Without it, physical sentinels (e.g. `-999`) are not blanked automatically. + +### Caller responsibilities + +- Pass an MSA study into `establish` when measurement-system fitness must be allowed to **STOP** go-live. +- Pass `valid_range` when disconnected-sensor / out-of-physics readings must be treated as measurement failures, not process OOC. +- Treat `phase1_checklist()` as the explicit go-live contract; do not equate “chart plotted” with “ready for Phase II production.” + +- **Attribute (count) data** skips Gaussian normality and Hartigan dip tests — those assume continuous distributions and would false-STOP in-control Poisson / binomial series. +- **Autocorrelated continuous series** route to EWMA or CUSUM; attribute charts stay on binomial / Poisson limits even if lag-1 ACF is elevated. +- **Non-normal after transform** takes the Wheeler path (points beyond 3σ only); subgroup structure is preserved. + +### Phase II: evaluate against the freeze + +New points are scored against the frozen `ControlLimits`. Signals carry rule ids (`1`…`8`, `WE1`…`WE4`). Primary and secondary panels (R / MR / S) are both evaluated when present. + +### Where to go next + +| If you need… | Read | +|--------------|------| +| Feature inventory and manufacturing scenarios | [capabilities](capabilities.md) | +| Concrete integration stories | [use-cases](use-cases.md) | +| Performance, accuracy, robustness numbers | [benchmarking](benchmarking.md) | +| Chart math and MSA rules | [concepts](../concepts.md) | +| Gate sequence and checklist | [pipeline](../pipeline.md) | + +## Differentiators (summary) + +| Concern | Typical desktop SPC | Typical open chart snippet | ASPC | +|---------|---------------------|----------------------------|------| +| Streaming Phase II | Rare | Absent | Built-in (Kafka / MQTT → engine) | +| Frozen limit versions | Often manual | Absent | Content-hashed, immutable eval | +| MSA STOP/WARN when study data is supplied | Study in a separate tool | Absent | Pipeline STOP / WARN **only if** MSA inputs are passed | +| Bad-data policy | Analyst judgment | Silent NaN / sentinel as OOC | Classified flags + range blanking | +| Embeddability | Seat license | Script | Library + REST + WebSocket | +| Proof of behavior | Proprietary validation | Often none | Resilience catalog + accuracy / perf benchmarks | diff --git a/docs/overview/use-cases.md b/docs/overview/use-cases.md new file mode 100644 index 0000000..183a610 --- /dev/null +++ b/docs/overview/use-cases.md @@ -0,0 +1,106 @@ +# Use cases + +Three integration patterns that match what ASPC implements today. Numbers in the “outcome” sections are **illustrative scenarios** for planning discussions — not measured plant results and **not product SLAs**. Wire the same patterns; measure your own scrap saved and your own average run length (ARL) under your sampling rate and ruleset. + +Detection delay is approximately a function of **sampling interval × ruleset ARL**, not a fixed “tens of seconds” guarantee from the software. + +## 1. Automotive stamping — streaming I-MR + +### Problem + +Blank thickness drifts after a coil change. Downstream paint adhesion fails. An hourly CSV download from the PLC historian finds the shift late. + +### Approach + +1. Collect ≥ 25–50 in-control thickness readings from a stable coil; run `establish()` (or API Phase I). Pass physical `valid_range` **explicitly** so sensor sentinels are blanked — this is not automatic. +2. Pass MSA study inputs into `establish` (or run MSA first) if %GRR / NDC must be allowed to STOP go-live; omitting MSA only warns. +3. Freeze limits (`limits.version`); register the stream key and go-live. +4. Publish thickness over MQTT → mqtt_bridge → Redpanda → stream_engine → `Phase2Evaluator`. +5. Operators subscribe to WebSocket alerts; HMI shows the latest I and MR panels plus rule ids. + +```mermaid +sequenceDiagram + participant Gage + participant MQTT + participant Engine + participant UI + Gage->>MQTT: thickness sample + MQTT->>Engine: Redpanda topic + Engine->>Engine: Phase2Evaluator.observe + Engine->>UI: Redis / WebSocket Signal +``` + +### Why ASPC fits + +- Real-time Phase II against **frozen** limits (no silent limit refresh). +- Run rules catch sustained small shifts that never hit 3σ. +- Range gate keeps disconnected-sensor values out of the chart. + +### Outcome (scenario) + +If the process is sampled frequently enough for the chosen ruleset’s ARL, a mean shift can be flagged well before the next hourly batch review. Scrap avoided is a plant metric, not an ASPC SLA. + +--- + +## 2. Pharmaceutical tablet weight — batch Xbar-R + capability + +### Problem + +Tablet weight is a critical quality attribute. Release needs documented capability (e.g. Cpk ≥ 1.33) and evidence the balance is fit for use. + +### Approach + +1. Run Gage R&R (parts × operators × trials) through `gage_rr_anova` / CLI MSA; gate resolution 10:1 vs tolerance. +2. Export lab balance CSV (subgroup id + weight). +3. `establish(..., chart_type="Xbar-R")` on Phase I lots; address STOP / WARN gates (missing, normality, multimodal). +4. `capability_analysis(weights, usl=..., lsl=...)` for the same study window; use transformed method if the weight distribution is skewed after filling. +5. Archive `limits.version`, capability report, and MSA outputs with the batch record. + +### Why ASPC fits + +- MSA is not a side spreadsheet — it can STOP go-live. +- Capability routing is explicit (parametric / transformed / nonparametric). +- CLI and library work offline (SQLite or files). Reports are **HTML/JSON**, not PDF. +- Suitable for engineering review and archival of `limits.version` + study outputs. Regulated CSV / 21 CFR Part 11 computer-system validation is **out of scope** for this product. + +### Outcome (scenario) + +Unacceptable %GRR caught before charting; after balance / method fix, Cpk meets the internal release threshold with versioned limits retained for audit. + +--- + +## 3. Injection molding — attribute P chart streaming + +### Problem + +Flash defects appear in bursts. Operators adjust mold temperature on gut feel. Counts are not continuous measurements — Gaussian normality tests and dip tests would be the wrong tools. + +### Approach + +1. Vision system posts defectives and inspected count per lot via REST (`/streams/...` or ingest then Phase II). +2. Phase I builds a **P** chart with variable sample sizes; distribution gates are skipped for attribute data. +3. Freeze limits; Phase II evaluates each new lot. Prefer Western Electric or Nelson zone rules for “2 of 3 beyond 2σ” style detection. +4. Correlate signal timestamps with mold-temp PID logs in the MES (ASPC emits when; process engineering explains why). + +### Why ASPC fits + +- Proper binomial P limits, not “treat counts as I-MR.” +- Attribute path does not false-STOP on discrete dip artifacts. +- API + WebSocket fit a vision → quality service architecture without a desktop seat. + +### Outcome (scenario) + +Bursts of elevated defect rate generate repeatable signals; investigation ties them to PID oscillation rather than random “operator error.” + +--- + +## Choosing a pattern + +| Situation | Start with | +|-----------|------------| +| One sensor, continuous, live | Use case 1 (I-MR stream) | +| Lab / offline / regulated documentation | Use case 2 (batch + MSA + capability) | +| Defectives / defects from inspection | Use case 3 (P / NP / C / U) | +| Autocorrelated continuous sensor | Establish → EWMA or CUSUM route ([concepts](../concepts.md)) | + +Next: [benchmarking](benchmarking.md) for measured performance and correctness evidence, or [deployment](../deployment.md) to stand up the Compose stack. diff --git a/docs/pipeline.md b/docs/pipeline.md new file mode 100644 index 0000000..dfa1ff8 --- /dev/null +++ b/docs/pipeline.md @@ -0,0 +1,125 @@ +# Gated Phase I pipeline + +Source: [`spc_core/pipeline.py`](../spc_core/pipeline.py). + +The master entry point is `establish(...)`. It returns a `PipelineResult` with a chart, a list of `Gate` objects, optional MSA / normality / ACF / multimodal / transform details, a `stopped` flag, and a `chart_route`. + +## Gate statuses + +Each `Gate` has: + +- `step` — name of the check +- `status` — `ok` | `warn` | `stop` +- `reason` — human-readable explanation +- `detail` — optional structured fields + +`pipeline.stopped` is `True` if **any** gate has status `stop`. Callers (API go-live, operators) should refuse Phase II when stopped, even though a diagnostic chart may still be produced. + +## Ordered steps + +```mermaid +flowchart TD + start[Input values] --> msa[1 MSA gate] + msa --> missing[2 Missing-value classification] + missing --> acf[3 Autocorrelation] + acf -->|autocorrelated| ewma[Route EWMA or CUSUM] + acf -->|iid| mm[4 Multimodal] + mm -->|multimodal| stopStrat[STOP stratify] + mm -->|ok| norm[5 Normality] + norm -->|normal| chart[6 Chart + freeze] + norm -->|transform OK| chart + norm -->|still non-normal| wheeler[Wheeler I-MR + points-outside] + wheeler --> chart + ewma --> chart + chart --> freeze[7 Freeze versioned limits] +``` + +### 1. MSA (`step="msa"`, optional `gage_resolution`) + +Runs only when `msa_parts`, `msa_operators`, and `msa_measurements` are provided. + +- `%GRR > 30` or NDC < 5 → **stop** +- `10 ≤ %GRR < 30` → **warn** +- `%GRR < 10` and NDC ≥ 5 → **ok** +- If MSA inputs omitted → **warn** (“proceeding without measurement-system gate”) + +Optional `gage_resolution` + `msa_tolerance` adds a `gage_resolution` gate via the 10:1 rule. + +### 2. Missing values (`step="missing"`) + +Uses `classify_missing`. Imputed (LOCF) and excluded reasons are counted; excluded rows produce a **warn**. No silent drops. + +### 3. Autocorrelation (`step="autocorrelation"`) + +`check_autocorrelation` vs `acf_threshold` (default 0.2). If autocorrelated → **warn** and route to `EWMA` or `CUSUM` (`autocorrelated_chart`, default `"EWMA"`). + +### 4. Multimodal (`step="multimodal"`) + +Skipped when already routed to EWMA/CUSUM. Multimodal → **stop** (stratify before SPC). + +### 5. Normality / transform / Wheeler (`step="normality"`) + +- Normal → **ok** +- Non-normal but transform restores normality → **ok**, `DistributionFlag.TRANSFORMED` +- Still non-normal → **warn**, Wheeler path: force `I-MR` + `ruleset="wheeler"` + +### 6–7. Chart + freeze (`step="chart"`, `step="freeze"`) + +`analyze_control_chart` with the resolved chart type and ruleset. Limits are frozen; `limits.version` is the content hash. + +## `phase1_checklist()` + +Ten go-live items (plus optional stratification fail): + +| Item | Pass condition | +|------|----------------| +| `msa_grr_ndc` | MSA run and `%GRR < 10` and NDC ≥ 5 | +| `normality_path` | Normality gate ok/warn (or skipped for EWMA/CUSUM) | +| `autocorrelation` | ACF gate ran | +| `outliers_investigated` | Caller affirms investigation (`outliers_investigated=True`) | +| `missing_classified` | Missing gate ok/warn | +| `min_subgroups` | Plotted points ≥ `min_subgroups` (default 25) | +| `limits_frozen` | Version hash present | +| `transform_documented` | Transform label set when flag is TRANSFORMED | +| `gage_calibration` | Caller affirms active calibration | +| `phase2_enabled` | Caller affirms Phase II monitoring is on | + +Returns `{passed, items, limits_version, stopped, msa_ok}`. + +## Per-point `SPCRecord` + +`ControlChartResult.to_records()` emits one record per plotted point: + +| Field | Meaning | +|-------|---------| +| `measurement_value` | Plotted statistic | +| `data_quality_flag` | Provenance (`QualityFlag`) | +| `distribution_flag` | How distribution was handled | +| `transform_applied` | Label if transformed | +| `phase` | `PHASE_I` or `PHASE_II` | +| `ucl` / `lcl` / `centerline` | Limits used for this point | +| `signals` | List of `Signal` (rule_id, index, description, …) | +| `gage_id` / `machine_id` | Optional context | +| `subgroup_id` / `timestamp` | Optional identity | + +`SPCReport.from_chart_result(..., include_records=True, gates=..., checklist=...)` packages chart + gates + checklist for API/CLI persistence. + +## Minimal usage + +```python +from sample_data import spc_individual +from spc_core import establish, phase1_checklist + +cols = spc_individual(in_control=True) +pipe = establish(cols["measurement"]) +print(pipe.chart.chart_type, pipe.limits_version, pipe.stopped) +for g in pipe.gates: + print(g.status, g.step, g.reason) + +checklist = phase1_checklist(pipe, phase2_enabled=False) +print(checklist["passed"], checklist["items"]) + +records = pipe.chart.to_records() +``` + +Next: [CLI](cli.md) · [Python API](python-api.md) · [Concepts](concepts.md) diff --git a/docs/python-api.md b/docs/python-api.md new file mode 100644 index 0000000..f337329 --- /dev/null +++ b/docs/python-api.md @@ -0,0 +1,178 @@ +# Python library (`spc_core`) + +Pure computation — no I/O, no FastAPI. Public surface: [`spc_core/__init__.py`](../spc_core/__init__.py). + +Install: + +```bash +uv pip install -e ".[dev]" +``` + +Demo data without CSV files: + +```python +from sample_data import spc_individual, msa_gage_rr, capability +``` + +## Phase I (gated) + +```python +from sample_data import spc_individual +from spc_core import establish, phase1_checklist + +cols = spc_individual(in_control=True) +pipe = establish(cols["measurement"], ruleset="nelson", acf_threshold=0.2) + +print(pipe.chart.chart_type, pipe.limits_version, pipe.chart_route, pipe.stopped) +for g in pipe.gates: + print(f"[{g.status}] {g.step}: {g.reason}") + +checklist = phase1_checklist(pipe, min_subgroups=25, phase2_enabled=False) +assert "items" in checklist + +records = pipe.chart.to_records() # list[SPCRecord] +``` + +Optional MSA gate inside `establish`: + +```python +from sample_data import msa_gage_rr +from spc_core import establish + +msa = msa_gage_rr(quality="excellent") +pipe = establish( + spc_individual()["measurement"], + msa_parts=msa["Part"], + msa_operators=msa["Operator"], + msa_measurements=msa["Measurement"], + msa_tolerance=1.0, +) +``` + +## Charts directly + +```python +from spc_core import analyze_control_chart, ChartType +from spc_core import ewma_chart, cusum_chart + +result = analyze_control_chart(values, subgroup_ids=None, ruleset="nelson") +# or force type: +result = analyze_control_chart(values, chart_type=ChartType.XBAR_R, subgroup_ids=sids) + +ew = ewma_chart(values, lam=0.2, L=3.0) +cu = cusum_chart(values, k=0.5, h=5.0) +``` + +Limit builders: `imr_limits`, `xbar_r_limits`, `xbar_s_limits`, `p_limits`, `np_limits`, `c_limits`, `u_limits`. + +## Capability + +```python +from sample_data import capability +from spc_core import capability_analysis, check_normality + +values = capability(kind="excellent")["measurement"] +norm = check_normality(values) +cap = capability_analysis(values, usl=10.5, lsl=9.5, target=10.0) +print(cap.method, cap.cpk, cap.ppk, cap.sigma_level, cap.rating) +``` + +## MSA + +```python +from sample_data import msa_gage_rr, msa_bias +from spc_core import gage_rr_anova, bias_study, ndc_gate, gage_resolution_gate + +cols = msa_gage_rr(quality="excellent") +rr = gage_rr_anova(cols["Part"], cols["Operator"], cols["Measurement"]) +print(rr.grr_percent, rr.ndc, rr.acceptability) +print(ndc_gate(rr.ndc)) +print(gage_resolution_gate(0.01, tolerance=1.0)) + +b = bias_study(msa_bias()["Measurement"], msa_bias()["Reference"]) +``` + +Continuous / streaming MSA: + +```python +from spc_core import ContinuousMSA + +msa = ContinuousMSA(reference=10.0, bias_threshold=0.2) +alert = msa.update(10.15) # CalibrationAlert or None +``` + +## Phase II evaluation + +Against **frozen** limits from Phase I: + +```python +from spc_core import evaluate_batch, Phase2Evaluator +from adapters.stream import FileReplaySource, stream_evaluate +from sample_data import write_csv, spc_individual + +pipe = establish(spc_individual(in_control=True)["measurement"]) +limits = pipe.chart.limits + +# Batch +signals = evaluate_batch(new_values, limits, ruleset="nelson") + +# Stateful evaluator +ev = Phase2Evaluator(limits, ruleset="nelson") +for x in new_values: + sigs = ev.update(x) + +# File / stream adapter +path = write_csv(spc_individual(in_control=False), "examples/data/phase2.csv") +signals = stream_evaluate(FileReplaySource(path, "measurement"), limits) +``` + +## Explain signals + +Deterministic “why” for an OOC `Signal` (catalog text only — no LLM): + +```python +from spc_core.explain import explain_signal + +exp = explain_signal(signals[0], limits_version=pipe.limits_version, limits=limits) +print(exp["rule_id"], exp["operator_summary"]) +``` + +HTTP: `POST /analyze/explain`. Lab UI: `/lab`. + +## Reports + +```python +from spc_core.report import SPCReport, CapabilityReport, MSAReport + +report = SPCReport.from_chart_result( + pipe.chart, + normality=pipe.normality, + autocorrelation=pipe.autocorrelation, + gates=pipe.gates, + checklist=checklist, + include_records=True, +) +payload = report.model_dump(mode="json") +``` + +## Ingest helpers + +```python +from spc_core import ingest, detect_columns +from adapters.io_files import load_columns + +cols = load_columns("examples/data/spc_subgroup_data.csv") +frame = ingest(cols) # frame.column_map.value_col, .subgroup_col, … +``` + +## Package extras + +```bash +uv pip install -e ".[stream]" # aiokafka, aiomqtt +uv pip install -e ".[tsdb]" # sqlalchemy, alembic, asyncpg, psycopg +uv pip install -e ".[apps]" # FastAPI stack +uv pip install -e ".[all]" +uv pip install -e ".[dev]" # everything needed for tests + local apps +``` + +Next: [pipeline.md](pipeline.md) · [cli.md](cli.md) · [api.md](api.md) diff --git a/env.example b/env.example index 1dd5086..60a5c81 100644 --- a/env.example +++ b/env.example @@ -1,31 +1,49 @@ -# SPC & Quality Management System - Environment Variables -# Copy this file to .env and fill in your API keys +# ASPC environment variables +# Copy to .env and adjust for your deployment: +# cp env.example .env +# +# Local quickstart (skips startup refusal of default secrets): +# ASPC_DEV_INSECURE=1 +# Never set ASPC_DEV_INSECURE in a reachable deploy. -# ======================================== -# LLM API Keys (based on your chosen provider in config.yaml) -# ======================================== +# Auth +ASPC_AUTH_ENABLED=true +ASPC_JWT_SECRET=replace-with-long-random-string +ASPC_ADMIN_USERNAME=admin +ASPC_ADMIN_PASSWORD=replace-with-strong-password +# Comma-separated API keys for ingest / stream endpoints (required unless ASPC_DEV_INSECURE=1) +ASPC_API_KEYS=replace-with-api-key +# Local-only escape hatch (never in production): +# ASPC_DEV_INSECURE=1 +# Weak/default secrets are refused even when ASPC_AUTH_ENABLED=false unless +# ASPC_DEV_INSECURE=1 is set. -# Groq API Key (default provider) -# Get your key at: https://console.groq.com/keys -GROQ_API_KEY=your_groq_api_key_here +# API +# ASPC_API_HOST=0.0.0.0 +# ASPC_API_PORT=8000 +ASPC_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 -# OpenAI API Key (if using OpenAI provider) -# Get your key at: https://platform.openai.com/api-keys -# OPENAI_API_KEY=your_openai_api_key_here +# Persistence (sqlite | timescale) +# ASPC_PERSISTENCE_BACKEND=sqlite +# ASPC_SQLITE_PATH=aspc.db +# ASPC_TIMESCALE_DSN=postgresql+asyncpg://aspc:aspc@localhost:5432/aspc +# Skip create_all when Alembic already ran (compose migrate service): +# ASPC_TSDB_INIT=0 -# Anthropic API Key (if using Anthropic/Claude provider) -# Get your key at: https://console.anthropic.com/ -# ANTHROPIC_API_KEY=your_anthropic_api_key_here +# Streaming +# ASPC_REDIS_URL=redis://localhost:6379/0 +# ASPC_KAFKA_BOOTSTRAP=localhost:9092 +# ASPC_KAFKA_TOPIC=spc.measurements +# ASPC_KAFKA_DLQ_TOPIC=spc.measurements.dlq +# ASPC_KAFKA_BATCH_FLUSH=50 -# Ollama (if using local Ollama) -# No API key needed - just ensure Ollama is running locally -# OLLAMA_BASE_URL=http://localhost:11434 - -# ======================================== -# Instructions: -# ======================================== -# 1. Copy this file: cp env.example .env -# 2. Edit .env and add your actual API keys -# 3. Set the provider in agent_config/config.yaml to match your key (groq, openai, anthropic, etc.) -# 4. Never commit .env to version control (it's in .gitignore) +# Outbound OOC webhooks (optional) +# ASPC_WEBHOOK_URL=https://hooks.example.com/aspc +# ASPC_WEBHOOK_SECRET=shared-hmac-secret +# Multi-user demo / tenancy (optional) +# ASPC_DEFAULT_ROLE=admin +# ASPC_TENANT_ID=plant-a +# ASPC_REDIS_TENANT_PREFIX=1 +# When tenant prefix is on, Redis channels are spc:live:{tenant_id}:{stream_key}. +# For multi-replica stream-engine demos, use sticky Kafka consumers per stream_key partition. diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..d28ec82 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,4 @@ +NEXT_PUBLIC_API_URL=/backend +NEXT_PUBLIC_WS_URL=ws://localhost:8000 +# Local npm run dev: next.config.js proxies /backend → http://127.0.0.1:8000 +# Compose build sets ASPC_API_PROXY_TARGET=http://api:8000 diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json new file mode 100644 index 0000000..bffb357 --- /dev/null +++ b/frontend/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..5664ee0 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,14 @@ +node_modules +.next +out +dist +coverage +playwright-report +test-results +e2e/.auth +.env +.env.local +*.log +.DS_Store +.vercel +.env* diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d042c19 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,39 @@ +# ASPC Operator Console + +Next.js dashboard for ASPC — batch analysis, Phase I checklist, limits go-live, and live WebSocket monitoring. + +## Setup + +```bash +cp .env.example .env.local +npm install +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000). REST defaults to same-origin **`/backend`** (Next rewrites to `http://127.0.0.1:8000` in local dev, `http://api:8000` in Compose). WebSocket still uses `ws://localhost:8000`. + +Routes: `/` Overview · `/onboarding` · `/live` · `/analyze` · `/capability` · `/msa` · `/runs` · `/lab`. Sign in at `/login`. + +## Scripts + +| Script | Purpose | +|--------|---------| +| `npm run dev` | Dev server | +| `npm run build` | Production build | +| `npm start` | Serve production build | +| `npm run lint` | ESLint | +| `npm test` | Vitest unit tests | +| `npm run test:e2e` | Playwright (login + all operator pages; Compose must be up) | + +```bash +# UI + API already running (Compose) +E2E_USERNAME=admin E2E_PASSWORD='change-me-admin-password' npm run test:e2e +``` + +`home.spec.ts` skips if `:3000` is down. Other specs fail if the stack is unreachable. Auth cookies live in `e2e/.auth/` (gitignored). + +## Env + +- `NEXT_PUBLIC_API_URL` — REST base (default `/backend`) +- `NEXT_PUBLIC_WS_URL` — WebSocket base (default `ws://localhost:8000`) +- `ASPC_API_PROXY_TARGET` — rewrite target for `/backend` (dev: `http://127.0.0.1:8000`; Compose build: `http://api:8000`) diff --git a/frontend/e2e/analyze.spec.ts b/frontend/e2e/analyze.spec.ts new file mode 100644 index 0000000..a0809f1 --- /dev/null +++ b/frontend/e2e/analyze.spec.ts @@ -0,0 +1,15 @@ +import { test, expect } from "@playwright/test"; +import { expectNoUnreachableApi, expectShellTitle, SPC_CSV } from "./helpers"; + +test("analyze uploads I-MR CSV and shows a result", async ({ page }) => { + await page.goto("/analyze"); + await expectShellTitle(page, "Analyze"); + await expect(page.getByRole("heading", { name: "Upload" })).toBeVisible(); + await expectNoUnreachableApi(page); + + await page.locator("#file").setInputFiles(SPC_CSV); + await page.getByRole("button", { name: /run analysis/i }).click(); + await expect(page.getByRole("heading", { name: "Result" })).toBeVisible({ timeout: 45_000 }); + await expect(page.getByText(/Run ID/)).toBeVisible(); + await expectNoUnreachableApi(page); +}); diff --git a/frontend/e2e/auth.setup.ts b/frontend/e2e/auth.setup.ts new file mode 100644 index 0000000..3f68df3 --- /dev/null +++ b/frontend/e2e/auth.setup.ts @@ -0,0 +1,33 @@ +import { test as setup, expect } from "@playwright/test"; +import path from "path"; +import fs from "fs"; + +const authFile = path.join(__dirname, ".auth/user.json"); + +setup("authenticate", async ({ page, baseURL }) => { + const username = process.env.E2E_USERNAME || "admin"; + const password = process.env.E2E_PASSWORD || "change-me-admin-password"; + + let reachable = false; + try { + const res = await page.request.get(baseURL || "http://localhost:3000/login", { timeout: 5000 }); + reachable = res.ok() || res.status() < 500; + } catch { + reachable = false; + } + if (!reachable) { + throw new Error( + `UI not reachable at ${baseURL}. Start Compose: docker compose -f deploy/compose/docker-compose.yml --env-file deploy/compose/.env up -d`, + ); + } + + fs.mkdirSync(path.dirname(authFile), { recursive: true }); + + await page.goto("/login"); + await page.getByLabel("Username").fill(username); + await page.getByLabel("Password").fill(password); + await page.getByRole("button", { name: /sign in/i }).click(); + await expect(page).toHaveURL(/\/$/, { timeout: 20_000 }); + await expect(page.getByRole("heading", { name: "Dashboard", level: 1 })).toBeVisible(); + await page.context().storageState({ path: authFile }); +}); diff --git a/frontend/e2e/capability.spec.ts b/frontend/e2e/capability.spec.ts new file mode 100644 index 0000000..3d86ea1 --- /dev/null +++ b/frontend/e2e/capability.spec.ts @@ -0,0 +1,17 @@ +import { test, expect } from "@playwright/test"; +import { expectNoUnreachableApi, expectShellTitle, SPC_CSV } from "./helpers"; + +test("capability study runs against uploaded measurements", async ({ page }) => { + await page.goto("/capability"); + await expectShellTitle(page, "Capability"); + await expect(page.getByRole("heading", { name: "Study setup" })).toBeVisible(); + await expectNoUnreachableApi(page); + + await page.locator("#cap_file").setInputFiles(SPC_CSV); + await page.getByLabel("USL").fill("110"); + await page.getByLabel("LSL").fill("90"); + await page.getByRole("button", { name: /run capability/i }).click(); + await expect(page.getByRole("heading", { name: "Results" })).toBeVisible({ timeout: 45_000 }); + await expect(page.getByText("Run", { exact: true })).toBeVisible(); + await expectNoUnreachableApi(page); +}); diff --git a/frontend/e2e/helpers.ts b/frontend/e2e/helpers.ts new file mode 100644 index 0000000..25264a4 --- /dev/null +++ b/frontend/e2e/helpers.ts @@ -0,0 +1,30 @@ +import { expect, type Page } from "@playwright/test"; +import path from "path"; + +export const SPC_CSV = path.join( + __dirname, + "..", + "..", + "resilience_data", + "cases", + "spc", + "imr_in_control_n50.csv", +); + +export const MSA_CSV = path.join( + __dirname, + "..", + "..", + "resilience_data", + "cases", + "msa", + "gage_rr_excellent.csv", +); + +export async function expectNoUnreachableApi(page: Page) { + await expect(page.getByText(/Cannot reach API/i)).toHaveCount(0); +} + +export async function expectShellTitle(page: Page, title: string) { + await expect(page.getByRole("heading", { name: title, level: 1 })).toBeVisible(); +} diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts new file mode 100644 index 0000000..09f99ee --- /dev/null +++ b/frontend/e2e/home.spec.ts @@ -0,0 +1,24 @@ +import { test, expect } from "@playwright/test"; + +/** + * Smoke: loads / when the Next.js server is up. + * Skips cleanly if nothing is listening (CI without frontend server). + */ +test("home page loads", async ({ page, baseURL }) => { + test.skip(!baseURL, "No PLAYWRIGHT_BASE_URL / baseURL configured"); + + let reachable = false; + try { + const res = await page.request.get(baseURL!, { timeout: 3000 }); + reachable = res.ok() || res.status() < 500; + } catch { + reachable = false; + } + test.skip(!reachable, "Next.js server not running — skipping smoke e2e"); + + await page.goto("/"); + await expect(page.getByRole("heading", { name: /sign in|dashboard|aspc|overview/i })).toBeVisible({ + timeout: 10_000, + }); + await expect(page.getByRole("link", { name: /live/i }).or(page.getByRole("button", { name: /sign in/i }))).toBeVisible(); +}); diff --git a/frontend/e2e/lab.spec.ts b/frontend/e2e/lab.spec.ts new file mode 100644 index 0000000..6734e3e --- /dev/null +++ b/frontend/e2e/lab.spec.ts @@ -0,0 +1,15 @@ +import { test, expect } from "@playwright/test"; +import { expectNoUnreachableApi, expectShellTitle } from "./helpers"; + +test("lab lists catalog cases and runs one", async ({ page }) => { + await page.goto("/lab"); + await expectShellTitle(page, "Resilience Lab"); + await expect(page.getByRole("heading", { name: "Cases" })).toBeVisible(); + await expectNoUnreachableApi(page); + + const runBtn = page.getByRole("button", { name: /^Run$/ }).first(); + await expect(runBtn).toBeVisible({ timeout: 20_000 }); + await runBtn.click(); + await expect(page.getByRole("heading", { name: "Result" })).toBeVisible({ timeout: 60_000 }); + await expectNoUnreachableApi(page); +}); diff --git a/frontend/e2e/live.spec.ts b/frontend/e2e/live.spec.ts new file mode 100644 index 0000000..9404670 --- /dev/null +++ b/frontend/e2e/live.spec.ts @@ -0,0 +1,13 @@ +import { test, expect } from "@playwright/test"; +import { expectNoUnreachableApi, expectShellTitle } from "./helpers"; + +test("live page shows stream controls without requiring Kafka traffic", async ({ page }) => { + await page.goto("/live"); + await expectShellTitle(page, "Live"); + await expect(page.getByText(/Phase II stream against frozen limits/i)).toBeVisible(); + await expect(page.getByRole("heading", { name: "Stream" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Connect", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Disconnect" })).toBeVisible(); + await expect(page.getByText("Disconnected")).toBeVisible(); + await expectNoUnreachableApi(page); +}); diff --git a/frontend/e2e/msa.spec.ts b/frontend/e2e/msa.spec.ts new file mode 100644 index 0000000..d99cbe3 --- /dev/null +++ b/frontend/e2e/msa.spec.ts @@ -0,0 +1,18 @@ +import { test, expect } from "@playwright/test"; +import { expectNoUnreachableApi, expectShellTitle, MSA_CSV } from "./helpers"; + +test("msa gage R&R upload produces a batch result", async ({ page }) => { + await page.goto("/msa"); + await expectShellTitle(page, "MSA"); + await expect(page.getByRole("heading", { name: "Study upload" })).toBeVisible(); + await expectNoUnreachableApi(page); + + await page.locator("#msa_file").setInputFiles(MSA_CSV); + await page.getByLabel("Study type").selectOption("gage_rr"); + await page.getByRole("button", { name: /run msa/i }).click(); + await expect(page.getByRole("heading", { name: "Batch MSA result" })).toBeVisible({ + timeout: 45_000, + }); + await expect(page.getByText("Run", { exact: true })).toBeVisible(); + await expectNoUnreachableApi(page); +}); diff --git a/frontend/e2e/nav.spec.ts b/frontend/e2e/nav.spec.ts new file mode 100644 index 0000000..e106f6c --- /dev/null +++ b/frontend/e2e/nav.spec.ts @@ -0,0 +1,25 @@ +import { test, expect } from "@playwright/test"; +import { expectNoUnreachableApi, expectShellTitle } from "./helpers"; + +const PAGES = [ + { name: "Overview", path: "/", title: "Dashboard" }, + { name: "Onboarding", path: "/onboarding", title: "Onboarding" }, + { name: "Live", path: "/live", title: "Live" }, + { name: "Analyze", path: "/analyze", title: "Analyze" }, + { name: "Capability", path: "/capability", title: "Capability" }, + { name: "MSA", path: "/msa", title: "MSA" }, + { name: "Runs", path: "/runs", title: "Runs" }, + { name: "Lab", path: "/lab", title: "Resilience Lab" }, +] as const; + +test("sidebar navigates every operator page", async ({ page }) => { + await page.goto("/"); + await expectShellTitle(page, "Dashboard"); + + for (const item of PAGES) { + await page.locator("aside nav").getByRole("link", { name: item.name, exact: true }).click(); + await expect(page).toHaveURL(new RegExp(`${item.path.replace("/", "\\/")}$`)); + await expectShellTitle(page, item.title); + await expectNoUnreachableApi(page); + } +}); diff --git a/frontend/e2e/onboarding.spec.ts b/frontend/e2e/onboarding.spec.ts new file mode 100644 index 0000000..73cde26 --- /dev/null +++ b/frontend/e2e/onboarding.spec.ts @@ -0,0 +1,15 @@ +import { test, expect } from "@playwright/test"; +import { expectNoUnreachableApi, expectShellTitle } from "./helpers"; + +test("onboarding loads sample and establishes Phase I", async ({ page }) => { + await page.goto("/onboarding"); + await expectShellTitle(page, "Onboarding"); + await expect(page.getByRole("heading", { name: /Step 1 of 4/i })).toBeVisible(); + await expect(page.getByText("Sample + establish")).toBeVisible(); + await expectNoUnreachableApi(page); + + await page.getByRole("button", { name: /load sample & establish/i }).click(); + await expect(page.getByRole("heading", { name: /Step 2 of 4/i })).toBeVisible({ timeout: 45_000 }); + await expect(page.getByText(/Run /)).toBeVisible(); + await expectNoUnreachableApi(page); +}); diff --git a/frontend/e2e/overview.spec.ts b/frontend/e2e/overview.spec.ts new file mode 100644 index 0000000..b139535 --- /dev/null +++ b/frontend/e2e/overview.spec.ts @@ -0,0 +1,11 @@ +import { test, expect } from "@playwright/test"; +import { expectNoUnreachableApi, expectShellTitle } from "./helpers"; + +test("overview dashboard chrome and action tiles", async ({ page }) => { + await page.goto("/"); + await expectShellTitle(page, "Dashboard"); + await expectNoUnreachableApi(page); + await expect(page.getByRole("link", { name: "Onboarding", exact: true }).first()).toBeVisible(); + await expect(page.getByText(/Sample → freeze → go-live|Batch control charts|Phase II streams|Resilience cases/i).first()).toBeVisible(); + await expect(page.getByRole("heading", { name: /recent runs/i })).toBeVisible(); +}); diff --git a/frontend/e2e/runs.spec.ts b/frontend/e2e/runs.spec.ts new file mode 100644 index 0000000..6bb80f8 --- /dev/null +++ b/frontend/e2e/runs.spec.ts @@ -0,0 +1,21 @@ +import { test, expect } from "@playwright/test"; +import { expectNoUnreachableApi, expectShellTitle } from "./helpers"; + +test("runs list loads and opens a detail page when a run exists", async ({ page }) => { + await page.goto("/runs"); + await expectShellTitle(page, "Runs"); + await expect(page.getByText(/Audit trail of batch analyses/i)).toBeVisible(); + await expectNoUnreachableApi(page); + + const empty = page.getByText("No analysis runs stored yet."); + const firstRun = page.locator("table tbody tr td a").first(); + await expect(empty.or(firstRun)).toBeVisible({ timeout: 20_000 }); + + if (await firstRun.isVisible()) { + await firstRun.click(); + await expect(page).toHaveURL(/\/runs\/.+/); + await expectShellTitle(page, "Run detail"); + await expect(page.getByText(/Run Report|Summary/i).first()).toBeVisible(); + await expectNoUnreachableApi(page); + } +}); diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts new file mode 100644 index 0000000..40c3d68 --- /dev/null +++ b/frontend/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/frontend/next.config.js b/frontend/next.config.js new file mode 100644 index 0000000..298b1f7 --- /dev/null +++ b/frontend/next.config.js @@ -0,0 +1,32 @@ +/** @type {import('next').NextConfig} */ +const apiProxyTarget = ( + process.env.ASPC_API_PROXY_TARGET || + process.env.NEXT_PUBLIC_API_PROXY_TARGET || + "http://127.0.0.1:8000" +).replace(/\/$/, ""); + +const nextConfig = { + // Docker Compose needs standalone; Vercel provides its own output and breaks with it. + ...(process.env.VERCEL ? {} : { output: "standalone" }), + reactStrictMode: true, + transpilePackages: ["react-plotly.js"], + // Same-origin /backend/* → ASPC API. Avoids browser CORS to :8000 (Compose + IDE browsers). + async rewrites() { + return [ + { + source: "/backend/:path*", + destination: `${apiProxyTarget}/:path*`, + }, + ]; + }, + webpack: (config) => { + config.resolve.alias = { + ...config.resolve.alias, + "plotly.js/dist/plotly": "plotly.js/dist/plotly.min.js", + }; + config.externals = [...(config.externals || []), { canvas: "canvas" }]; + return config; + }, +}; + +module.exports = nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..7a7e148 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,11085 @@ +{ + "name": "aspc-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aspc-frontend", + "version": "1.0.0", + "dependencies": { + "@tanstack/react-query": "^5.66.0", + "next": "^14.2.24", + "plotly.js": "^2.35.3", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-plotly.js": "^2.6.0" + }, + "devDependencies": { + "@playwright/test": "^1.50.1", + "@types/node": "^20.17.17", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@types/react-plotly.js": "^2.6.3", + "autoprefixer": "^10.4.20", + "eslint": "^8.57.1", + "eslint-config-next": "^14.2.24", + "postcss": "^8.5.1", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@choojs/findup": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@choojs/findup/-/findup-0.2.1.tgz", + "integrity": "sha512-YstAqNb0MCN8PjdLCDfRsBcGVRN41f3vgLvaI0IrIcBp4AqILRSS0DeWNGkicC+f/zRIPJLc+9RURVSepwvfBw==", + "license": "MIT", + "dependencies": { + "commander": "^2.15.1" + }, + "bin": { + "findup": "bin/findup.js" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mapbox/geojson-rewind": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", + "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", + "license": "ISC", + "dependencies": { + "get-stream": "^6.0.1", + "minimist": "^1.2.6" + }, + "bin": { + "geojson-rewind": "geojson-rewind" + } + }, + "node_modules/@mapbox/geojson-types": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-types/-/geojson-types-1.0.2.tgz", + "integrity": "sha512-e9EBqHHv3EORHrSfbR9DqecPNn+AmuAoQxV6aL8Xu30bJMJR1o8PZLZzpk1Wq7/NfCbuhmakHTPYRhoqLsXRnw==", + "license": "ISC" + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz", + "integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==", + "license": "MIT", + "engines": { + "node": ">= 22" + } + }, + "node_modules/@mapbox/mapbox-gl-supported": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-1.5.0.tgz", + "integrity": "sha512-/PT1P6DNf7vjEEiPkVIRJkvibbqWtqnyGaBz3nfRdcxclNSnSdaLU5tfAgcD7I8Yt5i+L19s406YLl1koLnLbg==", + "license": "BSD-3-Clause", + "peerDependencies": { + "mapbox-gl": ">=0.32.1 <2.0.0" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", + "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-1.2.5.tgz", + "integrity": "sha512-cD8A/zJlm6fdJOk6DqPUV8mcpyJkRz2x2R+/fYcWDYG3oWbG7/L7Yl/WqQ1VZCjnL9OTIMAn6c+BC5Eru4sQEw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.0.tgz", + "integrity": "sha512-HPnRdYO0WjFjRTSwO3frz1wKaU649OBFPX3Zo/2WZvuRi6zMiRGui8SnPQiQABgqCf8YikDe5t3HViTVw1WUzA==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz", + "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~0.1.0" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "20.4.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-20.4.0.tgz", + "integrity": "sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^2.0.0", + "rw": "^1.3.3", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@next/env": { + "version": "14.2.35", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.35.tgz", + "integrity": "sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "14.2.35", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.2.35.tgz", + "integrity": "sha512-Jw9A3ICz2183qSsqwi7fgq4SBPiNfmOLmTPXKvlnzstUwyvBrtySiY+8RXJweNAs9KThb1+bYhZh9XWcNOr2zQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "10.3.10" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz", + "integrity": "sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz", + "integrity": "sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz", + "integrity": "sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz", + "integrity": "sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz", + "integrity": "sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz", + "integrity": "sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz", + "integrity": "sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz", + "integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz", + "integrity": "sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@plotly/d3": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@plotly/d3/-/d3-3.8.2.tgz", + "integrity": "sha512-wvsNmh1GYjyJfyEBPKJLTMzgf2c2bEbSIL50lmqVUi+o1NHaLPi1Lb4v7VxXXJn043BhNyrxUrWI85Q+zmjOVA==", + "license": "BSD-3-Clause" + }, + "node_modules/@plotly/d3-sankey": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@plotly/d3-sankey/-/d3-sankey-0.7.2.tgz", + "integrity": "sha512-2jdVos1N3mMp3QW0k2q1ph7Gd6j5PY1YihBrwpkFnKqO+cqtZq3AdEYUeSGXMeLsBDQYiqTVcihYfk8vr5tqhw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1", + "d3-collection": "1", + "d3-shape": "^1.2.0" + } + }, + "node_modules/@plotly/d3-sankey-circular": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@plotly/d3-sankey-circular/-/d3-sankey-circular-0.33.1.tgz", + "integrity": "sha512-FgBV1HEvCr3DV7RHhDsPXyryknucxtfnLwPtCKKxdolKyTFYoLX/ibEfX39iFYIL7DYbVeRtP43dbFcrHNE+KQ==", + "license": "MIT", + "dependencies": { + "d3-array": "^1.2.1", + "d3-collection": "^1.0.4", + "d3-shape": "^1.2.0", + "elementary-circuits-directed-graph": "^1.0.4" + } + }, + "node_modules/@plotly/mapbox-gl": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/@plotly/mapbox-gl/-/mapbox-gl-1.13.4.tgz", + "integrity": "sha512-sR3/Pe5LqT/fhYgp4rT4aSFf1rTsxMbGiH6Hojc7PH36ny5Bn17iVFUjpzycafETURuFbLZUfjODO8LvSI+5zQ==", + "deprecated": "This package is deprecated as of August 2026. plotly.js v4 uses MapLibre for map traces — see https://github.com/maplibre/maplibre-gl-js.", + "license": "SEE LICENSE IN LICENSE.txt", + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/geojson-types": "^1.0.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/mapbox-gl-supported": "^1.5.0", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^1.1.1", + "@mapbox/unitbezier": "^0.0.0", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "csscolorparser": "~1.0.3", + "earcut": "^2.2.2", + "geojson-vt": "^3.2.1", + "gl-matrix": "^3.2.1", + "grid-index": "^1.1.0", + "murmurhash-js": "^1.0.0", + "pbf": "^3.2.1", + "potpack": "^1.0.1", + "quickselect": "^2.0.0", + "rw": "^1.3.3", + "supercluster": "^7.1.0", + "tinyqueue": "^2.0.3", + "vt-pbf": "^3.1.1" + }, + "engines": { + "node": ">=6.4.0" + } + }, + "node_modules/@plotly/point-cluster": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/@plotly/point-cluster/-/point-cluster-3.1.9.tgz", + "integrity": "sha512-MwaI6g9scKf68Orpr1pHZ597pYx9uP8UEFXLPbsCmuw3a84obwz6pnMXGc90VhgDNeNiLEdlmuK7CPo+5PIxXw==", + "license": "MIT", + "dependencies": { + "array-bounds": "^1.0.1", + "binary-search-bounds": "^2.0.4", + "clamp": "^1.0.1", + "defined": "^1.0.0", + "dtype": "^2.0.0", + "flatten-vertex-data": "^1.0.2", + "is-obj": "^1.0.1", + "math-log2": "^1.0.1", + "parse-rect": "^1.2.0", + "pick-by-alias": "^1.2.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rushstack/eslint-patch": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.16.1.tgz", + "integrity": "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, + "node_modules/@swc/helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", + "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@turf/area": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@turf/area/-/area-7.3.5.tgz", + "integrity": "sha512-sSn80wPT7XfBIDN3vurCPxhk9W4U8ozS/XImSqeLN8qveTICOxzZkhsGDMp0CuncaN+plWut4a2TdNM7mzZB6Q==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.5", + "@turf/meta": "7.3.5", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/bbox": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-7.3.5.tgz", + "integrity": "sha512-oG1ya/HtBjAIg4TimbWx+nOYPbY0bCvt82Bq8tm6sBw3qqtbOyRSfDz79Sq90TnH7DXJprJ1qnVGKNtZ6jemfw==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.5", + "@turf/meta": "7.3.5", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/centroid": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@turf/centroid/-/centroid-7.3.5.tgz", + "integrity": "sha512-hkWaqwGFdOn6Tf0EWfn2yn1XZ1FWE1h2C5ZWstDMu/FxYO5DB+YjlmOFPl4K6SmSOEgdV07eK2vDCyPeTHqKGA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.5", + "@turf/meta": "7.3.5", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/helpers": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.3.5.tgz", + "integrity": "sha512-E/NMGV5MwbjjP7AJXBtsanC3yY8N2MQ87IGdIgkB2ji5AtBpwnH4L3gEqpYN4RlCJJWbLbzO91BbKv2waUd0eg==", + "license": "MIT", + "dependencies": { + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/meta": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-7.3.5.tgz", + "integrity": "sha512-r+ohqxoyqeigFB0oFrQx/YEHIkOKqcKpCjvZkvZs7Tkv+IFco5MezAd2zd4rzK+0DfFgDP3KpJc7HqrYjvEjhg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.5", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/geojson-vt": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz", + "integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mapbox__point-geometry": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz", + "integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==", + "license": "MIT" + }, + "node_modules/@types/mapbox__vector-tile": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz", + "integrity": "sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*", + "@types/mapbox__point-geometry": "*", + "@types/pbf": "*" + } + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pbf": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", + "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==", + "license": "MIT" + }, + "node_modules/@types/plotly.js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/plotly.js/-/plotly.js-3.0.10.tgz", + "integrity": "sha512-q+MgO4aajC2HrO7FllTYWzrpdfbTjboSMfjkz/aXKjg1v7HNo1zMEFfAW7quKfk6SL+bH74A5ThBEps/7hZxOA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/react-plotly.js": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/@types/react-plotly.js/-/react-plotly.js-2.6.4.tgz", + "integrity": "sha512-AU6w1u3qEGM0NmBA69PaOgNc0KPFA/+qkH6Uu9EBTJ45/WYOUoXi9AF5O15PRM2klpHSiHAAs4WnlI+OZAFmUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/plotly.js": "*", + "@types/react": "*" + } + }, + "node_modules/@types/supercluster": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", + "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/type-utils": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.65.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", + "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", + "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", + "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", + "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", + "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", + "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.65.0", + "@typescript-eslint/tsconfig-utils": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", + "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", + "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/abs-svg-path": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz", + "integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT", + "peer": true + }, + "node_modules/almost-equal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/almost-equal/-/almost-equal-1.1.0.tgz", + "integrity": "sha512-0V/PkoculFl5+0Lp47JoxUcO0xSxhIBvm+BxHdD/OgXNmdRpRHCFnKVuUoWyS9EzQP+otSGv0m9Lb4yVkQBn2A==", + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-bounds": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-bounds/-/array-bounds-1.0.1.tgz", + "integrity": "sha512-8wdW3ZGk6UjMPJx/glyEt0sLzzwAE1bhToPsO1W2pbpR2gULyxe3BjSiuJFheP50T/GgODVPz2fuMUmIywt8cQ==", + "license": "MIT" + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-find-index": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", + "integrity": "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-normalize": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array-normalize/-/array-normalize-1.1.4.tgz", + "integrity": "sha512-fCp0wKFLjvSPmCn4F5Tiw4M3lpMZoHlCjfcs7nNzuj3vqQQ1/a8cgB9DXcpDSn18c+coLnaW7rqfcYCvKbyJXg==", + "license": "MIT", + "dependencies": { + "array-bounds": "^1.0.0" + } + }, + "node_modules/array-range": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-range/-/array-range-1.0.1.tgz", + "integrity": "sha512-shdaI1zT3CVNL2hnx9c0JMc0ZogGaxDs5e85akgHWKYa0yVbIyp06Ind3dVkTj/uuFrzaHBOyqFzo+VV6aXgtA==", + "license": "MIT" + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/autoprefixer": { + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz", + "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.6", + "caniuse-lite": "^1.0.30001806", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz", + "integrity": "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/binary-search-bounds": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/binary-search-bounds/-/binary-search-bounds-2.0.5.tgz", + "integrity": "sha512-H0ea4Fd3lS1+sTEB2TgcLoK21lLhwEJzlQv3IN47pJS976Gx4zoWe0ak3q+uYh60ppQxg9F16Ri4tS1sfD4+jA==", + "license": "MIT" + }, + "node_modules/bit-twiddle": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bit-twiddle/-/bit-twiddle-1.0.2.tgz", + "integrity": "sha512-B9UhK0DKFZhoTFcfvAzhqsjStvGJp9vYWf3+6SNTtdSQnvIgfkHbgHrg/e4+TH71N2GDu8tpmCVoyfrL1d7ntA==", + "license": "MIT" + }, + "node_modules/bitmap-sdf": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/bitmap-sdf/-/bitmap-sdf-1.0.4.tgz", + "integrity": "sha512-1G3U4n5JE6RAiALMxu0p1XmeZkTeCwGKykzsLTCqVzfSDaN6S7fKnkIkfejogz+iwqBWc0UYAIKnKHNN7pSfDg==", + "license": "MIT" + }, + "node_modules/bl": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/bl/-/bl-2.2.1.tgz", + "integrity": "sha512-6Pesp1w0DEX1N550i/uGV/TqucVL4AM/pgThFSN/Qq9si1/DF9aIHs1BxD8V/QU0HoeHO6cQRTAuYnLPKq1e4g==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.3.5", + "safe-buffer": "^5.1.1" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/canvas-fit": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/canvas-fit/-/canvas-fit-1.5.0.tgz", + "integrity": "sha512-onIcjRpz69/Hx5bB5HGbYKUF2uC6QT6Gp+pfpGm3A7mPfcluSLV5v4Zu+oflDUwLdUw0rLIBhUbi0v8hM4FJQQ==", + "license": "MIT", + "dependencies": { + "element-size": "^1.1.1" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/clamp": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/clamp/-/clamp-1.0.1.tgz", + "integrity": "sha512-kgMuFyE78OC6Dyu3Dy7vcx4uy97EIbVxJB/B0eJ3bUNAkwdNcxYzgKltnyADiYwsR7SEqkkUPsEUT//OVS6XMA==", + "license": "MIT" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/color-alpha": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/color-alpha/-/color-alpha-1.0.4.tgz", + "integrity": "sha512-lr8/t5NPozTSqli+duAN+x+no/2WaKTeWvxhHGN+aXT6AJ8vPlzLa7UriyjWak0pSC2jHol9JgjBYnnHsGha9A==", + "license": "MIT", + "dependencies": { + "color-parse": "^1.3.8" + } + }, + "node_modules/color-alpha/node_modules/color-parse": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/color-parse/-/color-parse-1.4.3.tgz", + "integrity": "sha512-BADfVl/FHkQkyo8sRBwMYBqemqsgnu7JZAwUgvBvuwwuNUZAhSvLTbsEErS5bQXzOjDR0dWzJ4vXN2Q+QoPx0A==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/color-id/-/color-id-1.1.0.tgz", + "integrity": "sha512-2iRtAn6dC/6/G7bBIo0uupVrIne1NsQJvJxZOBCzQOfk7jRq97feaDZ3RdzuHakRXXnHGNwglto3pqtRx1sX0g==", + "license": "MIT", + "dependencies": { + "clamp": "^1.0.1" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-normalize": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/color-normalize/-/color-normalize-1.5.0.tgz", + "integrity": "sha512-rUT/HDXMr6RFffrR53oX3HGWkDOP9goSAQGBkUaAYKjOE2JxozccdGyufageWDlInRAjm/jYPrf/Y38oa+7obw==", + "license": "MIT", + "dependencies": { + "clamp": "^1.0.1", + "color-rgba": "^2.1.1", + "dtype": "^2.0.0" + } + }, + "node_modules/color-parse": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/color-parse/-/color-parse-2.0.0.tgz", + "integrity": "sha512-g2Z+QnWsdHLppAbrpcFWo629kLOnOPtpxYV69GCqm92gqSgyXbzlfyN3MXs0412fPBkFmiuS+rXposgBgBa6Kg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0" + } + }, + "node_modules/color-rgba": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/color-rgba/-/color-rgba-2.1.1.tgz", + "integrity": "sha512-VaX97wsqrMwLSOR6H7rU1Doa2zyVdmShabKrPEIFywLlHoibgD3QW9Dw6fSqM4+H/LfjprDNAUUW31qEQcGzNw==", + "license": "MIT", + "dependencies": { + "clamp": "^1.0.1", + "color-parse": "^1.3.8", + "color-space": "^1.14.6" + } + }, + "node_modules/color-rgba/node_modules/color-parse": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/color-parse/-/color-parse-1.4.3.tgz", + "integrity": "sha512-BADfVl/FHkQkyo8sRBwMYBqemqsgnu7JZAwUgvBvuwwuNUZAhSvLTbsEErS5bQXzOjDR0dWzJ4vXN2Q+QoPx0A==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0" + } + }, + "node_modules/color-space": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/color-space/-/color-space-1.16.0.tgz", + "integrity": "sha512-A6WMiFzunQ8KEPFmj02OnnoUnqhmSaHaZ/0LVFcPTdlvm8+3aMJ5x1HRHy3bDHPkovkf4sS0f4wsVvwk71fKkg==", + "license": "MIT", + "dependencies": { + "hsluv": "^0.0.3", + "mumath": "^3.3.4" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/country-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/country-regex/-/country-regex-1.1.0.tgz", + "integrity": "sha512-iSPlClZP8vX7MC3/u6s3lrDuoQyhQukh5LyABJ3hvfzbQ3Yyayd4fp04zjLnfi267B/B2FkumcWWgrbban7sSA==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-font": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/css-font/-/css-font-1.2.0.tgz", + "integrity": "sha512-V4U4Wps4dPDACJ4WpgofJ2RT5Yqwe1lEH6wlOOaIxMi0gTjdIijsc5FmxQlZ7ZZyKQkkutqqvULOp07l9c7ssA==", + "license": "MIT", + "dependencies": { + "css-font-size-keywords": "^1.0.0", + "css-font-stretch-keywords": "^1.0.1", + "css-font-style-keywords": "^1.0.1", + "css-font-weight-keywords": "^1.0.0", + "css-global-keywords": "^1.0.1", + "css-system-font-keywords": "^1.0.0", + "pick-by-alias": "^1.2.0", + "string-split-by": "^1.0.0", + "unquote": "^1.1.0" + } + }, + "node_modules/css-font-size-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-font-size-keywords/-/css-font-size-keywords-1.0.0.tgz", + "integrity": "sha512-Q+svMDbMlelgCfH/RVDKtTDaf5021O486ZThQPIpahnIjUkMUslC+WuOQSWTgGSrNCH08Y7tYNEmmy0hkfMI8Q==", + "license": "MIT" + }, + "node_modules/css-font-stretch-keywords": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/css-font-stretch-keywords/-/css-font-stretch-keywords-1.0.1.tgz", + "integrity": "sha512-KmugPO2BNqoyp9zmBIUGwt58UQSfyk1X5DbOlkb2pckDXFSAfjsD5wenb88fNrD6fvS+vu90a/tsPpb9vb0SLg==", + "license": "MIT" + }, + "node_modules/css-font-style-keywords": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/css-font-style-keywords/-/css-font-style-keywords-1.0.1.tgz", + "integrity": "sha512-0Fn0aTpcDktnR1RzaBYorIxQily85M2KXRpzmxQPgh8pxUN9Fcn00I8u9I3grNr1QXVgCl9T5Imx0ZwKU973Vg==", + "license": "MIT" + }, + "node_modules/css-font-weight-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-font-weight-keywords/-/css-font-weight-keywords-1.0.0.tgz", + "integrity": "sha512-5So8/NH+oDD+EzsnF4iaG4ZFHQ3vaViePkL1ZbZ5iC/KrsCY+WHq/lvOgrtmuOQ9pBBZ1ADGpaf+A4lj1Z9eYA==", + "license": "MIT" + }, + "node_modules/css-global-keywords": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/css-global-keywords/-/css-global-keywords-1.0.1.tgz", + "integrity": "sha512-X1xgQhkZ9n94WDwntqst5D/FKkmiU0GlJSFZSV3kLvyJ1WC5VeyoXDOuleUD+SIuH9C7W05is++0Woh0CGfKjQ==", + "license": "MIT" + }, + "node_modules/css-system-font-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-system-font-keywords/-/css-system-font-keywords-1.0.0.tgz", + "integrity": "sha512-1umTtVd/fXS25ftfjB71eASCrYhilmEsvDEI6wG/QplnmlfmVM5HkZ/ZX46DT5K3eblFPgLUHt5BRCb0YXkSFA==", + "license": "MIT" + }, + "node_modules/csscolorparser": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz", + "integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==", + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/d": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", + "license": "ISC", + "dependencies": { + "es5-ext": "^0.10.64", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/d3-array": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", + "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-collection": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-collection/-/d3-collection-1.0.7.tgz", + "integrity": "sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz", + "integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-force": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-1.2.1.tgz", + "integrity": "sha512-HHvehyaiUlVo5CxBJ0yF/xny4xoaxFxDnBXNvNcfW9adORGZfyNF1dj6DGLKyk4Yh3brP/1h3rnDzdIAwL08zg==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-collection": "1", + "d3-dispatch": "1", + "d3-quadtree": "1", + "d3-timer": "1" + } + }, + "node_modules/d3-format": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz", + "integrity": "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-geo": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.12.1.tgz", + "integrity": "sha512-XG4d1c/UJSEX9NfU02KwBL6BYPj8YKHxgBEw5om2ZnTRSbIcego6dhHwcxuSR3clxh0EpE38os1DVPOmnYtTPg==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1" + } + }, + "node_modules/d3-geo-projection": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/d3-geo-projection/-/d3-geo-projection-2.9.0.tgz", + "integrity": "sha512-ZULvK/zBn87of5rWAfFMc9mJOipeSo57O+BBitsKIXmU4rTVAnX1kSsJkE0R+TxY8pGNoM1nbyRRE7GYHhdOEQ==", + "license": "BSD-3-Clause", + "dependencies": { + "commander": "2", + "d3-array": "1", + "d3-geo": "^1.12.0", + "resolve": "^1.1.10" + }, + "bin": { + "geo2svg": "bin/geo2svg", + "geograticule": "bin/geograticule", + "geoproject": "bin/geoproject", + "geoquantize": "bin/geoquantize", + "geostitch": "bin/geostitch" + } + }, + "node_modules/d3-geo-projection/node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/d3-hierarchy": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz", + "integrity": "sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-quadtree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-1.0.7.tgz", + "integrity": "sha512-RKPAeXnkC59IDGD0Wu5mANy0Q2V28L+fNe65pOCXVdVuTJS3WPKaJlFHer32Rbh9gIo9qMuJXio8ra4+YmIymA==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-time": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz", + "integrity": "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-time-format": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-2.3.0.tgz", + "integrity": "sha512-guv6b2H37s2Uq/GefleCDtbe0XZAuy7Wa49VGkPVPMfLL9qObgBST3lEHJBMUp8S7NdLQAGIvr2KXk8Hc98iKQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-time": "1" + } + }, + "node_modules/d3-timer": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz", + "integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==", + "license": "BSD-3-Clause" + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/defined": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.1.tgz", + "integrity": "sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-kerning": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-kerning/-/detect-kerning-2.1.2.tgz", + "integrity": "sha512-I3JIbrnKPAntNLl1I6TpSQQdQ4AutYzv/sKMFKbepawV/hlH0GmYKhUoOEMd4xqaUHT+Bm0f4127lh5qs1m1tw==", + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/draw-svg-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/draw-svg-path/-/draw-svg-path-1.0.0.tgz", + "integrity": "sha512-P8j3IHxcgRMcY6sDzr0QvJDLzBnJJqpTG33UZ2Pvp8rw0apCHhJCWqYprqrXjrgHnJ6tuhP1iTJSAodPDHxwkg==", + "license": "MIT", + "dependencies": { + "abs-svg-path": "~0.1.1", + "normalize-svg-path": "~0.1.0" + } + }, + "node_modules/dtype": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dtype/-/dtype-2.0.0.tgz", + "integrity": "sha512-s2YVcLKdFGS0hpFqJaTwscsyt0E8nNFdmo73Ocd81xNPj4URI4rj6D60A+vFMIw7BXWlb4yRkEwfBqcZzPGiZg==", + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/dup": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dup/-/dup-1.0.0.tgz", + "integrity": "sha512-Bz5jxMMC0wgp23Zm15ip1x8IhYRqJvF3nFC0UInJUDkN1z4uNPk9jTnfCUJXbOGiQ1JbXLQsiV41Fb+HXcj5BA==", + "license": "MIT" + }, + "node_modules/duplexify": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", + "integrity": "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.0.0", + "stream-shift": "^1.0.0" + } + }, + "node_modules/earcut": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", + "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", + "license": "ISC" + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.395", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.395.tgz", + "integrity": "sha512-7zt9Aw+SrmxLWLN0zhaTWZQiCdryLVrYTq5R7iZakLvi2UQPYMMsROYV/2qVCzMeCiSXHwKOU+sZ4zOVVlrtKA==", + "license": "ISC" + }, + "node_modules/element-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/element-size/-/element-size-1.1.1.tgz", + "integrity": "sha512-eaN+GMOq/Q+BIWy0ybsgpcYImjGIdNLyjLFJU4XsLHXYQao5jCNb36GyN6C2qwmDDYSfIBmKpPpr4VnBdLCsPQ==", + "license": "MIT" + }, + "node_modules/elementary-circuits-directed-graph": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/elementary-circuits-directed-graph/-/elementary-circuits-directed-graph-1.3.1.tgz", + "integrity": "sha512-ZEiB5qkn2adYmpXGnJKkxT8uJHlW/mxmBpmeqawEHzPxh9HkLD4/1mFYX5l0On+f6rcPIt8/EWlRU2Vo3fX6dQ==", + "license": "MIT", + "dependencies": { + "strongly-connected-components": "^1.0.1" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz", + "integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.4.0.tgz", + "integrity": "sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es5-ext": { + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-symbol": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.2", + "ext": "^1.7.0" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/es6-weak-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz", + "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==", + "license": "ISC", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.46", + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-next": { + "version": "14.2.35", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-14.2.35.tgz", + "integrity": "sha512-BpLsv01UisH193WyT/1lpHqq5iJ/Orfz9h/NOOlAmTUq4GY349PextQ62K4XpnaM9supeiEn3TaOTeQO07gURg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "14.2.35", + "@rushstack/eslint-patch": "^1.3.3", + "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.28.1", + "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.5.0 || 5.0.0-canary-7118f5dd7-20230705" + }, + "peerDependencies": { + "eslint": "^7.23.0 || ^8.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.14.0.tgz", + "integrity": "sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.0.0-canary-7118f5dd7-20230705", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.0.0-canary-7118f5dd7-20230705.tgz", + "integrity": "sha512-AZYbMo/NW9chdL7vk6HQzQhT+PvTAEVqWk9ziruUoW2kAOcN5qNyelv70e0F1VNQAbvutOC9oc+xfWycI9FxDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/eslint-plugin-react/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "license": "ISC", + "dependencies": { + "type": "^2.7.2" + } + }, + "node_modules/falafel": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/falafel/-/falafel-2.2.5.tgz", + "integrity": "sha512-HuC1qF9iTnHDnML9YZAdCDQwT0yKl/U55K4XSUXqGAA2GLoafFgWRqdAbhWJxXaYD4pyoVxAJ8wH670jMpI9DQ==", + "license": "MIT", + "dependencies": { + "acorn": "^7.1.1", + "isarray": "^2.0.1" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/falafel/node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-isnumeric": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-isnumeric/-/fast-isnumeric-1.1.4.tgz", + "integrity": "sha512-1mM8qOr2LYz8zGaUdmiqRDiuue00Dxjgcb1NQR7TnhLVh6sQyngP9xvLo7Sl7LZpP/sk5eb+bcyWXw530NTBZw==", + "license": "MIT", + "dependencies": { + "is-string-blank": "^1.0.1" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz", + "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/flatten-vertex-data": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/flatten-vertex-data/-/flatten-vertex-data-1.0.2.tgz", + "integrity": "sha512-BvCBFK2NZqerFTdMDgqfHBwxYWnxeCkwONsw6PvBMcUXqo8U/KDWwmXhqx1x2kLIg7DqIsJfOaJFOmlua3Lxuw==", + "license": "MIT", + "dependencies": { + "dtype": "^2.0.0" + } + }, + "node_modules/font-atlas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/font-atlas/-/font-atlas-2.1.0.tgz", + "integrity": "sha512-kP3AmvX+HJpW4w3d+PiPR2X6E1yvsBXt2yhuCw+yReO9F1WYhvZwx3c95DGZGwg9xYzDGrgJYa885xmVA+28Cg==", + "license": "MIT", + "dependencies": { + "css-font": "^1.0.0" + } + }, + "node_modules/font-measure": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/font-measure/-/font-measure-1.2.2.tgz", + "integrity": "sha512-mRLEpdrWzKe9hbfaF3Qpr06TAjquuBVP5cHy4b3hyeNdjc9i0PO6HniGsX5vjL5OWv7+Bd++NiooNpT/s8BvIA==", + "license": "MIT", + "dependencies": { + "css-font": "^1.2.0" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/from2": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", + "integrity": "sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "readable-stream": "^2.0.0" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/geojson-vt": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz", + "integrity": "sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==", + "license": "ISC" + }, + "node_modules/get-canvas-context": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-canvas-context/-/get-canvas-context-1.0.2.tgz", + "integrity": "sha512-LnpfLf/TNzr9zVOGiIY6aKCz8EKuXmlYNV7CM2pUjBa/B+c2I15tS7KLySep75+FuerJdmArvJLcsAXWEy2H0A==", + "license": "MIT" + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gl-mat4": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gl-mat4/-/gl-mat4-1.2.0.tgz", + "integrity": "sha512-sT5C0pwB1/e9G9AvAoLsoaJtbMGjfd/jfxo8jMCKqYYEnjZuFvqV5rehqar0538EmssjdDeiEWnKyBSTw7quoA==", + "license": "Zlib" + }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, + "node_modules/gl-text": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/gl-text/-/gl-text-1.4.0.tgz", + "integrity": "sha512-o47+XBqLCj1efmuNyCHt7/UEJmB9l66ql7pnobD6p+sgmBUdzfMZXIF0zD2+KRfpd99DJN+QXdvTFAGCKCVSmQ==", + "license": "MIT", + "dependencies": { + "bit-twiddle": "^1.0.2", + "color-normalize": "^1.5.0", + "css-font": "^1.2.0", + "detect-kerning": "^2.1.2", + "es6-weak-map": "^2.0.3", + "flatten-vertex-data": "^1.0.2", + "font-atlas": "^2.1.0", + "font-measure": "^1.2.2", + "gl-util": "^3.1.2", + "is-plain-obj": "^1.1.0", + "object-assign": "^4.1.1", + "parse-rect": "^1.2.0", + "parse-unit": "^1.0.1", + "pick-by-alias": "^1.2.0", + "regl": "^2.0.0", + "to-px": "^1.0.1", + "typedarray-pool": "^1.1.0" + } + }, + "node_modules/gl-util": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/gl-util/-/gl-util-3.1.3.tgz", + "integrity": "sha512-dvRTggw5MSkJnCbh74jZzSoTOGnVYK+Bt+Ckqm39CVcl6+zSsxqWk4lr5NKhkqXHL6qvZAU9h17ZF8mIskY9mA==", + "license": "MIT", + "dependencies": { + "is-browser": "^2.0.1", + "is-firefox": "^1.0.3", + "is-plain-obj": "^1.1.0", + "number-is-integer": "^1.0.1", + "object-assign": "^4.1.0", + "pick-by-alias": "^1.2.0", + "weak-map": "^1.0.5" + } + }, + "node_modules/glob": { + "version": "10.3.10", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", + "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^2.3.5", + "minimatch": "^9.0.1", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", + "path-scurry": "^1.10.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/global-prefix": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-4.0.0.tgz", + "integrity": "sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==", + "license": "MIT", + "dependencies": { + "ini": "^4.1.3", + "kind-of": "^6.0.3", + "which": "^4.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/global-prefix/node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/global-prefix/node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glsl-inject-defines": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/glsl-inject-defines/-/glsl-inject-defines-1.0.3.tgz", + "integrity": "sha512-W49jIhuDtF6w+7wCMcClk27a2hq8znvHtlGnrYkSWEr8tHe9eA2dcnohlcAmxLYBSpSSdzOkRdyPTrx9fw49+A==", + "license": "MIT", + "dependencies": { + "glsl-token-inject-block": "^1.0.0", + "glsl-token-string": "^1.0.1", + "glsl-tokenizer": "^2.0.2" + } + }, + "node_modules/glsl-resolve": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/glsl-resolve/-/glsl-resolve-0.0.1.tgz", + "integrity": "sha512-xxFNsfnhZTK9NBhzJjSBGX6IOqYpvBHxxmo+4vapiljyGNCY0Bekzn0firQkQrazK59c1hYxMDxYS8MDlhw4gA==", + "license": "MIT", + "dependencies": { + "resolve": "^0.6.1", + "xtend": "^2.1.2" + } + }, + "node_modules/glsl-resolve/node_modules/resolve": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-0.6.3.tgz", + "integrity": "sha512-UHBY3viPlJKf85YijDUcikKX6tmF4SokIDp518ZDVT92JNDcG5uKIthaT/owt3Sar0lwtOafsQuwrg22/v2Dwg==", + "license": "MIT" + }, + "node_modules/glsl-resolve/node_modules/xtend": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-2.2.0.tgz", + "integrity": "sha512-SLt5uylT+4aoXxXuwtQp5ZnMMzhDb1Xkg4pEqc00WUJCQifPfV9Ub1VrNhp9kXkrjZD2I2Hl8WnjP37jzZLPZw==", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/glsl-token-assignments": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/glsl-token-assignments/-/glsl-token-assignments-2.0.2.tgz", + "integrity": "sha512-OwXrxixCyHzzA0U2g4btSNAyB2Dx8XrztY5aVUCjRSh4/D0WoJn8Qdps7Xub3sz6zE73W3szLrmWtQ7QMpeHEQ==", + "license": "MIT" + }, + "node_modules/glsl-token-defines": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/glsl-token-defines/-/glsl-token-defines-1.0.0.tgz", + "integrity": "sha512-Vb5QMVeLjmOwvvOJuPNg3vnRlffscq2/qvIuTpMzuO/7s5kT+63iL6Dfo2FYLWbzuiycWpbC0/KV0biqFwHxaQ==", + "license": "MIT", + "dependencies": { + "glsl-tokenizer": "^2.0.0" + } + }, + "node_modules/glsl-token-depth": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/glsl-token-depth/-/glsl-token-depth-1.1.2.tgz", + "integrity": "sha512-eQnIBLc7vFf8axF9aoi/xW37LSWd2hCQr/3sZui8aBJnksq9C7zMeUYHVJWMhFzXrBU7fgIqni4EhXVW4/krpg==", + "license": "MIT" + }, + "node_modules/glsl-token-descope": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/glsl-token-descope/-/glsl-token-descope-1.0.2.tgz", + "integrity": "sha512-kS2PTWkvi/YOeicVjXGgX5j7+8N7e56srNDEHDTVZ1dcESmbmpmgrnpjPcjxJjMxh56mSXYoFdZqb90gXkGjQw==", + "license": "MIT", + "dependencies": { + "glsl-token-assignments": "^2.0.0", + "glsl-token-depth": "^1.1.0", + "glsl-token-properties": "^1.0.0", + "glsl-token-scope": "^1.1.0" + } + }, + "node_modules/glsl-token-inject-block": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/glsl-token-inject-block/-/glsl-token-inject-block-1.1.0.tgz", + "integrity": "sha512-q/m+ukdUBuHCOtLhSr0uFb/qYQr4/oKrPSdIK2C4TD+qLaJvqM9wfXIF/OOBjuSA3pUoYHurVRNao6LTVVUPWA==", + "license": "MIT" + }, + "node_modules/glsl-token-properties": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/glsl-token-properties/-/glsl-token-properties-1.0.1.tgz", + "integrity": "sha512-dSeW1cOIzbuUoYH0y+nxzwK9S9O3wsjttkq5ij9ZGw0OS41BirKJzzH48VLm8qLg+au6b0sINxGC0IrGwtQUcA==", + "license": "MIT" + }, + "node_modules/glsl-token-scope": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/glsl-token-scope/-/glsl-token-scope-1.1.2.tgz", + "integrity": "sha512-YKyOMk1B/tz9BwYUdfDoHvMIYTGtVv2vbDSLh94PT4+f87z21FVdou1KNKgF+nECBTo0fJ20dpm0B1vZB1Q03A==", + "license": "MIT" + }, + "node_modules/glsl-token-string": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/glsl-token-string/-/glsl-token-string-1.0.1.tgz", + "integrity": "sha512-1mtQ47Uxd47wrovl+T6RshKGkRRCYWhnELmkEcUAPALWGTFe2XZpH3r45XAwL2B6v+l0KNsCnoaZCSnhzKEksg==", + "license": "MIT" + }, + "node_modules/glsl-token-whitespace-trim": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/glsl-token-whitespace-trim/-/glsl-token-whitespace-trim-1.0.0.tgz", + "integrity": "sha512-ZJtsPut/aDaUdLUNtmBYhaCmhIjpKNg7IgZSfX5wFReMc2vnj8zok+gB/3Quqs0TsBSX/fGnqUUYZDqyuc2xLQ==", + "license": "MIT" + }, + "node_modules/glsl-tokenizer": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/glsl-tokenizer/-/glsl-tokenizer-2.1.5.tgz", + "integrity": "sha512-XSZEJ/i4dmz3Pmbnpsy3cKh7cotvFlBiZnDOwnj/05EwNp2XrhQ4XKJxT7/pDt4kp4YcpRSKz8eTV7S+mwV6MA==", + "license": "MIT", + "dependencies": { + "through2": "^0.6.3" + } + }, + "node_modules/glsl-tokenizer/node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "license": "MIT" + }, + "node_modules/glsl-tokenizer/node_modules/readable-stream": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "node_modules/glsl-tokenizer/node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", + "license": "MIT" + }, + "node_modules/glsl-tokenizer/node_modules/through2": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz", + "integrity": "sha512-RkK/CCESdTKQZHdmKICijdKKsCRVHs5KsLZ6pACAmF/1GPUQhonHSXWNERctxEp7RmvjdNbZTL5z9V7nSCXKcg==", + "license": "MIT", + "dependencies": { + "readable-stream": ">=1.0.33-1 <1.1.0-0", + "xtend": ">=4.0.0 <4.1.0-0" + } + }, + "node_modules/glslify": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/glslify/-/glslify-7.1.1.tgz", + "integrity": "sha512-bud98CJ6kGZcP9Yxcsi7Iz647wuDz3oN+IZsjCRi5X1PI7t/xPKeL0mOwXJjo+CRZMqvq0CkSJiywCcY7kVYog==", + "license": "MIT", + "dependencies": { + "bl": "^2.2.1", + "concat-stream": "^1.5.2", + "duplexify": "^3.4.5", + "falafel": "^2.1.0", + "from2": "^2.3.0", + "glsl-resolve": "0.0.1", + "glsl-token-whitespace-trim": "^1.0.0", + "glslify-bundle": "^5.0.0", + "glslify-deps": "^1.2.5", + "minimist": "^1.2.5", + "resolve": "^1.1.5", + "stack-trace": "0.0.9", + "static-eval": "^2.0.5", + "through2": "^2.0.1", + "xtend": "^4.0.0" + }, + "bin": { + "glslify": "bin.js" + } + }, + "node_modules/glslify-bundle": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/glslify-bundle/-/glslify-bundle-5.1.1.tgz", + "integrity": "sha512-plaAOQPv62M1r3OsWf2UbjN0hUYAB7Aph5bfH58VxJZJhloRNbxOL9tl/7H71K7OLJoSJ2ZqWOKk3ttQ6wy24A==", + "license": "MIT", + "dependencies": { + "glsl-inject-defines": "^1.0.1", + "glsl-token-defines": "^1.0.0", + "glsl-token-depth": "^1.1.1", + "glsl-token-descope": "^1.0.2", + "glsl-token-scope": "^1.1.1", + "glsl-token-string": "^1.0.1", + "glsl-token-whitespace-trim": "^1.0.0", + "glsl-tokenizer": "^2.0.2", + "murmurhash-js": "^1.0.0", + "shallow-copy": "0.0.1" + } + }, + "node_modules/glslify-deps": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/glslify-deps/-/glslify-deps-1.3.2.tgz", + "integrity": "sha512-7S7IkHWygJRjcawveXQjRXLO2FTjijPDYC7QfZyAQanY+yGLCFHYnPtsGT9bdyHiwPTw/5a1m1M9hamT2aBpag==", + "license": "ISC", + "dependencies": { + "@choojs/findup": "^0.2.0", + "events": "^3.2.0", + "glsl-resolve": "0.0.1", + "glsl-tokenizer": "^2.0.0", + "graceful-fs": "^4.1.2", + "inherits": "^2.0.1", + "map-limit": "0.0.1", + "resolve": "^1.0.0" + } + }, + "node_modules/glslify-deps/node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glslify/node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/grid-index": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz", + "integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==", + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-hover": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-hover/-/has-hover-1.0.1.tgz", + "integrity": "sha512-0G6w7LnlcpyDzpeGUTuT0CEw05+QlMuGVk1IHNAlHrGJITGodjZu3x8BNDUMfKJSZXNB2ZAclqc1bvrd+uUpfg==", + "license": "MIT", + "dependencies": { + "is-browser": "^2.0.1" + } + }, + "node_modules/has-passive-events": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-passive-events/-/has-passive-events-1.0.0.tgz", + "integrity": "sha512-2vSj6IeIsgvsRMyeQ0JaCX5Q3lX4zMn5HpoVc7MEhQ6pv8Iq9rsXjsp+E5ZwaT7T0xhMT0KmU8gtt1EFVdbJiw==", + "license": "MIT", + "dependencies": { + "is-browser": "^2.0.1" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hsluv": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/hsluv/-/hsluv-0.0.3.tgz", + "integrity": "sha512-08iL2VyCRbkQKBySkSh6m8zMUa3sADAxGVWs3Z1aPcUkTJeK0ETG4Fc27tEmQBGUAXZjIsXOZqBvacuVNSC/fQ==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-browser": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-browser/-/is-browser-2.1.0.tgz", + "integrity": "sha512-F5rTJxDQ2sW81fcfOR1GnCXT6sVJC104fCyfj+mjpwNEwaPYSn5fte5jiHmBg3DHsIoL/l8Kvw5VN5SsTRcRFQ==", + "license": "MIT" + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-finite": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.1.0.tgz", + "integrity": "sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-firefox": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-firefox/-/is-firefox-1.0.3.tgz", + "integrity": "sha512-6Q9ITjvWIm0Xdqv+5U12wgOKEM2KoBw4Y926m0OFkvlCxnbG94HKAsVz8w3fWcfAS5YA2fJORXX1dLrkprCCxA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-mobile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-4.0.0.tgz", + "integrity": "sha512-mlcHZA84t1qLSuWkt2v0I2l61PYdyQDt4aG1mLIXF5FDMm4+haBCxCPYSr/uwqQNRk1MiTizn0ypEuRAOLRAew==", + "license": "MIT" + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string-blank": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-string-blank/-/is-string-blank-1.0.1.tgz", + "integrity": "sha512-9H+ZBCVs3L9OYqv8nuUAzpcT9OTgMD1yAWrG7ihlnibdkbtB850heAmYWxHuXc4CHy4lKeK69tN+ny1K7gBIrw==", + "license": "MIT" + }, + "node_modules/is-svg-path": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-svg-path/-/is-svg-path-1.0.2.tgz", + "integrity": "sha512-Lj4vePmqpPR1ZnRctHv8ltSh1OrSxHkhUkd7wi+VQdcdP15/KvQFyk7LhNuM7ZW0EVbJz8kZLVmL9quLrfq4Kg==", + "license": "MIT" + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jackspeak": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", + "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/kdbush": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", + "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==", + "license": "ISC" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/map-limit": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/map-limit/-/map-limit-0.0.1.tgz", + "integrity": "sha512-pJpcfLPnIF/Sk3taPW21G/RQsEEirGaFpCW3oXRwH9dnFHPHNGjNyvh++rdmC2fNqEaTw2MhYJraoJWAHx8kEg==", + "license": "MIT", + "dependencies": { + "once": "~1.3.0" + } + }, + "node_modules/map-limit/node_modules/once": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/once/-/once-1.3.3.tgz", + "integrity": "sha512-6vaNInhu+CHxtONf3zw3vq4SP2DOQhjBvIa3rNcG0+P7eKWlYH6Peu7rHizSloRU2EwMz6GraLieis9Ac9+p1w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/mapbox-gl": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-1.13.3.tgz", + "integrity": "sha512-p8lJFEiqmEQlyv+DQxFAOG/XPWN0Wp7j/Psq93Zywz7qt9CcUKFYDBOoOEKzqe6gudHVJY8/Bhqw6VDpX2lSBg==", + "license": "SEE LICENSE IN LICENSE.txt", + "peer": true, + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/geojson-types": "^1.0.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/mapbox-gl-supported": "^1.5.0", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^1.1.1", + "@mapbox/unitbezier": "^0.0.0", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "csscolorparser": "~1.0.3", + "earcut": "^2.2.2", + "geojson-vt": "^3.2.1", + "gl-matrix": "^3.2.1", + "grid-index": "^1.1.0", + "murmurhash-js": "^1.0.0", + "pbf": "^3.2.1", + "potpack": "^1.0.1", + "quickselect": "^2.0.0", + "rw": "^1.3.3", + "supercluster": "^7.1.0", + "tinyqueue": "^2.0.3", + "vt-pbf": "^3.1.1" + }, + "engines": { + "node": ">=6.4.0" + } + }, + "node_modules/maplibre-gl": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz", + "integrity": "sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^2.0.6", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/maplibre-gl-style-spec": "^20.3.1", + "@types/geojson": "^7946.0.14", + "@types/geojson-vt": "3.2.5", + "@types/mapbox__point-geometry": "^0.1.4", + "@types/mapbox__vector-tile": "^1.3.4", + "@types/pbf": "^3.0.5", + "@types/supercluster": "^7.1.3", + "earcut": "^3.0.0", + "geojson-vt": "^4.0.2", + "gl-matrix": "^3.4.3", + "global-prefix": "^4.0.0", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^3.3.0", + "potpack": "^2.0.0", + "quickselect": "^3.0.0", + "supercluster": "^8.0.1", + "tinyqueue": "^3.0.0", + "vt-pbf": "^3.1.3" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, + "node_modules/maplibre-gl/node_modules/@mapbox/tiny-sdf": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz", + "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==", + "license": "BSD-2-Clause" + }, + "node_modules/maplibre-gl/node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/maplibre-gl/node_modules/earcut": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz", + "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", + "license": "ISC" + }, + "node_modules/maplibre-gl/node_modules/geojson-vt": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.3.tgz", + "integrity": "sha512-jR1MwkLaZGa8Zftct9ZFruyWFrdl9ZyD2OliXNy9Qq5bBPeg5wHVpBQF9p5GjnicSDQqvBVpysxTPKmWdsfWMA==", + "license": "ISC" + }, + "node_modules/maplibre-gl/node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, + "node_modules/maplibre-gl/node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, + "node_modules/maplibre-gl/node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, + "node_modules/maplibre-gl/node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/math-log2": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/math-log2/-/math-log2-1.0.1.tgz", + "integrity": "sha512-9W0yGtkaMAkf74XGYVy4Dqw3YUMnTNB2eeiw9aQbUl4A3KmuCEHTt2DgAB07ENzOYAjsYSAYufkAq0Zd+jU7zA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT", + "peer": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minimizer-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mouse-change": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/mouse-change/-/mouse-change-1.4.0.tgz", + "integrity": "sha512-vpN0s+zLL2ykyyUDh+fayu9Xkor5v/zRD9jhSqjRS1cJTGS0+oakVZzNm5n19JvvEj0you+MXlYTpNxUDQUjkQ==", + "license": "MIT", + "dependencies": { + "mouse-event": "^1.0.0" + } + }, + "node_modules/mouse-event": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/mouse-event/-/mouse-event-1.0.5.tgz", + "integrity": "sha512-ItUxtL2IkeSKSp9cyaX2JLUuKk2uMoxBg4bbOWVd29+CskYJR9BGsUqtXenNzKbnDshvupjUewDIYVrOB6NmGw==", + "license": "MIT" + }, + "node_modules/mouse-event-offset": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mouse-event-offset/-/mouse-event-offset-3.0.2.tgz", + "integrity": "sha512-s9sqOs5B1Ykox3Xo8b3Ss2IQju4UwlW6LSR+Q5FXWpprJ5fzMLefIIItr3PH8RwzfGy6gxs/4GAmiNuZScE25w==", + "license": "MIT" + }, + "node_modules/mouse-wheel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mouse-wheel/-/mouse-wheel-1.2.0.tgz", + "integrity": "sha512-+OfYBiUOCTWcTECES49neZwL5AoGkXE+lFjIvzwNCnYRlso+EnfvovcBxGoyQ0yQt806eSPjS675K0EwWknXmw==", + "license": "MIT", + "dependencies": { + "right-now": "^1.0.0", + "signum": "^1.0.0", + "to-px": "^1.0.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mumath": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/mumath/-/mumath-3.3.4.tgz", + "integrity": "sha512-VAFIOG6rsxoc7q/IaY3jdjmrsuX9f15KlRLYTHmixASBZkZEKC1IFqE2BC5CdhXmK6WLM1Re33z//AGmeRI6FA==", + "deprecated": "Redundant dependency in your project.", + "license": "Unlicense", + "dependencies": { + "almost-equal": "^1.1.0" + } + }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/native-promise-only": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/native-promise-only/-/native-promise-only-0.8.1.tgz", + "integrity": "sha512-zkVhZUA3y8mbz652WrL5x0fB0ehrBkulWT3TomAQ9iDtyXZvzKeEA6GPxAItBYeNYl5yngKRX612qHOhvMkDeg==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/needle": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/needle/-/needle-2.9.1.tgz", + "integrity": "sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==", + "license": "MIT", + "dependencies": { + "debug": "^3.2.6", + "iconv-lite": "^0.4.4", + "sax": "^1.2.4" + }, + "bin": { + "needle": "bin/needle" + }, + "engines": { + "node": ">= 4.4.x" + } + }, + "node_modules/needle/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT", + "peer": true + }, + "node_modules/next": { + "version": "14.2.35", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.35.tgz", + "integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==", + "license": "MIT", + "dependencies": { + "@next/env": "14.2.35", + "@swc/helpers": "0.5.5", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001579", + "graceful-fs": "^4.2.11", + "postcss": "8.4.31", + "styled-jsx": "5.1.1" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=18.17.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "14.2.33", + "@next/swc-darwin-x64": "14.2.33", + "@next/swc-linux-arm64-gnu": "14.2.33", + "@next/swc-linux-arm64-musl": "14.2.33", + "@next/swc-linux-x64-gnu": "14.2.33", + "@next/swc-linux-x64-musl": "14.2.33", + "@next/swc-win32-arm64-msvc": "14.2.33", + "@next/swc-win32-ia32-msvc": "14.2.33", + "@next/swc-win32-x64-msvc": "14.2.33" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", + "license": "ISC" + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-exports-info": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", + "integrity": "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-svg-path": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-0.1.0.tgz", + "integrity": "sha512-1/kmYej2iedi5+ROxkRESL/pI02pkg0OBnaR4hJkSIX6+ORzepwbuUXfrdZaPjysTsJInj0Rj5NuX027+dMBvA==", + "license": "MIT" + }, + "node_modules/number-is-integer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-integer/-/number-is-integer-1.0.1.tgz", + "integrity": "sha512-Dq3iuiFBkrbmuQjGFFF3zckXNCQoSD37/SdSbgcBailUx6knDvDwb5CympBgcoWHy36sfS12u74MHYkXyHq6bg==", + "license": "MIT", + "dependencies": { + "is-finite": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz", + "integrity": "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parenthesis": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/parenthesis/-/parenthesis-3.1.8.tgz", + "integrity": "sha512-KF/U8tk54BgQewkJPvB4s/US3VQY68BRDpH638+7O/n58TpnwiwnOtGIOsT2/i+M78s61BBpeC83STB88d8sqw==", + "license": "MIT" + }, + "node_modules/parse-rect": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/parse-rect/-/parse-rect-1.2.0.tgz", + "integrity": "sha512-4QZ6KYbnE6RTwg9E0HpLchUM9EZt6DnDxajFZZDSV4p/12ZJEvPO702DZpGvRYEPo00yKDys7jASi+/w7aO8LA==", + "license": "MIT", + "dependencies": { + "pick-by-alias": "^1.2.0" + } + }, + "node_modules/parse-svg-path": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz", + "integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==", + "license": "MIT" + }, + "node_modules/parse-unit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-unit/-/parse-unit-1.0.1.tgz", + "integrity": "sha512-hrqldJHokR3Qj88EIlV/kAyAi/G5R2+R56TBANxNMy0uPlYcttx0jnMW6Yx5KsKPSbC3KddM/7qQm3+0wEXKxg==", + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/pbf": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", + "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "ieee754": "^1.1.12", + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT" + }, + "node_modules/pick-by-alias": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pick-by-alias/-/pick-by-alias-1.2.0.tgz", + "integrity": "sha512-ESj2+eBxhGrcA1azgHs7lARG5+5iLakc/6nlfbpjcLl00HuuUOIuORhYXN4D1HfvMSKuVtFQjAlnwi1JHEeDIw==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/plotly.js": { + "version": "2.35.3", + "resolved": "https://registry.npmjs.org/plotly.js/-/plotly.js-2.35.3.tgz", + "integrity": "sha512-7RaC6FxmCUhpD6H4MpD+QLUu3hCn76I11rotRefrh3m1iDvWqGnVqVk9dSaKmRAhFD3vsNsYea0OxnR1rc2IzQ==", + "license": "MIT", + "dependencies": { + "@plotly/d3": "3.8.2", + "@plotly/d3-sankey": "0.7.2", + "@plotly/d3-sankey-circular": "0.33.1", + "@plotly/mapbox-gl": "1.13.4", + "@turf/area": "^7.1.0", + "@turf/bbox": "^7.1.0", + "@turf/centroid": "^7.1.0", + "base64-arraybuffer": "^1.0.2", + "canvas-fit": "^1.5.0", + "color-alpha": "1.0.4", + "color-normalize": "1.5.0", + "color-parse": "2.0.0", + "color-rgba": "2.1.1", + "country-regex": "^1.1.0", + "css-loader": "^7.1.2", + "d3-force": "^1.2.1", + "d3-format": "^1.4.5", + "d3-geo": "^1.12.1", + "d3-geo-projection": "^2.9.0", + "d3-hierarchy": "^1.1.9", + "d3-interpolate": "^3.0.1", + "d3-time": "^1.1.0", + "d3-time-format": "^2.2.3", + "fast-isnumeric": "^1.1.4", + "gl-mat4": "^1.2.0", + "gl-text": "^1.4.0", + "has-hover": "^1.0.1", + "has-passive-events": "^1.0.0", + "is-mobile": "^4.0.0", + "maplibre-gl": "^4.5.2", + "mouse-change": "^1.4.0", + "mouse-event-offset": "^3.0.2", + "mouse-wheel": "^1.2.0", + "native-promise-only": "^0.8.1", + "parse-svg-path": "^0.1.2", + "point-in-polygon": "^1.1.0", + "polybooljs": "^1.2.2", + "probe-image-size": "^7.2.3", + "regl": "npm:@plotly/regl@^2.1.2", + "regl-error2d": "^2.0.12", + "regl-line2d": "^3.1.3", + "regl-scatter2d": "^3.3.1", + "regl-splom": "^1.0.14", + "strongly-connected-components": "^1.0.1", + "style-loader": "^4.0.0", + "superscript-text": "^1.0.0", + "svg-path-sdf": "^1.1.3", + "tinycolor2": "^1.4.2", + "to-px": "1.0.1", + "topojson-client": "^3.1.0", + "webgl-context": "^2.2.0", + "world-calendars": "^1.0.3" + } + }, + "node_modules/plotly.js/node_modules/css-loader": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.4.tgz", + "integrity": "sha512-vv3J9tlOl04WjiMvHQI/9tmIrCxVrj6PFbHemBB1iihpeRbi/I4h033eoFIhwxBBqLhI0KYFS7yvynBFhIZfTw==", + "license": "MIT", + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.40", + "postcss-modules-extract-imports": "^3.1.0", + "postcss-modules-local-by-default": "^4.0.5", + "postcss-modules-scope": "^3.2.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.6.3" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", + "webpack": "^5.27.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/point-in-polygon": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/point-in-polygon/-/point-in-polygon-1.1.0.tgz", + "integrity": "sha512-3ojrFwjnnw8Q9242TzgXuTD+eKiutbzyslcq1ydfu82Db2y+Ogbmyrkpv0Hgj31qwT3lbS9+QAAO/pIQM35XRw==", + "license": "MIT" + }, + "node_modules/polybooljs": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/polybooljs/-/polybooljs-1.2.2.tgz", + "integrity": "sha512-ziHW/02J0XuNuUtmidBc6GXE8YohYydp3DWPWXYsd7O721TjcmN+k6ezjdwkDqep+gnWnFY+yqZHvzElra2oCg==", + "license": "MIT" + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.22", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", + "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-import/node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", + "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", + "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", + "license": "MIT", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", + "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "license": "ISC", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/probe-image-size": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/probe-image-size/-/probe-image-size-7.3.0.tgz", + "integrity": "sha512-7CaDeBwiAbh6ohXsvLbAZhO7wzsZAmaevfxe39qvCwRh8LyaZfDlBGGLU1CCTgrTLtCOdwBBhjOrIHaIIimHfQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "lodash.merge": "^4.6.2", + "needle": "^2.5.2", + "stream-parser": "~0.3.1" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/quickselect": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", + "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", + "license": "ISC" + }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "dependencies": { + "performance-now": "^2.1.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-plotly.js": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-plotly.js/-/react-plotly.js-2.6.0.tgz", + "integrity": "sha512-g93xcyhAVCSt9kV1svqG1clAEdL6k3U+jjuSzfTV7owaSU9Go6Ph8bl25J+jKfKvIGAEYpe4qj++WHJuc9IaeA==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "plotly.js": ">1.34.0", + "react": ">0.13.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regl": { + "name": "@plotly/regl", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@plotly/regl/-/regl-2.1.2.tgz", + "integrity": "sha512-Mdk+vUACbQvjd0m/1JJjOOafmkp/EpmHjISsopEz5Av44CBq7rPC05HHNbYGKVyNUF2zmEoBS/TT0pd0SPFFyw==", + "license": "MIT" + }, + "node_modules/regl-error2d": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/regl-error2d/-/regl-error2d-2.0.12.tgz", + "integrity": "sha512-r7BUprZoPO9AbyqM5qlJesrSRkl+hZnVKWKsVp7YhOl/3RIpi4UDGASGJY0puQ96u5fBYw/OlqV24IGcgJ0McA==", + "license": "MIT", + "dependencies": { + "array-bounds": "^1.0.1", + "color-normalize": "^1.5.0", + "flatten-vertex-data": "^1.0.2", + "object-assign": "^4.1.1", + "pick-by-alias": "^1.2.0", + "to-float32": "^1.1.0", + "update-diff": "^1.1.0" + } + }, + "node_modules/regl-line2d": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/regl-line2d/-/regl-line2d-3.1.3.tgz", + "integrity": "sha512-fkgzW+tTn4QUQLpFKsUIE0sgWdCmXAM3ctXcCgoGBZTSX5FE2A0M7aynz7nrZT5baaftLrk9te54B+MEq4QcSA==", + "license": "MIT", + "dependencies": { + "array-bounds": "^1.0.1", + "array-find-index": "^1.0.2", + "array-normalize": "^1.1.4", + "color-normalize": "^1.5.0", + "earcut": "^2.1.5", + "es6-weak-map": "^2.0.3", + "flatten-vertex-data": "^1.0.2", + "object-assign": "^4.1.1", + "parse-rect": "^1.2.0", + "pick-by-alias": "^1.2.0", + "to-float32": "^1.1.0" + } + }, + "node_modules/regl-scatter2d": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/regl-scatter2d/-/regl-scatter2d-3.4.0.tgz", + "integrity": "sha512-DavKQlHsI+iHZuLgOL+yGkg+sPd94CS+7FCBWkcQ6s/TbaNfUsF9eN591fjjSWIoKrGNfb/SEGhsXR5lXjqZ2w==", + "license": "MIT", + "dependencies": { + "@plotly/point-cluster": "^3.1.9", + "array-bounds": "^1.0.1", + "color-id": "^1.1.0", + "color-normalize": "^1.5.0", + "flatten-vertex-data": "^1.0.2", + "glslify": "^7.0.0", + "parse-rect": "^1.2.0", + "pick-by-alias": "^1.2.0", + "to-float32": "^1.1.0", + "update-diff": "^1.1.0" + } + }, + "node_modules/regl-splom": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/regl-splom/-/regl-splom-1.0.14.tgz", + "integrity": "sha512-OiLqjmPRYbd7kDlHC6/zDf6L8lxgDC65BhC8JirhP4ykrK4x22ZyS+BnY8EUinXKDeMgmpRwCvUmk7BK4Nweuw==", + "license": "MIT", + "dependencies": { + "array-bounds": "^1.0.1", + "array-range": "^1.0.1", + "color-alpha": "^1.0.4", + "flatten-vertex-data": "^1.0.2", + "parse-rect": "^1.2.0", + "pick-by-alias": "^1.2.0", + "raf": "^3.4.1", + "regl-scatter2d": "^3.2.3" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.2", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/right-now": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/right-now/-/right-now-1.0.0.tgz", + "integrity": "sha512-DA8+YS+sMIVpbsuKgy+Z67L9Lxb1p05mNxRpDPNksPDEFir4vmBlUtuN9jkTGn9YMMdlBuK7XQgFiz6ws+yhSg==", + "license": "MIT" + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/schema-utils/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/schema-utils/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/schema-utils/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT", + "peer": true + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shallow-copy": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/shallow-copy/-/shallow-copy-0.0.1.tgz", + "integrity": "sha512-b6i4ZpVuUxB9h5gfCxPiusKYkqTMOjEbBs4wMaFbkfia4yFv92UKZ6Df8WXcKbn08JNL/abvg3FnMAOfakDvUw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/signum": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/signum/-/signum-1.0.0.tgz", + "integrity": "sha512-yodFGwcyt59XRh7w5W3jPcIQb3Bwi21suEfT7MAWnBX3iCdklJpgDgvGT9o04UonglZN5SNMfJFkHIR/jO8GHw==", + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "peer": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stack-trace": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.9.tgz", + "integrity": "sha512-vjUc6sfgtgY0dxCdnc40mK6Oftjo9+2K8H/NG81TMhgL392FtiPA9tn9RLyTxXmTLPJPjF3VyzFp6bsWFLisMQ==", + "engines": { + "node": "*" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/static-eval": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.1.1.tgz", + "integrity": "sha512-MgWpQ/ZjGieSVB3eOJVs4OA2LT/q1vx98KPCTTQPzq/aLr0YUXTsgryTXr4SLfR0ZfUUCiedM9n/ABeDIyy4mA==", + "license": "MIT", + "dependencies": { + "escodegen": "^2.1.0" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/stream-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/stream-parser/-/stream-parser-0.3.1.tgz", + "integrity": "sha512-bJ/HgKq41nlKvlhccD5kaCr/P+Hu0wPNKPJOH7en+YrJu/9EgqUF+88w5Jb6KNcjOFMhfX4B2asfeAtIGuHObQ==", + "license": "MIT", + "dependencies": { + "debug": "2" + } + }, + "node_modules/stream-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/stream-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT" + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/string-split-by": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string-split-by/-/string-split-by-1.0.0.tgz", + "integrity": "sha512-KaJKY+hfpzNyet/emP81PJA9hTVSfxNLS9SFTWxdCnnW1/zOOwiV248+EfoX7IQFcBaOp4G5YE6xTJMF+pLg6A==", + "license": "MIT", + "dependencies": { + "parenthesis": "^3.1.5" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/strongly-connected-components": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strongly-connected-components/-/strongly-connected-components-1.0.1.tgz", + "integrity": "sha512-i0TFx4wPcO0FwX+4RkLJi1MxmcTv90jNZgxMu9XRnMXMeFUY1VJlIoXpZunPUvUUqbCT1pg5PEkFqqpcaElNaA==", + "license": "MIT" + }, + "node_modules/style-loader": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-4.0.0.tgz", + "integrity": "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA==", + "license": "MIT", + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.27.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", + "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/supercluster": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-7.1.5.tgz", + "integrity": "sha512-EulshI3pGUM66o6ZdH3ReiFcvHpM3vAigyK+vcxdjpJyEbIIrtbmBdY23mGgnI24uXiGFvrGq9Gkum/8U7vJWg==", + "license": "ISC", + "dependencies": { + "kdbush": "^3.0.0" + } + }, + "node_modules/supercluster/node_modules/kdbush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-3.0.0.tgz", + "integrity": "sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew==", + "license": "ISC" + }, + "node_modules/superscript-text": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/superscript-text/-/superscript-text-1.0.0.tgz", + "integrity": "sha512-gwu8l5MtRZ6koO0icVTlmN5pm7Dhh1+Xpe9O4x6ObMAsW+3jPbW14d1DsBq1F4wiI+WOFjXF35pslgec/G8yCQ==", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svg-arc-to-cubic-bezier": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz", + "integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==", + "license": "ISC" + }, + "node_modules/svg-path-bounds": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/svg-path-bounds/-/svg-path-bounds-1.0.2.tgz", + "integrity": "sha512-H4/uAgLWrppIC0kHsb2/dWUYSmb4GE5UqH06uqWBcg6LBjX2fu0A8+JrO2/FJPZiSsNOKZAhyFFgsLTdYUvSqQ==", + "license": "MIT", + "dependencies": { + "abs-svg-path": "^0.1.1", + "is-svg-path": "^1.0.1", + "normalize-svg-path": "^1.0.0", + "parse-svg-path": "^0.1.2" + } + }, + "node_modules/svg-path-bounds/node_modules/normalize-svg-path": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz", + "integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==", + "license": "MIT", + "dependencies": { + "svg-arc-to-cubic-bezier": "^3.0.0" + } + }, + "node_modules/svg-path-sdf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/svg-path-sdf/-/svg-path-sdf-1.1.3.tgz", + "integrity": "sha512-vJJjVq/R5lSr2KLfVXVAStktfcfa1pNFjFOgyJnzZFXlO/fDZ5DmM8FpnSKKzLPfEYTVeXuVBTHF296TpxuJVg==", + "license": "MIT", + "dependencies": { + "bitmap-sdf": "^1.0.0", + "draw-svg-path": "^1.0.0", + "is-svg-path": "^1.0.1", + "parse-svg-path": "^0.1.2", + "svg-path-bounds": "^1.0.1" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.49.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.49.0.tgz", + "integrity": "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==", + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "license": "MIT", + "dependencies": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyqueue": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz", + "integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==", + "license": "ISC" + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/to-float32": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/to-float32/-/to-float32-1.1.0.tgz", + "integrity": "sha512-keDnAusn/vc+R3iEiSDw8TOF7gPiTLdK1ArvWtYbJQiVfmRg6i/CAvbKq3uIS0vWroAC7ZecN3DjQKw3aSklUg==", + "license": "MIT" + }, + "node_modules/to-px": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/to-px/-/to-px-1.0.1.tgz", + "integrity": "sha512-2y3LjBeIZYL19e5gczp14/uRWFDtDUErJPVN3VU9a7SJO+RjGRtYR47aMN2bZgGlxvW4ZcEz2ddUPVHXcMfuXw==", + "license": "MIT", + "dependencies": { + "parse-unit": "^1.0.1" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/topojson-client": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", + "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", + "license": "ISC", + "dependencies": { + "commander": "2" + }, + "bin": { + "topo2geo": "bin/topo2geo", + "topomerge": "bin/topomerge", + "topoquantize": "bin/topoquantize" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", + "license": "ISC" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typedarray-pool": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/typedarray-pool/-/typedarray-pool-1.2.0.tgz", + "integrity": "sha512-YTSQbzX43yvtpfRtIDAYygoYtgT+Rpjuxy9iOpczrjpXLgGoyG7aS5USJXV2d3nn8uHTeb9rXDvzS27zUg5KYQ==", + "license": "MIT", + "dependencies": { + "bit-twiddle": "^1.0.0", + "dup": "^1.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unquote": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unquote/-/unquote-1.1.1.tgz", + "integrity": "sha512-vRCqFv6UhXpWxZPyGDh/F3ZpNv8/qo7w6iufLpQg9aKnQ71qM4B5KiI7Mia9COcjEhrO9LueHpMYjYzsWH3OIg==", + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/update-diff": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/update-diff/-/update-diff-1.1.0.tgz", + "integrity": "sha512-rCiBPiHxZwT4+sBhEbChzpO5hYHjm91kScWgdHf4Qeafs6Ba7MBl+d9GlGv72bcTZQO0sLmtQS1pHSWoCLtN/A==", + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vt-pbf": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz", + "integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "0.1.0", + "@mapbox/vector-tile": "^1.3.1", + "pbf": "^3.2.1" + } + }, + "node_modules/watchpack": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", + "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/weak-map": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/weak-map/-/weak-map-1.0.8.tgz", + "integrity": "sha512-lNR9aAefbGPpHO7AEnY0hCFjz1eTkWCXYvkTRrTHs9qv8zJp+SkVYpzfLIFXQQiG3tVvbNFQgVg2bQS8YGgxyw==", + "license": "Apache-2.0" + }, + "node_modules/webgl-context": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/webgl-context/-/webgl-context-2.2.0.tgz", + "integrity": "sha512-q/fGIivtqTT7PEoF07axFIlHNk/XCPaYpq64btnepopSWvKNFkoORlQYgqDigBIuGA1ExnFd/GnSUnBNEPQY7Q==", + "license": "MIT", + "dependencies": { + "get-canvas-context": "^1.0.1" + } + }, + "node_modules/webpack": { + "version": "5.109.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.109.0.tgz", + "integrity": "sha512-vomrngskVVXEZF9sMZfYAd4pXZUnfaWdJGlF+BTNF+gJBCKYCQBnOeVPlrh39Ewl7nlCsirDplMy6o5g9xJHBg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.24.2", + "es-module-lexer": "^2.1.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "graceful-fs": "^4.2.11", + "mime-db": "^1.54.0", + "minimizer-webpack-plugin": "^5.6.1", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "watchpack": "^2.5.2", + "webpack-sources": "^3.5.1" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-sources": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.1.tgz", + "integrity": "sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "license": "MIT", + "peer": true + }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/webpack/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/world-calendars": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/world-calendars/-/world-calendars-1.0.4.tgz", + "integrity": "sha512-VGRnLJS+xJmGDPodgJRnGIDwGu0s+Cr9V2HB3EzlDZ5n0qb8h5SJtGUEkjrphZYAglEiXZ6kiXdmk0H/h/uu/w==", + "license": "MIT", + "dependencies": { + "object-assign": "^4.1.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..224bd77 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,36 @@ +{ + "name": "aspc-frontend", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "test": "vitest run", + "test:watch": "vitest", + "test:e2e": "playwright test" + }, + "dependencies": { + "@tanstack/react-query": "^5.66.0", + "next": "^14.2.24", + "plotly.js": "^2.35.3", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-plotly.js": "^2.6.0" + }, + "devDependencies": { + "@playwright/test": "^1.50.1", + "@types/node": "^20.17.17", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@types/react-plotly.js": "^2.6.3", + "autoprefixer": "^10.4.20", + "eslint": "^8.57.1", + "eslint-config-next": "^14.2.24", + "postcss": "^8.5.1", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + } +} diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..33da39e --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,33 @@ +import { defineConfig, devices } from "@playwright/test"; +import path from "path"; + +const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:3000"; +const authFile = path.join(__dirname, "e2e/.auth/user.json"); + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: "list", + timeout: 60_000, + expect: { timeout: 15_000 }, + use: { + baseURL, + trace: "on-first-retry", + }, + projects: [ + { name: "setup", testMatch: /auth\.setup\.ts/ }, + { + name: "chromium", + use: { ...devices["Desktop Chrome"], storageState: authFile }, + dependencies: ["setup"], + testIgnore: [/auth\.setup\.ts/, /home\.spec\.ts/], + }, + { + name: "smoke", + use: { ...devices["Desktop Chrome"] }, + testMatch: /home\.spec\.ts/, + }, + ], +}); diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/public/.gitkeep b/frontend/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/__tests__/formatLimits.test.ts b/frontend/src/__tests__/formatLimits.test.ts new file mode 100644 index 0000000..48718bd --- /dev/null +++ b/frontend/src/__tests__/formatLimits.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { formatLimits, limitAt, shortId } from "@/lib/format"; + +describe("formatLimits", () => { + it("formats scalar limits", () => { + expect(formatLimits({ center: 10, ucl: 13, lcl: 7 }, 2)).toBe( + "UCL 13.00 · CL 10.00 · LCL 7.00", + ); + }); + + it("formats variable (array) limits as a range", () => { + expect(formatLimits({ center: 0.1, ucl: [0.2, 0.3, 0.25], lcl: [0, 0.05, 0.01] }, 2)).toBe( + "UCL 0.20…0.30 · CL 0.10 · LCL 0.00…0.05", + ); + }); +}); + +describe("limitAt", () => { + it("returns scalar or indexed value", () => { + expect(limitAt(5, 0)).toBe(5); + expect(limitAt([1, 2, 3], 1)).toBe(2); + expect(limitAt(undefined, 0)).toBeUndefined(); + }); +}); + +describe("shortId", () => { + it("truncates long ids", () => { + expect(shortId("abcdefghijklmnop", 8)).toBe("abcdefgh…"); + expect(shortId("abc", 8)).toBe("abc"); + }); +}); diff --git a/frontend/src/app/analyze/page.tsx b/frontend/src/app/analyze/page.tsx new file mode 100644 index 0000000..eb71f0d --- /dev/null +++ b/frontend/src/app/analyze/page.tsx @@ -0,0 +1,298 @@ +"use client"; + +import { FormEvent, useMemo, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { Checklist } from "@/components/Checklist"; +import { ControlChart } from "@/components/ControlChart"; +import { GateList } from "@/components/GateList"; +import { + ErrorBanner, + FileField, + PageHeader, + Panel, + PrimaryButton, + SelectInput, + Spinner, + TextInput, +} from "@/components/ui"; +import { ApiError, api } from "@/lib/api"; +import { formatLimits } from "@/lib/format"; +import type { AnalyzeResponse, Gate, Phase1Checklist, SPCReport } from "@/lib/types"; + +const API_KEY_STORAGE = "aspc_api_key"; + +function asReport(raw: AnalyzeResponse["report"]): SPCReport | null { + if (!raw || typeof raw !== "object") return null; + if ("plotted_values" in raw && "limits" in raw) return raw as SPCReport; + return null; +} + +export default function AnalyzePage() { + const router = useRouter(); + const [file, setFile] = useState(null); + const [msaFile, setMsaFile] = useState(null); + const [chartType, setChartType] = useState(""); + const [ruleset, setRuleset] = useState("nelson"); + const [validMin, setValidMin] = useState(""); + const [validMax, setValidMax] = useState(""); + const [msaTolerance, setMsaTolerance] = useState(""); + const [busy, setBusy] = useState(false); + const [goLiveBusy, setGoLiveBusy] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + const [streamKey, setStreamKey] = useState("line-1"); + const [apiKey, setApiKey] = useState(() => + typeof window !== "undefined" ? localStorage.getItem(API_KEY_STORAGE) || "" : "", + ); + + async function onSubmit(e: FormEvent) { + e.preventDefault(); + if (!file) { + setError("Choose a data file"); + return; + } + setBusy(true); + setError(null); + setResult(null); + try { + const fd = new FormData(); + fd.append("file", file); + if (chartType) fd.append("chart_type", chartType); + if (ruleset) fd.append("ruleset", ruleset); + if (validMin !== "" && validMax !== "") { + fd.append("valid_range_min", validMin); + fd.append("valid_range_max", validMax); + } + if (msaFile) { + fd.append("msa_file", msaFile); + if (msaTolerance !== "") fd.append("msa_tolerance", msaTolerance); + } + const res = await api.analyzeControlChart(fd); + setResult(res); + } catch (err) { + setError(err instanceof ApiError ? err.message : (err as Error).message); + } finally { + setBusy(false); + } + } + + const report = useMemo(() => (result ? asReport(result.report) : null), [result]); + const gates = (report?.gates ?? (result?.report as { gates?: Gate[] })?.gates) as Gate[] | undefined; + const checklist = (report?.checklist ?? + result?.checklist ?? + (result?.report as { checklist?: Phase1Checklist })?.checklist) as Phase1Checklist | undefined; + + const primary = report?.limits?.components + ? Object.values(report.limits.components)[0] + : null; + const oocIndices = report?.signals?.map((s) => s.index) ?? []; + const limitsVersion = report?.limits?.version; + const checklistOk = checklist?.passed !== false; + + async function oneClickGoLive() { + if (!limitsVersion) { + setError("No frozen limits version on this result"); + return; + } + if (!apiKey) { + setError("Enter X-API-Key for stream mutations"); + return; + } + setGoLiveBusy(true); + setError(null); + try { + localStorage.setItem(API_KEY_STORAGE, apiKey); + await api.registerStream({ stream_key: streamKey }, apiKey); + await api.goLive(streamKey, { limits_version: limitsVersion }, apiKey); + router.push( + `/live?stream=${encodeURIComponent(streamKey)}&limits=${encodeURIComponent(limitsVersion)}`, + ); + } catch (err) { + setError(err instanceof ApiError ? err.message : (err as Error).message); + } finally { + setGoLiveBusy(false); + } + } + + return ( +
    + + Onboarding wizard + + } + /> + + {error && } + + +
    +
    + +
    + + setChartType(e.target.value)} + > + + + + + + + + + + + + + setRuleset(e.target.value)} + > + + + + + + setValidMin(e.target.value)} + /> + setValidMax(e.target.value)} + /> + +
    + +
    + setMsaTolerance(e.target.value)} + /> + +
    + + {busy ? "Running…" : "Run analysis"} + + {busy && } +
    + +

    + Freeze can succeed with MSA warn; go-live still requires a passing checklist + (including Gage R&R / NDC when study data is supplied). +

    +
    + + {result && ( +
    + +
    +
    + Run ID + {result.run_id} +
    +
    + Type + {result.analysis_type} +
    + {limitsVersion && ( +
    + Limits version + {limitsVersion} +
    + )} + {primary && ( +
    + Limits + {formatLimits(primary)} +
    + )} +
    +
    + + {gates && } + {checklist && } + + {limitsVersion && ( + +
    + setStreamKey(e.target.value)} + /> + setApiKey(e.target.value)} + /> +
    + + {goLiveBusy ? "Activating…" : "Go live → Live"} + + + Open Live only + +
    +
    + {!checklistOk && ( +

    + Checklist did not pass — go-live will be rejected by the API until resolved. +

    + )} +
    + )} + + {report?.plotted_values && primary && ( + + + + )} +
    + )} +
    + ); +} diff --git a/frontend/src/app/capability/page.tsx b/frontend/src/app/capability/page.tsx new file mode 100644 index 0000000..07131cc --- /dev/null +++ b/frontend/src/app/capability/page.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import { + ErrorBanner, + FileField, + PageHeader, + Panel, + PrimaryButton, + Spinner, + TextInput, +} from "@/components/ui"; +import { ApiError, api } from "@/lib/api"; +import type { AnalyzeResponse } from "@/lib/types"; + +export default function CapabilityPage() { + const [file, setFile] = useState(null); + const [usl, setUsl] = useState("10.5"); + const [lsl, setLsl] = useState("9.5"); + const [target, setTarget] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + + async function onSubmit(e: FormEvent) { + e.preventDefault(); + if (!file) { + setError("Choose a data file"); + return; + } + setBusy(true); + setError(null); + try { + const fd = new FormData(); + fd.append("file", file); + fd.append("usl", usl); + fd.append("lsl", lsl); + if (target) fd.append("target", target); + const res = await api.analyzeCapability(fd); + setResult(res); + } catch (err) { + setError(err instanceof ApiError ? err.message : (err as Error).message); + } finally { + setBusy(false); + } + } + + const cap = + result && typeof result.report === "object" && result.report && "result" in result.report + ? (result.report as { result: Record }).result + : null; + + return ( +
    + + + {error && } + + +
    +
    + +
    + setUsl(e.target.value)} + required + /> + setLsl(e.target.value)} + required + /> + setTarget(e.target.value)} + /> +
    + + {busy ? "Computing…" : "Run capability"} + + {busy && } +
    + +
    + + {result && ( + +
    + Run + {result.run_id} +
    + {cap ? ( +
    + + + + + + + + + {["cp", "cpk", "pp", "ppk", "sigma_level", "method"].map((k) => + cap[k] !== undefined && cap[k] !== null ? ( + + + + + ) : null, + )} + +
    IndexValue
    {k} + {typeof cap[k] === "number" ? (cap[k] as number).toFixed(4) : String(cap[k])} +
    +
    + ) : ( +
    +              {JSON.stringify(result.report, null, 2)}
    +            
    + )} +
    + )} +
    + ); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css new file mode 100644 index 0000000..36bc49f --- /dev/null +++ b/frontend/src/app/globals.css @@ -0,0 +1,37 @@ +@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Sora:wght@400;500;600;700&display=swap"); + +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + color-scheme: dark; + --aspc-bg: #0b0b0f; + --aspc-accent: #e8c547; +} + +html, +body { + min-height: 100%; +} + +body { + background: var(--aspc-bg); + color: #f2f2f4; + font-family: "Sora", system-ui, sans-serif; + -webkit-font-smoothing: antialiased; +} + +* { + box-sizing: border-box; +} + +::selection { + background: rgba(232, 197, 71, 0.28); +} + +#__next, +body > div:first-child { + position: relative; + z-index: 1; +} diff --git a/frontend/src/app/lab/page.tsx b/frontend/src/app/lab/page.tsx new file mode 100644 index 0000000..4fa740c --- /dev/null +++ b/frontend/src/app/lab/page.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { ErrorBanner, PageHeader, Panel, PrimaryButton, Spinner } from "@/components/ui"; +import { ApiError, api } from "@/lib/api"; + +function ReachabilityHint({ message }: { message: string }) { + if (!message.includes("Cannot reach API")) return null; + return ( +
    +

    API unreachable from the browser

    +
      +
    1. + Confirm API:{" "} + curl -sS http://127.0.0.1:8000/health +
    2. +
    3. + Confirm UI proxy:{" "} + curl -sS http://127.0.0.1:3000/backend/health +
    4. +
    5. + Compose should use NEXT_PUBLIC_API_URL=/backend{" "} + (rebuild frontend after changing build args). +
    6. +
    7. Log in (Lab requires an authenticated analyst/admin session).
    8. +
    +
    + ); +} + +export default function LabPage() { + const casesQ = useQuery({ queryKey: ["lab-cases"], queryFn: api.labCases }); + const [running, setRunning] = useState(null); + const [result, setResult] = useState | null>(null); + const [error, setError] = useState(null); + + async function runCase(id: string) { + setRunning(id); + setError(null); + setResult(null); + try { + const r = await api.labRunCase(id); + setResult(r); + } catch (err) { + setError(err instanceof ApiError ? err.message : (err as Error).message); + } finally { + setRunning(null); + } + } + + const listError = casesQ.isError ? (casesQ.error as Error).message : null; + + return ( +
    + + {error && } + {error && } + {listError && } + {listError && } + {casesQ.isLoading && } + + +
      + {(casesQ.data?.cases || []).map((c) => ( +
    • +
      +
      {c.id}
      +
      + {c.category || "—"} · {c.description || c.entry || ""} +
      +
      + runCase(c.id)} + disabled={running === c.id} + > + {running === c.id ? "Running…" : "Run"} + +
    • + ))} +
    +
    + + {result && ( + +
    +            {JSON.stringify(result, null, 2)}
    +          
    +
    + )} +
    + ); +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx new file mode 100644 index 0000000..008ada6 --- /dev/null +++ b/frontend/src/app/layout.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from "next"; +import { Layout } from "@/components/Layout"; +import { Providers } from "@/components/Providers"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "ASPC Operator Console", + description: "Statistical Process Control — live monitoring and batch analysis", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + + ); +} diff --git a/frontend/src/app/live/live-inner.tsx b/frontend/src/app/live/live-inner.tsx new file mode 100644 index 0000000..15ee52b --- /dev/null +++ b/frontend/src/app/live/live-inner.tsx @@ -0,0 +1,384 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useSearchParams } from "next/navigation"; +import { ControlChart } from "@/components/ControlChart"; +import { ErrorBanner, PageHeader, Panel, PrimaryButton, SelectInput, Spinner } from "@/components/ui"; +import { api, getToken } from "@/lib/api"; +import { formatTimestamp } from "@/lib/format"; +import type { LivePointMessage, Signal } from "@/lib/types"; +import { connectLiveSocket, type LiveSocketHandle } from "@/lib/ws"; + +const MAX_POINTS = 200; +const API_KEY_STORAGE = "aspc_api_key"; + +interface AlertItem extends Signal { + ts?: string; + explanation?: string; +} + +export default function LivePageInner() { + const search = useSearchParams(); + const streamsQ = useQuery({ + queryKey: ["streams"], + queryFn: api.listStreams, + retry: false, + }); + + const [streamKey, setStreamKey] = useState(search.get("stream") || ""); + const [manualKey, setManualKey] = useState(search.get("stream") || "line-1"); + const [connected, setConnected] = useState(false); + const [error, setError] = useState(null); + const [values, setValues] = useState([]); + const [ooc, setOoc] = useState([]); + const [streamIndices, setStreamIndices] = useState([]); + const [ucl, setUcl] = useState(0); + const [cl, setCl] = useState(0); + const [lcl, setLcl] = useState(0); + const [alerts, setAlerts] = useState([]); + const [apiKey, setApiKey] = useState(() => + typeof window !== "undefined" ? localStorage.getItem(API_KEY_STORAGE) || "" : "", + ); + const [limitsVersion, setLimitsVersion] = useState(search.get("limits") || ""); + const [goLiveBusy, setGoLiveBusy] = useState(false); + const [explain, setExplain] = useState | null>(null); + const [sensorHealth, setSensorHealth] = useState<{ + window?: number; + rates?: Record; + } | null>(null); + const sockRef = useRef(null); + + const activeKey = streamKey || manualKey; + + const onMessage = useCallback( + ( + msg: LivePointMessage & { + explanations?: { operator_summary?: string }[]; + sensor_health?: { window?: number; rates?: Record }; + }, + ) => { + if (msg.type === "limits" && msg.limits) { + setUcl(Array.isArray(msg.limits.ucl) ? msg.limits.ucl[0] : msg.limits.ucl); + setCl(msg.limits.center); + setLcl(Array.isArray(msg.limits.lcl) ? msg.limits.lcl[0] : msg.limits.lcl); + return; + } + + if (msg.sensor_health) { + setSensorHealth(msg.sensor_health); + } + + if (msg.type === "point" || msg.value !== undefined) { + const v = msg.value; + if (typeof v === "number") { + setValues((prev) => [...prev, v].slice(-MAX_POINTS)); + setStreamIndices((prev) => { + const streamIdx = typeof msg.index === "number" ? msg.index : (prev.at(-1) ?? -1) + 1; + return [...prev, streamIdx].slice(-MAX_POINTS); + }); + if (typeof msg.ucl === "number") setUcl(msg.ucl); + if (typeof msg.center === "number") setCl(msg.center); + if (typeof msg.lcl === "number") setLcl(msg.lcl); + } + } + + const sigs = msg.signals || (msg.signal ? [msg.signal] : []); + if (sigs.length || msg.type === "alert") { + setStreamIndices((indices) => { + const streamIdx = typeof msg.index === "number" ? msg.index : indices.at(-1); + if (typeof streamIdx === "number") { + const chartIdx = indices.lastIndexOf(streamIdx); + if (chartIdx >= 0) { + setOoc((prev) => [...prev, chartIdx].slice(-MAX_POINTS)); + } + } + return indices; + }); + setAlerts((prev) => { + const added = sigs.map((s, i) => ({ + ...s, + ts: msg.ts, + explanation: msg.explanations?.[i]?.operator_summary, + })); + if (!added.length && msg.type === "alert") { + added.push({ + rule_id: "alert", + rule_name: "Alert", + index: msg.index ?? 0, + value: msg.value ?? 0, + description: msg.message || "Out of control", + ts: msg.ts, + explanation: undefined, + }); + } + return [...added, ...prev].slice(0, 50); + }); + } + + if (msg.type === "error") { + setError(msg.message || "WebSocket error"); + } + }, + [], + ); + + function disconnect() { + sockRef.current?.close(); + sockRef.current = null; + setConnected(false); + } + + function connect() { + setError(null); + disconnect(); + setValues([]); + setOoc([]); + setStreamIndices([]); + setAlerts([]); + const handle = connectLiveSocket(activeKey, onMessage, { + token: getToken(), + reconnect: true, + onOpen: () => setConnected(true), + onClose: () => setConnected(false), + onError: () => setError("WebSocket connection failed"), + }); + sockRef.current = handle; + } + + async function onGoLive() { + if (!activeKey || !limitsVersion || !apiKey) { + setError("Stream key, limits version, and API key are required to go live"); + return; + } + setGoLiveBusy(true); + setError(null); + try { + localStorage.setItem(API_KEY_STORAGE, apiKey); + await api.registerStream({ stream_key: activeKey }, apiKey); + await api.goLive(activeKey, { limits_version: limitsVersion }, apiKey); + await streamsQ.refetch(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setGoLiveBusy(false); + } + } + + async function openExplain(a: AlertItem) { + try { + const body = await api.explain({ + signal: { + rule_id: a.rule_id, + rule_name: a.rule_name, + index: a.index, + value: a.value, + description: a.description, + side: a.side, + }, + limits_version: limitsVersion || undefined, + }); + setExplain(body); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + } + + useEffect(() => () => disconnect(), []); + + const streamOptions = useMemo( + () => streamsQ.data?.streams?.filter((s) => s.active) ?? [], + [streamsQ.data], + ); + + const healthRates = sensorHealth?.rates || {}; + + return ( +
    + + {connected ? "Connected" : "Disconnected"} + + } + /> + + {error && } + + +
    + setStreamKey(e.target.value)} + > + + {streamOptions.map((s) => ( + + ))} + +
    + + setManualKey(e.target.value)} + className="w-full rounded-2xl border border-aspc-border bg-aspc-elevated px-3 py-2.5 text-sm outline-none focus:border-aspc-accent/50 disabled:opacity-50" + /> +
    +
    + + Connect + + +
    +
    +
    + + +
    +
    + + setLimitsVersion(e.target.value)} + className="w-full rounded-2xl border border-aspc-border bg-aspc-elevated px-3 py-2.5 text-sm outline-none focus:border-aspc-accent/50" + placeholder="from Analyze result" + /> +
    +
    + + setApiKey(e.target.value)} + className="w-full rounded-2xl border border-aspc-border bg-aspc-elevated px-3 py-2.5 text-sm outline-none focus:border-aspc-accent/50" + placeholder="stream mutation key" + /> +
    +
    + + {goLiveBusy ? "Activating…" : "Register + go live"} + +
    +
    +
    + + {sensorHealth && (sensorHealth.window ?? 0) > 0 && ( + +

    + Rolling QualityFlag rates (window={sensorHealth.window}) +

    +
    + {Object.entries(healthRates).map(([k, v]) => ( + + {k}: {(v * 100).toFixed(1)}% + + ))} + {Object.keys(healthRates).length === 0 && ( + No quality flags yet + )} +
    +
    + )} + +
    +
    + +
    + + {alerts.length === 0 &&

    No alerts yet.

    } +
      + {alerts.map((a, i) => ( +
    • +
      + [{a.rule_id}] idx {a.index} +
      +

      {a.description || a.rule_name}

      + {a.explanation && ( +

      {a.explanation}

      + )} +
      + + value={a.value} + {a.ts ? ` · ${formatTimestamp(a.ts)}` : ""} + + +
      +
    • + ))} +
    + {streamsQ.isLoading && } +
    +
    + + {explain && ( + +
    +            {JSON.stringify(explain, null, 2)}
    +          
    + +
    + )} +
    + ); +} diff --git a/frontend/src/app/live/page.tsx b/frontend/src/app/live/page.tsx new file mode 100644 index 0000000..65dd053 --- /dev/null +++ b/frontend/src/app/live/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { Suspense } from "react"; +import LivePageInner from "./live-inner"; + +export default function LivePage() { + return ( + Loading live…

    }> + +
    + ); +} diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx new file mode 100644 index 0000000..17a553a --- /dev/null +++ b/frontend/src/app/login/page.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import { useRouter } from "next/navigation"; +import { ApiError, login } from "@/lib/api"; +import { ErrorBanner, PrimaryButton, TextInput } from "@/components/ui"; + +export default function LoginPage() { + const router = useRouter(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + async function onSubmit(e: FormEvent) { + e.preventDefault(); + setError(null); + setBusy(true); + try { + await login(username, password); + router.push("/"); + } catch (err) { + setError(err instanceof ApiError ? err.message : (err as Error).message || "Login failed"); + } finally { + setBusy(false); + } + } + + return ( +
    +
    +
    +
    + + + +
    +
    ASPC
    +

    Sign in

    +

    Operator console · JWT against the ASPC API

    +
    + + {error && } + +
    + setUsername(e.target.value)} + required + /> + setPassword(e.target.value)} + required + /> + + {busy ? "Signing in…" : "Sign in"} + + +
    +
    + ); +} diff --git a/frontend/src/app/msa/page.tsx b/frontend/src/app/msa/page.tsx new file mode 100644 index 0000000..5460041 --- /dev/null +++ b/frontend/src/app/msa/page.tsx @@ -0,0 +1,234 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import { + ErrorBanner, + FileField, + PageHeader, + Panel, + PrimaryButton, + SelectInput, + Spinner, +} from "@/components/ui"; +import { ApiError, api } from "@/lib/api"; +import type { AnalyzeResponse } from "@/lib/types"; + +export default function MsaPage() { + const [file, setFile] = useState(null); + const [studyType, setStudyType] = useState("gage_rr"); + const [method, setMethod] = useState("anova"); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + + async function onSubmit(e: FormEvent) { + e.preventDefault(); + if (!file) { + setError("Choose a data file"); + return; + } + setBusy(true); + setError(null); + try { + const fd = new FormData(); + fd.append("file", file); + fd.append("study_type", studyType); + fd.append("method", method); + const res = await api.analyzeMsa(fd); + setResult(res); + } catch (err) { + setError(err instanceof ApiError ? err.message : (err as Error).message); + } finally { + setBusy(false); + } + } + + const msa = + result && typeof result.report === "object" && result.report + ? (result.report as { study_type?: string; result?: Record }) + : null; + + return ( +
    + + + {error && } + + +
    +
    + +
    + setStudyType(e.target.value)} + > + + + + + + setMethod(e.target.value)} + disabled={studyType !== "gage_rr"} + > + + + +
    + + {busy ? "Running…" : "Run MSA"} + + {busy && } +
    +
    +
    + + {result && ( + +
    + Run + {result.run_id} + {msa?.study_type && ( + <> + · + {msa.study_type} + + )} +
    + {msa?.result ? ( +
    + + + + + + + + + {Object.entries(msa.result) + .filter(([, v]) => typeof v === "number" || typeof v === "string" || typeof v === "boolean") + .map(([k, v]) => ( + + + + + ))} + +
    MetricValue
    {k} + {typeof v === "number" ? v.toFixed(4) : String(v)} +
    +
    + ) : ( +
    +              {JSON.stringify(result.report, null, 2)}
    +            
    + )} +
    + )} + + + + +
    + ); +} + +function ContinuousMsaPanel() { + const [measured, setMeasured] = useState("10.1,10.2,10.0,10.4,10.5"); + const [reference, setReference] = useState("10,10,10,10,10"); + const [tolerance, setTolerance] = useState("1"); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [summary, setSummary] = useState | null>(null); + + async function run() { + setBusy(true); + setError(null); + try { + const m = measured.split(",").map((s) => Number(s.trim())); + const r = reference.split(",").map((s) => Number(s.trim())); + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/analyze/msa-continuous`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(typeof window !== "undefined" && localStorage.getItem("aspc_token") + ? { Authorization: `Bearer ${localStorage.getItem("aspc_token")}` } + : {}), + }, + body: JSON.stringify({ + measured: m, + reference: r, + tolerance: Number(tolerance) || 1, + }), + }, + ); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || res.statusText); + } + const body = (await res.json()) as { summary: Record }; + setSummary(body.summary); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + return ( +
    +

    + Evaluate reference-standard injections with{" "} + ContinuousMSA (EWMA bias α=0.2, rolling R, + calibration alerts). +

    + {error && } +
    + + + +
    + + {busy ? "Evaluating…" : "Run continuous MSA"} + + {summary && ( +
    +          {JSON.stringify(summary, null, 2)}
    +        
    + )} +
    + ); +} diff --git a/frontend/src/app/onboarding/page.tsx b/frontend/src/app/onboarding/page.tsx new file mode 100644 index 0000000..fd1ab2e --- /dev/null +++ b/frontend/src/app/onboarding/page.tsx @@ -0,0 +1,249 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Checklist } from "@/components/Checklist"; +import { GateList } from "@/components/GateList"; +import { + ErrorBanner, + PageHeader, + Panel, + PrimaryButton, + SelectInput, + Spinner, + TextInput, +} from "@/components/ui"; +import { ApiError, api } from "@/lib/api"; +import type { AnalyzeResponse, Gate, Phase1Checklist, SPCReport } from "@/lib/types"; + +const API_KEY_STORAGE = "aspc_api_key"; + +function asReport(raw: AnalyzeResponse["report"]): SPCReport | null { + if (!raw || typeof raw !== "object") return null; + if ("plotted_values" in raw && "limits" in raw) return raw as SPCReport; + return null; +} + +type Step = 1 | 2 | 3 | 4; + +export default function OnboardingPage() { + const router = useRouter(); + const [step, setStep] = useState(1); + const [dataset, setDataset] = useState("spc_individual_in_control"); + const [catalog, setCatalog] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + const [streamKey, setStreamKey] = useState("demo-line-1"); + const [apiKey, setApiKey] = useState(() => + typeof window !== "undefined" ? localStorage.getItem(API_KEY_STORAGE) || "" : "", + ); + const [goLiveDone, setGoLiveDone] = useState(false); + + const report = useMemo(() => (result ? asReport(result.report) : null), [result]); + const gates = (report?.gates ?? (result?.report as { gates?: Gate[] })?.gates) as Gate[] | undefined; + const checklist = (report?.checklist ?? + result?.checklist ?? + (result?.report as { checklist?: Phase1Checklist })?.checklist) as Phase1Checklist | undefined; + const limitsVersion = report?.limits?.version; + + async function loadSampleAndAnalyze() { + setBusy(true); + setError(null); + setResult(null); + try { + const sample = await api.onboardingSample(dataset); + setCatalog(sample.catalog || []); + const blob = new Blob([sample.csv], { type: "text/csv" }); + const file = new File([blob], sample.filename, { type: "text/csv" }); + const fd = new FormData(); + fd.append("file", file); + fd.append("ruleset", "nelson"); + const res = await api.analyzeControlChart(fd); + setResult(res); + setStep(2); + } catch (err) { + setError(err instanceof ApiError ? err.message : (err as Error).message); + } finally { + setBusy(false); + } + } + + async function doGoLive() { + if (!limitsVersion) { + setError("No frozen limits version — Phase I must freeze before go-live"); + return; + } + if (!apiKey) { + setError("API key required (or set ASPC_DEV_INSECURE and use any key in local demos)"); + return; + } + setBusy(true); + setError(null); + try { + localStorage.setItem(API_KEY_STORAGE, apiKey); + await api.registerStream({ stream_key: streamKey }, apiKey); + await api.goLive(streamKey, { limits_version: limitsVersion }, apiKey); + setGoLiveDone(true); + setStep(4); + } catch (err) { + setError(err instanceof ApiError ? err.message : (err as Error).message); + } finally { + setBusy(false); + } + } + + function openLive() { + const q = new URLSearchParams({ + stream: streamKey, + limits: limitsVersion || "", + }); + router.push(`/live?${q.toString()}`); + } + + return ( +
    + + + {error && } + + +
      + {["Sample + establish", "Review gates", "Go live", "Open Live"].map((label, i) => ( +
    1. i + 1 + ? "border-aspc-ok/40 text-aspc-ok" + : "border-aspc-border" + }`} + > + {i + 1}. {label} +
    2. + ))} +
    + + {step === 1 && ( +
    + setDataset(e.target.value)} + > + + + + {catalog + .filter( + (c) => + ![ + "spc_individual_in_control", + "spc_individual_out_of_control", + "spc_subgroup_data", + ].includes(c), + ) + .map((c) => ( + + ))} + +
    + + {busy ? "Running…" : "Load sample & establish"} + + {busy && } +
    +
    + )} + + {step === 2 && result && ( +
    +

    + Run {result.run_id} + {limitsVersion && ( + <> + {" "} + · limits {limitsVersion} + + )} +

    + {gates && } + {checklist && } +
    + setStep(3)} disabled={!limitsVersion}> + Continue to go-live + + +
    + {!limitsVersion && ( +

    + Limits were not frozen — resolve STOP gates and retry. +

    + )} +
    + )} + + {step === 3 && ( +
    + setStreamKey(e.target.value)} + /> + setApiKey(e.target.value)} + /> +
    + + {busy ? "Activating…" : "Register + go live"} + + +
    +

    + Requires Timescale persistence for the stream registry. Limits version:{" "} + {limitsVersion || "—"} +

    +
    + )} + + {step === 4 && ( +
    +

    + {goLiveDone + ? `Stream ${streamKey} is live against frozen limits.` + : "Ready to open Live monitoring."} +

    + + Open Live console + +
    + )} +
    +
    + ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx new file mode 100644 index 0000000..ce02c9b --- /dev/null +++ b/frontend/src/app/page.tsx @@ -0,0 +1,274 @@ +"use client"; + +import Link from "next/link"; +import { useQuery } from "@tanstack/react-query"; +import { ControlChart } from "@/components/ControlChart"; +import { DeltaChip, ErrorBanner, Panel, Spinner } from "@/components/ui"; +import { api } from "@/lib/api"; +import { formatTimestamp, shortId } from "@/lib/format"; +import type { SPCReport } from "@/lib/types"; + +function asSpcReport(raw: unknown): SPCReport | null { + if (!raw || typeof raw !== "object") return null; + if ("plotted_values" in raw && "limits" in raw) return raw as SPCReport; + return null; +} + +const ACTIONS = [ + { + href: "/onboarding", + title: "Onboarding", + ticker: "15m", + blurb: "Sample → freeze → go-live", + tone: "accent" as const, + }, + { + href: "/analyze", + title: "Analyze", + ticker: "SPC", + blurb: "Batch control charts", + tone: "ok" as const, + }, + { + href: "/live", + title: "Live", + ticker: "WS", + blurb: "Phase II streams", + tone: "accent" as const, + }, + { + href: "/lab", + title: "Lab", + ticker: "RES", + blurb: "Resilience cases", + tone: "warn" as const, + }, +]; + +export default function DashboardPage() { + const health = useQuery({ queryKey: ["health"], queryFn: api.health, refetchInterval: 30_000 }); + const runs = useQuery({ queryKey: ["runs", 8], queryFn: () => api.listRuns({ limit: 8 }) }); + const streams = useQuery({ + queryKey: ["streams"], + queryFn: api.listStreams, + retry: false, + }); + const ops = useQuery({ + queryKey: ["ops-summary"], + queryFn: api.opsSummary, + retry: false, + }); + + const recent = runs.data?.runs ?? []; + const latestId = recent[0]?.run_id; + const latest = useQuery({ + queryKey: ["run", latestId], + queryFn: () => api.getRun(latestId!), + enabled: !!latestId, + }); + + const healthy = health.data?.status === "healthy" || health.data?.status === "ok"; + const liveCount = streams.data?.streams?.filter((s) => s.active).length ?? 0; + const report = latest.data ? asSpcReport(latest.data.report) : null; + const primary = report?.limits?.components ? Object.values(report.limits.components)[0] : null; + const oocIndices = report?.signals?.map((s) => s.index) ?? []; + + return ( +
    + {(health.isError || runs.isError) && ( + + )} + + {/* Summary strip */} +
    +
    +
    + Platform status +
    +
    + {health.isLoading ? "…" : healthy ? "Online" : health.isError ? "Down" : health.data?.status || "—"} +
    +

    + {health.data?.version ? `API v${health.data.version}` : "SPC operator console"} +

    +
    +
    + +
    + 0 ? true : null} /> +
    + 0 ? true : null} + /> +
    + +
    +
    + + {ops.data && ops.data.streams.length > 0 && ( + +
    + + + + + + + + + + + {ops.data.streams.map((s) => ( + + + + + + + ))} + +
    StreamActiveChartLimits
    {s.stream_key}{s.active ? "yes" : "no"}{s.chart_type || "—"}{s.limits_version || "—"}
    +
    +
    + )} + + {/* Action cards */} +
    + {ACTIONS.map((a) => ( + +
    +
    +
    {a.title}
    +
    {a.ticker}
    +
    + + ↗ + +
    +

    + Open +

    +

    {a.blurb}

    +
    + + ))} +
    + + {/* Portfolio + Chart */} +
    + + View all + + } + > + {runs.isLoading && } + {!runs.isLoading && recent.length === 0 && ( +

    No runs yet. Upload a file on Analyze.

    + )} +
      + {recent.map((r) => ( +
    • + + + {r.analysis_type.slice(0, 2).toUpperCase()} + +
      +
      {shortId(r.run_id, 14)}
      +
      {r.analysis_type}
      +
      + + {formatTimestamp(r.created_at).split(",")[0]} + + +
    • + ))} +
    +
    + + + Latest run + + ) : null + } + > + {latest.isLoading && } + {!latestId && !runs.isLoading && ( +
    + Run a control-chart analysis to populate this panel +
    + )} + {report && primary && ( +
    +
    +
    +
    + {report.chart_type} + {report.phase ? ` · ${report.phase}` : ""} +
    +
    + CL {primary.center.toPrecision(5)} +
    +
    +
    + +
    + )} + {latestId && latest.isSuccess && !report && ( +
    + Latest run has no plotted control-chart series +
    + )} +
    +
    +
    + ); +} diff --git a/frontend/src/app/runs/[run_id]/page.tsx b/frontend/src/app/runs/[run_id]/page.tsx new file mode 100644 index 0000000..4f04696 --- /dev/null +++ b/frontend/src/app/runs/[run_id]/page.tsx @@ -0,0 +1,322 @@ +"use client"; + +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Checklist } from "@/components/Checklist"; +import { ControlChart } from "@/components/ControlChart"; +import { GateList } from "@/components/GateList"; +import { ErrorBanner, PageHeader, Panel, PrimaryButton, Spinner, TextInput } from "@/components/ui"; +import { ApiError, api, getToken, reportUrl } from "@/lib/api"; +import { formatLimits, formatTimestamp, shortId } from "@/lib/format"; +import type { Gate, Phase1Checklist, SPCReport } from "@/lib/types"; + +const API_KEY_STORAGE = "aspc_api_key"; + +function asSpcReport(raw: unknown): SPCReport | null { + if (!raw || typeof raw !== "object") return null; + if ("plotted_values" in raw && "limits" in raw) return raw as SPCReport; + return null; +} + +export default function RunDetailPage() { + const params = useParams(); + const router = useRouter(); + const runId = String(params.run_id ?? ""); + const [streamKey, setStreamKey] = useState("line-1"); + const [apiKey, setApiKey] = useState(() => + typeof window !== "undefined" ? localStorage.getItem(API_KEY_STORAGE) || "" : "", + ); + const [busy, setBusy] = useState(false); + const [actionError, setActionError] = useState(null); + const [diffOther, setDiffOther] = useState(""); + const [diffResult, setDiffResult] = useState | null>(null); + + const { data, isLoading, error } = useQuery({ + queryKey: ["run", runId], + queryFn: () => api.getRun(runId), + enabled: !!runId, + }); + + const report = data ? asSpcReport(data.report) : null; + const primary = report?.limits?.components + ? Object.values(report.limits.components)[0] + : null; + const oocIndices = report?.signals?.map((s) => s.index) ?? []; + const gates = (report?.gates ?? (data?.report as { gates?: Gate[] })?.gates) as Gate[] | undefined; + const checklist = (report?.checklist ?? + (data?.report as { checklist?: Phase1Checklist })?.checklist) as Phase1Checklist | undefined; + const limitsVersion = data?.limits_version || report?.limits?.version; + + const capResult = + data?.report && typeof data.report === "object" && "result" in data.report + ? (data.report as { result: Record }).result + : null; + + const msaResult = + data?.report && typeof data.report === "object" && "result" in data.report + ? (data.report as { study_type?: string; result: Record }) + : null; + + async function goLive() { + if (!limitsVersion || !apiKey) { + setActionError("Limits version and API key required"); + return; + } + setBusy(true); + setActionError(null); + try { + localStorage.setItem(API_KEY_STORAGE, apiKey); + await api.registerStream({ stream_key: streamKey }, apiKey); + await api.goLive(streamKey, { limits_version: limitsVersion }, apiKey); + router.push( + `/live?stream=${encodeURIComponent(streamKey)}&limits=${encodeURIComponent(limitsVersion)}`, + ); + } catch (err) { + setActionError(err instanceof ApiError ? err.message : (err as Error).message); + } finally { + setBusy(false); + } + } + + async function runDiff() { + if (!limitsVersion || !diffOther) return; + setActionError(null); + try { + setDiffResult(await api.limitsDiff(limitsVersion, diffOther)); + } catch (err) { + setActionError(err instanceof ApiError ? err.message : (err as Error).message); + } + } + + function exportXlsx() { + const token = getToken(); + const url = api.exportRunXlsxUrl(runId); + void (async () => { + try { + const res = await fetch(url, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (!res.ok) throw new Error(await res.text()); + const blob = await res.blob(); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = `${runId}.xlsx`; + a.click(); + URL.revokeObjectURL(a.href); + } catch (err) { + setActionError(err instanceof Error ? err.message : String(err)); + } + })(); + } + + return ( +
    + + ← All runs + + } + /> + + {error && } + {actionError && } + {isLoading && } + + {data && ( +
    + +
    +
    +
    Run ID
    +
    {data.run_id}
    +
    +
    +
    Type
    +
    {data.analysis_type}
    +
    +
    +
    Created
    +
    {formatTimestamp(data.created_at)}
    +
    +
    +
    Source
    +
    {data.source_file || "—"}
    +
    + {limitsVersion && ( +
    +
    Limits version
    +
    {limitsVersion}
    +
    + )} +
    +
    + + Open HTML report ↗ + + {report && ( + + )} +
    +
    + + {limitsVersion && ( + +
    + setStreamKey(e.target.value)} + /> + setApiKey(e.target.value)} + /> +
    + + {busy ? "Activating…" : "Go live → Live"} + +
    +
    +
    + )} + + {limitsVersion && ( + +
    + setDiffOther(e.target.value)} + /> +
    + + Diff + +
    +
    + {diffResult && ( +
    +                  {JSON.stringify(diffResult, null, 2)}
    +                
    + )} +
    + )} + + {report && primary && ( + <> + +
    {formatLimits(primary)}
    +
    + + + + + + +
    + + )} + + {capResult && ( + +
    + + + + + + + + + {["cp", "cpk", "pp", "ppk", "sigma_level", "method"].map((k) => + capResult[k] !== undefined && capResult[k] !== null ? ( + + + + + ) : null, + )} + +
    IndexValue
    {k} + {typeof capResult[k] === "number" + ? (capResult[k] as number).toFixed(4) + : String(capResult[k])} +
    +
    +
    + )} + + {msaResult?.result && ( + +
    + + + + + + + + + {Object.entries(msaResult.result) + .filter( + ([, v]) => + typeof v === "number" || typeof v === "string" || typeof v === "boolean", + ) + .map(([k, v]) => ( + + + + + ))} + +
    MetricValue
    {k} + {typeof v === "number" ? v.toFixed(4) : String(v)} +
    +
    +
    + )} + + {!report && !capResult && !msaResult?.result && ( + +
    +                {JSON.stringify(data.report, null, 2)}
    +              
    +
    + )} +
    + )} +
    + ); +} diff --git a/frontend/src/app/runs/page.tsx b/frontend/src/app/runs/page.tsx new file mode 100644 index 0000000..4646317 --- /dev/null +++ b/frontend/src/app/runs/page.tsx @@ -0,0 +1,105 @@ +"use client"; + +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { Suspense, useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import { formatTimestamp, shortId } from "@/lib/format"; +import { ErrorBanner, PageHeader, Panel, Spinner } from "@/components/ui"; + +function RunsPageContent() { + const searchParams = useSearchParams(); + const q = (searchParams.get("q") || "").trim().toLowerCase(); + + const { data, isLoading, error, refetch, isFetching } = useQuery({ + queryKey: ["runs"], + queryFn: () => api.listRuns({ limit: 100 }), + }); + + const runs = useMemo(() => { + const all = data?.runs ?? []; + if (!q) return all; + return all.filter( + (r) => + r.run_id.toLowerCase().includes(q) || + r.analysis_type.toLowerCase().includes(q) || + (r.source_file || "").toLowerCase().includes(q), + ); + }, [data?.runs, q]); + + return ( +
    + refetch()} + className="rounded-pill border border-aspc-border px-4 py-1.5 text-xs text-aspc-muted hover:text-aspc-text" + > + {isFetching ? "Refreshing…" : "Refresh"} + + } + /> + + {error && } + + + {isLoading && } + {!isLoading && runs.length === 0 && ( +

    + {q ? `No runs matching “${q}”.` : "No analysis runs stored yet."} +

    + )} + {runs.length > 0 && ( +
    + + + + + + + + + + + + {runs.map((r) => ( + + + + + + + + ))} + +
    Run IDTypeSourceLimitsCreated
    + + {shortId(r.run_id, 14)} + + {r.analysis_type} + {r.source_file ? r.source_file.split(/[/\\]/).pop() : "—"} + + {r.limits_version ? shortId(r.limits_version) : "—"} + {formatTimestamp(r.created_at)}
    +
    + )} +
    +
    + ); +} + +export default function RunsPage() { + return ( + }> + + + ); +} diff --git a/frontend/src/components/Checklist.tsx b/frontend/src/components/Checklist.tsx new file mode 100644 index 0000000..22d8331 --- /dev/null +++ b/frontend/src/components/Checklist.tsx @@ -0,0 +1,67 @@ +"use client"; + +import type { ChecklistItem, Phase1Checklist } from "@/lib/types"; + +function ItemRow({ item }: { item: ChecklistItem }) { + return ( +
  • + + {item.passed ? "✓" : "✕"} + +
    +
    + {item.item.replace(/_/g, " ")} +
    +

    {item.reason}

    +
    +
  • + ); +} + +export function Checklist({ + checklist, + items, +}: { + checklist?: Phase1Checklist | null; + items?: ChecklistItem[]; +}) { + const list = items ?? checklist?.items ?? []; + const allPass = checklist?.passed ?? (list.length > 0 && list.every((i) => i.passed)); + + if (!list.length) { + return ( +

    Phase I checklist not available for this analysis.

    + ); + } + + return ( +
    +
    +

    + Phase I · 10-item go-live +

    + + {allPass ? "Ready" : "Blocked"} + +
    +
      + {list.map((item, i) => ( + + ))} +
    +
    + ); +} diff --git a/frontend/src/components/ControlChart.tsx b/frontend/src/components/ControlChart.tsx new file mode 100644 index 0000000..270e39d --- /dev/null +++ b/frontend/src/components/ControlChart.tsx @@ -0,0 +1,148 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { useMemo, type ComponentType } from "react"; +import { limitAt } from "@/lib/format"; + +// Plotly requires browser APIs +const Plot = dynamic(() => import("react-plotly.js"), { + ssr: false, + loading: () => ( +
    + Loading chart… +
    + ), +}) as unknown as ComponentType>; + +export interface ControlChartProps { + values: number[]; + ucl: number | number[]; + cl: number; + lcl: number | number[]; + /** Indices of out-of-control points (0-based). */ + oocIndices?: number[]; + title?: string; + height?: number; +} + +export function ControlChart({ + values, + ucl, + cl, + lcl, + oocIndices = [], + title = "Control Chart", + height = 360, +}: ControlChartProps) { + const x = useMemo(() => values.map((_, i) => i + 1), [values]); + const oocSet = useMemo(() => new Set(oocIndices), [oocIndices]); + + const inControlX: number[] = []; + const inControlY: number[] = []; + const oocX: number[] = []; + const oocY: number[] = []; + + values.forEach((v, i) => { + if (oocSet.has(i)) { + oocX.push(i + 1); + oocY.push(v); + } else { + inControlX.push(i + 1); + inControlY.push(v); + } + }); + + const uclSeries = values.map((_, i) => limitAt(ucl, i) ?? null); + const lclSeries = values.map((_, i) => limitAt(lcl, i) ?? null); + const clSeries = values.map(() => cl); + + const data = [ + { + x, + y: uclSeries, + type: "scatter", + mode: "lines", + name: "UCL", + line: { color: "#F97316", width: 1.5, dash: "dash" }, + hoverinfo: "y+name", + }, + { + x, + y: clSeries, + type: "scatter", + mode: "lines", + name: "CL", + line: { color: "#E8C547", width: 2 }, + hoverinfo: "y+name", + }, + { + x, + y: lclSeries, + type: "scatter", + mode: "lines", + name: "LCL", + line: { color: "#F97316", width: 1.5, dash: "dash" }, + hoverinfo: "y+name", + }, + { + x: inControlX, + y: inControlY, + type: "scatter", + mode: "lines+markers", + name: "Value", + line: { color: "#F2F2F4", width: 1.5 }, + marker: { color: "#F2F2F4", size: 6 }, + }, + { + x: oocX, + y: oocY, + type: "scatter", + mode: "markers", + name: "OOC", + marker: { color: "#2DD4BF", size: 10, symbol: "x", line: { width: 2, color: "#2DD4BF" } }, + }, + ]; + + const layout = { + title: { text: title, font: { color: "#F2F2F4", size: 14 }, x: 0, xanchor: "left" }, + paper_bgcolor: "rgba(0,0,0,0)", + plot_bgcolor: "#141418", + font: { color: "#8A8A96", family: "Sora, sans-serif", size: 11 }, + margin: { t: 40, r: 16, b: 40, l: 48 }, + height, + xaxis: { + title: "Subgroup / Index", + gridcolor: "#2A2A32", + zeroline: false, + color: "#8A8A96", + }, + yaxis: { + title: "Value", + gridcolor: "#2A2A32", + zeroline: false, + color: "#8A8A96", + }, + legend: { + orientation: "h", + y: 1.12, + x: 1, + xanchor: "right", + font: { size: 10 }, + }, + hovermode: "closest", + }; + + const config = { displayModeBar: false, responsive: true }; + + return ( +
    + +
    + ); +} diff --git a/frontend/src/components/GateList.tsx b/frontend/src/components/GateList.tsx new file mode 100644 index 0000000..8b4f2e3 --- /dev/null +++ b/frontend/src/components/GateList.tsx @@ -0,0 +1,44 @@ +"use client"; + +import type { Gate, GateStatus } from "@/lib/types"; + +const styles: Record = { + ok: "bg-aspc-ok/15 text-aspc-ok border-aspc-ok/40", + warn: "bg-aspc-warn/15 text-aspc-warn border-aspc-warn/40", + stop: "bg-aspc-stop/15 text-aspc-stop border-aspc-stop/40", +}; + +function Badge({ status }: { status: GateStatus }) { + return ( + + {status} + + ); +} + +export function GateList({ gates }: { gates: Gate[] }) { + if (!gates?.length) { + return ( +

    No pipeline gates returned for this run.

    + ); + } + + return ( +
      + {gates.map((g, i) => ( +
    • + +
      +
      {g.step}
      +

      {g.reason}

      +
      +
    • + ))} +
    + ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..79c533d --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,279 @@ +"use client"; + +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { getToken, logout } from "@/lib/api"; + +const NAV = [ + { + href: "/", + label: "Overview", + icon: ( + + + + + + + ), + }, + { + href: "/onboarding", + label: "Onboarding", + icon: ( + + + + ), + }, + { + href: "/live", + label: "Live", + icon: ( + + + + + ), + }, + { + href: "/analyze", + label: "Analyze", + icon: ( + + + + + ), + }, + { + href: "/capability", + label: "Capability", + icon: ( + + + + + ), + }, + { + href: "/msa", + label: "MSA", + icon: ( + + + + + ), + }, + { + href: "/runs", + label: "Runs", + icon: ( + + + + + ), + }, + { + href: "/lab", + label: "Lab", + icon: ( + + + + + ), + }, +]; + +function titleForPath(pathname: string): string { + if (pathname === "/") return "Dashboard"; + if (pathname.startsWith("/onboarding")) return "Onboarding"; + if (pathname.startsWith("/live")) return "Live"; + if (pathname.startsWith("/analyze")) return "Analyze"; + if (pathname.startsWith("/capability")) return "Capability"; + if (pathname.startsWith("/msa")) return "MSA"; + if (pathname.startsWith("/lab")) return "Resilience Lab"; + if (pathname.startsWith("/runs/")) return "Run detail"; + if (pathname.startsWith("/runs")) return "Runs"; + return "ASPC"; +} + +export function Layout({ children }: { children: ReactNode }) { + const pathname = usePathname(); + const router = useRouter(); + const [open, setOpen] = useState(false); + const [authed, setAuthed] = useState(false); + const [query, setQuery] = useState(""); + const isLogin = pathname === "/login"; + const pageTitle = useMemo(() => titleForPath(pathname), [pathname]); + + useEffect(() => { + const token = getToken(); + setAuthed(!!token); + if (!token && !isLogin) { + router.replace("/login"); + } + }, [pathname, isLogin, router]); + + if (isLogin) { + return <>{children}; + } + + return ( +
    + {/* Mobile top bar */} +
    + + + ASPC + + +
    + + {/* Sidebar */} + + + {open && ( +
    + ); +} diff --git a/frontend/src/components/Providers.tsx b/frontend/src/components/Providers.tsx new file mode 100644 index 0000000..c31126a --- /dev/null +++ b/frontend/src/components/Providers.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useState, type ReactNode } from "react"; + +export function Providers({ children }: { children: ReactNode }) { + const [client] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 15_000, + retry: 1, + refetchOnWindowFocus: false, + }, + }, + }), + ); + + return {children}; +} diff --git a/frontend/src/components/ui.tsx b/frontend/src/components/ui.tsx new file mode 100644 index 0000000..617fa66 --- /dev/null +++ b/frontend/src/components/ui.tsx @@ -0,0 +1,227 @@ +"use client"; + +import type { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from "react"; + +export function PageHeader({ + title, + subtitle, + actions, + hideTitle = false, +}: { + title: string; + subtitle?: string; + actions?: ReactNode; + /** When true, only subtitle/actions render (shell already shows page title). */ + hideTitle?: boolean; +}) { + if (hideTitle && !subtitle && !actions) return null; + return ( +
    +
    + {!hideTitle && ( +

    {title}

    + )} + {subtitle &&

    {subtitle}

    } +
    + {actions} +
    + ); +} + +export function Panel({ + title, + children, + className = "", + variant = "default", + action, +}: { + title?: string; + children: ReactNode; + className?: string; + variant?: "default" | "accent"; + action?: ReactNode; +}) { + const base = + variant === "accent" + ? "bg-aspc-accent text-aspc-bg border-transparent" + : "bg-aspc-panel text-aspc-text border-aspc-border"; + return ( +
    + {(title || action) && ( +
    + {title && ( +

    + {title} +

    + )} + {action} +
    + )} + {children} +
    + ); +} + +export function StatCard({ + label, + value, + hint, + tone = "default", + className = "", +}: { + label: string; + value: ReactNode; + hint?: string; + tone?: "default" | "ok" | "warn" | "stop" | "cyan" | "accent"; + className?: string; +}) { + const toneClass = + tone === "ok" + ? "text-aspc-ok" + : tone === "warn" + ? "text-aspc-warn" + : tone === "stop" + ? "text-aspc-stop" + : tone === "cyan" || tone === "accent" + ? "text-aspc-accent" + : "text-aspc-text"; + + return ( +
    +
    {label}
    +
    {value}
    + {hint &&
    {hint}
    } +
    + ); +} + +export function DeltaChip({ + label, + value, + positive, +}: { + label: string; + value: string; + positive?: boolean | null; +}) { + const tone = + positive === true ? "text-aspc-ok" : positive === false ? "text-aspc-warn" : "text-aspc-muted"; + return ( +
    +
    {label}
    +
    + {positive === true && } + {positive === false && } + {value} +
    +
    + ); +} + +export function ErrorBanner({ message }: { message: string }) { + return ( +
    + {message} +
    + ); +} + +export function Spinner({ label = "Loading…" }: { label?: string }) { + return ( +
    + + {label} +
    + ); +} + +export function FileField({ + id, + label, + accept = ".csv,.parquet,.pq", + onChange, + required, +}: { + id: string; + label: string; + accept?: string; + onChange: (file: File | null) => void; + required?: boolean; +}) { + return ( +
    + + onChange(e.target.files?.[0] ?? null)} + className="block w-full cursor-pointer rounded-2xl border border-aspc-border bg-aspc-elevated px-3 py-2.5 text-sm file:mr-3 file:rounded-pill file:border-0 file:bg-aspc-accent-soft file:px-3 file:py-1 file:text-xs file:font-medium file:text-aspc-accent" + /> +
    + ); +} + +export function TextInput({ + id, + label, + ...props +}: InputHTMLAttributes & { label: string }) { + return ( +
    + + +
    + ); +} + +export function SelectInput({ + id, + label, + children, + ...props +}: SelectHTMLAttributes & { label: string }) { + return ( +
    + + +
    + ); +} + +export function PrimaryButton({ + children, + className = "", + ...props +}: ButtonHTMLAttributes) { + return ( + + ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..5e0eb58 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,256 @@ +import type { + AnalyzeResponse, + RunDetail, + RunSummary, + StreamInfo, + TokenResponse, +} from "./types"; + +const API_URL = (process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000").replace(/\/$/, ""); +const TOKEN_KEY = "aspc_token"; + +function unreachableApiHint(err: unknown): string { + if (!(err instanceof TypeError)) return ""; + return ( + " — browser could not complete the request. " + + `Tried ${API_URL}. Prefer same-origin NEXT_PUBLIC_API_URL=/backend (Compose default). ` + + "Or open http://localhost:3000 and ensure ASPC_CORS_ORIGINS includes your UI origin." + ); +} + +export class ApiError extends Error { + status: number; + + constructor(message: string, status: number) { + super(message); + this.name = "ApiError"; + this.status = status; + } +} + +export function getToken(): string | null { + if (typeof window === "undefined") return null; + return localStorage.getItem(TOKEN_KEY); +} + +export function setToken(token: string): void { + localStorage.setItem(TOKEN_KEY, token); +} + +export function logout(): void { + localStorage.removeItem(TOKEN_KEY); +} + +export function reportUrl(runId: string): string { + return `${API_URL}/reports/${runId}`; +} + +async function parseError(res: Response): Promise { + const text = await res.text(); + if (!text) return res.statusText || `HTTP ${res.status}`; + try { + const body = JSON.parse(text) as { detail?: unknown; message?: string }; + if (typeof body.detail === "string") return body.detail; + if (Array.isArray(body.detail)) { + return body.detail.map((d) => (typeof d === "object" && d && "msg" in d ? String(d.msg) : String(d))).join("; "); + } + if (body.message) return body.message; + return text; + } catch { + return text; + } +} + +async function request(path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + const token = getToken(); + if (token) headers.set("Authorization", `Bearer ${token}`); + + let res: Response; + try { + res = await fetch(`${API_URL}${path}`, { ...init, headers }); + } catch (err) { + throw new ApiError(`Cannot reach API at ${API_URL}${unreachableApiHint(err)}`, 0); + } + + if (!res.ok) { + if (res.status === 401 && typeof window !== "undefined") { + logout(); + if (!window.location.pathname.startsWith("/login")) { + window.location.href = "/login"; + } + } + throw new ApiError(await parseError(res), res.status); + } + + if (res.status === 204) return undefined as T; + return res.json() as Promise; +} + +async function upload(path: string, form: FormData): Promise { + const headers = new Headers(); + const token = getToken(); + if (token) headers.set("Authorization", `Bearer ${token}`); + + let res: Response; + try { + res = await fetch(`${API_URL}${path}`, { method: "POST", headers, body: form }); + } catch (err) { + throw new ApiError(`Cannot reach API at ${API_URL}${unreachableApiHint(err)}`, 0); + } + + if (!res.ok) { + if (res.status === 401 && typeof window !== "undefined") { + logout(); + if (!window.location.pathname.startsWith("/login")) { + window.location.href = "/login"; + } + } + throw new ApiError(await parseError(res), res.status); + } + + return res.json() as Promise; +} + +export async function login(username: string, password: string): Promise { + const body = new URLSearchParams({ username, password, grant_type: "password" }); + + let res: Response; + try { + res = await fetch(`${API_URL}/auth/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + } catch (err) { + throw new ApiError(`Cannot reach API at ${API_URL}${unreachableApiHint(err)}`, 0); + } + + if (!res.ok) { + throw new ApiError(await parseError(res), res.status); + } + + const data = (await res.json()) as TokenResponse; + if (!data.access_token) { + throw new ApiError("No access_token in response", res.status); + } + setToken(data.access_token); +} + +export const api = { + health: () => request<{ status: string; version?: string }>("/health"), + + listRuns: (params?: { analysis_type?: string; limit?: number }) => { + const q = new URLSearchParams(); + if (params?.analysis_type) q.set("analysis_type", params.analysis_type); + if (params?.limit) q.set("limit", String(params.limit)); + const qs = q.toString(); + return request<{ runs: RunSummary[] }>(`/runs${qs ? `?${qs}` : ""}`); + }, + + getRun: (runId: string) => request(`/runs/${runId}`), + + analyzeControlChart: (form: FormData) => upload("/analyze/control-chart", form), + + analyzeCapability: (form: FormData) => upload("/analyze/capability", form), + + analyzeMsa: (form: FormData) => upload("/analyze/msa", form), + + /** Optional — returns empty list if the streams endpoint is unavailable. */ + listStreams: async (): Promise<{ streams: StreamInfo[] }> => { + try { + return await request<{ streams: StreamInfo[] }>("/streams"); + } catch (err) { + if (err instanceof ApiError && (err.status === 404 || err.status === 0)) { + return { streams: [] }; + } + throw err; + } + }, + + registerStream: (body: { + stream_key: string; + topic?: string; + chart_type?: string; + ruleset?: string; + }, apiKey: string) => + request<{ stream_key: string; active: boolean }>("/streams/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-API-Key": apiKey, + }, + body: JSON.stringify(body), + }), + + goLive: (streamKey: string, body: { limits_version: string; ruleset?: string }, apiKey: string) => + request<{ stream_key: string; limits_version: string; active: boolean; ruleset: string }>( + `/streams/${encodeURIComponent(streamKey)}/go-live`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-API-Key": apiKey, + }, + body: JSON.stringify(body), + }, + ), + + ackAlert: (eventId: number, apiKey: string) => + request>(`/alerts/${eventId}/ack`, { + method: "POST", + headers: { "X-API-Key": apiKey }, + }), + + me: () => + request<{ username?: string; role?: string; tenant_id?: string; auth?: string }>("/auth/me"), + + onboardingSample: (dataset = "spc_individual_in_control") => + request<{ dataset: string; filename: string; csv: string; catalog: string[] }>( + `/onboarding/sample?dataset=${encodeURIComponent(dataset)}`, + ), + + explain: (body: { + signal: Record; + limits_version?: string; + gates?: unknown[]; + checklist?: unknown; + }) => + request>("/analyze/explain", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + + limitsDiff: (a: string, b: string) => + request>( + `/limits/${encodeURIComponent(a)}/diff/${encodeURIComponent(b)}`, + ), + + labCases: () => + request<{ + cases: { id: string; category?: string; entry?: string; description?: string }[]; + }>("/lab/cases"), + + labRunCase: (caseId: string) => + request>(`/lab/cases/${encodeURIComponent(caseId)}/run`, { + method: "POST", + }), + + opsSummary: () => + request<{ + streams_total: number; + streams_active: number; + streams: { + stream_key: string; + active?: boolean; + limits_version?: string; + chart_type?: string; + tenant_id?: string; + }[]; + recent_runs: number; + checklist_debt: number; + }>("/ops/summary"), + + exportRunXlsxUrl: (runId: string) => `${API_URL}/runs/${encodeURIComponent(runId)}/export.xlsx`, +}; diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts new file mode 100644 index 0000000..5dd37f8 --- /dev/null +++ b/frontend/src/lib/format.ts @@ -0,0 +1,52 @@ +/** Display helpers for limits, ids, and timestamps. */ + +export interface LimitLike { + center: number; + ucl: number | number[]; + lcl: number | number[]; +} + +function fmt(n: number, digits: number): string { + return n.toFixed(digits); +} + +function limitRange(v: number | number[], digits: number): string { + if (Array.isArray(v)) { + if (v.length === 0) return "—"; + const lo = Math.min(...v); + const hi = Math.max(...v); + return lo === hi ? fmt(lo, digits) : `${fmt(lo, digits)}…${fmt(hi, digits)}`; + } + return fmt(v, digits); +} + +/** Format UCL / CL / LCL for display (handles variable limits). */ +export function formatLimits(limits: LimitLike, digits = 2): string { + return `UCL ${limitRange(limits.ucl, digits)} · CL ${fmt(limits.center, digits)} · LCL ${limitRange(limits.lcl, digits)}`; +} + +/** Resolve scalar or per-index limit value. */ +export function limitAt(limit: number | number[] | undefined, index: number): number | undefined { + if (limit === undefined) return undefined; + if (Array.isArray(limit)) return limit[index] ?? limit[limit.length - 1]; + return limit; +} + +/** Truncate long UUIDs for table cells. */ +export function shortId(id: string, max = 10): string { + if (id.length <= max) return id; + return `${id.slice(0, max)}…`; +} + +/** Human-readable timestamp from ISO string. */ +export function formatTimestamp(iso: string | undefined | null): string { + if (!iso) return "—"; + try { + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(iso)); + } catch { + return iso; + } +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts new file mode 100644 index 0000000..13a3526 --- /dev/null +++ b/frontend/src/lib/types.ts @@ -0,0 +1,107 @@ +/** Shared API / report types for the operator console. */ + +export type GateStatus = "ok" | "warn" | "stop"; + +export interface Gate { + step: string; + status: GateStatus; + reason: string; + detail?: Record; +} + +export interface ChecklistItem { + item: string; + passed: boolean; + reason: string; +} + +export interface Phase1Checklist { + passed: boolean; + items: ChecklistItem[]; +} + +export interface LimitSet { + center: number; + ucl: number | number[]; + lcl: number | number[]; +} + +export interface ControlLimits { + version: string; + chart_type: string; + subgroup_size?: number; + components: Record; +} + +export interface Signal { + rule_id: string; + rule_name: string; + index: number; + value: number; + description: string; + side?: string; +} + +export interface SPCReport { + analysis_type?: string; + chart_type: string; + phase?: string; + limits: ControlLimits; + plotted_values: number[]; + secondary_values?: number[]; + secondary_name?: string; + signals?: Signal[]; + gates?: Gate[]; + checklist?: Phase1Checklist; + summary?: Record; + source_file?: string; + created_at?: string; +} + +export interface AnalyzeResponse { + status: string; + run_id: string; + analysis_type: string; + report: SPCReport | Record; + html_report?: string | null; + checklist?: Phase1Checklist | null; +} + +export interface RunSummary { + run_id: string; + analysis_type: string; + limits_version?: string | null; + source_file?: string | null; + user_id?: string | null; + created_at: string; +} + +export interface RunDetail extends RunSummary { + report: Record; +} + +export interface StreamInfo { + stream_key: string; + active: boolean; + chart_type?: string; + limits_version?: string; +} + +export interface LivePointMessage { + type?: "point" | "limits" | "alert" | "error"; + value?: number; + index?: number; + ts?: string; + ucl?: number; + center?: number; + lcl?: number; + message?: string; + limits?: { ucl: number | number[]; center: number; lcl: number | number[] }; + signals?: Signal[]; + signal?: Signal; +} + +export interface TokenResponse { + access_token: string; + token_type?: string; +} diff --git a/frontend/src/lib/ws.ts b/frontend/src/lib/ws.ts new file mode 100644 index 0000000..22c3112 --- /dev/null +++ b/frontend/src/lib/ws.ts @@ -0,0 +1,88 @@ +import type { LivePointMessage } from "./types"; + +const WS_URL = process.env.NEXT_PUBLIC_WS_URL || "ws://localhost:8000"; + +export interface LiveSocketOptions { + token?: string | null; + onOpen?: () => void; + onClose?: () => void; + onError?: (ev: Event) => void; + /** Reconnect with exponential backoff (default true). */ + reconnect?: boolean; + maxBackoffMs?: number; +} + +export interface LiveSocketHandle { + close: () => void; +} + +/** Connect to NEXT_PUBLIC_WS_URL/ws/live/{streamKey} with optional reconnect. */ +export function connectLiveSocket( + streamKey: string, + onMessage: (msg: LivePointMessage) => void, + options: LiveSocketOptions = {}, +): LiveSocketHandle { + // Auth is sent as the first JSON message after connect — never in the URL. + const url = `${WS_URL}/ws/live/${encodeURIComponent(streamKey)}`; + + let closed = false; + let ws: WebSocket | null = null; + let timer: ReturnType | null = null; + let attempt = 0; + const maxBackoff = options.maxBackoffMs ?? 15_000; + const shouldReconnect = options.reconnect !== false; + + function clearTimer() { + if (timer) { + clearTimeout(timer); + timer = null; + } + } + + function connect() { + if (closed) return; + clearTimer(); + ws = new WebSocket(url); + + ws.onopen = () => { + attempt = 0; + if (options.token) { + ws?.send(JSON.stringify({ type: "auth", token: options.token })); + } + options.onOpen?.(); + }; + + ws.onclose = () => { + options.onClose?.(); + if (!closed && shouldReconnect) { + const delay = Math.min(1000 * 2 ** attempt, maxBackoff); + attempt += 1; + timer = setTimeout(connect, delay); + } + }; + + ws.onerror = (ev) => options.onError?.(ev); + + ws.onmessage = (ev) => { + try { + const msg = JSON.parse(String(ev.data)) as LivePointMessage; + onMessage(msg); + } catch { + onMessage({ type: "error", message: "Invalid WebSocket payload" }); + } + }; + } + + connect(); + + return { + close: () => { + closed = true; + clearTimer(); + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { + ws.close(); + } + ws = null; + }, + }; +} diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts new file mode 100644 index 0000000..4d2c32b --- /dev/null +++ b/frontend/tailwind.config.ts @@ -0,0 +1,47 @@ +import type { Config } from "tailwindcss"; + +const config: Config = { + content: [ + "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", + "./src/components/**/*.{js,ts,jsx,tsx,mdx}", + "./src/app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + colors: { + aspc: { + bg: "#0B0B0F", + panel: "#141418", + elevated: "#1A1A20", + border: "#2A2A32", + muted: "#8A8A96", + text: "#F2F2F4", + accent: "#E8C547", + "accent-dim": "#C4A63A", + "accent-soft": "rgba(232, 197, 71, 0.12)", + // Keep cyan alias pointing at accent for any leftover class names + cyan: "#E8C547", + "cyan-dim": "#C4A63A", + ok: "#2DD4BF", + warn: "#F97316", + stop: "#F04343", + }, + }, + fontFamily: { + sans: ["Sora", "IBM Plex Sans", "system-ui", "sans-serif"], + mono: ["IBM Plex Mono", "ui-monospace", "monospace"], + }, + borderRadius: { + card: "1.5rem", + pill: "9999px", + }, + boxShadow: { + glow: "0 0 28px rgba(232, 197, 71, 0.18)", + card: "0 8px 32px rgba(0, 0, 0, 0.35)", + }, + }, + }, + plugins: [], +}; + +export default config; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..2ef7b01 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules", "e2e"] +} diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo new file mode 100644 index 0000000..b93bea2 --- /dev/null +++ b/frontend/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/typescript/lib/lib.es2023.d.ts","./node_modules/typescript/lib/lib.es2024.d.ts","./node_modules/typescript/lib/lib.esnext.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/typescript/lib/lib.es2022.object.d.ts","./node_modules/typescript/lib/lib.es2022.string.d.ts","./node_modules/typescript/lib/lib.es2022.regexp.d.ts","./node_modules/typescript/lib/lib.es2023.array.d.ts","./node_modules/typescript/lib/lib.es2023.collection.d.ts","./node_modules/typescript/lib/lib.es2023.intl.d.ts","./node_modules/typescript/lib/lib.es2024.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2024.collection.d.ts","./node_modules/typescript/lib/lib.es2024.object.d.ts","./node_modules/typescript/lib/lib.es2024.promise.d.ts","./node_modules/typescript/lib/lib.es2024.regexp.d.ts","./node_modules/typescript/lib/lib.es2024.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2024.string.d.ts","./node_modules/typescript/lib/lib.esnext.array.d.ts","./node_modules/typescript/lib/lib.esnext.collection.d.ts","./node_modules/typescript/lib/lib.esnext.intl.d.ts","./node_modules/typescript/lib/lib.esnext.disposable.d.ts","./node_modules/typescript/lib/lib.esnext.promise.d.ts","./node_modules/typescript/lib/lib.esnext.decorators.d.ts","./node_modules/typescript/lib/lib.esnext.iterator.d.ts","./node_modules/typescript/lib/lib.esnext.float16.d.ts","./node_modules/typescript/lib/lib.esnext.error.d.ts","./node_modules/typescript/lib/lib.esnext.sharedmemory.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/next/dist/styled-jsx/types/css.d.ts","./node_modules/@types/react/global.d.ts","./node_modules/csstype/index.d.ts","./node_modules/@types/prop-types/index.d.ts","./node_modules/@types/react/index.d.ts","./node_modules/next/dist/styled-jsx/types/index.d.ts","./node_modules/next/dist/styled-jsx/types/macro.d.ts","./node_modules/next/dist/styled-jsx/types/style.d.ts","./node_modules/next/dist/styled-jsx/types/global.d.ts","./node_modules/next/dist/shared/lib/amp.d.ts","./node_modules/next/amp.d.ts","./node_modules/@types/node/compatibility/disposable.d.ts","./node_modules/@types/node/compatibility/indexable.d.ts","./node_modules/@types/node/compatibility/iterators.d.ts","./node_modules/@types/node/compatibility/index.d.ts","./node_modules/@types/node/globals.typedarray.d.ts","./node_modules/@types/node/buffer.buffer.d.ts","./node_modules/@types/node/globals.d.ts","./node_modules/@types/node/web-globals/abortcontroller.d.ts","./node_modules/@types/node/web-globals/domexception.d.ts","./node_modules/@types/node/web-globals/events.d.ts","./node_modules/undici-types/header.d.ts","./node_modules/undici-types/readable.d.ts","./node_modules/undici-types/file.d.ts","./node_modules/undici-types/fetch.d.ts","./node_modules/undici-types/formdata.d.ts","./node_modules/undici-types/connector.d.ts","./node_modules/undici-types/client.d.ts","./node_modules/undici-types/errors.d.ts","./node_modules/undici-types/dispatcher.d.ts","./node_modules/undici-types/global-dispatcher.d.ts","./node_modules/undici-types/global-origin.d.ts","./node_modules/undici-types/pool-stats.d.ts","./node_modules/undici-types/pool.d.ts","./node_modules/undici-types/handlers.d.ts","./node_modules/undici-types/balanced-pool.d.ts","./node_modules/undici-types/agent.d.ts","./node_modules/undici-types/mock-interceptor.d.ts","./node_modules/undici-types/mock-agent.d.ts","./node_modules/undici-types/mock-client.d.ts","./node_modules/undici-types/mock-pool.d.ts","./node_modules/undici-types/mock-errors.d.ts","./node_modules/undici-types/proxy-agent.d.ts","./node_modules/undici-types/env-http-proxy-agent.d.ts","./node_modules/undici-types/retry-handler.d.ts","./node_modules/undici-types/retry-agent.d.ts","./node_modules/undici-types/api.d.ts","./node_modules/undici-types/interceptors.d.ts","./node_modules/undici-types/util.d.ts","./node_modules/undici-types/cookies.d.ts","./node_modules/undici-types/patch.d.ts","./node_modules/undici-types/websocket.d.ts","./node_modules/undici-types/eventsource.d.ts","./node_modules/undici-types/filereader.d.ts","./node_modules/undici-types/diagnostics-channel.d.ts","./node_modules/undici-types/content-type.d.ts","./node_modules/undici-types/cache.d.ts","./node_modules/undici-types/index.d.ts","./node_modules/@types/node/web-globals/fetch.d.ts","./node_modules/@types/node/assert.d.ts","./node_modules/@types/node/assert/strict.d.ts","./node_modules/@types/node/async_hooks.d.ts","./node_modules/@types/node/buffer.d.ts","./node_modules/@types/node/child_process.d.ts","./node_modules/@types/node/cluster.d.ts","./node_modules/@types/node/console.d.ts","./node_modules/@types/node/constants.d.ts","./node_modules/@types/node/crypto.d.ts","./node_modules/@types/node/dgram.d.ts","./node_modules/@types/node/diagnostics_channel.d.ts","./node_modules/@types/node/dns.d.ts","./node_modules/@types/node/dns/promises.d.ts","./node_modules/@types/node/domain.d.ts","./node_modules/@types/node/events.d.ts","./node_modules/@types/node/fs.d.ts","./node_modules/@types/node/fs/promises.d.ts","./node_modules/@types/node/http.d.ts","./node_modules/@types/node/http2.d.ts","./node_modules/@types/node/https.d.ts","./node_modules/@types/node/inspector.generated.d.ts","./node_modules/@types/node/module.d.ts","./node_modules/@types/node/net.d.ts","./node_modules/@types/node/os.d.ts","./node_modules/@types/node/path.d.ts","./node_modules/@types/node/perf_hooks.d.ts","./node_modules/@types/node/process.d.ts","./node_modules/@types/node/punycode.d.ts","./node_modules/@types/node/querystring.d.ts","./node_modules/@types/node/readline.d.ts","./node_modules/@types/node/readline/promises.d.ts","./node_modules/@types/node/repl.d.ts","./node_modules/@types/node/sea.d.ts","./node_modules/@types/node/stream.d.ts","./node_modules/@types/node/stream/promises.d.ts","./node_modules/@types/node/stream/consumers.d.ts","./node_modules/@types/node/stream/web.d.ts","./node_modules/@types/node/string_decoder.d.ts","./node_modules/@types/node/test.d.ts","./node_modules/@types/node/timers.d.ts","./node_modules/@types/node/timers/promises.d.ts","./node_modules/@types/node/tls.d.ts","./node_modules/@types/node/trace_events.d.ts","./node_modules/@types/node/tty.d.ts","./node_modules/@types/node/url.d.ts","./node_modules/@types/node/util.d.ts","./node_modules/@types/node/v8.d.ts","./node_modules/@types/node/vm.d.ts","./node_modules/@types/node/wasi.d.ts","./node_modules/@types/node/worker_threads.d.ts","./node_modules/@types/node/zlib.d.ts","./node_modules/@types/node/index.d.ts","./node_modules/next/dist/server/get-page-files.d.ts","./node_modules/@types/react/canary.d.ts","./node_modules/@types/react/experimental.d.ts","./node_modules/@types/react-dom/index.d.ts","./node_modules/@types/react-dom/canary.d.ts","./node_modules/@types/react-dom/experimental.d.ts","./node_modules/next/dist/compiled/webpack/webpack.d.ts","./node_modules/next/dist/server/config.d.ts","./node_modules/next/dist/lib/load-custom-routes.d.ts","./node_modules/next/dist/shared/lib/image-config.d.ts","./node_modules/next/dist/build/webpack/plugins/subresource-integrity-plugin.d.ts","./node_modules/next/dist/server/body-streams.d.ts","./node_modules/next/dist/server/future/route-kind.d.ts","./node_modules/next/dist/server/future/route-definitions/route-definition.d.ts","./node_modules/next/dist/server/future/route-matches/route-match.d.ts","./node_modules/next/dist/client/components/app-router-headers.d.ts","./node_modules/next/dist/server/request-meta.d.ts","./node_modules/next/dist/server/lib/revalidate.d.ts","./node_modules/next/dist/server/config-shared.d.ts","./node_modules/next/dist/server/base-http/index.d.ts","./node_modules/next/dist/server/api-utils/index.d.ts","./node_modules/next/dist/server/node-environment.d.ts","./node_modules/next/dist/server/require-hook.d.ts","./node_modules/next/dist/server/node-polyfill-crypto.d.ts","./node_modules/next/dist/lib/page-types.d.ts","./node_modules/next/dist/build/analysis/get-page-static-info.d.ts","./node_modules/next/dist/build/webpack/loaders/get-module-build-info.d.ts","./node_modules/next/dist/build/webpack/plugins/middleware-plugin.d.ts","./node_modules/next/dist/server/render-result.d.ts","./node_modules/next/dist/server/future/helpers/i18n-provider.d.ts","./node_modules/next/dist/server/web/next-url.d.ts","./node_modules/next/dist/compiled/@edge-runtime/cookies/index.d.ts","./node_modules/next/dist/server/web/spec-extension/cookies.d.ts","./node_modules/next/dist/server/web/spec-extension/request.d.ts","./node_modules/next/dist/server/web/spec-extension/fetch-event.d.ts","./node_modules/next/dist/server/web/spec-extension/response.d.ts","./node_modules/next/dist/server/web/types.d.ts","./node_modules/next/dist/lib/setup-exception-listeners.d.ts","./node_modules/next/dist/lib/constants.d.ts","./node_modules/next/dist/build/index.d.ts","./node_modules/next/dist/build/webpack/plugins/pages-manifest-plugin.d.ts","./node_modules/next/dist/shared/lib/router/utils/route-regex.d.ts","./node_modules/next/dist/shared/lib/router/utils/route-matcher.d.ts","./node_modules/next/dist/shared/lib/router/utils/parse-url.d.ts","./node_modules/next/dist/server/base-http/node.d.ts","./node_modules/next/dist/server/font-utils.d.ts","./node_modules/next/dist/build/webpack/plugins/flight-manifest-plugin.d.ts","./node_modules/next/dist/server/future/route-modules/route-module.d.ts","./node_modules/next/dist/shared/lib/deep-readonly.d.ts","./node_modules/next/dist/server/load-components.d.ts","./node_modules/next/dist/shared/lib/router/utils/middleware-route-matcher.d.ts","./node_modules/next/dist/build/webpack/plugins/next-font-manifest-plugin.d.ts","./node_modules/next/dist/server/future/route-definitions/locale-route-definition.d.ts","./node_modules/next/dist/server/future/route-definitions/pages-route-definition.d.ts","./node_modules/next/dist/shared/lib/mitt.d.ts","./node_modules/next/dist/client/with-router.d.ts","./node_modules/next/dist/client/router.d.ts","./node_modules/next/dist/client/route-loader.d.ts","./node_modules/next/dist/client/page-loader.d.ts","./node_modules/next/dist/shared/lib/bloom-filter.d.ts","./node_modules/next/dist/shared/lib/router/router.d.ts","./node_modules/next/dist/shared/lib/router-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/loadable-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/loadable.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/image-config-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/hooks-client-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/head-manager-context.shared-runtime.d.ts","./node_modules/next/dist/server/future/route-definitions/app-page-route-definition.d.ts","./node_modules/next/dist/shared/lib/modern-browserslist-target.d.ts","./node_modules/next/dist/shared/lib/constants.d.ts","./node_modules/next/dist/build/webpack/loaders/metadata/types.d.ts","./node_modules/next/dist/build/page-extensions-type.d.ts","./node_modules/next/dist/build/webpack/loaders/next-app-loader.d.ts","./node_modules/next/dist/server/lib/app-dir-module.d.ts","./node_modules/next/dist/server/response-cache/types.d.ts","./node_modules/next/dist/server/response-cache/index.d.ts","./node_modules/next/dist/server/lib/incremental-cache/index.d.ts","./node_modules/next/dist/client/components/hooks-server-context.d.ts","./node_modules/next/dist/server/app-render/dynamic-rendering.d.ts","./node_modules/next/dist/client/components/static-generation-async-storage-instance.d.ts","./node_modules/next/dist/client/components/static-generation-async-storage.external.d.ts","./node_modules/next/dist/server/web/spec-extension/adapters/request-cookies.d.ts","./node_modules/next/dist/server/async-storage/draft-mode-provider.d.ts","./node_modules/next/dist/server/web/spec-extension/adapters/headers.d.ts","./node_modules/next/dist/client/components/request-async-storage-instance.d.ts","./node_modules/next/dist/client/components/request-async-storage.external.d.ts","./node_modules/next/dist/server/app-render/create-error-handler.d.ts","./node_modules/next/dist/server/app-render/app-render.d.ts","./node_modules/next/dist/shared/lib/server-inserted-html.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/amp-context.shared-runtime.d.ts","./node_modules/next/dist/server/future/route-modules/app-page/vendored/contexts/entrypoints.d.ts","./node_modules/next/dist/server/future/route-modules/app-page/module.compiled.d.ts","./node_modules/@types/react/jsx-runtime.d.ts","./node_modules/next/dist/client/components/error-boundary.d.ts","./node_modules/next/dist/client/components/router-reducer/create-initial-router-state.d.ts","./node_modules/next/dist/client/components/app-router.d.ts","./node_modules/next/dist/client/components/layout-router.d.ts","./node_modules/next/dist/client/components/render-from-template-context.d.ts","./node_modules/next/dist/client/components/action-async-storage-instance.d.ts","./node_modules/next/dist/client/components/action-async-storage.external.d.ts","./node_modules/next/dist/client/components/client-page.d.ts","./node_modules/next/dist/client/components/search-params.d.ts","./node_modules/next/dist/client/components/not-found-boundary.d.ts","./node_modules/next/dist/server/app-render/rsc/preloads.d.ts","./node_modules/next/dist/server/app-render/rsc/postpone.d.ts","./node_modules/next/dist/server/app-render/rsc/taint.d.ts","./node_modules/next/dist/server/app-render/entry-base.d.ts","./node_modules/next/dist/build/templates/app-page.d.ts","./node_modules/next/dist/server/future/route-modules/app-page/module.d.ts","./node_modules/next/dist/server/lib/builtin-request-context.d.ts","./node_modules/next/dist/server/app-render/types.d.ts","./node_modules/next/dist/client/components/router-reducer/fetch-server-response.d.ts","./node_modules/next/dist/client/components/router-reducer/router-reducer-types.d.ts","./node_modules/next/dist/shared/lib/app-router-context.shared-runtime.d.ts","./node_modules/next/dist/server/future/route-modules/pages/vendored/contexts/entrypoints.d.ts","./node_modules/next/dist/server/future/route-modules/pages/module.compiled.d.ts","./node_modules/next/dist/build/templates/pages.d.ts","./node_modules/next/dist/server/future/route-modules/pages/module.d.ts","./node_modules/next/dist/server/render.d.ts","./node_modules/next/dist/server/future/route-definitions/pages-api-route-definition.d.ts","./node_modules/next/dist/server/future/route-matches/pages-api-route-match.d.ts","./node_modules/next/dist/server/future/route-matchers/route-matcher.d.ts","./node_modules/next/dist/server/future/route-matcher-providers/route-matcher-provider.d.ts","./node_modules/next/dist/server/future/route-matcher-managers/route-matcher-manager.d.ts","./node_modules/next/dist/server/future/normalizers/normalizer.d.ts","./node_modules/next/dist/server/future/normalizers/locale-route-normalizer.d.ts","./node_modules/next/dist/server/future/normalizers/request/pathname-normalizer.d.ts","./node_modules/next/dist/server/future/normalizers/request/suffix.d.ts","./node_modules/next/dist/server/future/normalizers/request/rsc.d.ts","./node_modules/next/dist/server/future/normalizers/request/prefix.d.ts","./node_modules/next/dist/server/future/normalizers/request/postponed.d.ts","./node_modules/next/dist/server/future/normalizers/request/action.d.ts","./node_modules/next/dist/server/future/normalizers/request/prefetch-rsc.d.ts","./node_modules/next/dist/server/future/normalizers/request/next-data.d.ts","./node_modules/next/dist/server/base-server.d.ts","./node_modules/next/dist/server/image-optimizer.d.ts","./node_modules/next/dist/server/next-server.d.ts","./node_modules/next/dist/lib/coalesced-function.d.ts","./node_modules/next/dist/server/lib/router-utils/types.d.ts","./node_modules/next/dist/trace/types.d.ts","./node_modules/next/dist/trace/trace.d.ts","./node_modules/next/dist/trace/shared.d.ts","./node_modules/next/dist/trace/index.d.ts","./node_modules/next/dist/build/load-jsconfig.d.ts","./node_modules/next/dist/build/webpack-config.d.ts","./node_modules/next/dist/build/webpack/plugins/define-env-plugin.d.ts","./node_modules/next/dist/build/swc/index.d.ts","./node_modules/next/dist/server/dev/parse-version-info.d.ts","./node_modules/next/dist/server/dev/hot-reloader-types.d.ts","./node_modules/next/dist/telemetry/storage.d.ts","./node_modules/next/dist/server/lib/types.d.ts","./node_modules/next/dist/server/lib/render-server.d.ts","./node_modules/next/dist/server/lib/router-server.d.ts","./node_modules/next/dist/shared/lib/router/utils/path-match.d.ts","./node_modules/next/dist/server/lib/router-utils/filesystem.d.ts","./node_modules/next/dist/server/lib/router-utils/setup-dev-bundler.d.ts","./node_modules/next/dist/server/lib/dev-bundler-service.d.ts","./node_modules/next/dist/server/dev/static-paths-worker.d.ts","./node_modules/next/dist/server/dev/next-dev-server.d.ts","./node_modules/next/dist/server/next.d.ts","./node_modules/next/dist/lib/metadata/types/alternative-urls-types.d.ts","./node_modules/next/dist/lib/metadata/types/extra-types.d.ts","./node_modules/next/dist/lib/metadata/types/metadata-types.d.ts","./node_modules/next/dist/lib/metadata/types/manifest-types.d.ts","./node_modules/next/dist/lib/metadata/types/opengraph-types.d.ts","./node_modules/next/dist/lib/metadata/types/twitter-types.d.ts","./node_modules/next/dist/lib/metadata/types/metadata-interface.d.ts","./node_modules/next/types/index.d.ts","./node_modules/next/dist/shared/lib/html-context.shared-runtime.d.ts","./node_modules/@next/env/dist/index.d.ts","./node_modules/next/dist/shared/lib/utils.d.ts","./node_modules/next/dist/pages/_app.d.ts","./node_modules/next/app.d.ts","./node_modules/next/dist/server/web/spec-extension/unstable-cache.d.ts","./node_modules/next/dist/server/web/spec-extension/revalidate.d.ts","./node_modules/next/dist/server/web/spec-extension/unstable-no-store.d.ts","./node_modules/next/cache.d.ts","./node_modules/next/dist/shared/lib/runtime-config.external.d.ts","./node_modules/next/config.d.ts","./node_modules/next/dist/pages/_document.d.ts","./node_modules/next/document.d.ts","./node_modules/next/dist/shared/lib/dynamic.d.ts","./node_modules/next/dynamic.d.ts","./node_modules/next/dist/pages/_error.d.ts","./node_modules/next/error.d.ts","./node_modules/next/dist/shared/lib/head.d.ts","./node_modules/next/head.d.ts","./node_modules/next/dist/client/components/draft-mode.d.ts","./node_modules/next/dist/client/components/headers.d.ts","./node_modules/next/headers.d.ts","./node_modules/next/dist/shared/lib/get-img-props.d.ts","./node_modules/next/dist/client/image-component.d.ts","./node_modules/next/dist/shared/lib/image-external.d.ts","./node_modules/next/image.d.ts","./node_modules/next/dist/client/link.d.ts","./node_modules/next/link.d.ts","./node_modules/next/dist/client/components/redirect-status-code.d.ts","./node_modules/next/dist/client/components/redirect.d.ts","./node_modules/next/dist/client/components/not-found.d.ts","./node_modules/next/dist/client/components/navigation.react-server.d.ts","./node_modules/next/dist/client/components/navigation.d.ts","./node_modules/next/navigation.d.ts","./node_modules/next/router.d.ts","./node_modules/next/dist/client/script.d.ts","./node_modules/next/script.d.ts","./node_modules/next/dist/server/web/spec-extension/user-agent.d.ts","./node_modules/next/dist/compiled/@edge-runtime/primitives/url.d.ts","./node_modules/next/dist/server/web/spec-extension/image-response.d.ts","./node_modules/next/dist/compiled/@vercel/og/satori/index.d.ts","./node_modules/next/dist/compiled/@vercel/og/emoji/index.d.ts","./node_modules/next/dist/compiled/@vercel/og/types.d.ts","./node_modules/next/server.d.ts","./node_modules/next/types/global.d.ts","./node_modules/next/types/compiled.d.ts","./node_modules/next/index.d.ts","./node_modules/next/image-types/global.d.ts","./next-env.d.ts","./node_modules/playwright-core/types/protocol.d.ts","./node_modules/playwright-core/types/structs.d.ts","./node_modules/playwright-core/types/types.d.ts","./node_modules/playwright-core/index.d.ts","./node_modules/playwright/types/test.d.ts","./node_modules/playwright/test.d.ts","./node_modules/@playwright/test/index.d.ts","./playwright.config.ts","./node_modules/source-map-js/source-map.d.ts","./node_modules/postcss/lib/previous-map.d.ts","./node_modules/postcss/lib/input.d.ts","./node_modules/postcss/lib/css-syntax-error.d.ts","./node_modules/postcss/lib/declaration.d.ts","./node_modules/postcss/lib/root.d.ts","./node_modules/postcss/lib/warning.d.ts","./node_modules/postcss/lib/lazy-result.d.ts","./node_modules/postcss/lib/no-work-result.d.ts","./node_modules/postcss/lib/processor.d.ts","./node_modules/postcss/lib/result.d.ts","./node_modules/postcss/lib/document.d.ts","./node_modules/postcss/lib/rule.d.ts","./node_modules/postcss/lib/node.d.ts","./node_modules/postcss/lib/comment.d.ts","./node_modules/postcss/lib/container.d.ts","./node_modules/postcss/lib/at-rule.d.ts","./node_modules/postcss/lib/list.d.ts","./node_modules/postcss/lib/postcss.d.ts","./node_modules/postcss/lib/postcss.d.mts","./node_modules/tailwindcss/types/generated/corePluginList.d.ts","./node_modules/tailwindcss/types/generated/colors.d.ts","./node_modules/tailwindcss/types/config.d.ts","./node_modules/tailwindcss/types/index.d.ts","./tailwind.config.ts","./node_modules/@vitest/spy/dist/index.d.ts","./node_modules/@vitest/pretty-format/dist/index.d.ts","./node_modules/@vitest/utils/dist/types.d.ts","./node_modules/@vitest/utils/dist/helpers.d.ts","./node_modules/tinyrainbow/dist/index-8b61d5bc.d.ts","./node_modules/tinyrainbow/dist/node.d.ts","./node_modules/@vitest/utils/dist/index.d.ts","./node_modules/@vitest/utils/dist/types.d-BCElaP-c.d.ts","./node_modules/@vitest/utils/dist/diff.d.ts","./node_modules/@vitest/expect/dist/index.d.ts","./node_modules/vite/types/hmrPayload.d.ts","./node_modules/vite/dist/node/chunks/moduleRunnerTransport.d.ts","./node_modules/vite/types/customEvent.d.ts","./node_modules/@types/estree/index.d.ts","./node_modules/rollup/dist/rollup.d.ts","./node_modules/rollup/dist/parseAst.d.ts","./node_modules/vite/types/hot.d.ts","./node_modules/vite/dist/node/module-runner.d.ts","./node_modules/esbuild/lib/main.d.ts","./node_modules/@jridgewell/trace-mapping/types/sourcemap-segment.d.mts","./node_modules/@jridgewell/trace-mapping/types/types.d.mts","./node_modules/@jridgewell/trace-mapping/types/flatten-map.d.mts","./node_modules/@jridgewell/trace-mapping/types/trace-mapping.d.mts","./node_modules/@jridgewell/gen-mapping/types/sourcemap-segment.d.mts","./node_modules/@jridgewell/gen-mapping/types/types.d.mts","./node_modules/@jridgewell/gen-mapping/types/gen-mapping.d.mts","./node_modules/@jridgewell/source-map/types/source-map.d.mts","./node_modules/terser/tools/terser.d.ts","./node_modules/vite/types/internal/terserOptions.d.ts","./node_modules/vite/types/internal/cssPreprocessorOptions.d.ts","./node_modules/vite/types/internal/lightningcssOptions.d.ts","./node_modules/vite/types/importGlob.d.ts","./node_modules/vite/types/metadata.d.ts","./node_modules/vite/dist/node/index.d.ts","./node_modules/@vitest/runner/dist/tasks.d-CkscK4of.d.ts","./node_modules/@vitest/runner/dist/types.d.ts","./node_modules/@vitest/utils/dist/error.d.ts","./node_modules/@vitest/runner/dist/index.d.ts","./node_modules/vitest/optional-types.d.ts","./node_modules/vitest/dist/chunks/environment.d.cL3nLXbE.d.ts","./node_modules/@vitest/mocker/dist/registry.d-D765pazg.d.ts","./node_modules/@vitest/mocker/dist/types.d-D_aRZRdy.d.ts","./node_modules/@vitest/mocker/dist/index.d.ts","./node_modules/@vitest/utils/dist/source-map.d.ts","./node_modules/vite-node/dist/trace-mapping.d-DLVdEqOp.d.ts","./node_modules/vite-node/dist/index.d-DGmxD2U7.d.ts","./node_modules/vite-node/dist/index.d.ts","./node_modules/@vitest/snapshot/dist/environment.d-DHdQ1Csl.d.ts","./node_modules/@vitest/snapshot/dist/rawSnapshot.d-lFsMJFUd.d.ts","./node_modules/@vitest/snapshot/dist/index.d.ts","./node_modules/@vitest/snapshot/dist/environment.d.ts","./node_modules/vitest/dist/chunks/config.d.BKdhh7Zx.d.ts","./node_modules/vitest/dist/chunks/worker.d.CUgIPz9V.d.ts","./node_modules/@types/deep-eql/index.d.ts","./node_modules/assertion-error/index.d.ts","./node_modules/@types/chai/index.d.ts","./node_modules/@vitest/runner/dist/utils.d.ts","./node_modules/tinybench/dist/index.d.ts","./node_modules/vitest/dist/chunks/benchmark.d.BwvBVTda.d.ts","./node_modules/vite-node/dist/client.d.ts","./node_modules/vitest/dist/chunks/coverage.d.S9RMNXIe.d.ts","./node_modules/@vitest/snapshot/dist/manager.d.ts","./node_modules/vitest/dist/chunks/reporters.d.BuRON0I0.d.ts","./node_modules/vitest/dist/chunks/vite.d.BnOPPc46.d.ts","./node_modules/vitest/dist/config.d.ts","./node_modules/vitest/config.d.ts","./vitest.config.ts","./node_modules/vitest/dist/chunks/worker.d.uzWsCv9X.d.ts","./node_modules/vitest/dist/chunks/global.d.MAmajcmJ.d.ts","./node_modules/vitest/dist/chunks/mocker.d.BE_2ls6u.d.ts","./node_modules/vitest/dist/chunks/suite.d.FvehnV49.d.ts","./node_modules/expect-type/dist/utils.d.ts","./node_modules/expect-type/dist/overloads.d.ts","./node_modules/expect-type/dist/branding.d.ts","./node_modules/expect-type/dist/messages.d.ts","./node_modules/expect-type/dist/index.d.ts","./node_modules/vitest/dist/index.d.ts","./src/lib/format.ts","./src/__tests__/formatLimits.test.ts","./src/lib/types.ts","./src/lib/api.ts","./src/lib/ws.ts","./src/components/Layout.tsx","./node_modules/@tanstack/query-core/build/modern/_tsup-dts-rollup.d.ts","./node_modules/@tanstack/query-core/build/modern/index.d.ts","./node_modules/@tanstack/react-query/build/modern/_tsup-dts-rollup.d.ts","./node_modules/@tanstack/react-query/build/modern/index.d.ts","./src/components/Providers.tsx","./src/app/layout.tsx","./node_modules/@types/plotly.js/lib/scatter.d.ts","./node_modules/@types/plotly.js/lib/box.d.ts","./node_modules/@types/plotly.js/lib/ohlc.d.ts","./node_modules/@types/plotly.js/lib/candlestick.d.ts","./node_modules/@types/plotly.js/lib/pie.d.ts","./node_modules/@types/plotly.js/lib/sankey.d.ts","./node_modules/@types/plotly.js/lib/violin.d.ts","./node_modules/@types/plotly.js/index.d.ts","./node_modules/@types/react-plotly.js/index.d.ts","./src/components/ControlChart.tsx","./src/components/ui.tsx","./src/app/page.tsx","./src/components/Checklist.tsx","./src/components/GateList.tsx","./src/app/analyze/page.tsx","./src/app/capability/page.tsx","./src/app/lab/page.tsx","./src/app/live/live-inner.tsx","./src/app/live/page.tsx","./src/app/login/page.tsx","./src/app/msa/page.tsx","./src/app/onboarding/page.tsx","./src/app/runs/page.tsx","./src/app/runs/[run_id]/page.tsx","./.next/types/app/layout.ts","./.next/types/app/page.ts","./.next/types/app/analyze/page.ts","./.next/types/app/capability/page.ts","./.next/types/app/live/page.ts","./.next/types/app/login/page.ts","./.next/types/app/msa/page.ts","./.next/types/app/runs/page.ts","./node_modules/@types/geojson/index.d.ts","./node_modules/@types/geojson-vt/index.d.ts","./node_modules/@types/json-schema/index.d.ts","./node_modules/@types/json5/index.d.ts","./node_modules/@types/mapbox__point-geometry/index.d.ts","./node_modules/@types/pbf/index.d.ts","./node_modules/@types/mapbox__vector-tile/index.d.ts","./node_modules/@types/supercluster/index.d.ts"],"fileIdsList":[[99,145,360,547],[99,145,360,548],[99,145,360,532],[99,145,360,551],[99,145,360,552],[99,145,360,553],[99,145,360,544],[99,145,360,555],[99,145,408,409],[99,145,466,468],[99,145],[99,145,467],[99,145,466,469],[99,145,464,466],[99,145,463,464,465],[99,145,463,466],[99,145,416],[99,145,527],[87,99,145,286,528],[99,145,529],[99,145,497,498],[99,145,565],[99,145,565,569,570],[99,142,145],[99,144,145],[145],[99,145,150,178],[99,145,146,151,156,164,175,186],[99,145,146,147,156,164],[94,95,96,99,145],[99,145,148,187],[99,145,149,150,157,165],[99,145,150,175,183],[99,145,151,153,156,164],[99,144,145,152],[99,145,153,154],[99,145,155,156],[99,144,145,156],[99,145,156,157,158,175,186],[99,145,156,157,158,171,175,178],[99,145,153,156,159,164,175,186],[99,145,156,157,159,160,164,175,183,186],[99,145,159,161,175,183,186],[97,98,99,100,101,102,103,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192],[99,145,156,162],[99,145,163,186,191],[99,145,153,156,164,175],[99,145,165],[99,145,166],[99,144,145,167],[99,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192],[99,145,169],[99,145,170],[99,145,156,171,172],[99,145,171,173,187,189],[99,145,156,175,176,178],[99,145,177,178],[99,145,175,176],[99,145,178],[99,145,179],[99,142,145,175,180],[99,145,156,181,182],[99,145,181,182],[99,145,150,164,175,183],[99,145,184],[99,145,164,185],[99,145,159,170,186],[99,145,150,187],[99,145,175,188],[99,145,163,189],[99,145,190],[99,140,145],[99,140,145,156,158,167,175,178,186,189,191],[99,145,175,192],[99,145,534,535,536,537,538,539],[99,145,533,540],[99,145,535],[99,145,540],[99,145,534,540],[87,99,145,197,198,199],[87,99,145,197,198],[87,99,145],[87,99,145,540],[87,91,99,145,196,361,404],[87,91,99,145,195,361,404],[84,85,86,99,145],[99,145,444,449,450,452],[99,145,484,485],[99,145,450,452,478,479,480],[99,145,450],[99,145,450,452,478],[99,145,450,478],[99,145,491],[99,145,445,491,492],[99,145,445,491],[99,145,445,451],[99,145,446],[99,145,445,446,447,449],[99,145,445],[99,145,515,516],[99,145,515,516,517,518],[99,145,515,517],[99,145,515],[92,99,145],[99,145,365],[99,145,367,368,369],[99,145,371],[99,145,202,212,218,220,361],[99,145,202,209,211,214,232],[99,145,212],[99,145,212,214,339],[99,145,267,285,300,407],[99,145,309],[99,145,202,212,219,253,263,336,337,407],[99,145,219,407],[99,145,212,263,264,265,407],[99,145,212,219,253,407],[99,145,407],[99,145,202,219,220,407],[99,145,293],[99,144,145,193,292],[87,99,145,286,287,288,306,307],[87,99,145,286],[99,145,276],[99,145,275,277,381],[87,99,145,286,287,304],[99,145,282,307,393],[99,145,391,392],[99,145,226,390],[99,145,279],[99,144,145,193,226,242,275,276,277,278],[87,99,145,304,306,307],[99,145,304,306],[99,145,304,305,307],[99,145,170,193],[99,145,274],[99,144,145,193,211,213,270,271,272,273],[87,99,145,203,384],[87,99,145,186,193],[87,99,145,219,251],[87,99,145,219],[99,145,249,254],[87,99,145,250,364],[87,91,99,145,159,193,195,196,361,402,403],[99,145,361],[99,145,201],[99,145,354,355,356,357,358,359],[99,145,356],[87,99,145,250,286,364],[87,99,145,286,362,364],[87,99,145,286,364],[99,145,159,193,213,364],[99,145,159,193,210,211,222,240,242,274,279,280,302,304],[99,145,271,274,279,287,289,290,291,293,294,295,296,297,298,299,407],[99,145,272],[87,99,145,170,193,211,212,240,242,243,245,270,302,303,307,361,407],[99,145,159,193,213,214,226,227,275],[99,145,159,193,212,214],[99,145,159,175,193,210,213,214],[99,145,159,170,186,193,210,211,212,213,214,219,222,223,233,234,236,239,240,242,243,244,245,269,270,303,304,312,314,317,319,322,324,325,326,327],[99,145,159,175,193],[99,145,202,203,204,210,211,361,364,407],[99,145,159,175,186,193,207,338,340,341,407],[99,145,170,186,193,207,210,213,230,234,236,237,238,243,270,317,328,330,336,350,351],[99,145,212,216,270],[99,145,210,212],[99,145,223,318],[99,145,320,321],[99,145,320],[99,145,318],[99,145,320,323],[99,145,206,207],[99,145,206,246],[99,145,206],[99,145,208,223,316],[99,145,315],[99,145,207,208],[99,145,208,313],[99,145,207],[99,145,302],[99,145,159,193,210,222,241,261,267,281,284,301,304],[99,145,255,256,257,258,259,260,282,283,307,362],[99,145,311],[99,145,159,193,210,222,241,247,308,310,312,361,364],[99,145,159,186,193,203,210,212,269],[99,145,266],[99,145,159,193,344,349],[99,145,233,242,269,364],[99,145,332,336,350,353],[99,145,159,216,336,344,345,353],[99,145,202,212,233,244,347],[99,145,159,193,212,219,244,331,332,342,343,346,348],[99,145,194,240,241,242,361,364],[99,145,159,170,186,193,208,210,211,213,216,221,222,230,233,234,236,237,238,239,243,245,269,270,314,328,329,364],[99,145,159,193,210,212,216,330,352],[99,145,159,193,211,213],[87,99,145,159,170,193,201,203,210,211,214,222,239,240,242,243,245,311,361,364],[99,145,159,170,186,193,205,208,209,213],[99,145,206,268],[99,145,159,193,206,211,222],[99,145,159,193,212,223],[99,145,159,193],[99,145,226],[99,145,225],[99,145,227],[99,145,212,224,226,230],[99,145,212,224,226],[99,145,159,193,205,212,213,219,227,228,229],[87,99,145,304,305,306],[99,145,262],[87,99,145,203],[87,99,145,236],[87,99,145,194,239,242,245,361,364],[99,145,203,384,385],[87,99,145,254],[87,99,145,170,186,193,201,248,250,252,253,364],[99,145,213,219,236],[99,145,235],[87,99,145,157,159,170,193,201,254,263,361,362,363],[83,87,88,89,90,99,145,195,196,361,404],[99,145,150],[99,145,333,334,335],[99,145,333],[99,145,373],[99,145,375],[99,145,377],[99,145,379],[99,145,382],[99,145,386],[91,93,99,145,361,366,370,372,374,376,378,380,383,387,389,395,396,398,405,406,407],[99,145,388],[99,145,394],[99,145,250],[99,145,397],[99,144,145,227,228,229,230,399,400,401,404],[99,145,193],[87,91,99,145,159,161,170,193,195,196,197,199,201,214,353,360,364,404],[99,145,413],[99,145,146,157,175,411,412],[99,145,415],[99,145,414],[99,145,434],[99,145,432,434],[99,145,423,431,432,433,435,437],[99,145,421],[99,145,424,429,434,437],[99,145,420,437],[99,145,424,425,428,429,430,437],[99,145,424,425,426,428,429,437],[99,145,421,422,423,424,425,429,430,431,433,434,435,437],[99,145,437],[99,145,419,421,422,423,424,425,426,428,429,430,431,432,433,434,435,436],[99,145,419,437],[99,145,424,426,427,429,430,437],[99,145,428,437],[99,145,429,430,434,437],[99,145,422,432],[99,145,458,476,477],[99,145,457,458],[99,145,439,440],[99,145,438,441],[99,145,470],[99,145,448],[99,112,116,145,186],[99,112,145,175,186],[99,107,145],[99,109,112,145,183,186],[99,145,164,183],[99,107,145,193],[99,109,112,145,164,186],[99,104,105,108,111,145,156,175,186],[99,112,119,145],[99,104,110,145],[99,112,133,134,145],[99,108,112,145,178,186,193],[99,133,145,193],[99,106,107,145,193],[99,112,145],[99,106,107,108,109,110,111,112,113,114,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,134,135,136,137,138,139,145],[99,112,127,145],[99,112,119,120,145],[99,110,112,120,121,145],[99,111,145],[99,104,107,112,145],[99,112,116,120,121,145],[99,116,145],[99,110,112,115,145,186],[99,104,109,112,119,145],[99,145,175],[99,107,112,133,145,191,193],[99,145,488,489],[99,145,488],[99,145,454],[99,145,156,157,159,160,161,164,175,183,186,192,193,438,454,455,456,458,459,461,462,472,473,474,475,476,477],[99,145,454,455,456,460],[99,145,456],[99,145,471],[99,145,458,477],[99,145,453,508,512],[99,145,481,500,501,512],[99,145,445,452,481,493,494,512],[99,145,503],[99,145,482],[99,145,445,453,481,483,493,502,512],[99,145,486],[99,145,148,157,175,445,450,452,477,481,483,486,487,490,493,495,496,499,502,504,505,507,512],[99,145,481,500,501,502,512],[99,145,477,506,507],[99,145,481,483,490,493,495,512],[99,145,191,496],[99,145,148,157,175,445,450,452,477,481,482,483,486,487,490,493,494,495,496,499,500,501,502,503,504,505,506,507,512],[99,145,148,157,175,191,444,445,450,452,453,477,481,482,483,486,487,490,493,494,495,496,499,500,501,502,503,504,505,506,507,511,512,513,514,519],[99,145,417],[99,145,520,521],[87,99,145,389,395,521,523,524,542,543,545,546],[87,99,145,523,524,543],[87,99,145,524,530,543],[99,145,408,526,531],[87,99,145,395,521,523,524,525,530,542,543],[87,99,145,550],[87,99,145,395,524,543],[87,99,145,395,523,524,543,545,546],[99,145,389,521,523,524,530,542,543],[87,99,145,389,395,521,523,524,530,542,543,545,546],[87,99,145,389,395,521,524,530,543],[99,145,523],[87,99,145,376,521,541],[87,99,145,389,395,524],[87,99,145,530],[99,145,442],[99,145,166,509]],"fileInfos":[{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"feecb1be483ed332fad555aff858affd90a48ab19ba7272ee084704eb7167569","impliedFormat":1},{"version":"ee7bad0c15b58988daa84371e0b89d313b762ab83cb5b31b8a2d1162e8eb41c2","impliedFormat":1},{"version":"27bdc30a0e32783366a5abeda841bc22757c1797de8681bbe81fbc735eeb1c10","impliedFormat":1},{"version":"8fd575e12870e9944c7e1d62e1f5a73fcf23dd8d3a321f2a2c74c20d022283fe","impliedFormat":1},{"version":"2ab096661c711e4a81cc464fa1e6feb929a54f5340b46b0a07ac6bbf857471f0","impliedFormat":1},{"version":"080941d9f9ff9307f7e27a83bcd888b7c8270716c39af943532438932ec1d0b9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2e80ee7a49e8ac312cc11b77f1475804bee36b3b2bc896bead8b6e1266befb43","affectsGlobalScope":true,"impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"af3dd424cf267428f30ccfc376f47a2c0114546b55c44d8c0f1d57d841e28d74","affectsGlobalScope":true,"impliedFormat":1},{"version":"995c005ab91a498455ea8dfb63aa9f83fa2ea793c3d8aa344be4a1678d06d399","affectsGlobalScope":true,"impliedFormat":1},{"version":"959d36cddf5e7d572a65045b876f2956c973a586da58e5d26cde519184fd9b8a","affectsGlobalScope":true,"impliedFormat":1},{"version":"965f36eae237dd74e6cca203a43e9ca801ce38824ead814728a2807b1910117d","affectsGlobalScope":true,"impliedFormat":1},{"version":"3925a6c820dcb1a06506c90b1577db1fdbf7705d65b62b99dce4be75c637e26b","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a3d63ef2b853447ec4f749d3f368ce642264246e02911fcb1590d8c161b8005","affectsGlobalScope":true,"impliedFormat":1},{"version":"8cdf8847677ac7d20486e54dd3fcf09eda95812ac8ace44b4418da1bbbab6eb8","affectsGlobalScope":true,"impliedFormat":1},{"version":"8444af78980e3b20b49324f4a16ba35024fef3ee069a0eb67616ea6ca821c47a","affectsGlobalScope":true,"impliedFormat":1},{"version":"3287d9d085fbd618c3971944b65b4be57859f5415f495b33a6adc994edd2f004","affectsGlobalScope":true,"impliedFormat":1},{"version":"b4b67b1a91182421f5df999988c690f14d813b9850b40acd06ed44691f6727ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"df83c2a6c73228b625b0beb6669c7ee2a09c914637e2d35170723ad49c0f5cd4","affectsGlobalScope":true,"impliedFormat":1},{"version":"436aaf437562f276ec2ddbee2f2cdedac7664c1e4c1d2c36839ddd582eeb3d0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e3c06ea092138bf9fa5e874a1fdbc9d54805d074bee1de31b99a11e2fec239d","affectsGlobalScope":true,"impliedFormat":1},{"version":"87dc0f382502f5bbce5129bdc0aea21e19a3abbc19259e0b43ae038a9fc4e326","affectsGlobalScope":true,"impliedFormat":1},{"version":"b1cb28af0c891c8c96b2d6b7be76bd394fddcfdb4709a20ba05a7c1605eea0f9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2fef54945a13095fdb9b84f705f2b5994597640c46afeb2ce78352fab4cb3279","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac77cb3e8c6d3565793eb90a8373ee8033146315a3dbead3bde8db5eaf5e5ec6","affectsGlobalScope":true,"impliedFormat":1},{"version":"56e4ed5aab5f5920980066a9409bfaf53e6d21d3f8d020c17e4de584d29600ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ece9f17b3866cc077099c73f4983bddbcb1dc7ddb943227f1ec070f529dedd1","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a6282c8827e4b9a95f4bf4f5c205673ada31b982f50572d27103df8ceb8013c","affectsGlobalScope":true,"impliedFormat":1},{"version":"1c9319a09485199c1f7b0498f2988d6d2249793ef67edda49d1e584746be9032","affectsGlobalScope":true,"impliedFormat":1},{"version":"e3a2a0cee0f03ffdde24d89660eba2685bfbdeae955a6c67e8c4c9fd28928eeb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811c71eee4aa0ac5f7adf713323a5c41b0cf6c4e17367a34fbce379e12bbf0a4","affectsGlobalScope":true,"impliedFormat":1},{"version":"51ad4c928303041605b4d7ae32e0c1ee387d43a24cd6f1ebf4a2699e1076d4fa","affectsGlobalScope":true,"impliedFormat":1},{"version":"60037901da1a425516449b9a20073aa03386cce92f7a1fd902d7602be3a7c2e9","affectsGlobalScope":true,"impliedFormat":1},{"version":"d4b1d2c51d058fc21ec2629fff7a76249dec2e36e12960ea056e3ef89174080f","affectsGlobalScope":true,"impliedFormat":1},{"version":"22adec94ef7047a6c9d1af3cb96be87a335908bf9ef386ae9fd50eeb37f44c47","affectsGlobalScope":true,"impliedFormat":1},{"version":"196cb558a13d4533a5163286f30b0509ce0210e4b316c56c38d4c0fd2fb38405","affectsGlobalScope":true,"impliedFormat":1},{"version":"73f78680d4c08509933daf80947902f6ff41b6230f94dd002ae372620adb0f60","affectsGlobalScope":true,"impliedFormat":1},{"version":"c5239f5c01bcfa9cd32f37c496cf19c61d69d37e48be9de612b541aac915805b","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"0990a7576222f248f0a3b888adcb7389f957928ce2afb1cd5128169086ff4d29","impliedFormat":1},{"version":"eb5b19b86227ace1d29ea4cf81387279d04bb34051e944bc53df69f58914b788","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac51dd7d31333793807a6abaa5ae168512b6131bd41d9c5b98477fc3b7800f9f","impliedFormat":1},{"version":"87d9d29dbc745f182683f63187bf3d53fd8673e5fca38ad5eaab69798ed29fbc","impliedFormat":1},{"version":"09ddcfcfbe77a8232d155ca1030005106b1328f6210df43629d0be750da07c16","affectsGlobalScope":true,"impliedFormat":1},{"version":"cc69795d9954ee4ad57545b10c7bf1a7260d990231b1685c147ea71a6faa265c","impliedFormat":1},{"version":"8bc6c94ff4f2af1f4023b7bb2379b08d3d7dd80c698c9f0b07431ea16101f05f","impliedFormat":1},{"version":"1b61d259de5350f8b1e5db06290d31eaebebc6baafd5f79d314b5af9256d7153","impliedFormat":1},{"version":"57194e1f007f3f2cbef26fa299d4c6b21f4623a2eddc63dfeef79e38e187a36e","impliedFormat":1},{"version":"0f6666b58e9276ac3a38fdc80993d19208442d6027ab885580d93aec76b4ef00","impliedFormat":1},{"version":"05fd364b8ef02fb1e174fbac8b825bdb1e5a36a016997c8e421f5fab0a6da0a0","impliedFormat":1},{"version":"70521b6ab0dcba37539e5303104f29b721bfb2940b2776da4cc818c07e1fefc1","affectsGlobalScope":true,"impliedFormat":1},{"version":"ab41ef1f2cdafb8df48be20cd969d875602483859dc194e9c97c8a576892c052","affectsGlobalScope":true,"impliedFormat":1},{"version":"d153a11543fd884b596587ccd97aebbeed950b26933ee000f94009f1ab142848","affectsGlobalScope":true,"impliedFormat":1},{"version":"21d819c173c0cf7cc3ce57c3276e77fd9a8a01d35a06ad87158781515c9a438a","impliedFormat":1},{"version":"98cffbf06d6bab333473c70a893770dbe990783904002c4f1a960447b4b53dca","affectsGlobalScope":true,"impliedFormat":1},{"version":"ba481bca06f37d3f2c137ce343c7d5937029b2468f8e26111f3c9d9963d6568d","affectsGlobalScope":true,"impliedFormat":1},{"version":"6d9ef24f9a22a88e3e9b3b3d8c40ab1ddb0853f1bfbd5c843c37800138437b61","affectsGlobalScope":true,"impliedFormat":1},{"version":"1db0b7dca579049ca4193d034d835f6bfe73096c73663e5ef9a0b5779939f3d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"9798340ffb0d067d69b1ae5b32faa17ab31b82466a3fc00d8f2f2df0c8554aaa","affectsGlobalScope":true,"impliedFormat":1},{"version":"f26b11d8d8e4b8028f1c7d618b22274c892e4b0ef5b3678a8ccbad85419aef43","affectsGlobalScope":true,"impliedFormat":1},{"version":"5929864ce17fba74232584d90cb721a89b7ad277220627cc97054ba15a98ea8f","impliedFormat":1},{"version":"763fe0f42b3d79b440a9b6e51e9ba3f3f91352469c1e4b3b67bfa4ff6352f3f4","impliedFormat":1},{"version":"25c8056edf4314820382a5fdb4bb7816999acdcb929c8f75e3f39473b87e85bc","impliedFormat":1},{"version":"c464d66b20788266e5353b48dc4aa6bc0dc4a707276df1e7152ab0c9ae21fad8","impliedFormat":1},{"version":"78d0d27c130d35c60b5e5566c9f1e5be77caf39804636bc1a40133919a949f21","impliedFormat":1},{"version":"c6fd2c5a395f2432786c9cb8deb870b9b0e8ff7e22c029954fabdd692bff6195","impliedFormat":1},{"version":"1d6e127068ea8e104a912e42fc0a110e2aa5a66a356a917a163e8cf9a65e4a75","impliedFormat":1},{"version":"5ded6427296cdf3b9542de4471d2aa8d3983671d4cac0f4bf9c637208d1ced43","impliedFormat":1},{"version":"7f182617db458e98fc18dfb272d40aa2fff3a353c44a89b2c0ccb3937709bfb5","impliedFormat":1},{"version":"cadc8aced301244057c4e7e73fbcae534b0f5b12a37b150d80e5a45aa4bebcbd","impliedFormat":1},{"version":"385aab901643aa54e1c36f5ef3107913b10d1b5bb8cbcd933d4263b80a0d7f20","impliedFormat":1},{"version":"9670d44354bab9d9982eca21945686b5c24a3f893db73c0dae0fd74217a4c219","impliedFormat":1},{"version":"0b8a9268adaf4da35e7fa830c8981cfa22adbbe5b3f6f5ab91f6658899e657a7","impliedFormat":1},{"version":"11396ed8a44c02ab9798b7dca436009f866e8dae3c9c25e8c1fbc396880bf1bb","impliedFormat":1},{"version":"ba7bc87d01492633cb5a0e5da8a4a42a1c86270e7b3d2dea5d156828a84e4882","impliedFormat":1},{"version":"4893a895ea92c85345017a04ed427cbd6a1710453338df26881a6019432febdd","impliedFormat":1},{"version":"c21dc52e277bcfc75fac0436ccb75c204f9e1b3fa5e12729670910639f27343e","impliedFormat":1},{"version":"13f6f39e12b1518c6650bbb220c8985999020fe0f21d818e28f512b7771d00f9","impliedFormat":1},{"version":"9b5369969f6e7175740bf51223112ff209f94ba43ecd3bb09eefff9fd675624a","impliedFormat":1},{"version":"4fe9e626e7164748e8769bbf74b538e09607f07ed17c2f20af8d680ee49fc1da","impliedFormat":1},{"version":"24515859bc0b836719105bb6cc3d68255042a9f02a6022b3187948b204946bd2","impliedFormat":1},{"version":"ea0148f897b45a76544ae179784c95af1bd6721b8610af9ffa467a518a086a43","impliedFormat":1},{"version":"24c6a117721e606c9984335f71711877293a9651e44f59f3d21c1ea0856f9cc9","impliedFormat":1},{"version":"dd3273ead9fbde62a72949c97dbec2247ea08e0c6952e701a483d74ef92d6a17","impliedFormat":1},{"version":"405822be75ad3e4d162e07439bac80c6bcc6dbae1929e179cf467ec0b9ee4e2e","impliedFormat":1},{"version":"0db18c6e78ea846316c012478888f33c11ffadab9efd1cc8bcc12daded7a60b6","impliedFormat":1},{"version":"e61be3f894b41b7baa1fbd6a66893f2579bfad01d208b4ff61daef21493ef0a8","impliedFormat":1},{"version":"bd0532fd6556073727d28da0edfd1736417a3f9f394877b6d5ef6ad88fba1d1a","impliedFormat":1},{"version":"89167d696a849fce5ca508032aabfe901c0868f833a8625d5a9c6e861ef935d2","impliedFormat":1},{"version":"615ba88d0128ed16bf83ef8ccbb6aff05c3ee2db1cc0f89ab50a4939bfc1943f","impliedFormat":1},{"version":"a4d551dbf8746780194d550c88f26cf937caf8d56f102969a110cfaed4b06656","impliedFormat":1},{"version":"8bd86b8e8f6a6aa6c49b71e14c4ffe1211a0e97c80f08d2c8cc98838006e4b88","impliedFormat":1},{"version":"317e63deeb21ac07f3992f5b50cdca8338f10acd4fbb7257ebf56735bf52ab00","impliedFormat":1},{"version":"4732aec92b20fb28c5fe9ad99521fb59974289ed1e45aecb282616202184064f","impliedFormat":1},{"version":"2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","impliedFormat":1},{"version":"c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","impliedFormat":1},{"version":"bf67d53d168abc1298888693338cb82854bdb2e69ef83f8a0092093c2d562107","impliedFormat":1},{"version":"b52476feb4a0cbcb25e5931b930fc73cb6643fb1a5060bf8a3dda0eeae5b4b68","affectsGlobalScope":true,"impliedFormat":1},{"version":"e2677634fe27e87348825bb041651e22d50a613e2fdf6a4a3ade971d71bac37e","impliedFormat":1},{"version":"7394959e5a741b185456e1ef5d64599c36c60a323207450991e7a42e08911419","impliedFormat":1},{"version":"8c0bcd6c6b67b4b503c11e91a1fb91522ed585900eab2ab1f61bba7d7caa9d6f","impliedFormat":1},{"version":"8cd19276b6590b3ebbeeb030ac271871b9ed0afc3074ac88a94ed2449174b776","affectsGlobalScope":true,"impliedFormat":1},{"version":"696eb8d28f5949b87d894b26dc97318ef944c794a9a4e4f62360cd1d1958014b","impliedFormat":1},{"version":"3f8fa3061bd7402970b399300880d55257953ee6d3cd408722cb9ac20126460c","impliedFormat":1},{"version":"35ec8b6760fd7138bbf5809b84551e31028fb2ba7b6dc91d95d098bf212ca8b4","affectsGlobalScope":true,"impliedFormat":1},{"version":"5524481e56c48ff486f42926778c0a3cce1cc85dc46683b92b1271865bcf015a","impliedFormat":1},{"version":"68bd56c92c2bd7d2339457eb84d63e7de3bd56a69b25f3576e1568d21a162398","affectsGlobalScope":true,"impliedFormat":1},{"version":"3e93b123f7c2944969d291b35fed2af79a6e9e27fdd5faa99748a51c07c02d28","impliedFormat":1},{"version":"9d19808c8c291a9010a6c788e8532a2da70f811adb431c97520803e0ec649991","impliedFormat":1},{"version":"87aad3dd9752067dc875cfaa466fc44246451c0c560b820796bdd528e29bef40","impliedFormat":1},{"version":"4aacb0dd020eeaef65426153686cc639a78ec2885dc72ad220be1d25f1a439df","impliedFormat":1},{"version":"f0bd7e6d931657b59605c44112eaf8b980ba7f957a5051ed21cb93d978cf2f45","impliedFormat":1},{"version":"8db0ae9cb14d9955b14c214f34dae1b9ef2baee2fe4ce794a4cd3ac2531e3255","affectsGlobalScope":true,"impliedFormat":1},{"version":"15fc6f7512c86810273af28f224251a5a879e4261b4d4c7e532abfbfc3983134","impliedFormat":1},{"version":"58adba1a8ab2d10b54dc1dced4e41f4e7c9772cbbac40939c0dc8ce2cdb1d442","impliedFormat":1},{"version":"641942a78f9063caa5d6b777c99304b7d1dc7328076038c6d94d8a0b81fc95c1","impliedFormat":1},{"version":"1123a83f35cf56c97de746f0a7250012153c61a167e4a61668bf50e558162d14","impliedFormat":1},{"version":"855cd5f7eb396f5f1ab1bc0f8580339bff77b68a770f84c6b254e319bbfd1ac7","impliedFormat":1},{"version":"5650cf3dace09e7c25d384e3e6b818b938f68f4e8de96f52d9c5a1b3db068e86","impliedFormat":1},{"version":"1354ca5c38bd3fd3836a68e0f7c9f91f172582ba30ab15bb8c075891b91502b7","affectsGlobalScope":true,"impliedFormat":1},{"version":"7e20d899c28ca26a2a7afc98beaa69e63ff7fba0a8bc47b4e3bf3ede5e09e424","impliedFormat":1},{"version":"2d2fcaab481b31a5882065c7951255703ddbe1c0e507af56ea42d79ac3911201","impliedFormat":1},{"version":"a192fe8ec33f75edbc8d8f3ed79f768dfae11ff5735e7fe52bfa69956e46d78d","impliedFormat":1},{"version":"ca867399f7db82df981d6915bcbb2d81131d7d1ef683bc782b59f71dda59bc85","affectsGlobalScope":true,"impliedFormat":1},{"version":"372413016d17d804e1d139418aca0c68e47a83fb6669490857f4b318de8cccb3","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e043a1bc8fbf2a255bccf9bf27e0f1caf916c3b0518ea34aa72357c0afd42ec","impliedFormat":1},{"version":"b4f70ec656a11d570e1a9edce07d118cd58d9760239e2ece99306ee9dfe61d02","impliedFormat":1},{"version":"3bc2f1e2c95c04048212c569ed38e338873f6a8593930cf5a7ef24ffb38fc3b6","impliedFormat":1},{"version":"6e70e9570e98aae2b825b533aa6292b6abd542e8d9f6e9475e88e1d7ba17c866","impliedFormat":1},{"version":"f9d9d753d430ed050dc1bf2667a1bab711ccbb1c1507183d794cc195a5b085cc","impliedFormat":1},{"version":"9eece5e586312581ccd106d4853e861aaaa1a39f8e3ea672b8c3847eedd12f6e","impliedFormat":1},{"version":"085f552d005479e2e6a7311cdbbe5d8c55c497b4d19274285df161ee9684cd9c","impliedFormat":1},{"version":"37ba7b45141a45ce6e80e66f2a96c8a5ab1bcef0fc2d0f56bb58df96ec67e972","impliedFormat":1},{"version":"45650f47bfb376c8a8ed39d4bcda5902ab899a3150029684ee4c10676d9fbaee","impliedFormat":1},{"version":"007faacc9268357caa21d24169f3f3f2497af3e9241308df2d89f6e6d9bb3f2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"74cf591a0f63db318651e0e04cb55f8791385f86e987a67fd4d2eaab8191f730","impliedFormat":1},{"version":"5eab9b3dc9b34f185417342436ec3f106898da5f4801992d8ff38ab3aff346b5","impliedFormat":1},{"version":"12ed4559eba17cd977aa0db658d25c4047067444b51acfdcbf38470630642b23","affectsGlobalScope":true,"impliedFormat":1},{"version":"f3ffabc95802521e1e4bcba4c88d8615176dc6e09111d920c7a213bdda6e1d65","impliedFormat":1},{"version":"809821b8a065e3234a55b3a9d7846231ed18d66dd749f2494c66288d890daf7f","impliedFormat":1},{"version":"ae56f65caf3be91108707bd8dfbccc2a57a91feb5daabf7165a06a945545ed26","impliedFormat":1},{"version":"a136d5de521da20f31631a0a96bf712370779d1c05b7015d7019a9b2a0446ca9","impliedFormat":1},{"version":"c3b41e74b9a84b88b1dca61ec39eee25c0dbc8e7d519ba11bb070918cfacf656","affectsGlobalScope":true,"impliedFormat":1},{"version":"4737a9dc24d0e68b734e6cfbcea0c15a2cfafeb493485e27905f7856988c6b29","affectsGlobalScope":true,"impliedFormat":1},{"version":"36d8d3e7506b631c9582c251a2c0b8a28855af3f76719b12b534c6edf952748d","impliedFormat":1},{"version":"1ca69210cc42729e7ca97d3a9ad48f2e9cb0042bada4075b588ae5387debd318","impliedFormat":1},{"version":"f5ebe66baaf7c552cfa59d75f2bfba679f329204847db3cec385acda245e574e","impliedFormat":1},{"version":"ed59add13139f84da271cafd32e2171876b0a0af2f798d0c663e8eeb867732cf","affectsGlobalScope":true,"impliedFormat":1},{"version":"b7c5e2ea4a9749097c347454805e933844ed207b6eefec6b7cfd418b5f5f7b28","impliedFormat":1},{"version":"b1810689b76fd473bd12cc9ee219f8e62f54a7d08019a235d07424afbf074d25","impliedFormat":1},{"version":"8caa5c86be1b793cd5f599e27ecb34252c41e011980f7d61ae4989a149ff6ccc","impliedFormat":1},{"version":"f9fd93190acb1ffe0bc0fb395df979452f8d625071e9ffc8636e4dfb86ab2508","impliedFormat":1},{"version":"5f41fd8732a89e940c58ce22206e3df85745feb8983e2b4c6257fb8cbb118493","impliedFormat":1},{"version":"17ed71200119e86ccef2d96b73b02ce8854b76ad6bd21b5021d4269bec527b5f","impliedFormat":1},{"version":"1cfa8647d7d71cb03847d616bd79320abfc01ddea082a49569fda71ac5ece66b","impliedFormat":1},{"version":"bb7a61dd55dc4b9422d13da3a6bb9cc5e89be888ef23bbcf6558aa9726b89a1c","impliedFormat":1},{"version":"db6d2d9daad8a6d83f281af12ce4355a20b9a3e71b82b9f57cddcca0a8964a96","impliedFormat":1},{"version":"cfe4ef4710c3786b6e23dae7c086c70b4f4835a2e4d77b75d39f9046106e83d3","impliedFormat":1},{"version":"cbea99888785d49bb630dcbb1613c73727f2b5a2cf02e1abcaab7bcf8d6bf3c5","impliedFormat":1},{"version":"3a8bddb66b659f6bd2ff641fc71df8a8165bafe0f4b799cc298be5cd3755bb20","impliedFormat":1},{"version":"a86f82d646a739041d6702101afa82dcb935c416dd93cbca7fd754fd0282ce1f","impliedFormat":1},{"version":"2dad084c67e649f0f354739ec7df7c7df0779a28a4f55c97c6b6883ae850d1ce","impliedFormat":1},{"version":"fa5bbc7ab4130dd8cdc55ea294ec39f76f2bc507a0f75f4f873e38631a836ca7","impliedFormat":1},{"version":"df45ca1176e6ac211eae7ddf51336dc075c5314bc5c253651bae639defd5eec5","impliedFormat":1},{"version":"cf86de1054b843e484a3c9300d62fbc8c97e77f168bbffb131d560ca0474d4a8","impliedFormat":1},{"version":"196c960b12253fde69b204aa4fbf69470b26daf7a430855d7f94107a16495ab0","impliedFormat":1},{"version":"ee15ea5dd7a9fc9f5013832e5843031817a880bf0f24f37a29fd8337981aae07","impliedFormat":1},{"version":"bf24f6d35f7318e246010ffe9924395893c4e96d34324cde77151a73f078b9ad","impliedFormat":1},{"version":"ea53732769832d0f127ae16620bd5345991d26bf0b74e85e41b61b27d74ea90f","impliedFormat":1},{"version":"10595c7ff5094dd5b6a959ccb1c00e6a06441b4e10a87bc09c15f23755d34439","impliedFormat":1},{"version":"9620c1ff645afb4a9ab4044c85c26676f0a93e8c0e4b593aea03a89ccb47b6d0","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"a9af0e608929aaf9ce96bd7a7b99c9360636c31d73670e4af09a09950df97841","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"c86fe861cf1b4c46a0fb7d74dffe596cf679a2e5e8b1456881313170f092e3fa","impliedFormat":1},{"version":"08ed0b3f0166787f84a6606f80aa3b1388c7518d78912571b203817406e471da","impliedFormat":1},{"version":"47e5af2a841356a961f815e7c55d72554db0c11b4cba4d0caab91f8717846a94","impliedFormat":1},{"version":"65f43099ded6073336e697512d9b80f2d4fec3182b7b2316abf712e84104db00","impliedFormat":1},{"version":"f5f541902bf7ae0512a177295de9b6bcd6809ea38307a2c0a18bfca72212f368","impliedFormat":1},{"version":"b0decf4b6da3ebc52ea0c96095bdfaa8503acc4ac8e9081c5f2b0824835dd3bd","impliedFormat":1},{"version":"ca1b882a105a1972f82cc58e3be491e7d750a1eb074ffd13b198269f57ed9e1b","impliedFormat":1},{"version":"fc3e1c87b39e5ba1142f27ec089d1966da168c04a859a4f6aab64dceae162c2b","impliedFormat":1},{"version":"3b414b99a73171e1c4b7b7714e26b87d6c5cb03d200352da5342ab4088a54c85","impliedFormat":1},{"version":"61888522cec948102eba94d831c873200aa97d00d8989fdfd2a3e0ee75ec65a2","impliedFormat":1},{"version":"4e10622f89fea7b05dd9b52fb65e1e2b5cbd96d4cca3d9e1a60bb7f8a9cb86a1","impliedFormat":1},{"version":"74b2a5e5197bd0f2e0077a1ea7c07455bbea67b87b0869d9786d55104006784f","impliedFormat":1},{"version":"59bf32919de37809e101acffc120596a9e45fdbab1a99de5087f31fdc36e2f11","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"faa03dffb64286e8304a2ca96dd1317a77db6bfc7b3fb385163648f67e535d77","impliedFormat":1},{"version":"c40c848daad198266370c1c72a7a8c3d18d2f50727c7859fcfefd3ff69a7f288","impliedFormat":1},{"version":"ac60bbee0d4235643cc52b57768b22de8c257c12bd8c2039860540cab1fa1d82","impliedFormat":1},{"version":"6428e6edd944ce6789afdf43f9376c1f2e4957eea34166177625aaff4c0da1a0","impliedFormat":1},{"version":"ada39cbb2748ab2873b7835c90c8d4620723aedf323550e8489f08220e477c7f","impliedFormat":1},{"version":"6e5f5cee603d67ee1ba6120815497909b73399842254fc1e77a0d5cdc51d8c9c","impliedFormat":1},{"version":"8dba67056cbb27628e9b9a1cba8e57036d359dceded0725c72a3abe4b6c79cd4","impliedFormat":1},{"version":"70f3814c457f54a7efe2d9ce9d2686de9250bb42eb7f4c539bd2280a42e52d33","impliedFormat":1},{"version":"154dd2e22e1e94d5bc4ff7726706bc0483760bae40506bdce780734f11f7ec47","impliedFormat":1},{"version":"ef61792acbfa8c27c9bd113f02731e66229f7d3a169e3c1993b508134f1a58e0","impliedFormat":1},{"version":"9c82171d836c47486074e4ca8e059735bf97b205e70b196535b5efd40cbe1bc5","impliedFormat":1},{"version":"0131e203d8560edb39678abe10db42564a068f98c4ebd1ed9ffe7279c78b3c81","impliedFormat":1},{"version":"f6404e7837b96da3ea4d38c4f1a3812c96c9dcdf264e93d5bdb199f983a3ef4b","impliedFormat":1},{"version":"c5426dbfc1cf90532f66965a7aa8c1136a78d4d0f96d8180ecbfc11d7722f1a5","impliedFormat":1},{"version":"65a15fc47900787c0bd18b603afb98d33ede930bed1798fc984d5ebb78b26cf9","impliedFormat":1},{"version":"9d202701f6e0744adb6314d03d2eb8fc994798fc83d91b691b75b07626a69801","impliedFormat":1},{"version":"de9d2df7663e64e3a91bf495f315a7577e23ba088f2949d5ce9ec96f44fba37d","impliedFormat":1},{"version":"c7af78a2ea7cb1cd009cfb5bdb48cd0b03dad3b54f6da7aab615c2e9e9d570c5","impliedFormat":1},{"version":"1ee45496b5f8bdee6f7abc233355898e5bf9bd51255db65f5ff7ede617ca0027","impliedFormat":1},{"version":"8b8f00491431fe82f060dfe8c7f2180a9fb239f3d851527db909b83230e75882","affectsGlobalScope":true,"impliedFormat":1},{"version":"db01d18853469bcb5601b9fc9826931cc84cc1a1944b33cad76fd6f1e3d8c544","affectsGlobalScope":true,"impliedFormat":1},{"version":"dba114fb6a32b355a9cfc26ca2276834d72fe0e94cd2c3494005547025015369","impliedFormat":1},{"version":"903e299a28282fa7b714586e28409ed73c3b63f5365519776bf78e8cf173db36","affectsGlobalScope":true,"impliedFormat":1},{"version":"fa6c12a7c0f6b84d512f200690bfc74819e99efae69e4c95c4cd30f6884c526e","impliedFormat":1},{"version":"f1c32f9ce9c497da4dc215c3bc84b722ea02497d35f9134db3bb40a8d918b92b","impliedFormat":1},{"version":"b73c319af2cc3ef8f6421308a250f328836531ea3761823b4cabbd133047aefa","affectsGlobalScope":true,"impliedFormat":1},{"version":"e433b0337b8106909e7953015e8fa3f2d30797cea27141d1c5b135365bb975a6","impliedFormat":1},{"version":"dd3900b24a6a8745efeb7ad27629c0f8a626470ac229c1d73f1fe29d67e44dca","impliedFormat":1},{"version":"ddff7fc6edbdc5163a09e22bf8df7bef75f75369ebd7ecea95ba55c4386e2441","impliedFormat":1},{"version":"106c6025f1d99fd468fd8bf6e5bda724e11e5905a4076c5d29790b6c3745e50c","impliedFormat":1},{"version":"ec29be0737d39268696edcec4f5e97ce26f449fa9b7afc2f0f99a86def34a418","impliedFormat":1},{"version":"aeab39e8e0b1a3b250434c3b2bb8f4d17bbec2a9dbce5f77e8a83569d3d2cbc2","impliedFormat":1},{"version":"ec6cba1c02c675e4dd173251b156792e8d3b0c816af6d6ad93f1a55d674591aa","impliedFormat":1},{"version":"b620391fe8060cf9bedc176a4d01366e6574d7a71e0ac0ab344a4e76576fcbb8","impliedFormat":1},{"version":"d729408dfde75b451530bcae944cf89ee8277e2a9df04d1f62f2abfd8b03c1e1","impliedFormat":1},{"version":"e15d3c84d5077bb4a3adee4c791022967b764dc41cb8fa3cfa44d4379b2c95f5","impliedFormat":1},{"version":"5f58e28cd22e8fc1ac1b3bc6b431869f1e7d0b39e2c21fbf79b9fa5195a85980","impliedFormat":1},{"version":"e1fc1a1045db5aa09366be2b330e4ce391550041fc3e925f60998ca0b647aa97","impliedFormat":1},{"version":"63533978dcda286422670f6e184ac516805a365fb37a086eeff4309e812f1402","impliedFormat":1},{"version":"43ba4f2fa8c698f5c304d21a3ef596741e8e85a810b7c1f9b692653791d8d97a","impliedFormat":1},{"version":"31fb49ef3aa3d76f0beb644984e01eab0ea222372ea9b49bb6533be5722d756c","impliedFormat":1},{"version":"33cd131e1461157e3e06b06916b5176e7a8ec3fce15a5cfe145e56de744e07d2","impliedFormat":1},{"version":"889ef863f90f4917221703781d9723278db4122d75596b01c429f7c363562b86","impliedFormat":1},{"version":"3556cfbab7b43da96d15a442ddbb970e1f2fc97876d055b6555d86d7ac57dae5","impliedFormat":1},{"version":"437751e0352c6e924ddf30e90849f1d9eb00ca78c94d58d6a37202ec84eb8393","impliedFormat":1},{"version":"48e8af7fdb2677a44522fd185d8c87deff4d36ee701ea003c6c780b1407a1397","impliedFormat":1},{"version":"d11308de5a36c7015bb73adb5ad1c1bdaac2baede4cc831a05cf85efa3cc7f2f","impliedFormat":1},{"version":"38e4684c22ed9319beda6765bab332c724103d3a966c2e5e1c5a49cf7007845f","impliedFormat":1},{"version":"f9812cfc220ecf7557183379531fa409acd249b9e5b9a145d0d52b76c20862de","affectsGlobalScope":true,"impliedFormat":1},{"version":"e650298721abc4f6ae851e60ae93ee8199791ceec4b544c3379862f81f43178c","impliedFormat":1},{"version":"2e4f37ffe8862b14d8e24ae8763daaa8340c0df0b859d9a9733def0eee7562d9","impliedFormat":1},{"version":"13283350547389802aa35d9f2188effaeac805499169a06ef5cd77ce2a0bd63f","impliedFormat":1},{"version":"680793958f6a70a44c8d9ae7d46b7a385361c69ac29dcab3ed761edce1c14ab8","impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","impliedFormat":1},{"version":"b838d4c72740eb0afd284bf7575b74c624b105eff2e8c7b4aeead57e7ac320ff","impliedFormat":1},{"version":"913ddbba170240070bd5921b8f33ea780021bdf42fbdfcd4fcb2691b1884ddde","impliedFormat":1},{"version":"b4e6d416466999ff40d3fe5ceb95f7a8bfb7ac2262580287ac1a8391e5362431","impliedFormat":1},{"version":"5fe23bd829e6be57d41929ac374ee9551ccc3c44cee893167b7b5b77be708014","impliedFormat":1},{"version":"0a626484617019fcfbfc3c1bc1f9e84e2913f1adb73692aa9075817404fb41a1","impliedFormat":1},{"version":"438c7513b1df91dcef49b13cd7a1c4720f91a36e88c1df731661608b7c055f10","impliedFormat":1},{"version":"cf185cc4a9a6d397f416dd28cca95c227b29f0f27b160060a95c0e5e36cda865","impliedFormat":1},{"version":"0086f3e4ad898fd7ca56bb223098acfacf3fa065595182aaf0f6c4a6a95e6fbd","impliedFormat":1},{"version":"efaa078e392f9abda3ee8ade3f3762ab77f9c50b184e6883063a911742a4c96a","impliedFormat":1},{"version":"54a8bb487e1dc04591a280e7a673cdfb272c83f61e28d8a64cf1ac2e63c35c51","impliedFormat":1},{"version":"021a9498000497497fd693dd315325484c58a71b5929e2bbb91f419b04b24cea","impliedFormat":1},{"version":"9385cdc09850950bc9b59cca445a3ceb6fcca32b54e7b626e746912e489e535e","impliedFormat":1},{"version":"2894c56cad581928bb37607810af011764a2f511f575d28c9f4af0f2ef02d1ab","impliedFormat":1},{"version":"0a72186f94215d020cb386f7dca81d7495ab6c17066eb07d0f44a5bf33c1b21a","impliedFormat":1},{"version":"84124384abae2f6f66b7fbfc03862d0c2c0b71b826f7dbf42c8085d31f1d3f95","impliedFormat":1},{"version":"63a8e96f65a22604eae82737e409d1536e69a467bb738bec505f4f97cce9d878","impliedFormat":1},{"version":"3fd78152a7031315478f159c6a5872c712ece6f01212c78ea82aef21cb0726e2","impliedFormat":1},{"version":"b01bd582a6e41457bc56e6f0f9de4cb17f33f5f3843a7cf8210ac9c18472fb0f","impliedFormat":1},{"version":"58b49e5c1def740360b5ae22ae2405cfac295fee74abd88d74ac4ea42502dc03","impliedFormat":1},{"version":"512fc15cca3a35b8dbbf6e23fe9d07e6f87ad03c895acffd3087ce09f352aad0","impliedFormat":1},{"version":"9a0946d15a005832e432ea0cd4da71b57797efb25b755cc07f32274296d62355","impliedFormat":1},{"version":"a52ff6c0a149e9f370372fc3c715d7f2beee1f3bab7980e271a7ab7d313ec677","impliedFormat":1},{"version":"fd933f824347f9edd919618a76cdb6a0c0085c538115d9a287fa0c7f59957ab3","impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","impliedFormat":1},{"version":"6a1aa3e55bdc50503956c5cd09ae4cd72e3072692d742816f65c66ca14f4dfdd","impliedFormat":1},{"version":"ab75cfd9c4f93ffd601f7ca1753d6a9d953bbedfbd7a5b3f0436ac8a1de60dfa","impliedFormat":1},{"version":"f95180f03d827525ca4f990f49e17ec67198c316dd000afbe564655141f725cd","impliedFormat":1},{"version":"b73cbf0a72c8800cf8f96a9acfe94f3ad32ca71342a8908b8ae484d61113f647","impliedFormat":1},{"version":"bae6dd176832f6423966647382c0d7ba9e63f8c167522f09a982f086cd4e8b23","impliedFormat":1},{"version":"1364f64d2fb03bbb514edc42224abd576c064f89be6a990136774ecdd881a1da","impliedFormat":1},{"version":"c9958eb32126a3843deedda8c22fb97024aa5d6dd588b90af2d7f2bfac540f23","impliedFormat":1},{"version":"950fb67a59be4c2dbe69a5786292e60a5cb0e8612e0e223537784c731af55db1","impliedFormat":1},{"version":"e927c2c13c4eaf0a7f17e6022eee8519eb29ef42c4c13a31e81a611ab8c95577","impliedFormat":1},{"version":"07ca44e8d8288e69afdec7a31fa408ce6ab90d4f3d620006701d5544646da6aa","impliedFormat":1},{"version":"70246ad95ad8a22bdfe806cb5d383a26c0c6e58e7207ab9c431f1cb175aca657","impliedFormat":1},{"version":"f00f3aa5d64ff46e600648b55a79dcd1333458f7a10da2ed594d9f0a44b76d0b","impliedFormat":1},{"version":"772d8d5eb158b6c92412c03228bd9902ccb1457d7a705b8129814a5d1a6308fc","impliedFormat":1},{"version":"4e4475fba4ed93a72f167b061cd94a2e171b82695c56de9899275e880e06ba41","impliedFormat":1},{"version":"97c5f5d580ab2e4decd0a3135204050f9b97cd7908c5a8fbc041eadede79b2fa","impliedFormat":1},{"version":"c99a3a5f2215d5b9d735aa04cec6e61ed079d8c0263248e298ffe4604d4d0624","impliedFormat":1},{"version":"49b2375c586882c3ac7f57eba86680ff9742a8d8cb2fe25fe54d1b9673690d41","impliedFormat":1},{"version":"802e797bcab5663b2c9f63f51bdf67eff7c41bc64c0fd65e6da3e7941359e2f7","impliedFormat":1},{"version":"847e160d709c74cc714fbe1f99c41d3425b74cd47b1be133df1623cd87014089","impliedFormat":1},{"version":"9fee04f1e1afa50524862289b9f0b0fdc3735b80e2a0d684cec3b9ff3d94cecc","impliedFormat":1},{"version":"5cdc27fbc5c166fc5c763a30ac21cbac9859dc5ba795d3230db6d4e52a1965bb","impliedFormat":1},{"version":"6459054aabb306821a043e02b89d54da508e3a6966601a41e71c166e4ea1474f","impliedFormat":1},{"version":"f416c9c3eee9d47ff49132c34f96b9180e50485d435d5748f0e8b72521d28d2e","impliedFormat":1},{"version":"05c97cddbaf99978f83d96de2d8af86aded9332592f08ce4a284d72d0952c391","impliedFormat":1},{"version":"14e5cdec6f8ae82dfd0694e64903a0a54abdfe37e1d966de3d4128362acbf35f","impliedFormat":1},{"version":"bbc183d2d69f4b59fd4dd8799ffdf4eb91173d1c4ad71cce91a3811c021bf80c","impliedFormat":1},{"version":"7b6ff760c8a240b40dab6e4419b989f06a5b782f4710d2967e67c695ef3e93c4","impliedFormat":1},{"version":"8dbc4134a4b3623fc476be5f36de35c40f2768e2e3d9ed437e0d5f1c4cd850f6","impliedFormat":1},{"version":"4e06330a84dec7287f7ebdd64978f41a9f70a668d3b5edc69d5d4a50b9b376bb","impliedFormat":1},{"version":"65bfa72967fbe9fc33353e1ac03f0480aa2e2ea346d61ff3ea997dfd850f641a","impliedFormat":1},{"version":"c06f0bb92d1a1a5a6c6e4b5389a5664d96d09c31673296cb7da5fe945d54d786","impliedFormat":1},{"version":"f974e4a06953682a2c15d5bd5114c0284d5abf8bc0fe4da25cb9159427b70072","impliedFormat":1},{"version":"872caaa31423f4345983d643e4649fb30f548e9883a334d6d1c5fff68ede22d4","impliedFormat":1},{"version":"94404c4a878fe291e7578a2a80264c6f18e9f1933fbb57e48f0eb368672e389c","impliedFormat":1},{"version":"5c1b7f03aa88be854bc15810bfd5bd5a1943c5a7620e1c53eddd2a013996343e","impliedFormat":1},{"version":"09dfc64fcd6a2785867f2368419859a6cc5a8d4e73cbe2538f205b1642eb0f51","impliedFormat":1},{"version":"bcf6f0a323653e72199105a9316d91463ad4744c546d1271310818b8cef7c608","impliedFormat":1},{"version":"01aa917531e116485beca44a14970834687b857757159769c16b228eb1e49c5f","impliedFormat":1},{"version":"351475f9c874c62f9b45b1f0dc7e2704e80dfd5f1af83a3a9f841f9dfe5b2912","impliedFormat":1},{"version":"ac457ad39e531b7649e7b40ee5847606eac64e236efd76c5d12db95bf4eacd17","impliedFormat":1},{"version":"187a6fdbdecb972510b7555f3caacb44b58415da8d5825d03a583c4b73fde4cf","impliedFormat":1},{"version":"d4c3250105a612202289b3a266bb7e323db144f6b9414f9dea85c531c098b811","impliedFormat":1},{"version":"95b444b8c311f2084f0fb51c616163f950fb2e35f4eaa07878f313a2d36c98a4","impliedFormat":1},{"version":"741067675daa6d4334a2dc80a4452ca3850e89d5852e330db7cb2b5f867173b1","impliedFormat":1},{"version":"f8acecec1114f11690956e007d920044799aefeb3cece9e7f4b1f8a1d542b2c9","impliedFormat":1},{"version":"178071ccd043967a58c5d1a032db0ddf9bd139e7920766b537d9783e88eb615e","impliedFormat":1},{"version":"3a17f09634c50cce884721f54fd9e7b98e03ac505889c560876291fcf8a09e90","impliedFormat":1},{"version":"32531dfbb0cdc4525296648f53b2b5c39b64282791e2a8c765712e49e6461046","impliedFormat":1},{"version":"0ce1b2237c1c3df49748d61568160d780d7b26693bd9feb3acb0744a152cd86d","impliedFormat":1},{"version":"e489985388e2c71d3542612685b4a7db326922b57ac880f299da7026a4e8a117","impliedFormat":1},{"version":"5cad4158616d7793296dd41e22e1257440910ea8d01c7b75045d4dfb20c5a41a","impliedFormat":1},{"version":"04d3aad777b6af5bd000bfc409907a159fe77e190b9d368da4ba649cdc28d39e","affectsGlobalScope":true,"impliedFormat":1},{"version":"74efc1d6523bd57eb159c18d805db4ead810626bc5bc7002a2c7f483044b2e0f","impliedFormat":1},{"version":"19252079538942a69be1645e153f7dbbc1ef56b4f983c633bf31fe26aeac32cd","impliedFormat":1},{"version":"bc11f3ac00ac060462597add171220aed628c393f2782ac75dd29ff1e0db871c","impliedFormat":1},{"version":"616775f16134fa9d01fc677ad3f76e68c051a056c22ab552c64cc281a9686790","impliedFormat":1},{"version":"65c24a8baa2cca1de069a0ba9fba82a173690f52d7e2d0f1f7542d59d5eb4db0","impliedFormat":1},{"version":"f9fe6af238339a0e5f7563acee3178f51db37f32a2e7c09f85273098cee7ec49","impliedFormat":1},{"version":"3b0b1d352b8d2e47f1c4df4fb0678702aee071155b12ef0185fce9eb4fa4af1e","impliedFormat":1},{"version":"77e71242e71ebf8528c5802993697878f0533db8f2299b4d36aa015bae08a79c","impliedFormat":1},{"version":"a344403e7a7384e0e7093942533d309194ad0a53eca2a3100c0b0ab4d3932773","impliedFormat":1},{"version":"b7fff2d004c5879cae335db8f954eb1d61242d9f2d28515e67902032723caeab","impliedFormat":1},{"version":"5f3dc10ae646f375776b4e028d2bed039a93eebbba105694d8b910feebbe8b9c","impliedFormat":1},{"version":"bb18bf4a61a17b4a6199eb3938ecfa4a59eb7c40843ad4a82b975ab6f7e3d925","impliedFormat":1},{"version":"4545c1a1ceca170d5d83452dd7c4994644c35cf676a671412601689d9a62da35","impliedFormat":1},{"version":"e9b6fc05f536dfddcdc65dbcf04e09391b1c968ab967382e48924f5cb90d88e1","impliedFormat":1},{"version":"a2d648d333cf67b9aeac5d81a1a379d563a8ffa91ddd61c6179f68de724260ff","impliedFormat":1},{"version":"2b664c3cc544d0e35276e1fb2d4989f7d4b4027ffc64da34ec83a6ccf2e5c528","impliedFormat":1},{"version":"a3f41ed1b4f2fc3049394b945a68ae4fdefd49fa1739c32f149d32c0545d67f5","impliedFormat":1},{"version":"3cd8f0464e0939b47bfccbb9bb474a6d87d57210e304029cd8eb59c63a81935d","impliedFormat":1},{"version":"47699512e6d8bebf7be488182427189f999affe3addc1c87c882d36b7f2d0b0e","impliedFormat":1},{"version":"3026abd48e5e312f2328629ede6e0f770d21c3cd32cee705c450e589d015ee09","impliedFormat":1},{"version":"8b140b398a6afbd17cc97c38aea5274b2f7f39b1ae5b62952cfe65bf493e3e75","impliedFormat":1},{"version":"7663d2c19ce5ef8288c790edba3d45af54e58c84f1b37b1249f6d49d962f3d91","impliedFormat":1},{"version":"5cce3b975cdb72b57ae7de745b3c5de5790781ee88bcb41ba142f07c0fa02e97","impliedFormat":1},{"version":"00bd6ebe607246b45296aa2b805bd6a58c859acecda154bfa91f5334d7c175c6","impliedFormat":1},{"version":"ad036a85efcd9e5b4f7dd5c1a7362c8478f9a3b6c3554654ca24a29aa850a9c5","impliedFormat":1},{"version":"fedebeae32c5cdd1a85b4e0504a01996e4a8adf3dfa72876920d3dd6e42978e7","impliedFormat":1},{"version":"0d28b974a7605c4eda20c943b3fa9ae16cb452c1666fc9b8c341b879992c7612","impliedFormat":1},{"version":"cdf21eee8007e339b1b9945abf4a7b44930b1d695cc528459e68a3adc39a622e","impliedFormat":1},{"version":"db036c56f79186da50af66511d37d9fe77fa6793381927292d17f81f787bb195","impliedFormat":1},{"version":"87ac2fb61e629e777f4d161dff534c2023ee15afd9cb3b1589b9b1f014e75c58","impliedFormat":1},{"version":"13c8b4348db91e2f7d694adc17e7438e6776bc506d5c8f5de9ad9989707fa3fe","impliedFormat":1},{"version":"3c1051617aa50b38e9efaabce25e10a5dd9b1f42e372ef0e8a674076a68742ed","impliedFormat":1},{"version":"07a3e20cdcb0f1182f452c0410606711fbea922ca76929a41aacb01104bc0d27","impliedFormat":1},{"version":"1de80059b8078ea5749941c9f863aa970b4735bdbb003be4925c853a8b6b4450","impliedFormat":1},{"version":"1d079c37fa53e3c21ed3fa214a27507bda9991f2a41458705b19ed8c2b61173d","impliedFormat":1},{"version":"4cd4b6b1279e9d744a3825cbd7757bbefe7f0708f3f1069179ad535f19e8ed2c","impliedFormat":1},{"version":"5835a6e0d7cd2738e56b671af0e561e7c1b4fb77751383672f4b009f4e161d70","impliedFormat":1},{"version":"c0eeaaa67c85c3bb6c52b629ebbfd3b2292dc67e8c0ffda2fc6cd2f78dc471e6","impliedFormat":1},{"version":"4b7f74b772140395e7af67c4841be1ab867c11b3b82a51b1aeb692822b76c872","impliedFormat":1},{"version":"27be6622e2922a1b412eb057faa854831b95db9db5035c3f6d4b677b902ab3b7","impliedFormat":1},{"version":"b95a6f019095dd1d48fd04965b50dfd63e5743a6e75478343c46d2582a5132bf","impliedFormat":99},{"version":"c2008605e78208cfa9cd70bd29856b72dda7ad89df5dc895920f8e10bcb9cd0a","impliedFormat":99},{"version":"b97cb5616d2ab82a98ec9ada7b9e9cabb1f5da880ec50ea2b8dc5baa4cbf3c16","impliedFormat":99},{"version":"d23df9ff06ae8bf1dcb7cc933e97ae7da418ac77749fecee758bb43a8d69f840","affectsGlobalScope":true,"impliedFormat":1},{"version":"040c71dde2c406f869ad2f41e8d4ce579cc60c8dbe5aa0dd8962ac943b846572","affectsGlobalScope":true,"impliedFormat":1},{"version":"3586f5ea3cc27083a17bd5c9059ede9421d587286d5a47f4341a4c2d00e4fa91","impliedFormat":1},{"version":"a6df929821e62f4719551f7955b9f42c0cd53c1370aec2dd322e24196a7dfe33","impliedFormat":1},{"version":"b789bf89eb19c777ed1e956dbad0925ca795701552d22e68fd130a032008b9f9","impliedFormat":1},"9dd9d642cdb87d4d5b3173217e0c45429b3e47a6f5cf5fb0ead6c644ec5fed01",{"version":"17c6561066a9708a09115ad4b49ff23cf81a607f54278c9b9b5865964906f415","impliedFormat":1},{"version":"32727845ab5bd8a9ef3e4844c567c09f6d418fcf0f90d381c00652a6f23e7f6e","impliedFormat":1},{"version":"60200590d8ecf247a6e9c769c6b79bd8e688c3af14ef208072d0c6954b25f125","impliedFormat":1},{"version":"7a8ec10b0834eb7183e4bfcd929838ac77583828e343211bb73676d1e47f6f01","impliedFormat":1},{"version":"6e8044a9ca35eb66823c9ddddf826ca28bf900cd0cb3d31b3007277d094dc21e","affectsGlobalScope":true,"impliedFormat":1},{"version":"3f00324f263189b385c3a9383b1f4dae6237697bcf0801f96aa35c340512d79c","impliedFormat":1},{"version":"ec8997c2e5cea26befc76e7bf990750e96babb16977673a9ff3b5c0575d01e48","impliedFormat":1},"9bda10cfbe5cc511eb8215c5aa63194f7aab211472cb5a18a81626e066157c56",{"version":"402e5c534fb2b85fa771170595db3ac0dd532112c8fa44fc23f233bc6967488b","impliedFormat":1},{"version":"52dcc257df5119fb66d864625112ce5033ac51a4c2afe376a0b299d2f7f76e4a","impliedFormat":1},{"version":"e5bab5f871ef708d52d47b3e5d0aa72a08ee7a152f33931d9a60809711a2a9a3","impliedFormat":1},{"version":"e16dc2a81595736024a206c7d5c8a39bfe2e6039208ef29981d0d95434ba8fcf","impliedFormat":1},{"version":"cc4a4903fb698ca1d961d4c10dce658aa3a479faf40509d526f122b044eaf6a4","impliedFormat":1},{"version":"19ee8416e6473ed6c7adb868fa796b5653cf0fa2a337658e677eaa0d134388c3","impliedFormat":1},{"version":"1328ab4e442614b28cdb3d4b414cf68325c0da0dca07287a338d0654b7a00261","impliedFormat":1},{"version":"a039dc21f045919f3cbee2ec13812cc6cc3eebc99dae4be00973230f468d19a6","impliedFormat":1},{"version":"3fbe57af01460e49dcd29df55d6931e1672bc6f1be0fb073d11410bc16f9037d","impliedFormat":1},{"version":"f760be449e8562ec5c09bb5187e8e1eabf3c113c0c58cddda53ef8c69f3e2131","impliedFormat":1},{"version":"44325ed13294fce6ab825b82947bbeed2611db7dad9d9135260192f375e5a189","impliedFormat":1},{"version":"e392e8fb5b514eafc585601c1d781485aa6dd6a320e75daf1064a4c6918a1b45","impliedFormat":1},{"version":"46e4a36e8ddbdfb4e7330e11c81c970dc8b218611df9183d39c41c5f8c653b55","impliedFormat":1},{"version":"3cc8a3d123b6b232d48d34b51b785f9da8d193f5b5817fa521fcd2f3b9315c55","impliedFormat":1},{"version":"6332f565867cf4a740a70e30f31cefba37ef7cebcf74f22eab8d744fde6d193e","impliedFormat":1},{"version":"2977b7884aedc895a1d0c9c210c7cf3272c29d6959a08a6fa3ff71e0aff08175","impliedFormat":1},{"version":"17f2922d41ddd032830a91371c948cd9ce903b35c95adca72271a54584f19b0b","impliedFormat":1},{"version":"3eed76ede2a1a14d7c9bb0a642041282dcc264811139d3dd275c9fe14efc9840","impliedFormat":1},{"version":"354a7f8e1287d9d6b7561bc97fdd8cbc2f7c1dd79e4cb37b942e8a5cfaff1085","impliedFormat":1},{"version":"8d369483f0c2b9ee388129cfdb6a43bc8112b377e86a41884bd06e19ce04f4c1","impliedFormat":99},{"version":"b558c9a18ea4e6e4157124465c3ef1063e64640da139e67be5edb22f534f2f08","impliedFormat":1},{"version":"01374379f82be05d25c08d2f30779fa4a4c41895a18b93b33f14aeef51768692","impliedFormat":1},{"version":"b0dee183d4e65cf938242efaf3d833c6b645afb35039d058496965014f158141","impliedFormat":1},{"version":"c0bbbf84d3fbd85dd60d040c81e8964cc00e38124a52e9c5dcdedf45fea3f213","impliedFormat":1},"670d49e8d06eee59d831f4134a3928a52748113528fd847887745484ae8cf4e6",{"version":"04471dc55f802c29791cc75edda8c4dd2a121f71c2401059da61eff83099e8ab","impliedFormat":99},{"version":"5c54a34e3d91727f7ae840bfe4d5d1c9a2f93c54cb7b6063d06ee4a6c3322656","impliedFormat":99},{"version":"db4da53b03596668cf6cc9484834e5de3833b9e7e64620cf08399fe069cd398d","impliedFormat":99},{"version":"ac7c28f153820c10850457994db1462d8c8e462f253b828ad942a979f726f2f9","impliedFormat":99},{"version":"f9b028d3c3891dd817e24d53102132b8f696269309605e6ed4f0db2c113bbd82","impliedFormat":99},{"version":"fb7c8d90e52e2884509166f96f3d591020c7b7977ab473b746954b0c8d100960","impliedFormat":99},{"version":"0bff51d6ed0c9093f6955b9d8258ce152ddb273359d50a897d8baabcb34de2c4","impliedFormat":99},{"version":"ef13c73d6157a32933c612d476c1524dd674cf5b9a88571d7d6a0d147544d529","impliedFormat":99},{"version":"13918e2b81c4288695f9b1f3dcc2468caf0f848d5c1f3dc00071c619d34ff63a","impliedFormat":99},{"version":"120a80aa556732f684db3ed61aeff1d6671e1655bd6cba0aa88b22b88ac9a6b1","affectsGlobalScope":true,"impliedFormat":99},{"version":"a7ca8df4f2931bef2aa4118078584d84a0b16539598eaadf7dce9104dfaa381c","impliedFormat":1},{"version":"10073cdcf56982064c5337787cc59b79586131e1b28c106ede5bff362f912b70","impliedFormat":99},{"version":"72950913f4900b680f44d8cab6dd1ea0311698fc1eefb014eb9cdfc37ac4a734","impliedFormat":1},{"version":"751764bb94219b4ce8f5475dc35d3de2e432fea01a0c9610cd7f69ad05e398c6","impliedFormat":1},{"version":"ee70b8037ecdf0de6c04f35277f253663a536d7e38f1539d270e4e916d225a3f","affectsGlobalScope":true,"impliedFormat":1},{"version":"a660aa95476042d3fdcc1343cf6bb8fdf24772d31712b1db321c5a4dcc325434","impliedFormat":1},{"version":"36977c14a7f7bfc8c0426ae4343875689949fb699f3f84ecbe5b300ebf9a2c55","impliedFormat":1},{"version":"ff0a83c9a0489a627e264ffcb63f2264b935b20a502afa3a018848139e3d8575","impliedFormat":99},{"version":"161c8e0690c46021506e32fda85956d785b70f309ae97011fd27374c065cac9b","affectsGlobalScope":true,"impliedFormat":1},{"version":"971f12a5fc236419ced0b7b9f23a53c1758233713f565635bbf4b85e2b23f55a","impliedFormat":99},{"version":"76de3321ce519928f1ff7d7a30391c0dc7374af20f81d9167919f038895b5cb0","impliedFormat":99},{"version":"094b9210da23b8711709b0535c59841186267bf6b83c1609aa9b515f830ab274","impliedFormat":99},{"version":"fbfbb4e99c6259ff5ccc4a5a62b3b63c0c8cae6e84737786c4a4c761c9a9de91","impliedFormat":99},{"version":"604887bbd5b0a93234ce882543a465f008636185c52e0f0353330e2bc38b03b6","impliedFormat":99},{"version":"32bf912173e8a9533631f9e9d8dc90a2ac7b52c2355611ddd886beab24dfd182","impliedFormat":99},{"version":"82695324abf7f3278b6d9f0582f4a544e8f7055c8cbe1065ab5cbacde1719c4c","impliedFormat":99},{"version":"43bba542e50e19241ec64bc13cfc0d9273e6198f36563cecad1f4e4b78ad47f3","impliedFormat":99},{"version":"0b215f03e21d3b61e59ec62ae5ffe869b8f8db497ee1999f7aee263aafe2e38c","impliedFormat":99},{"version":"f582b0fcbf1eea9b318ab92fb89ea9ab2ebb84f9b60af89328a91155e1afce72","impliedFormat":1},{"version":"960bd764c62ac43edc24eaa2af958a4b4f1fa5d27df5237e176d0143b36a39c6","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ec16d7a4e366c06a4573d299e15fe6207fc080f41beac5da06f4af33ea9761e","impliedFormat":1},{"version":"59f8dc89b9e724a6a667f52cdf4b90b6816ae6c9842ce176d38fcc973669009e","affectsGlobalScope":true,"impliedFormat":1},{"version":"e4af494f7a14b226bbe732e9c130d8811f8c7025911d7c58dd97121a85519715","impliedFormat":1},{"version":"cbb1c5ba5dbabe42c19ca31b83e48fec95895484fe1d1a8fb649b69ea224c5b8","impliedFormat":99},{"version":"45cec9a1ba6549060552eead8959d47226048e0b71c7d0702ae58b7e16a28912","impliedFormat":99},{"version":"6907b09850f86610e7a528348c15484c1e1c09a18a9c1e98861399dfe4b18b46","impliedFormat":99},{"version":"12deea8eaa7a4fc1a2908e67da99831e5c5a6b46ad4f4f948fd4759314ea2b80","impliedFormat":99},{"version":"f0a8b376568a18f9a4976ecb0855187672b16b96c4df1c183a7e52dc1b5d98e8","impliedFormat":99},{"version":"8124828a11be7db984fcdab052fd4ff756b18edcfa8d71118b55388176210923","impliedFormat":99},{"version":"092944a8c05f9b96579161e88c6f211d5304a76bd2c47f8d4c30053269146bc8","impliedFormat":99},{"version":"b34b5f6b506abb206b1ea73c6a332b9ee9c8c98be0f6d17cdbda9430ecc1efab","impliedFormat":99},{"version":"75d4c746c3d16af0df61e7b0afe9606475a23335d9f34fcc525d388c21e9058b","impliedFormat":99},{"version":"fa959bf357232201c32566f45d97e70538c75a093c940af594865d12f31d4912","impliedFormat":99},{"version":"d2c52abd76259fc39a30dfae70a2e5ce77fd23144457a7ff1b64b03de6e3aec7","impliedFormat":99},{"version":"e6233e1c976265e85aa8ad76c3881febe6264cb06ae3136f0257e1eab4a6cc5a","impliedFormat":99},{"version":"f73e2335e568014e279927321770da6fe26facd4ac96cdc22a56687f1ecbb58e","impliedFormat":99},{"version":"317878f156f976d487e21fd1d58ad0461ee0a09185d5b0a43eedf2a56eb7e4ea","impliedFormat":99},{"version":"324ac98294dab54fbd580c7d0e707d94506d7b2c3d5efe981a8495f02cf9ad96","impliedFormat":99},{"version":"9ec72eb493ff209b470467e24264116b6a8616484bca438091433a545dfba17e","impliedFormat":99},{"version":"d6ee22aba183d5fc0c7b8617f77ee82ecadc2c14359cc51271c135e23f6ed51f","impliedFormat":99},{"version":"49747416f08b3ba50500a215e7a55d75268b84e31e896a40313c8053e8dec908","impliedFormat":99},{"version":"5e91172586d1be7d1508d1a40c8bf76161f6f4179d1c25158a20599f9fb26a66","impliedFormat":99},{"version":"09d215b379a93f8cbb6caf9e9f5d7b4d48042295ee697e7e4771288c7917a21e","impliedFormat":99},{"version":"427fe2004642504828c1476d0af4270e6ad4db6de78c0b5da3e4c5ca95052a99","impliedFormat":1},{"version":"2eeffcee5c1661ddca53353929558037b8cf305ffb86a803512982f99bcab50d","impliedFormat":99},{"version":"9afb4cb864d297e4092a79ee2871b5d3143ea14153f62ef0bb04ede25f432030","affectsGlobalScope":true,"impliedFormat":99},{"version":"891694d3694abd66f0b8872997b85fd8e52bc51632ce0f8128c96962b443189f","impliedFormat":99},{"version":"69bf2422313487956e4dacf049f30cb91b34968912058d244cb19e4baa24da97","impliedFormat":99},{"version":"971a2c327ff166c770c5fb35699575ba2d13bba1f6d2757309c9be4b30036c8e","impliedFormat":99},{"version":"4f45e8effab83434a78d17123b01124259fbd1e335732135c213955d85222234","impliedFormat":99},{"version":"7bd51996fb7717941cbe094b05adc0d80b9503b350a77b789bbb0fc786f28053","impliedFormat":99},{"version":"b62006bbc815fe8190c7aee262aad6bff993e3f9ade70d7057dfceab6de79d2f","impliedFormat":99},{"version":"ab80d2cb170a92c61ca8c822d0db0fc50eb15c7c6cf1a24cb01852a5a15a7db8","impliedFormat":99},{"version":"210955af8573671abebb6e1391d134d82fbcff130aa68922ea1e839f8f48a0ad","impliedFormat":99},{"version":"78a8f2e95e2904914c509e4bbc68e626f8b8ff8f30ca96cffc7a795fa17394f5","impliedFormat":99},{"version":"7bbff6783e96c691a41a7cf12dd5486b8166a01b0c57d071dbcfca55c9525ec4","impliedFormat":99},"409098dac2d2ba964071b774e1dcf406f3c3a3487fdc7f9ef25b4cd046ef2366",{"version":"d689a82dec12ec02e1c558870a50f71000e06f173151fcdff0458c587dbf016f","impliedFormat":99},{"version":"e58c0b5226aff07b63be6ac6e1bec9d55bc3d2bda3b11b9b68cccea8c24ae839","affectsGlobalScope":true,"impliedFormat":99},{"version":"5a88655bf852c8cc007d6bc874ab61d1d63fba97063020458177173c454e9b4a","impliedFormat":99},{"version":"7e4dfae2da12ec71ffd9f55f4641a6e05610ce0d6784838659490e259e4eb13c","impliedFormat":99},{"version":"dd51e53752b310bd20c9b1a87bbf12b1fe2be7fe40f505b43199496481096275","impliedFormat":1},{"version":"a87be4662442b3feeffc331ecafe6b36cafd08727e2d7f2425a5099577e7fd18","impliedFormat":1},{"version":"cd4cd9220a1ba793bc935e76d8e5481c110a90d9868ae7866a182ee71cdb6abb","impliedFormat":1},{"version":"0a7fb8619b10bc05fd933ca9ac1c8b2ab2220be7a57b57565c3ac158595494ef","impliedFormat":1},{"version":"c4a5f91feb9c5a6b2a91089d959c38391b79a961db3b9cc73b8877d57ad7dcdc","impliedFormat":1},{"version":"c371ea3efec17691f019bca670c161bee2f4787b1f464bdbbb10c4117bf0a55d","impliedFormat":99},"1ecd7888948105aa0cdcfb07c818e6862bf67067b540b485b96922522ee28f57","56f8c19338be5ec5d68605d117f4610a30d704ab92283aef7da7f762f60d497d",{"version":"caf1120156c7d8d50c2e07449caefc71c6e66118b99d66646aa1cac949942fb7","signature":"06fd844fc582e9374fefb9e0c5d45a94cba63a26d9fc33b627094ec0d3bf529c"},{"version":"76cb5d23e3f97937923fe4acb3c4fda85e0477d3da46356518cdf8dac1a0f85e","signature":"f5364e7e6767c1629845290358fd94263e04928bb64262c1261f45439b7a639e"},{"version":"6f3f34fba06e406b5f7ff02c78d63ce4e8d8f9531230d638aa50786b2f5c0376","signature":"232f50d8328910c25fbb8131400ab541a048d1843a88552064b265648313616f"},{"version":"bd09e8616b3e2768dbab35bcba762ad08f746fb886e23a3fac7d262c7f71f755","signature":"eebf26035503afa239a898fc45710cd66290829b99dc75566bb7074e14d95807"},{"version":"73c078fcbc0fa04ba70b1c3e5a3dea6a980d8765079cdbfb40f903eb8daa4319","impliedFormat":99},{"version":"5297e84d3de08bbe3c00f964d1c74f89cf101d59a4826b335654f44ff41529a8","impliedFormat":99},{"version":"355b33af59287683501f76cbf7d6a141544c5ff1ae5f5c0701a3f89cc38e5238","impliedFormat":99},{"version":"280a996092ab956e80dc7bb7497d472ca5c1be23a9c52ac771f5c750ede462b9","impliedFormat":99},"0bcc84b918ebe35f9b487919a447508929cfdfcd0454339c87674aba120cc574","b437cd2aaffcdda828f96adc4d88ce3bfa598aea60e7e88904b22838a3bf4759",{"version":"76d455214737eb00eb1ab7ca7886280a2744be8033433863ef3b98a030dc03bc","impliedFormat":1},{"version":"5141625f923b7208cb7a95d31ac294efb975cab0546ab3ea7d4b19168003f630","impliedFormat":1},{"version":"a4576c793e270ad3ec4261c339b7a6fe2276f641c4909510423162bbd644455f","impliedFormat":1},{"version":"0bd7fbf60db9e1f4cb2a03ece7392923b9c2a720b708108537231cc267810b3b","impliedFormat":1},{"version":"d3d46e7527e0f25baeb1498d4fee12d7bca05019beec61abe64072ade7d27a5f","impliedFormat":1},{"version":"a9ac0bace95193adce96fd9345605155174d0f8a03eb5b033db18395116caf94","impliedFormat":1},{"version":"4faf5e449e757bfe3cb6903dd8360436b8406d796504f8b128d5d460ee16829c","impliedFormat":1},{"version":"f197658dfc5b1cb54d7e9519b0837631bc28a432468ceb59a2a215cb67f4891c","impliedFormat":1},{"version":"ba3376658fc826fbebe18825644e849c92ac53d9d7b935b45e1029cf3e75bf1c","impliedFormat":1},"8a10e338a46c717cc7bde282f99f96c637d455b90058847205751775f551710b","f02d94aab2f52c6811214b0c7722200e3ea33e6ed5bb4cf43b29d9dc20552513",{"version":"30ebbd2b6478c67f09f797ea4e481e18762bee2cc98d0ae738bb87f4ceb03b45","signature":"6c45675928d5db83b67f6f8d0c2238049d5290a975c3d1c99b7d2cdf8b415168"},"3a9b5bf5e6418da8c66ffe2776763f7cced6e34118717cec5b89523483cb7f4a","5c93c490af1bc398119776ebe996f2fb6dd17ff13d1139aa8a6fb0bd0ae323d7",{"version":"ed1f42b9ccaee07f155f5da9a7e9af5fb7289baaf8076d7ad37fe27c7023fcb0","signature":"37da6a52ec6a21faa60e71b1993c2cf28fa321941b564c118e9b84dcd12d58a3"},"5e292acc19b5ca0829539393f0c90bc8f7f856d2fc28f5269a9ad5af3f0784e7",{"version":"a70c3bf8ae91dfe96432713e5f038d85cdef3b7eefb7eed8bceff6448dd0ea71","signature":"9adb987c4758bb3f1ffffec379f40b8e622cb0df1411ba9c803555ee39aa360e"},{"version":"f5c45ca7b9c7d4dd5fb13d74b7a9513c38448459ecb48b2c06222e47aced0406","signature":"3b5994df83c9492980b8e2ab25919f788430d4c1dc6cd2edaff7aa6a0daf707c"},{"version":"de23513d6bc5e5ee80d71dacd2491c73b07e6aa141ec9dd1d7e82ab2fff2072c","signature":"8771e042c97e43611c4a3a11eb92aba657e7dd107772ae3972202506ac1d9c30"},"fdc92afcb54a97a15906b8181f22e3cf31860379599149a68551d77770711e42",{"version":"e66203a88beb1b487b766bc47ced2512248c8297ae77f1bf5940d7f974f0ea17","signature":"80a1fa7cea4e0efd1de4376df48d4db7694cfd3ce27a8adb373c9172d8b620fd"},{"version":"3434761c06f5b13992004bc84a3b70eb5fbcf87de106b69ab4f8990160ba7f11","signature":"7120a797c71970bc8b6f6a73fee60be717fac09d40429d6194235837f65b4923"},"8b92642732596ae211287f8953532ac9e94f69ddadd1802758ef7189f679cd2e",{"version":"9f90acfb0d9ef6c3d46f08d47895ea95e41ce7b35dd52a80ad700c23732cdf24","signature":"0ac3b29d34332502580101e9461ef6bd974903264f343416e0aaf0645540ebc4"},{"version":"993c7d511af613d3d99a9ac68b3b94acc1c2a89a68373e924b0ed72ca5b4a2be","signature":"2cc743b624d6891f9275f11f76fedfe235af04641c806e7dc65e55740db4dd29"},"9c086a77151b6a2b5e0281e10de745e5375dab2ed6e295fef9472acd3a231ffb","68973696dac9e11bbeeae256772a2495c6d02f673988a345af8221ec084c0aa9","995476a56b6065f2939f8dc2a731c302a782ee7f6f82d482e9d4e2663d9868b4","642aa16139f9c73cfacae18eb72b0c2741cfe568ebf22136a97228b31d8e88e3","ccd70d5509ee7c00f3db165531df2ff9007d34d100fbb292185148551255607b","527c2bc1a80650cbb096d8e19a6447acb201fdbc5516571a03afe2f4dea4ed3f","4c7a837b5c7213cbfe629caf839843d55d6a97c1e244c81ed84b0a48ddaf4113",{"version":"d30e67059f5c545c5f8f0cc328a36d2e03b8c4a091b4301bc1d6afb2b1491a3a","impliedFormat":1},{"version":"37550007de426cbbb418582008deae1a508935eaebbd4d41e22805ad3b485ad4","impliedFormat":1},{"version":"f3d8c757e148ad968f0d98697987db363070abada5f503da3c06aefd9d4248c1","impliedFormat":1},{"version":"96d14f21b7652903852eef49379d04dbda28c16ed36468f8c9fa08f7c14c9538","impliedFormat":1},{"version":"ae4cda96b058f20db053c1f57e51257d1cffff9c0880326d2b8129ade5363402","impliedFormat":1},{"version":"12115a2a03125cb3f600e80e7f43ef57f71a2951bb6e60695fb00ac8e12b27f3","impliedFormat":1},{"version":"02f7c65c690af708e9da6b09698c86d34b6b39a05acd8288a079859d920aea9f","impliedFormat":1},{"version":"e3913b35c221b4468658743d6496b83323c895f8e5b566c48d8844c01bf24738","impliedFormat":1}],"root":[410,418,443,510,[521,526],531,532,[542,564]],"options":{"allowJs":true,"esModuleInterop":true,"jsx":1,"module":99,"skipLibCheck":true,"strict":true,"target":4},"referencedMap":[[559,1],[560,2],[557,3],[561,4],[562,5],[563,6],[558,7],[564,8],[410,9],[469,10],[467,11],[468,12],[470,13],[465,14],[463,11],[466,15],[464,16],[363,11],[417,17],[527,11],[528,18],[529,19],[530,20],[499,21],[497,11],[457,11],[566,22],[565,11],[567,11],[568,11],[569,11],[571,23],[142,24],[143,24],[144,25],[99,26],[145,27],[146,28],[147,29],[94,11],[97,30],[95,11],[96,11],[148,31],[149,32],[150,33],[151,34],[152,35],[153,36],[154,36],[155,37],[156,38],[157,39],[158,40],[100,11],[98,11],[159,41],[160,42],[161,43],[193,44],[162,45],[163,46],[164,47],[165,48],[166,49],[167,50],[168,51],[169,52],[170,53],[171,54],[172,54],[173,55],[174,11],[175,56],[177,57],[176,58],[178,59],[179,60],[180,61],[181,62],[182,63],[183,64],[184,65],[185,66],[186,67],[187,68],[188,69],[189,70],[190,71],[101,11],[102,11],[103,11],[141,72],[191,73],[192,74],[570,11],[540,75],[534,76],[536,77],[535,11],[537,78],[538,78],[533,78],[539,79],[86,11],[198,80],[199,81],[197,82],[541,83],[195,84],[196,85],[84,11],[87,86],[286,82],[572,22],[453,87],[486,88],[484,11],[485,11],[445,11],[481,89],[478,90],[479,91],[500,92],[491,11],[494,93],[493,94],[505,94],[492,95],[444,11],[452,96],[480,96],[447,97],[450,98],[487,97],[451,99],[446,11],[498,11],[85,11],[462,11],[517,100],[519,101],[518,102],[516,103],[515,11],[93,104],[366,105],[370,106],[372,107],[219,108],[233,109],[337,110],[265,11],[340,111],[301,112],[310,113],[338,114],[220,115],[264,11],[266,116],[339,117],[240,118],[221,119],[245,118],[234,118],[204,118],[292,120],[293,121],[209,11],[289,122],[294,123],[381,124],[287,123],[382,125],[271,11],[290,126],[394,127],[393,128],[296,123],[392,11],[390,11],[391,129],[291,82],[278,130],[279,131],[288,132],[305,133],[306,134],[295,135],[273,136],[274,137],[385,138],[388,139],[252,140],[251,141],[250,142],[397,82],[249,143],[225,11],[400,11],[403,11],[402,82],[404,144],[200,11],[331,11],[232,145],[202,146],[354,11],[355,11],[357,11],[360,147],[356,11],[358,148],[359,148],[218,11],[231,11],[365,149],[373,150],[377,151],[214,152],[281,153],[280,11],[272,136],[300,154],[298,155],[297,11],[299,11],[304,156],[276,157],[213,158],[238,159],[328,160],[205,161],[212,162],[201,110],[342,163],[352,164],[341,11],[351,165],[239,11],[223,166],[319,167],[318,11],[325,168],[327,169],[320,170],[324,171],[326,168],[323,170],[322,168],[321,170],[261,172],[246,172],[313,173],[247,173],[207,174],[206,11],[317,175],[316,176],[315,177],[314,178],[208,179],[285,180],[302,181],[284,182],[309,183],[311,184],[308,182],[241,179],[194,11],[329,185],[267,186],[303,11],[350,187],[270,188],[345,189],[211,11],[346,190],[348,191],[349,192],[332,11],[344,161],[243,193],[330,194],[353,195],[215,11],[217,11],[222,196],[312,197],[210,198],[216,11],[269,199],[268,200],[224,201],[277,202],[275,203],[226,204],[228,205],[401,11],[227,206],[229,207],[368,11],[367,11],[369,11],[399,11],[230,208],[283,82],[92,11],[307,209],[253,11],[263,210],[242,11],[375,82],[384,211],[260,82],[379,123],[259,212],[362,213],[258,211],[203,11],[386,214],[256,82],[257,82],[248,11],[262,11],[255,215],[254,216],[244,217],[237,135],[347,11],[236,218],[235,11],[371,11],[282,82],[364,219],[83,11],[91,220],[88,82],[89,11],[90,11],[343,221],[336,222],[335,11],[334,223],[333,11],[374,224],[376,225],[378,226],[380,227],[383,228],[409,229],[387,229],[408,230],[389,231],[395,232],[396,233],[398,234],[405,235],[407,11],[406,236],[361,237],[414,238],[411,11],[412,238],[413,239],[416,240],[415,241],[435,242],[433,243],[434,244],[422,245],[423,243],[430,246],[421,247],[426,248],[436,11],[427,249],[432,250],[438,251],[437,252],[420,253],[428,254],[429,255],[424,256],[431,242],[425,257],[459,258],[458,259],[419,11],[441,260],[440,11],[439,11],[442,261],[471,262],[501,11],[448,11],[449,263],[81,11],[82,11],[13,11],[14,11],[16,11],[15,11],[2,11],[17,11],[18,11],[19,11],[20,11],[21,11],[22,11],[23,11],[24,11],[3,11],[25,11],[26,11],[4,11],[27,11],[31,11],[28,11],[29,11],[30,11],[32,11],[33,11],[34,11],[5,11],[35,11],[36,11],[37,11],[38,11],[6,11],[42,11],[39,11],[40,11],[41,11],[43,11],[7,11],[44,11],[49,11],[50,11],[45,11],[46,11],[47,11],[48,11],[8,11],[54,11],[51,11],[52,11],[53,11],[55,11],[9,11],[56,11],[57,11],[58,11],[60,11],[59,11],[61,11],[62,11],[10,11],[63,11],[64,11],[65,11],[11,11],[66,11],[67,11],[68,11],[69,11],[70,11],[1,11],[71,11],[72,11],[12,11],[76,11],[74,11],[79,11],[78,11],[73,11],[77,11],[75,11],[80,11],[119,264],[129,265],[118,264],[139,266],[110,267],[109,268],[138,236],[132,269],[137,270],[112,271],[126,272],[111,273],[135,274],[107,275],[106,236],[136,276],[108,277],[113,278],[114,11],[117,278],[104,11],[140,279],[130,280],[121,281],[122,282],[124,283],[120,284],[123,285],[133,236],[115,286],[116,287],[125,288],[105,289],[128,280],[127,278],[131,11],[134,290],[503,291],[489,292],[490,291],[488,11],[455,293],[477,294],[461,295],[456,293],[454,11],[460,296],[475,11],[473,11],[474,11],[472,297],[476,298],[509,299],[502,300],[495,301],[504,302],[483,303],[512,304],[513,305],[506,306],[514,307],[507,308],[496,309],[511,310],[508,311],[520,312],[482,11],[418,313],[522,314],[547,315],[548,316],[549,317],[532,318],[550,319],[551,320],[552,321],[553,316],[554,322],[544,323],[556,324],[555,325],[545,326],[542,327],[546,326],[526,328],[531,329],[543,82],[524,326],[521,11],[523,11],[525,326],[443,330],[510,331]],"affectedFilesPendingEmit":[559,560,557,561,562,563,558,564,418,522,547,548,549,532,550,551,552,553,554,544,556,555,545,542,546,526,531,543,524,521,523,525,443,510],"version":"5.9.3"} \ No newline at end of file diff --git a/frontend/vercel.json b/frontend/vercel.json new file mode 100644 index 0000000..a667db8 --- /dev/null +++ b/frontend/vercel.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "nextjs" +} diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..526e218 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,14 @@ +import path from "path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.{test,spec}.{ts,tsx}", "src/__tests__/**/*.{ts,tsx}"], + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}); diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..80cef56 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,52 @@ +"""Alembic environment for ASPC schema migrations.""" +from __future__ import annotations + +import os +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from adapters.db_models import Base +from adapters.persistence_tsdb import normalize_sync_dsn + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + +dsn = os.environ.get("ASPC_TIMESCALE_DSN") or os.environ.get("DATABASE_URL") +if dsn: + config.set_main_option("sqlalchemy.url", normalize_sync_dsn(dsn)) + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..4d5e0e9 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,21 @@ +"""Alembic revision template.""" +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/001_initial.py b/migrations/versions/001_initial.py new file mode 100644 index 0000000..eef6ac0 --- /dev/null +++ b/migrations/versions/001_initial.py @@ -0,0 +1,137 @@ +"""Initial ASPC schema — control limits, runs, audit, OOC, capability, raw, streams.""" +from __future__ import annotations + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "001_initial" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "control_limits", + sa.Column("version", sa.String(64), primary_key=True), + sa.Column("chart_type", sa.String(32), nullable=False), + sa.Column("payload", sa.JSON(), nullable=False), + sa.Column("meta", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_table( + "analysis_runs", + sa.Column("run_id", sa.String(64), primary_key=True), + sa.Column("analysis_type", sa.String(64), nullable=False), + sa.Column("limits_version", sa.String(64), nullable=True), + sa.Column("source_file", sa.Text(), nullable=True), + sa.Column("user_id", sa.String(128), nullable=True), + sa.Column("report", sa.JSON(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_analysis_runs_analysis_type", "analysis_runs", ["analysis_type"]) + op.create_index("ix_analysis_runs_created_at", "analysis_runs", ["created_at"]) + + op.create_table( + "audit_log", + sa.Column("event_id", sa.String(64), primary_key=True), + sa.Column("event", sa.String(128), nullable=False), + sa.Column("detail", sa.JSON(), nullable=False), + sa.Column("user_id", sa.String(128), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_audit_log_event", "audit_log", ["event"]) + + op.create_table( + "ooc_events", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("stream_key", sa.String(256), nullable=False), + sa.Column("limits_version", sa.String(64), nullable=True), + sa.Column("index", sa.Integer(), nullable=False), + sa.Column("value", sa.Float(), nullable=False), + sa.Column("rule_id", sa.String(32), nullable=False), + sa.Column("rule_name", sa.String(128), nullable=False), + sa.Column("description", sa.Text(), nullable=False), + sa.Column("side", sa.String(16), nullable=True), + sa.Column("ts", sa.DateTime(timezone=True), nullable=False), + sa.Column("acked", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("acked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("acked_by", sa.String(128), nullable=True), + sa.UniqueConstraint("stream_key", "ts", "rule_id", name="uq_ooc_stream_ts_rule"), + ) + op.create_index("ix_ooc_stream_ts", "ooc_events", ["stream_key", "ts"]) + + op.create_table( + "capability_history", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("run_id", sa.String(64), nullable=False), + sa.Column("cpk", sa.Float(), nullable=True), + sa.Column("ppk", sa.Float(), nullable=True), + sa.Column("sigma_level", sa.Float(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_capability_history_run_id", "capability_history", ["run_id"]) + + op.create_table( + "raw_measurements", + sa.Column("id", sa.BigInteger(), nullable=False), + sa.Column("stream_key", sa.String(256), nullable=False), + sa.Column("ts", sa.DateTime(timezone=True), nullable=False), + sa.Column("value", sa.Float(), nullable=False), + sa.Column("quality_flag", sa.String(64), nullable=True), + sa.Column("machine_id", sa.String(128), nullable=True), + sa.Column("gage_id", sa.String(128), nullable=True), + sa.Column("limits_version", sa.String(64), nullable=True), + sa.PrimaryKeyConstraint("id", "ts", name="pk_raw_measurements"), + ) + op.create_index("ix_raw_stream_ts", "raw_measurements", ["stream_key", "ts"]) + + op.create_table( + "stream_registry", + sa.Column("stream_key", sa.String(256), primary_key=True), + sa.Column("topic", sa.String(512), nullable=True), + sa.Column("limits_version", sa.String(64), nullable=True), + sa.Column("chart_type", sa.String(32), nullable=True), + sa.Column("ruleset", sa.String(64), nullable=False, server_default="nelson"), + sa.Column("active", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("meta", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + + # Timescale-specific: only when running against PostgreSQL with the extension. + bind = op.get_bind() + if bind.dialect.name == "postgresql": + op.execute("CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE") + op.execute( + "SELECT create_hypertable('raw_measurements', 'ts', if_not_exists => TRUE)" + ) + op.execute( + """ + DO $$ + BEGIN + PERFORM add_retention_policy( + 'raw_measurements', INTERVAL '90 days', if_not_exists => TRUE + ); + EXCEPTION WHEN OTHERS THEN + NULL; + END $$; + """ + ) + + +def downgrade() -> None: + op.drop_table("stream_registry") + op.drop_index("ix_raw_stream_ts", table_name="raw_measurements") + op.drop_table("raw_measurements") + op.drop_index("ix_capability_history_run_id", table_name="capability_history") + op.drop_table("capability_history") + op.drop_index("ix_ooc_stream_ts", table_name="ooc_events") + op.drop_table("ooc_events") + op.drop_index("ix_audit_log_event", table_name="audit_log") + op.drop_table("audit_log") + op.drop_index("ix_analysis_runs_created_at", table_name="analysis_runs") + op.drop_index("ix_analysis_runs_analysis_type", table_name="analysis_runs") + op.drop_table("analysis_runs") + op.drop_table("control_limits") diff --git a/migrations/versions/002_tenant_watermark.py b/migrations/versions/002_tenant_watermark.py new file mode 100644 index 0000000..b9ef920 --- /dev/null +++ b/migrations/versions/002_tenant_watermark.py @@ -0,0 +1,55 @@ +"""Add tenant_id scaffolding, ooc acked index, stream measurement_count watermark. + +Revision ID: 002_tenant_watermark +Revises: 001_initial +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "002_tenant_watermark" +down_revision = "001_initial" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("control_limits", sa.Column("tenant_id", sa.String(length=128), nullable=True)) + op.create_index("ix_control_limits_tenant_id", "control_limits", ["tenant_id"]) + + op.add_column("analysis_runs", sa.Column("tenant_id", sa.String(length=128), nullable=True)) + op.create_index("ix_analysis_runs_tenant_id", "analysis_runs", ["tenant_id"]) + + op.add_column("ooc_events", sa.Column("tenant_id", sa.String(length=128), nullable=True)) + op.create_index("ix_ooc_events_tenant_id", "ooc_events", ["tenant_id"]) + op.create_index("ix_ooc_acked", "ooc_events", ["acked"]) + + op.add_column("raw_measurements", sa.Column("tenant_id", sa.String(length=128), nullable=True)) + op.create_index("ix_raw_measurements_tenant_id", "raw_measurements", ["tenant_id"]) + + op.add_column( + "stream_registry", + sa.Column("measurement_count", sa.Integer(), nullable=False, server_default="0"), + ) + op.add_column("stream_registry", sa.Column("tenant_id", sa.String(length=128), nullable=True)) + op.create_index("ix_stream_registry_tenant_id", "stream_registry", ["tenant_id"]) + + +def downgrade() -> None: + op.drop_index("ix_stream_registry_tenant_id", table_name="stream_registry") + op.drop_column("stream_registry", "tenant_id") + op.drop_column("stream_registry", "measurement_count") + + op.drop_index("ix_raw_measurements_tenant_id", table_name="raw_measurements") + op.drop_column("raw_measurements", "tenant_id") + + op.drop_index("ix_ooc_acked", table_name="ooc_events") + op.drop_index("ix_ooc_events_tenant_id", table_name="ooc_events") + op.drop_column("ooc_events", "tenant_id") + + op.drop_index("ix_analysis_runs_tenant_id", table_name="analysis_runs") + op.drop_column("analysis_runs", "tenant_id") + + op.drop_index("ix_control_limits_tenant_id", table_name="control_limits") + op.drop_column("control_limits", "tenant_id") diff --git a/msa_system/__init__.py b/msa_system/__init__.py deleted file mode 100644 index 338aa29..0000000 --- a/msa_system/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -""" -MSA System -Measurement System Analysis (MSA) with AI Agent capabilities - -This package contains: -- MSAPipeline: Core MSA analysis engine -- MSA Tool: Single comprehensive LangGraph tool -- MSA Agent: Conversational AI interface - -Supported Studies: -- Gage R&R (Repeatability & Reproducibility) -- Bias Analysis -- Linearity Studies -- Stability Analysis -""" - -from .msa_pipeline import MSAPipeline -from .msa_tools import run_msa_analysis - -__version__ = "2.0.0" -__author__ = "MSA AI Team" - -__all__ = [ - "MSAPipeline", - "run_msa_analysis" -] diff --git a/msa_system/msa_agent.py b/msa_system/msa_agent.py deleted file mode 100644 index 079e9e0..0000000 --- a/msa_system/msa_agent.py +++ /dev/null @@ -1,30 +0,0 @@ -import sys -from pathlib import Path -from langchain.agents import create_agent -from langgraph.checkpoint.memory import InMemorySaver -from langgraph.store.memory import InMemoryStore - -# Add parent directory to path for imports -parent_dir = Path(__file__).parent.parent -sys.path.insert(0, str(parent_dir)) - -from msa_system.msa_tools import run_msa_analysis -from agent_config.agent_prompts import get_msa_prompt -from agent_config.config_loader import get_llm_model_string - -# Load the agent prompt from JSON configuration -MSA_agent_prompt = get_msa_prompt() - -# Initialize shared memory components -checkpointer = InMemorySaver() -store = InMemoryStore() - -# Create the agent graph (exported for use in API) -msa_graph = create_agent( - model=get_llm_model_string(), # Load from config.yaml - tools=[run_msa_analysis], # Single comprehensive tool - checkpointer=checkpointer, - store=store, - system_prompt=MSA_agent_prompt, - name="MSA Agent" -) \ No newline at end of file diff --git a/msa_system/msa_pipeline.py b/msa_system/msa_pipeline.py deleted file mode 100644 index 3260c27..0000000 --- a/msa_system/msa_pipeline.py +++ /dev/null @@ -1,709 +0,0 @@ -""" -MSA (Measurement System Analysis) Pipeline -Comprehensive analysis of measurement system capability -""" -import pandas as pd -import numpy as np -import plotly.graph_objects as go -from plotly.subplots import make_subplots -from scipy import stats -import warnings - - -class MSAPipeline: - def __init__(self, data, part_col=None, operator_col=None, measurement_col=None, - trial_col=None, reference_col=None, date_col=None): - """ - Initialize the MSA pipeline - - Parameters: - data: DataFrame containing measurement data - part_col: Column name for part/sample identifiers - operator_col: Column name for operator/appraiser identifiers - measurement_col: Column name for measurement values - trial_col: Column name for trial/repeat number - reference_col: Column name for reference/standard values (for bias/linearity) - date_col: Column name for date/time (for stability) - """ - self.data = pd.DataFrame(data) if not isinstance(data, pd.DataFrame) else data - self.part_col = part_col - self.operator_col = operator_col - self.measurement_col = measurement_col - self.trial_col = trial_col - self.reference_col = reference_col - self.date_col = date_col - self.study_type = None - self.results = {} - self.data_quality_issues = [] - - def validate_data_quality(self, study_type=None): - """ - Validate data quality for MSA studies - Returns list of issues found - """ - issues = [] - - # Auto-detect measurement column (SMART DETECTION) - if self.measurement_col is None: - numeric_cols = self.data.select_dtypes(include=[np.number]).columns.tolist() - if len(numeric_cols) == 0: - issues.append("ERROR: No numeric columns found for measurements") - return issues - - # Skip columns that are likely identifiers/grouping variables - skip_patterns = ['id', 'part', 'operator', 'trial', 'batch', 'sample', 'group', 'appraiser'] - filtered_cols = [c for c in numeric_cols if not any(pattern in c.lower() for pattern in skip_patterns)] - - # If we have columns after filtering, use those - if filtered_cols: - numeric_cols = filtered_cols - - # Prefer columns with measurement-related names - priority_names = ['measurement', 'value', 'measure', 'reading', 'result', 'data'] - for priority in priority_names: - matching = [c for c in numeric_cols if priority in c.lower()] - if matching: - self.measurement_col = matching[0] - break - else: - # Fallback to first remaining numeric column - self.measurement_col = numeric_cols[0] - - # Check measurement column exists - if self.measurement_col not in self.data.columns: - issues.append(f"ERROR: Measurement column '{self.measurement_col}' not found. Available: {list(self.data.columns)}") - return issues - - values = self.data[self.measurement_col] - - # Check for missing values - missing_count = values.isna().sum() - if missing_count > 0: - issues.append(f"ERROR: {missing_count} missing measurements. MSA requires complete data (no missing values)") - - # Gage R&R specific checks - if study_type == "Gage R&R" or (self.part_col and self.operator_col): - if self.part_col not in self.data.columns: - issues.append(f"ERROR: Part column '{self.part_col}' not found") - if self.operator_col not in self.data.columns: - issues.append(f"ERROR: Operator column '{self.operator_col}' not found") - - if self.part_col in self.data.columns and self.operator_col in self.data.columns: - # Check balanced design - n_parts = self.data[self.part_col].nunique() - n_operators = self.data[self.operator_col].nunique() - - # Check each part-operator combination - combo_counts = self.data.groupby([self.part_col, self.operator_col]).size() - if combo_counts.nunique() > 1: - issues.append(f"WARNING: Unbalanced design detected. Some part-operator combinations have different number of trials") - - if n_parts < 10: - issues.append(f"WARNING: Only {n_parts} parts. Gage R&R typically requires 10+ parts for reliable results") - - if n_operators < 2: - issues.append(f"WARNING: Only {n_operators} operator. Gage R&R requires 2+ operators to assess reproducibility") - - # Bias/Linearity specific checks - if self.reference_col and self.reference_col in self.data.columns: - ref_missing = self.data[self.reference_col].isna().sum() - if ref_missing > 0: - issues.append(f"ERROR: {ref_missing} missing reference values. Bias/Linearity require reference values for all measurements") - - # Check for reasonable values - values_clean = values.dropna() - if len(values_clean) > 0 and values_clean.std() == 0: - issues.append("ERROR: All measurement values are identical. Cannot perform MSA analysis") - - self.data_quality_issues = issues - return issues - - def detect_study_type(self): - """Detect which type of MSA study based on available columns""" - has_part = self.part_col is not None and self.part_col in self.data.columns - has_operator = self.operator_col is not None and self.operator_col in self.data.columns - has_reference = self.reference_col is not None and self.reference_col in self.data.columns - has_date = self.date_col is not None and self.date_col in self.data.columns - has_trial = self.trial_col is not None and self.trial_col in self.data.columns - - # Determine study type based on data structure - if has_part and has_operator and has_trial: - self.study_type = "Gage R&R" - elif has_reference and not has_operator: - # Check if multiple reference values (linearity) or single (bias) - if has_reference: - n_references = self.data[self.reference_col].nunique() - if n_references > 1: - self.study_type = "Linearity" - else: - self.study_type = "Bias" - elif has_date and not has_operator: - self.study_type = "Stability" - else: - # Default to Gage R&R if unclear - self.study_type = "Gage R&R" - - print(f"Detected MSA study type: {self.study_type}") - return self.study_type - - def run_gage_rr(self, tolerance=None, method="anova"): - """ - Perform Gage R&R study using ANOVA or Range method - - Parameters: - tolerance: Process tolerance (for %Tolerance calculation) - method: "anova" or "range" - """ - if method == "anova": - return self._gage_rr_anova(tolerance) - else: - return self._gage_rr_range(tolerance) - - def _gage_rr_anova(self, tolerance=None): - """Gage R&R using ANOVA method (more accurate)""" - # Prepare data - parts = self.data[self.part_col].unique() - operators = self.data[self.operator_col].unique() - measurements = self.data[self.measurement_col].values - - n_parts = len(parts) - n_operators = len(operators) - n_trials = len(self.data) // (n_parts * n_operators) - - # Calculate means - grand_mean = measurements.mean() - part_means = self.data.groupby(self.part_col)[self.measurement_col].mean() - operator_means = self.data.groupby(self.operator_col)[self.measurement_col].mean() - - # ANOVA calculations - # Total Sum of Squares - SS_total = np.sum((measurements - grand_mean) ** 2) - - # Part Sum of Squares - SS_part = n_operators * n_trials * np.sum((part_means - grand_mean) ** 2) - - # Operator Sum of Squares - SS_operator = n_parts * n_trials * np.sum((operator_means - grand_mean) ** 2) - - # Interaction (Part x Operator) - interaction_means = self.data.groupby([self.part_col, self.operator_col])[self.measurement_col].mean() - SS_interaction = n_trials * np.sum((interaction_means - grand_mean) ** 2) - SS_part - SS_operator - - # Equipment/Repeatability - SS_equipment = SS_total - SS_part - SS_operator - SS_interaction - - # Degrees of freedom - df_part = n_parts - 1 - df_operator = n_operators - 1 - df_interaction = df_part * df_operator - df_equipment = n_parts * n_operators * (n_trials - 1) - df_total = len(measurements) - 1 - - # Mean Squares - MS_part = SS_part / df_part if df_part > 0 else 0 - MS_operator = SS_operator / df_operator if df_operator > 0 else 0 - MS_interaction = SS_interaction / df_interaction if df_interaction > 0 else 0 - MS_equipment = SS_equipment / df_equipment if df_equipment > 0 else 0 - - # Variance components - var_equipment = MS_equipment # Repeatability - var_reproducibility = max((MS_operator - MS_interaction) / (n_parts * n_trials), 0) - var_interaction = max((MS_interaction - MS_equipment) / n_trials, 0) - var_part = max((MS_part - MS_interaction) / (n_operators * n_trials), 0) - - # Total Gage R&R - var_repeatability = var_equipment - var_reproducibility_total = var_reproducibility + var_interaction - var_gage_rr = var_repeatability + var_reproducibility_total - var_total = var_gage_rr + var_part - - # Standard deviations - std_repeatability = np.sqrt(var_repeatability) - std_reproducibility = np.sqrt(var_reproducibility_total) - std_gage_rr = np.sqrt(var_gage_rr) - std_part = np.sqrt(var_part) - std_total = np.sqrt(var_total) - - # Study variation (6 sigma) - sv_repeatability = 6 * std_repeatability - sv_reproducibility = 6 * std_reproducibility - sv_gage_rr = 6 * std_gage_rr - sv_part = 6 * std_part - sv_total = 6 * std_total - - # Percent contribution - pct_repeatability = (var_repeatability / var_total * 100) if var_total > 0 else 0 - pct_reproducibility = (var_reproducibility_total / var_total * 100) if var_total > 0 else 0 - pct_gage_rr = (var_gage_rr / var_total * 100) if var_total > 0 else 0 - pct_part = (var_part / var_total * 100) if var_total > 0 else 0 - - # Percent study variation (%SV) - pct_sv_repeatability = (sv_repeatability / sv_total * 100) if sv_total > 0 else 0 - pct_sv_reproducibility = (sv_reproducibility / sv_total * 100) if sv_total > 0 else 0 - pct_sv_gage_rr = (sv_gage_rr / sv_total * 100) if sv_total > 0 else 0 - - # Percent tolerance - if tolerance: - pct_tol_repeatability = (sv_repeatability / tolerance * 100) - pct_tol_reproducibility = (sv_reproducibility / tolerance * 100) - pct_tol_gage_rr = (sv_gage_rr / tolerance * 100) - else: - pct_tol_repeatability = None - pct_tol_reproducibility = None - pct_tol_gage_rr = None - - # Number of distinct categories (NDC) - ndc = int(np.floor(np.sqrt(2) * std_part / std_gage_rr)) if std_gage_rr > 0 else 0 - - # Interpretation - if pct_sv_gage_rr < 10: - acceptability = "Excellent" - elif pct_sv_gage_rr < 30: - acceptability = "Acceptable" - else: - acceptability = "Unacceptable" - - self.results = { - "study_type": "Gage R&R (ANOVA)", - "n_parts": n_parts, - "n_operators": n_operators, - "n_trials": n_trials, - "variance_components": { - "repeatability": float(var_repeatability), - "reproducibility": float(var_reproducibility_total), - "gage_rr": float(var_gage_rr), - "part_to_part": float(var_part), - "total_variation": float(var_total) - }, - "standard_deviations": { - "repeatability": float(std_repeatability), - "reproducibility": float(std_reproducibility), - "gage_rr": float(std_gage_rr), - "part_to_part": float(std_part), - "total": float(std_total) - }, - "study_variation": { - "repeatability": float(sv_repeatability), - "reproducibility": float(sv_reproducibility), - "gage_rr": float(sv_gage_rr), - "part_to_part": float(sv_part), - "total": float(sv_total) - }, - "percent_contribution": { - "repeatability": float(pct_repeatability), - "reproducibility": float(pct_reproducibility), - "gage_rr": float(pct_gage_rr), - "part_to_part": float(pct_part) - }, - "percent_study_variation": { - "repeatability": float(pct_sv_repeatability), - "reproducibility": float(pct_sv_reproducibility), - "gage_rr": float(pct_sv_gage_rr) - }, - "percent_tolerance": { - "repeatability": float(pct_tol_repeatability) if pct_tol_repeatability else None, - "reproducibility": float(pct_tol_reproducibility) if pct_tol_reproducibility else None, - "gage_rr": float(pct_tol_gage_rr) if pct_tol_gage_rr else None - } if tolerance else None, - "ndc": ndc, - "acceptability": acceptability, - "interpretation": { - "gage_rr": f"{acceptability} - {pct_sv_gage_rr:.1f}% of total variation", - "ndc": f"{'Adequate' if ndc >= 5 else 'Inadequate'} - {ndc} distinct categories" - } - } - - return self.results - - def _gage_rr_range(self, tolerance=None): - """Gage R&R using Range method (simpler, less accurate)""" - # Calculate ranges for each part by each operator - ranges_by_part = [] - for part in self.data[self.part_col].unique(): - part_data = self.data[self.data[self.part_col] == part] - for operator in self.data[self.operator_col].unique(): - operator_data = part_data[part_data[self.operator_col] == operator] - if len(operator_data) > 1: - r = operator_data[self.measurement_col].max() - operator_data[self.measurement_col].min() - ranges_by_part.append(r) - - R_bar = np.mean(ranges_by_part) - - # Constants for range method (d2 values) - n_trials = len(self.data) // (len(self.data[self.part_col].unique()) * len(self.data[self.operator_col].unique())) - d2_values = {2: 1.128, 3: 1.693, 4: 2.059, 5: 2.326} - d2 = d2_values.get(n_trials, 2.326) - - # Equipment Variation (Repeatability) - EV = R_bar / d2 - - # Appraiser Variation (Reproducibility) - simplified - operator_avgs = self.data.groupby(self.operator_col)[self.measurement_col].mean() - R_operators = operator_avgs.max() - operator_avgs.min() - n_parts = len(self.data[self.part_col].unique()) - - AV = np.sqrt(max((R_operators / d2) ** 2 - (EV ** 2 / (n_parts * n_trials)), 0)) - - # Part Variation - part_avgs = self.data.groupby(self.part_col)[self.measurement_col].mean() - R_parts = part_avgs.max() - part_avgs.min() - PV = R_parts / d2 - - # Total Gage R&R - GRR = np.sqrt(EV ** 2 + AV ** 2) - - # Total Variation - TV = np.sqrt(GRR ** 2 + PV ** 2) - - # Percentages - pct_ev = (EV / TV * 100) if TV > 0 else 0 - pct_av = (AV / TV * 100) if TV > 0 else 0 - pct_grr = (GRR / TV * 100) if TV > 0 else 0 - pct_pv = (PV / TV * 100) if TV > 0 else 0 - - self.results = { - "study_type": "Gage R&R (Range)", - "equipment_variation": float(EV), - "appraiser_variation": float(AV), - "gage_rr": float(GRR), - "part_variation": float(PV), - "total_variation": float(TV), - "percent_study_variation": { - "repeatability": float(pct_ev), - "reproducibility": float(pct_av), - "gage_rr": float(pct_grr), - "part_to_part": float(pct_pv) - } - } - - return self.results - - def run_bias_study(self): - """Analyze measurement bias against reference values""" - if self.reference_col not in self.data.columns: - raise ValueError("Reference column required for bias study") - - measured = self.data[self.measurement_col] - reference = self.data[self.reference_col] - - # Calculate bias - bias_values = measured - reference - mean_bias = bias_values.mean() - std_bias = bias_values.std() - - # T-test for bias significance - t_stat, p_value = stats.ttest_1samp(bias_values, 0) - is_significant = p_value < 0.05 - - # Percent bias - mean_reference = reference.mean() - pct_bias = (mean_bias / mean_reference * 100) if mean_reference != 0 else 0 - - self.results = { - "study_type": "Bias", - "mean_bias": float(mean_bias), - "std_bias": float(std_bias), - "percent_bias": float(pct_bias), - "t_statistic": float(t_stat), - "p_value": float(p_value), - "is_significant": bool(is_significant), - "interpretation": "Significant bias detected" if is_significant else "No significant bias", - "n_measurements": len(measured) - } - - return self.results - - def run_linearity_study(self): - """Analyze measurement linearity across reference range""" - if self.reference_col not in self.data.columns: - raise ValueError("Reference column required for linearity study") - - measured = self.data[self.measurement_col] - reference = self.data[self.reference_col] - - # Calculate bias at each reference level - bias_by_ref = self.data.groupby(self.reference_col).apply( - lambda x: (x[self.measurement_col] - x[self.reference_col]).mean() - ) - - # Linear regression: Bias vs Reference - slope, intercept, r_value, p_value, std_err = stats.linregress( - reference, measured - reference - ) - - # Is linearity acceptable? (slope should be close to 0) - is_linear = abs(slope) < 0.1 # Threshold can be adjusted - - self.results = { - "study_type": "Linearity", - "slope": float(slope), - "intercept": float(intercept), - "r_squared": float(r_value ** 2), - "p_value": float(p_value), - "std_error": float(std_err), - "is_linear": bool(is_linear), - "interpretation": "Acceptable linearity" if is_linear else "Linearity issue detected", - "bias_by_reference": bias_by_ref.to_dict() - } - - return self.results - - def run_stability_study(self): - """Analyze measurement stability over time""" - if self.date_col not in self.data.columns: - raise ValueError("Date column required for stability study") - - # Sort by date - data_sorted = self.data.sort_values(self.date_col) - measurements = data_sorted[self.measurement_col] - - # Calculate statistics - mean = measurements.mean() - std = measurements.std() - - # Control limits (like I-chart) - moving_ranges = np.abs(measurements.diff().dropna()) - mr_bar = moving_ranges.mean() - ucl = mean + 2.66 * mr_bar - lcl = mean - 2.66 * mr_bar - - # Check for out of control points - out_of_control = [] - for i, val in enumerate(measurements): - if val > ucl or val < lcl: - out_of_control.append(i) - - # Trend test (Mann-Kendall) - n = len(measurements) - s = 0 - for i in range(n-1): - for j in range(i+1, n): - s += np.sign(measurements.iloc[j] - measurements.iloc[i]) - - # Simplified trend detection - has_trend = abs(s) > (n * (n - 1) / 4) # Simplified threshold - - self.results = { - "study_type": "Stability", - "mean": float(mean), - "std_dev": float(std), - "ucl": float(ucl), - "lcl": float(lcl), - "out_of_control_points": len(out_of_control), - "has_trend": bool(has_trend), - "interpretation": "Unstable" if (len(out_of_control) > 0 or has_trend) else "Stable", - "n_measurements": len(measurements) - } - - return self.results - - def generate_plot(self): - """Generate appropriate plot based on study type""" - if self.study_type == "Gage R&R": - return self._plot_gage_rr() - elif self.study_type == "Bias": - return self._plot_bias() - elif self.study_type == "Linearity": - return self._plot_linearity() - elif self.study_type == "Stability": - return self._plot_stability() - - def _plot_gage_rr(self): - """Generate Gage R&R plots""" - fig = make_subplots( - rows=2, cols=2, - subplot_titles=['Measurement by Part', 'Measurement by Operator', - 'Variance Components', 'R Chart by Operator'], - specs=[[{"type": "scatter"}, {"type": "scatter"}], - [{"type": "bar"}, {"type": "scatter"}]] - ) - - # Plot 1: Measurements by Part - for operator in self.data[self.operator_col].unique(): - op_data = self.data[self.data[self.operator_col] == operator] - fig.add_trace( - go.Scatter(x=op_data[self.part_col], y=op_data[self.measurement_col], - mode='markers+lines', name=f'Operator {operator}'), - row=1, col=1 - ) - - # Plot 2: Measurements by Operator - for part in self.data[self.part_col].unique(): - part_data = self.data[self.data[self.part_col] == part] - fig.add_trace( - go.Scatter(x=part_data[self.operator_col], y=part_data[self.measurement_col], - mode='markers', name=f'Part {part}', showlegend=False), - row=1, col=2 - ) - - # Plot 3: Variance Components - if 'variance_components' in self.results: - components = self.results['variance_components'] - fig.add_trace( - go.Bar(x=['Repeatability', 'Reproducibility', 'Part-to-Part'], - y=[components['repeatability'], components['reproducibility'], - components['part_to_part']]), - row=2, col=1 - ) - - # Plot 4: Range chart by operator - for operator in self.data[self.operator_col].unique(): - op_data = self.data[self.data[self.operator_col] == operator] - ranges = op_data.groupby(self.part_col)[self.measurement_col].apply( - lambda x: x.max() - x.min() if len(x) > 1 else 0 - ) - fig.add_trace( - go.Scatter(x=list(range(len(ranges))), y=ranges, - mode='markers+lines', name=f'Op {operator}', showlegend=False), - row=2, col=2 - ) - - fig.update_layout(height=800, title_text="Gage R&R Analysis", showlegend=True) - return fig - - def _plot_bias(self): - """Generate bias study plots""" - measured = self.data[self.measurement_col] - reference = self.data[self.reference_col] - bias = measured - reference - - fig = make_subplots( - rows=1, cols=2, - subplot_titles=['Measured vs Reference', 'Bias Distribution'] - ) - - # Scatter plot - fig.add_trace( - go.Scatter(x=reference, y=measured, mode='markers', name='Measurements'), - row=1, col=1 - ) - # Ideal line - min_val, max_val = reference.min(), reference.max() - fig.add_trace( - go.Scatter(x=[min_val, max_val], y=[min_val, max_val], - mode='lines', name='Ideal', line=dict(dash='dash')), - row=1, col=1 - ) - - # Histogram - fig.add_trace( - go.Histogram(x=bias, name='Bias'), - row=1, col=2 - ) - - fig.update_layout(height=400, title_text="Bias Study") - return fig - - def _plot_linearity(self): - """Generate linearity study plots""" - reference = self.data[self.reference_col] - measured = self.data[self.measurement_col] - bias = measured - reference - - fig = go.Figure() - - # Scatter plot of bias vs reference - fig.add_trace(go.Scatter(x=reference, y=bias, mode='markers', name='Bias')) - - # Regression line - slope = self.results['slope'] - intercept = self.results['intercept'] - x_range = np.array([reference.min(), reference.max()]) - y_line = slope * x_range + intercept - fig.add_trace(go.Scatter(x=x_range, y=y_line, mode='lines', - name='Regression Line', line=dict(color='red'))) - - # Zero line - fig.add_trace(go.Scatter(x=x_range, y=[0, 0], mode='lines', - name='Zero Bias', line=dict(dash='dash', color='green'))) - - fig.update_layout(title="Linearity Study", xaxis_title="Reference Value", - yaxis_title="Bias", height=500) - return fig - - def _plot_stability(self): - """Generate stability study plots""" - data_sorted = self.data.sort_values(self.date_col) - - fig = go.Figure() - - # Measurements over time - fig.add_trace(go.Scatter(x=data_sorted[self.date_col], - y=data_sorted[self.measurement_col], - mode='markers+lines', name='Measurements')) - - # Control limits - mean = self.results['mean'] - ucl = self.results['ucl'] - lcl = self.results['lcl'] - - fig.add_trace(go.Scatter(x=data_sorted[self.date_col], y=[mean] * len(data_sorted), - mode='lines', name='Mean', line=dict(color='green'))) - fig.add_trace(go.Scatter(x=data_sorted[self.date_col], y=[ucl] * len(data_sorted), - mode='lines', name='UCL', line=dict(color='red', dash='dash'))) - fig.add_trace(go.Scatter(x=data_sorted[self.date_col], y=[lcl] * len(data_sorted), - mode='lines', name='LCL', line=dict(color='red', dash='dash'))) - - fig.update_layout(title="Stability Study", xaxis_title="Date/Time", - yaxis_title="Measurement", height=500) - return fig - - def generate_report(self): - """Generate comprehensive MSA report""" - if not self.results: - self.detect_study_type() - if self.study_type == "Gage R&R": - self.run_gage_rr() - elif self.study_type == "Bias": - self.run_bias_study() - elif self.study_type == "Linearity": - self.run_linearity_study() - elif self.study_type == "Stability": - self.run_stability_study() - - return self.results - - def save_report(self, filename="msa_report.html"): - """Save comprehensive HTML report""" - import plotly.io as pio - - fig = self.generate_plot() - report = self.generate_report() - - html_content = f""" - - - MSA Report - {report['study_type']} - - - -

    MSA Report: {report['study_type']}

    - -
    -

    Summary

    -
    {str(report)}
    -
    - -
    -

    Visualization

    - {pio.to_html(fig, include_plotlyjs='cdn')} -
    - - - """ - - with open(filename, 'w') as f: - f.write(html_content) - - print(f"MSA report saved as: {filename}") - return filename - diff --git a/msa_system/msa_tools.py b/msa_system/msa_tools.py deleted file mode 100644 index 06e1476..0000000 --- a/msa_system/msa_tools.py +++ /dev/null @@ -1,337 +0,0 @@ -""" -MSA Tool for LangGraph Agent -Single comprehensive tool that wraps MSAPipeline functionality. -""" -import pandas as pd -import numpy as np -import json -from typing import Optional -from langchain.tools import tool -from .msa_pipeline import MSAPipeline - - -@tool -def run_msa_analysis( - data_path: str, - part_col: Optional[str] = None, - operator_col: Optional[str] = None, - measurement_col: Optional[str] = None, - trial_col: Optional[str] = None, - reference_col: Optional[str] = None, - date_col: Optional[str] = None, - tolerance: Optional[float] = None, - method: str = "anova", - study_type: Optional[str] = None -) -> str: - """ - Complete Measurement System Analysis with automatic study type detection and validation. - - This tool performs comprehensive MSA workflow: - 1. Validates data file and schema - 2. Auto-detects columns if not specified - 3. Detects study type (Gage R&R, Bias, Linearity, Stability) - 4. Validates data structure for the detected study - 5. Runs appropriate MSA study - 6. Calculates all relevant metrics - 7. Generates comprehensive HTML report - - Args: - data_path: Path to CSV file containing measurement data - part_col: Column for part/sample identifiers (auto-detected if None) - operator_col: Column for operator/appraiser identifiers (auto-detected if None) - measurement_col: Column for measurement values (auto-detected if None) - trial_col: Column for trial/repeat number (auto-detected if None) - reference_col: Column for reference/standard values (auto-detected if None) - date_col: Column for date/time (auto-detected if None) - tolerance: Process tolerance for %Tolerance calculation (optional) - method: Gage R&R method - 'anova' (default) or 'range' - study_type: Override auto-detection with specific study type (optional) - Options: 'Gage R&R', 'Bias', 'Linearity', 'Stability' - - Returns: - JSON string with complete MSA results including: - - Study type identified - - Validation status - - All MSA metrics (%GRR, bias, linearity, stability) - - Acceptance criteria evaluation - - HTML report path - - Recommendations - """ - try: - # ===== STEP 1: LOAD AND VALIDATE DATA ===== - try: - data = pd.read_csv(data_path) - except FileNotFoundError: - return json.dumps({ - "status": "error", - "error_type": "file_not_found", - "message": f"File not found: {data_path}", - "suggestion": "Please check the file path and try again." - }) - except pd.errors.EmptyDataError: - return json.dumps({ - "status": "error", - "error_type": "empty_file", - "message": "File is empty", - "suggestion": "Please provide a CSV file with measurement data." - }) - except Exception as e: - return json.dumps({ - "status": "error", - "error_type": "read_error", - "message": f"Error reading file: {str(e)}", - "suggestion": "Make sure it's a valid CSV file." - }) - - # ===== STEP 2: SCHEMA VALIDATION ===== - if data.empty: - return json.dumps({ - "status": "error", - "error_type": "no_data", - "message": "CSV file contains no data rows", - "suggestion": "Ensure the file has data rows, not just headers." - }) - - available_columns = list(data.columns) - - # Auto-detect measurement column if not specified - if measurement_col is None: - numeric_cols = data.select_dtypes(include=[np.number]).columns.tolist() - if len(numeric_cols) == 0: - return json.dumps({ - "status": "error", - "error_type": "no_numeric_columns", - "message": "No numeric columns found for measurements", - "available_columns": available_columns, - "suggestion": "Ensure your CSV has at least one numeric column for measurements." - }) - measurement_col = numeric_cols[0] - - # Auto-detect other columns based on common naming patterns - cols_lower = {col.lower(): col for col in data.columns} - - if part_col is None: - part_col = next((cols_lower[c] for c in cols_lower if 'part' in c), None) - - if operator_col is None: - operator_col = next((cols_lower[c] for c in cols_lower if 'operator' in c or 'appraiser' in c), None) - - if trial_col is None: - trial_col = next((cols_lower[c] for c in cols_lower if 'trial' in c or 'repeat' in c), None) - - if reference_col is None: - reference_col = next((cols_lower[c] for c in cols_lower if 'reference' in c or 'standard' in c or 'master' in c), None) - - if date_col is None: - date_col = next((cols_lower[c] for c in cols_lower if 'date' in c or 'time' in c), None) - - # Validate measurement column exists - if measurement_col not in data.columns: - return json.dumps({ - "status": "error", - "error_type": "column_not_found", - "message": f"Measurement column '{measurement_col}' not found", - "available_columns": available_columns, - "suggestion": "Specify measurement_col or ensure data has a numeric column." - }) - - # ===== STEP 3: CREATE PIPELINE ===== - pipeline = MSAPipeline( - data=data, - part_col=part_col, - operator_col=operator_col, - measurement_col=measurement_col, - trial_col=trial_col, - reference_col=reference_col, - date_col=date_col - ) - - # ===== STEP 4: DETECT OR USE SPECIFIED STUDY TYPE ===== - if study_type: - detected_study_type = study_type - pipeline.study_type = study_type - else: - detected_study_type = pipeline.detect_study_type() - - # ===== STEP 5: VALIDATE DATA QUALITY FOR STUDY TYPE ===== - quality_issues = pipeline.validate_data_quality(study_type=detected_study_type) - - # Check for critical errors - has_critical_errors = any('ERROR' in issue for issue in quality_issues) - if has_critical_errors: - error_messages = [issue for issue in quality_issues if 'ERROR' in issue] - warning_messages = [issue for issue in quality_issues if 'WARNING' in issue] - return json.dumps({ - "status": "error", - "error_type": "data_quality_error", - "message": "Data quality issues detected", - "study_type": detected_study_type, - "errors": error_messages, - "warnings": warning_messages, - "suggestion": "Fix the errors in your data structure and try again." - }) - - # ===== STEP 6: RUN APPROPRIATE MSA STUDY ===== - try: - if detected_study_type == "Gage R&R": - if not part_col or not operator_col: - return json.dumps({ - "status": "error", - "error_type": "missing_columns", - "message": "Gage R&R requires Part and Operator columns", - "available_columns": available_columns, - "detected_columns": { - "part_col": part_col, - "operator_col": operator_col, - "measurement_col": measurement_col - }, - "suggestion": "Specify part_col and operator_col explicitly or ensure columns are named 'Part' and 'Operator'." - }) - - results = pipeline.run_gage_rr(tolerance=tolerance, method=method) - - # Determine acceptance - grr_percent = results.get('grr_percent', 100) - if grr_percent < 10: - acceptance = "Excellent" - recommendation = "Measurement system is excellent. %GRR < 10% - ready for process control." - elif grr_percent < 30: - acceptance = "Acceptable" - recommendation = "Measurement system is acceptable. %GRR 10-30% - suitable for most applications." - else: - acceptance = "Unacceptable" - recommendation = "Measurement system is unacceptable. %GRR > 30% - requires improvement: calibrate equipment, train operators, improve measurement procedure." - - elif detected_study_type == "Bias": - if not reference_col: - return json.dumps({ - "status": "error", - "error_type": "missing_columns", - "message": "Bias study requires Reference column", - "available_columns": available_columns, - "suggestion": "Specify reference_col or ensure column is named 'Reference' or 'Standard'." - }) - - results = pipeline.run_bias_study() - - # Determine significance - is_significant = results.get('p_value', 1.0) < 0.05 - if is_significant: - acceptance = "Significant Bias Detected" - recommendation = f"Bias is statistically significant (p={results.get('p_value', 0):.4f}). Recalibrate equipment or adjust measurement procedure." - else: - acceptance = "No Significant Bias" - recommendation = "Bias is not statistically significant. Measurement system is unbiased." - - elif detected_study_type == "Linearity": - if not reference_col: - return json.dumps({ - "status": "error", - "error_type": "missing_columns", - "message": "Linearity study requires Reference column", - "available_columns": available_columns, - "suggestion": "Specify reference_col or ensure column is named 'Reference'." - }) - - results = pipeline.run_linearity_study() - - # Determine linearity issue - has_linearity_issue = results.get('p_value', 1.0) < 0.05 - if has_linearity_issue: - acceptance = "Linearity Issue Detected" - recommendation = "Bias changes across measurement range. Check calibration at all levels." - else: - acceptance = "Linearity Acceptable" - recommendation = "Bias is consistent across measurement range. No linearity issues." - - elif detected_study_type == "Stability": - if not date_col: - return json.dumps({ - "status": "error", - "error_type": "missing_columns", - "message": "Stability study requires Date/Time column", - "available_columns": available_columns, - "suggestion": "Specify date_col or ensure column is named 'Date' or 'Time'." - }) - - results = pipeline.run_stability_study() - - # Determine stability - is_stable = results.get('out_of_control_points', 0) == 0 - if is_stable: - acceptance = "Stable" - recommendation = "Measurement system is stable over time. No drift detected." - else: - acceptance = "Unstable" - recommendation = f"Instability detected: {results.get('out_of_control_points', 0)} out-of-control points. Investigate drift, wear, or environmental changes." - - else: - return json.dumps({ - "status": "error", - "error_type": "unknown_study_type", - "message": f"Unknown study type: {detected_study_type}", - "suggestion": "Specify study_type as 'Gage R&R', 'Bias', 'Linearity', or 'Stability'." - }) - - except Exception as e: - return json.dumps({ - "status": "error", - "error_type": "calculation_error", - "message": f"Error running {detected_study_type} study: {str(e)}", - "suggestion": "Check if your data structure matches the study type requirements." - }) - - # ===== STEP 7: GENERATE HTML REPORT ===== - try: - html_report_path = pipeline.save_report(filename=data_path.replace('.csv', '_msa_report.html')) - except Exception as e: - html_report_path = None - html_error = str(e) - - # ===== STEP 8: GENERATE PLOT ===== - try: - plot_fig = pipeline.generate_plot() - plot_generated = True - except Exception as e: - plot_generated = False - plot_error = str(e) - - # ===== STEP 9: PREPARE RESPONSE ===== - warnings = [issue for issue in quality_issues if 'WARNING' in issue] - - response = { - "status": "success", - "analysis_complete": True, - "study_info": { - "study_type": detected_study_type, - "file": data_path, - "sample_size": len(data), - "columns_used": { - "measurement_col": measurement_col, - "part_col": part_col, - "operator_col": operator_col, - "trial_col": trial_col, - "reference_col": reference_col, - "date_col": date_col - } - }, - "results": results, - "acceptance": acceptance, - "recommendations": recommendation, - "html_report": html_report_path if html_report_path else "Report generation failed", - "plot_generated": plot_generated - } - - # Add warnings if any - if warnings: - response["warnings"] = warnings - - return json.dumps(response, indent=2) - - except Exception as e: - return json.dumps({ - "status": "error", - "error_type": "unexpected_error", - "message": f"Unexpected error during MSA analysis: {str(e)}", - "suggestion": "Please check your data format and try again." - }) diff --git a/process_capability_system/__init__.py b/process_capability_system/__init__.py deleted file mode 100644 index 508ab78..0000000 --- a/process_capability_system/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -""" -Process Capability System -Process Capability Analysis with AI Agent capabilities - -This package contains: -- ProcessCapabilityPipeline: Core capability analysis engine -- Capability Tool: Single comprehensive LangGraph tool -- Capability Agent: Conversational AI interface - -Supported Analysis: -- Cp, Cpk (Short-term capability) -- Pp, Ppk (Long-term performance) -- Yield and DPMO analysis -- Process centering assessment -""" - -from .capability_pipeline import ProcessCapabilityPipeline -from .capability_tools import run_capability_analysis - -__version__ = "2.0.0" -__author__ = "Process Capability AI Team" - -__all__ = [ - "ProcessCapabilityPipeline", - "run_capability_analysis" -] diff --git a/process_capability_system/capability_agent.py b/process_capability_system/capability_agent.py deleted file mode 100644 index 9fa20b3..0000000 --- a/process_capability_system/capability_agent.py +++ /dev/null @@ -1,30 +0,0 @@ -import sys -from pathlib import Path -from langchain.agents import create_agent -from langgraph.checkpoint.memory import InMemorySaver -from langgraph.store.memory import InMemoryStore - -# Add parent directory to path for imports -parent_dir = Path(__file__).parent.parent -sys.path.insert(0, str(parent_dir)) - -from process_capability_system.capability_tools import run_capability_analysis -from agent_config.agent_prompts import get_capability_prompt -from agent_config.config_loader import get_llm_model_string - -# Load the agent prompt from JSON configuration -Capability_agent_prompt = get_capability_prompt() - -# Initialize shared memory components -checkpointer = InMemorySaver() -store = InMemoryStore() - -# Create the agent graph (exported for use in API) -capability_graph = create_agent( - model=get_llm_model_string(), # Load from config.yaml - tools=[run_capability_analysis], # Single comprehensive tool - checkpointer=checkpointer, - store=store, - system_prompt=Capability_agent_prompt, - name="Capability Agent" -) \ No newline at end of file diff --git a/process_capability_system/capability_pipeline.py b/process_capability_system/capability_pipeline.py deleted file mode 100644 index 69bc910..0000000 --- a/process_capability_system/capability_pipeline.py +++ /dev/null @@ -1,975 +0,0 @@ -""" -Process Capability Analysis Pipeline -Comprehensive analysis of process performance vs specifications -""" -import pandas as pd -import numpy as np -import plotly.graph_objects as go -from plotly.subplots import make_subplots -from scipy import stats -import warnings - - -class ProcessCapabilityPipeline: - def __init__(self, data, measurement_col=None, subgroup_col=None, - usl=None, lsl=None, target=None, date_col=None): - """ - Initialize the Process Capability Pipeline - - Parameters: - data: DataFrame containing process data - measurement_col: Column name for measurement values - subgroup_col: Column name for subgroup/rational subgroup (optional) - usl: Upper Specification Limit - lsl: Lower Specification Limit - target: Target value (nominal, optional - defaults to midpoint) - date_col: Column for time-series analysis (optional) - """ - self.data = pd.DataFrame(data) if not isinstance(data, pd.DataFrame) else data - self.measurement_col = measurement_col - self.subgroup_col = subgroup_col - self.usl = usl - self.lsl = lsl - self.target = target - self.date_col = date_col - self.results = {} - - # Auto-detect measurement column if not provided - if self.measurement_col is None: - numeric_cols = self.data.select_dtypes(include=[np.number]).columns - if len(numeric_cols) > 0: - self.measurement_col = numeric_cols[0] - else: - raise ValueError("No numeric columns found for analysis") - - # Set target to midpoint if not provided - if self.target is None and self.usl is not None and self.lsl is not None: - self.target = (self.usl + self.lsl) / 2 - - self.data_quality_issues = [] - - def validate_data_quality(self): - """ - Validate data quality before capability analysis - Returns list of issues found - """ - issues = [] - - # Auto-detect measurement column (SMART DETECTION) - if self.measurement_col is None: - numeric_cols = self.data.select_dtypes(include=[np.number]).columns.tolist() - if len(numeric_cols) == 0: - issues.append("ERROR: No numeric columns found for analysis") - return issues - - # Skip columns that are likely identifiers/grouping variables - skip_patterns = ['id', 'subgroup', 'batch', 'sample', 'group', 'lot', 'serial', 'number'] - filtered_cols = [c for c in numeric_cols if not any(pattern in c.lower() for pattern in skip_patterns)] - - # If we have columns after filtering, use those - if filtered_cols: - numeric_cols = filtered_cols - - # Prefer columns with measurement-related names - priority_names = ['measurement', 'value', 'measure', 'reading', 'result', 'data'] - for priority in priority_names: - matching = [c for c in numeric_cols if priority in c.lower()] - if matching: - self.measurement_col = matching[0] - break - else: - # Fallback to first remaining numeric column - self.measurement_col = numeric_cols[0] - - # Check column exists - if self.measurement_col not in self.data.columns: - issues.append(f"ERROR: Column '{self.measurement_col}' not found. Available: {list(self.data.columns)}") - return issues - - values = self.data[self.measurement_col] - - # Check for missing values - missing_count = values.isna().sum() - if missing_count > 0: - missing_rows = values[values.isna()].index.tolist() - issues.append(f"WARNING: {missing_count} missing values in rows: {missing_rows[:10]}{'...' if len(missing_rows) > 10 else ''}. Remove before capability analysis") - - # Check for non-numeric values - try: - numeric_values = pd.to_numeric(values, errors='coerce') - non_numeric = numeric_values.isna().sum() - missing_count - if non_numeric > 0: - issues.append(f"ERROR: {non_numeric} non-numeric values found in '{self.measurement_col}'") - except: - pass - - # Check sample size - values_clean = values.dropna() - if len(values_clean) < 30: - issues.append(f"WARNING: Only {len(values_clean)} points. Minimum 30 recommended for reliable Cpk (50+ preferred)") - - # Check for error codes - if len(values_clean) > 0: - error_codes = values_clean[values_clean.isin([999, 9999, -999, -9999])] - if len(error_codes) > 0: - issues.append(f"WARNING: Potential error codes (999, -999) in rows: {error_codes.index.tolist()}") - - # Check for impossible negative values - if values_clean.min() < 0: - negative_rows = values_clean[values_clean < 0].index.tolist() - issues.append(f"WARNING: Negative values in rows: {negative_rows}. Check if these are data errors") - - # Check if all identical - if len(values_clean) > 0 and values_clean.nunique() == 1: - issues.append(f"ERROR: All values are identical ({values_clean.iloc[0]}). Cannot calculate capability with no variation") - - # Check specifications - if self.usl is None or self.lsl is None: - issues.append("ERROR: Both USL and LSL are required for capability analysis. Please provide specification limits") - elif self.usl <= self.lsl: - issues.append(f"ERROR: USL ({self.usl}) must be greater than LSL ({self.lsl})") - - # Check if data is within specs range (sanity check) - if self.usl is not None and self.lsl is not None and len(values_clean) > 0: - outside_spec = ((values_clean < self.lsl) | (values_clean > self.usl)).sum() - if outside_spec > 0.5 * len(values_clean): - issues.append(f"WARNING: {outside_spec}/{len(values_clean)} points ({outside_spec/len(values_clean)*100:.1f}%) outside specifications. Process may be severely incapable") - - self.data_quality_issues = issues - return issues - - def check_normality(self, alpha=0.05): - """ - Check if process data follows normal distribution - Uses multiple normality tests - - Parameters: - alpha: Significance level (default 0.05) - - Returns: - Dictionary with normality test results - """ - values = self.data[self.measurement_col].dropna() - - if len(values) < 3: - return { - "is_normal": None, - "warning": "Insufficient data for normality test (n < 3)" - } - - results = {} - - # 1. Anderson-Darling Test (most common in SPC) - try: - ad_result = stats.anderson(values, dist='norm') - # Critical value at 5% significance is typically index 2 - critical_value = ad_result.critical_values[2] if len(ad_result.critical_values) > 2 else ad_result.critical_values[-1] - ad_pass = ad_result.statistic < critical_value - - results['anderson_darling'] = { - 'statistic': float(ad_result.statistic), - 'critical_value': float(critical_value), - 'passes': bool(ad_pass) - } - except Exception as e: - results['anderson_darling'] = {'error': str(e)} - - # 2. Shapiro-Wilk Test (good for small samples) - if len(values) >= 3 and len(values) <= 5000: - try: - sw_stat, sw_pvalue = stats.shapiro(values) - results['shapiro_wilk'] = { - 'statistic': float(sw_stat), - 'p_value': float(sw_pvalue), - 'passes': bool(sw_pvalue > alpha) - } - except Exception as e: - results['shapiro_wilk'] = {'error': str(e)} - - # 3. Kolmogorov-Smirnov Test - try: - mean = values.mean() - std = values.std() - ks_stat, ks_pvalue = stats.kstest(values, 'norm', args=(mean, std)) - results['kolmogorov_smirnov'] = { - 'statistic': float(ks_stat), - 'p_value': float(ks_pvalue), - 'passes': bool(ks_pvalue > alpha) - } - except Exception as e: - results['kolmogorov_smirnov'] = {'error': str(e)} - - # 4. Skewness and Kurtosis - try: - skew = stats.skew(values) - kurt = stats.kurtosis(values) - # Rules of thumb: skewness between -1 and 1, kurtosis between -1 and 1 - skew_ok = abs(skew) < 1 - kurt_ok = abs(kurt) < 1 - - results['distribution_shape'] = { - 'skewness': float(skew), - 'kurtosis': float(kurt), - 'skewness_acceptable': bool(skew_ok), - 'kurtosis_acceptable': bool(kurt_ok) - } - except Exception as e: - results['distribution_shape'] = {'error': str(e)} - - # Overall assessment - tests_passed = sum([ - results.get('anderson_darling', {}).get('passes', False), - results.get('shapiro_wilk', {}).get('passes', False), - results.get('kolmogorov_smirnov', {}).get('passes', False), - results.get('distribution_shape', {}).get('skewness_acceptable', False), - results.get('distribution_shape', {}).get('kurtosis_acceptable', False) - ]) - - total_tests = 5 - - # If majority of tests pass, consider normal - is_normal = tests_passed >= (total_tests / 2) - - results['overall'] = { - 'is_normal': bool(is_normal), - 'tests_passed': int(tests_passed), - 'total_tests': int(total_tests), - 'confidence': 'High' if tests_passed >= 4 else 'Medium' if tests_passed >= 3 else 'Low', - 'recommendation': self._normality_recommendation(is_normal, results) - } - - self.results['normality'] = results - return results - - def _normality_recommendation(self, is_normal, test_results): - """Generate recommendation based on normality test results""" - if is_normal: - return "Data appears normally distributed. Cp/Cpk calculations are valid." - else: - skew = test_results.get('distribution_shape', {}).get('skewness', 0) - - if abs(skew) > 2: - return "Data is highly skewed. Consider Box-Cox or log transformation before capability analysis." - elif abs(skew) > 1: - return "Data shows moderate skewness. Capability indices may be approximate. Consider using percentile method or transformation." - else: - return "Data is slightly non-normal. Capability indices are reasonable but interpret with caution." - - def suggest_transformation(self): - """ - Suggest appropriate transformation for non-normal data - """ - values = self.data[self.measurement_col] - - # Check current distribution - if 'normality' not in self.results: - self.check_normality() - - if self.results['normality']['overall']['is_normal']: - return { - 'needed': False, - 'message': 'Data is already normally distributed. No transformation needed.' - } - - skew = self.results['normality']['distribution_shape']['skewness'] - - # Determine best transformation - if skew > 1: - suggested = "Log transformation (for right-skewed data)" - formula = "log(x) or log(x+1) if values include zero" - elif skew < -1: - suggested = "Square transformation (for left-skewed data)" - formula = "x^2" - else: - suggested = "Box-Cox transformation (automatic optimization)" - formula = "Optimizes lambda parameter" - - # Try Box-Cox if all values positive - if np.all(values > 0): - try: - transformed, lambda_param = stats.boxcox(values) - boxcox_suggestion = f"Box-Cox with lambda={lambda_param:.3f}" - except: - boxcox_suggestion = "Box-Cox failed (try manual transformation)" - else: - boxcox_suggestion = "Box-Cox requires all positive values" - - return { - 'needed': True, - 'current_skewness': float(skew), - 'suggested_transformation': suggested, - 'formula': formula, - 'boxcox': boxcox_suggestion, - 'alternative': 'Use non-parametric capability methods (percentile-based)' - } - - def calculate_short_term_capability(self, check_normality=True): - """ - Calculate short-term capability (Cp, Cpk) - Uses within-subgroup variation (if subgroups present) - - Parameters: - check_normality: If True, checks normality before calculation (default: True) - """ - values = self.data[self.measurement_col] - - if self.usl is None or self.lsl is None: - raise ValueError("Both USL and LSL are required for capability analysis") - - # Check normality if requested - if check_normality: - normality_result = self.check_normality() - if not normality_result['overall']['is_normal']: - warnings.warn( - f"Data may not be normally distributed. " - f"{normality_result['overall']['recommendation']}" - ) - - # Calculate process mean - mean = values.mean() - - # Calculate short-term (within) standard deviation - if self.subgroup_col and self.subgroup_col in self.data.columns: - # Use within-subgroup variation (Rbar/d2 method) - subgroups = self.data.groupby(self.subgroup_col)[self.measurement_col] - ranges = subgroups.apply(lambda x: x.max() - x.min() if len(x) > 1 else 0) - rbar = ranges.mean() - - # d2 constant based on subgroup size - n = int(subgroups.size().mean()) - d2_values = {2: 1.128, 3: 1.693, 4: 2.059, 5: 2.326, 6: 2.534, - 7: 2.704, 8: 2.847, 9: 2.970, 10: 3.078} - d2 = d2_values.get(n, 3.0) # Default to 3.0 for larger groups - - sigma_within = rbar / d2 - else: - # No subgroups - use overall standard deviation as approximation - # For true short-term, consecutive measurements should be used - sigma_within = values.std(ddof=1) - - # Calculate Cp (potential capability) - spec_width = self.usl - self.lsl - process_width = 6 * sigma_within - cp = spec_width / process_width if process_width > 0 else 0 - - # Calculate Cpk (actual capability - accounts for centering) - cpu = (self.usl - mean) / (3 * sigma_within) if sigma_within > 0 else 0 - cpl = (mean - self.lsl) / (3 * sigma_within) if sigma_within > 0 else 0 - cpk = min(cpu, cpl) - - # Calculate Cpm (capability relative to target) - if self.target is not None: - tau_squared = sigma_within ** 2 + (mean - self.target) ** 2 - cpm = spec_width / (6 * np.sqrt(tau_squared)) if tau_squared > 0 else 0 - else: - cpm = None - - self.results['short_term'] = { - 'sigma_within': float(sigma_within), - 'Cp': float(cp), - 'Cpk': float(cpk), - 'Cpu': float(cpu), - 'Cpl': float(cpl), - 'Cpm': float(cpm) if cpm is not None else None - } - - return self.results['short_term'] - - def calculate_long_term_capability(self): - """ - Calculate long-term capability (Pp, Ppk) - Uses overall (total) variation - """ - values = self.data[self.measurement_col] - - if self.usl is None or self.lsl is None: - raise ValueError("Both USL and LSL are required for capability analysis") - - # Calculate process mean - mean = values.mean() - - # Calculate long-term (overall) standard deviation - sigma_overall = values.std(ddof=1) - - # Calculate Pp (potential performance) - spec_width = self.usl - self.lsl - process_width = 6 * sigma_overall - pp = spec_width / process_width if process_width > 0 else 0 - - # Calculate Ppk (actual performance - accounts for centering) - ppu = (self.usl - mean) / (3 * sigma_overall) if sigma_overall > 0 else 0 - ppl = (mean - self.lsl) / (3 * sigma_overall) if sigma_overall > 0 else 0 - ppk = min(ppu, ppl) - - # Calculate Ppm (performance relative to target) - if self.target is not None: - tau_squared = sigma_overall ** 2 + (mean - self.target) ** 2 - ppm = spec_width / (6 * np.sqrt(tau_squared)) if tau_squared > 0 else 0 - else: - ppm = None - - self.results['long_term'] = { - 'sigma_overall': float(sigma_overall), - 'Pp': float(pp), - 'Ppk': float(ppk), - 'Ppu': float(ppu), - 'Ppl': float(ppl), - 'Ppm': float(ppm) if ppm is not None else None - } - - return self.results['long_term'] - - def calculate_process_performance(self): - """ - Calculate process performance metrics - """ - values = self.data[self.measurement_col] - mean = values.mean() - std = values.std(ddof=1) - - # Count defects - above_usl = np.sum(values > self.usl) if self.usl is not None else 0 - below_lsl = np.sum(values < self.lsl) if self.lsl is not None else 0 - total_defects = above_usl + below_lsl - - # Calculate yield - total_count = len(values) - yield_pct = ((total_count - total_defects) / total_count * 100) if total_count > 0 else 0 - - # Calculate DPMO (Defects Per Million Opportunities) - dpmo = (total_defects / total_count * 1_000_000) if total_count > 0 else 0 - - # Estimate Z-score (sigma level) - if self.usl is not None and self.lsl is not None: - z_usl = (self.usl - mean) / std if std > 0 else 0 - z_lsl = (mean - self.lsl) / std if std > 0 else 0 - z_bench = min(z_usl, z_lsl) - elif self.usl is not None: - z_bench = (self.usl - mean) / std if std > 0 else 0 - elif self.lsl is not None: - z_bench = (mean - self.lsl) / std if std > 0 else 0 - else: - z_bench = None - - # Estimate expected DPMO from Z-score - if z_bench is not None and z_bench > 0: - expected_dpmo = (1 - stats.norm.cdf(z_bench)) * 1_000_000 - else: - expected_dpmo = None - - self.results['performance'] = { - 'mean': float(mean), - 'std_dev': float(std), - 'above_usl': int(above_usl), - 'below_lsl': int(below_lsl), - 'total_defects': int(total_defects), - 'total_count': int(total_count), - 'yield_percent': float(yield_pct), - 'dpmo': float(dpmo), - 'z_bench': float(z_bench) if z_bench is not None else None, - 'expected_dpmo': float(expected_dpmo) if expected_dpmo is not None else None - } - - return self.results['performance'] - - def analyze_centering(self): - """ - Analyze process centering relative to target and specifications - """ - values = self.data[self.measurement_col] - mean = values.mean() - - if self.usl is None or self.lsl is None: - return None - - # Calculate spec midpoint - spec_midpoint = (self.usl + self.lsl) / 2 - - # Calculate offset from target and midpoint - offset_from_target = mean - self.target if self.target is not None else None - offset_from_midpoint = mean - spec_midpoint - - # Calculate % of tolerance used - tolerance = self.usl - self.lsl - pct_tolerance_used = (abs(offset_from_midpoint) / (tolerance / 2) * 100) - - # Determine if process is centered - # Generally, within 25% of center is considered acceptably centered - is_centered = pct_tolerance_used < 25 - - self.results['centering'] = { - 'process_mean': float(mean), - 'spec_midpoint': float(spec_midpoint), - 'target': float(self.target) if self.target is not None else None, - 'offset_from_midpoint': float(offset_from_midpoint), - 'offset_from_target': float(offset_from_target) if offset_from_target is not None else None, - 'pct_tolerance_used': float(pct_tolerance_used), - 'is_centered': bool(is_centered), - 'recommendation': 'Process is well centered' if is_centered else 'Process should be re-centered' - } - - return self.results['centering'] - - def generate_full_analysis(self): - """ - Run complete capability analysis - """ - # Calculate all metrics - self.calculate_short_term_capability() - self.calculate_long_term_capability() - self.calculate_process_performance() - self.analyze_centering() - - # Generate interpretation - cpk = self.results['short_term']['Cpk'] - ppk = self.results['long_term']['Ppk'] - - if cpk >= 1.67: - cpk_rating = "Excellent (Six Sigma capable)" - elif cpk >= 1.33: - cpk_rating = "Adequate (meets requirements)" - elif cpk >= 1.0: - cpk_rating = "Marginal (may need improvement)" - else: - cpk_rating = "Unacceptable (requires improvement)" - - if ppk >= 1.67: - ppk_rating = "Excellent" - elif ppk >= 1.33: - ppk_rating = "Adequate" - elif ppk >= 1.0: - ppk_rating = "Marginal" - else: - ppk_rating = "Unacceptable" - - self.results['interpretation'] = { - 'cpk_rating': cpk_rating, - 'ppk_rating': ppk_rating, - 'overall_assessment': self._generate_assessment() - } - - return self.results - - def _generate_assessment(self): - """Generate overall process assessment""" - cpk = self.results['short_term']['Cpk'] - ppk = self.results['long_term']['Ppk'] - yield_pct = self.results['performance']['yield_percent'] - - assessment = [] - - # Capability assessment - if cpk >= 1.33: - assessment.append(f"✓ Process is capable (Cpk={cpk:.2f})") - else: - assessment.append(f"⚠ Process capability needs improvement (Cpk={cpk:.2f})") - - # Performance assessment - if ppk >= 1.33: - assessment.append(f"✓ Process performance is good (Ppk={ppk:.2f})") - else: - assessment.append(f"⚠ Process performance needs improvement (Ppk={ppk:.2f})") - - # Yield assessment - if yield_pct >= 99.73: - assessment.append(f"✓ Excellent yield ({yield_pct:.2f}%)") - elif yield_pct >= 99: - assessment.append(f"✓ Good yield ({yield_pct:.2f}%)") - else: - assessment.append(f"⚠ Yield needs improvement ({yield_pct:.2f}%)") - - # Centering - if self.results['centering']['is_centered']: - assessment.append("✓ Process is well centered") - else: - assessment.append("⚠ Process should be re-centered") - - return " | ".join(assessment) - - def generate_normality_plot(self): - """Generate comprehensive normality assessment plots""" - values = self.data[self.measurement_col].dropna() - - fig = make_subplots( - rows=2, cols=2, - subplot_titles=['Histogram with Normal Curve', 'Normal Probability Plot (Q-Q)', - 'Box Plot', 'Distribution Statistics'], - specs=[[{"type": "histogram"}, {"type": "scatter"}], - [{"type": "box"}, {"type": "table"}]] - ) - - mean = values.mean() - std = values.std() - - # 1. Histogram with normal overlay - fig.add_trace( - go.Histogram(x=values, name='Data', nbinsx=30, histnorm='probability density'), - row=1, col=1 - ) - - # Normal curve overlay - x_range = np.linspace(values.min(), values.max(), 200) - y_normal = stats.norm.pdf(x_range, mean, std) - fig.add_trace( - go.Scatter(x=x_range, y=y_normal, mode='lines', - name='Normal Curve', line=dict(color='red', width=2)), - row=1, col=1 - ) - - # 2. Q-Q Plot (Normal Probability Plot) - sorted_values = np.sort(values) - n = len(sorted_values) - theoretical_quantiles = stats.norm.ppf(np.arange(1, n + 1) / (n + 1)) - - fig.add_trace( - go.Scatter(x=theoretical_quantiles, y=sorted_values, mode='markers', - name='Data Points', marker=dict(color='blue')), - row=1, col=2 - ) - - # Reference line - slope = std - intercept = mean - ref_line = slope * theoretical_quantiles + intercept - fig.add_trace( - go.Scatter(x=theoretical_quantiles, y=ref_line, mode='lines', - line=dict(color='red', dash='dash'), name='Perfect Normal'), - row=1, col=2 - ) - - # 3. Box Plot - fig.add_trace( - go.Box(y=values, name='Distribution', boxmean='sd'), - row=2, col=1 - ) - - # 4. Statistics table - if 'normality' in self.results: - norm_results = self.results['normality'] - - table_data = [ - ['Test', 'Result', 'Status'], - ['Anderson-Darling', - f"{norm_results.get('anderson_darling', {}).get('statistic', 'N/A'):.4f}" if 'anderson_darling' in norm_results else 'N/A', - '✓ Pass' if norm_results.get('anderson_darling', {}).get('passes', False) else '✗ Fail'], - ['Shapiro-Wilk', - f"p={norm_results.get('shapiro_wilk', {}).get('p_value', 0):.4f}" if 'shapiro_wilk' in norm_results else 'N/A', - '✓ Pass' if norm_results.get('shapiro_wilk', {}).get('passes', False) else '✗ Fail'], - ['Skewness', - f"{norm_results.get('distribution_shape', {}).get('skewness', 0):.3f}", - '✓ OK' if norm_results.get('distribution_shape', {}).get('skewness_acceptable', False) else '✗ High'], - ['Kurtosis', - f"{norm_results.get('distribution_shape', {}).get('kurtosis', 0):.3f}", - '✓ OK' if norm_results.get('distribution_shape', {}).get('kurtosis_acceptable', False) else '✗ High'], - ['Overall', - f"{norm_results['overall']['tests_passed']}/{norm_results['overall']['total_tests']}", - '✓ Normal' if norm_results['overall']['is_normal'] else '✗ Non-Normal'] - ] - else: - # Run normality check - self.check_normality() - return self.generate_normality_plot() # Recursive call with results - - fig.add_trace( - go.Table( - header=dict(values=table_data[0], fill_color='paleturquoise', align='left'), - cells=dict(values=list(zip(*table_data[1:])), fill_color='lavender', align='left') - ), - row=2, col=2 - ) - - fig.update_layout( - height=800, - title_text="Normality Assessment", - showlegend=False - ) - - return fig - - def generate_histogram(self): - """Generate process histogram with spec limits and normal curve""" - values = self.data[self.measurement_col] - - fig = go.Figure() - - # Histogram - fig.add_trace(go.Histogram( - x=values, - name='Process Data', - nbinsx=30, - histnorm='probability density', - marker=dict(color='lightblue', line=dict(color='darkblue', width=1)) - )) - - # Normal distribution curve - mean = values.mean() - std = values.std() - x_range = np.linspace(values.min(), values.max(), 200) - y_normal = stats.norm.pdf(x_range, mean, std) - - fig.add_trace(go.Scatter( - x=x_range, - y=y_normal, - name='Normal Distribution', - line=dict(color='red', width=2) - )) - - # Specification limits - if self.lsl is not None: - fig.add_vline(x=self.lsl, line=dict(color='red', width=2, dash='dash'), - annotation_text="LSL", annotation_position="top") - - if self.usl is not None: - fig.add_vline(x=self.usl, line=dict(color='red', width=2, dash='dash'), - annotation_text="USL", annotation_position="top") - - # Target - if self.target is not None: - fig.add_vline(x=self.target, line=dict(color='green', width=2, dash='dot'), - annotation_text="Target", annotation_position="top") - - # Mean - fig.add_vline(x=mean, line=dict(color='blue', width=2), - annotation_text="Mean", annotation_position="bottom") - - fig.update_layout( - title="Process Capability Histogram", - xaxis_title="Measurement", - yaxis_title="Probability Density", - height=500, - showlegend=True - ) - - return fig - - def generate_capability_plot(self): - """Generate multi-panel capability analysis plot""" - fig = make_subplots( - rows=2, cols=2, - subplot_titles=['Histogram with Spec Limits', 'Normal Probability Plot', - 'Individual Values Chart', 'Capability Indices'], - specs=[[{"type": "histogram"}, {"type": "scatter"}], - [{"type": "scatter"}, {"type": "bar"}]] - ) - - values = self.data[self.measurement_col] - mean = values.mean() - std = values.std() - - # 1. Histogram - fig.add_trace( - go.Histogram(x=values, name='Data', nbinsx=30, showlegend=False), - row=1, col=1 - ) - - # Add spec limits to histogram - if self.lsl is not None: - fig.add_vline(x=self.lsl, line=dict(color='red', dash='dash'), row=1, col=1) - if self.usl is not None: - fig.add_vline(x=self.usl, line=dict(color='red', dash='dash'), row=1, col=1) - fig.add_vline(x=mean, line=dict(color='blue'), row=1, col=1) - - # 2. Normal Probability Plot - sorted_values = np.sort(values) - n = len(sorted_values) - theoretical_quantiles = stats.norm.ppf(np.arange(1, n + 1) / (n + 1)) - - fig.add_trace( - go.Scatter(x=theoretical_quantiles, y=sorted_values, mode='markers', - name='Data', showlegend=False), - row=1, col=2 - ) - - # Add reference line - slope = std - intercept = mean - ref_line = slope * theoretical_quantiles + intercept - fig.add_trace( - go.Scatter(x=theoretical_quantiles, y=ref_line, mode='lines', - line=dict(color='red', dash='dash'), name='Normal', showlegend=False), - row=1, col=2 - ) - - # 3. Individual Values Chart - sequence = list(range(1, len(values) + 1)) - fig.add_trace( - go.Scatter(x=sequence, y=values, mode='markers+lines', - name='Measurements', showlegend=False), - row=2, col=1 - ) - - if self.lsl is not None: - fig.add_hline(y=self.lsl, line=dict(color='red', dash='dash'), row=2, col=1) - if self.usl is not None: - fig.add_hline(y=self.usl, line=dict(color='red', dash='dash'), row=2, col=1) - fig.add_hline(y=mean, line=dict(color='blue'), row=2, col=1) - - # 4. Capability Indices Bar Chart - if 'short_term' in self.results and 'long_term' in self.results: - indices = ['Cp', 'Cpk', 'Pp', 'Ppk'] - values_bar = [ - self.results['short_term']['Cp'], - self.results['short_term']['Cpk'], - self.results['long_term']['Pp'], - self.results['long_term']['Ppk'] - ] - - colors = ['green' if v >= 1.33 else 'orange' if v >= 1.0 else 'red' for v in values_bar] - - fig.add_trace( - go.Bar(x=indices, y=values_bar, marker=dict(color=colors), - name='Indices', showlegend=False), - row=2, col=2 - ) - - # Add reference lines - fig.add_hline(y=1.33, line=dict(color='green', dash='dash'), row=2, col=2) - fig.add_hline(y=1.0, line=dict(color='orange', dash='dash'), row=2, col=2) - - fig.update_layout(height=800, title_text="Process Capability Analysis", showlegend=False) - - return fig - - def save_report(self, filename="capability_report.html"): - """Save comprehensive capability report""" - import plotly.io as pio - - if not self.results: - self.generate_full_analysis() - - fig = self.generate_capability_plot() - - html_content = f""" - - - Process Capability Analysis Report - - - -

    Process Capability Analysis Report

    - -
    -

    Specifications

    -

    USL: {self.usl}

    -

    LSL: {self.lsl}

    -

    Target: {self.target if self.target else 'Not specified'}

    -

    Tolerance: {self.usl - self.lsl if self.usl and self.lsl else 'N/A'}

    -
    - - {f'''
    -

    ⚠️ Normality Test Results

    -

    Is Normal: - {'✓ YES' if self.results.get('normality', {}).get('overall', {}).get('is_normal', False) else '✗ NO'} -

    -

    Confidence: {self.results.get('normality', {}).get('overall', {}).get('confidence', 'N/A')}

    -

    Tests Passed: {self.results.get('normality', {}).get('overall', {}).get('tests_passed', 0)}/{self.results.get('normality', {}).get('overall', {}).get('total_tests', 0)}

    -

    Recommendation: {self.results.get('normality', {}).get('overall', {}).get('recommendation', 'Check normality first')}

    -
    - Click for detailed test results -
      -
    • Anderson-Darling: {'✓ Pass' if self.results.get('normality', {}).get('anderson_darling', {}).get('passes', False) else '✗ Fail'} - (Stat: {self.results.get('normality', {}).get('anderson_darling', {}).get('statistic', 'N/A')})
    • -
    • Shapiro-Wilk: {'✓ Pass' if self.results.get('normality', {}).get('shapiro_wilk', {}).get('passes', False) else '✗ Fail'} - (p-value: {self.results.get('normality', {}).get('shapiro_wilk', {}).get('p_value', 'N/A')})
    • -
    • Skewness: {self.results.get('normality', {}).get('distribution_shape', {}).get('skewness', 'N/A'):.3f} - ({'✓ Acceptable' if self.results.get('normality', {}).get('distribution_shape', {}).get('skewness_acceptable', False) else '✗ High'})
    • -
    • Kurtosis: {self.results.get('normality', {}).get('distribution_shape', {}).get('kurtosis', 'N/A'):.3f} - ({'✓ Acceptable' if self.results.get('normality', {}).get('distribution_shape', {}).get('kurtosis_acceptable', False) else '✗ High'})
    • -
    -
    -
    ''' if 'normality' in self.results else ''} - -
    -

    Capability Indices

    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    IndexValueInterpretation
    Cp (Potential Capability){self.results['short_term']['Cp']:.3f} - {'Excellent' if self.results['short_term']['Cp'] >= 1.33 else 'Adequate' if self.results['short_term']['Cp'] >= 1.0 else 'Poor'} -
    Cpk (Actual Capability){self.results['short_term']['Cpk']:.3f} - {'Excellent' if self.results['short_term']['Cpk'] >= 1.33 else 'Adequate' if self.results['short_term']['Cpk'] >= 1.0 else 'Poor'} -
    Pp (Potential Performance){self.results['long_term']['Pp']:.3f} - {'Excellent' if self.results['long_term']['Pp'] >= 1.33 else 'Adequate' if self.results['long_term']['Pp'] >= 1.0 else 'Poor'} -
    Ppk (Actual Performance){self.results['long_term']['Ppk']:.3f} - {'Excellent' if self.results['long_term']['Ppk'] >= 1.33 else 'Adequate' if self.results['long_term']['Ppk'] >= 1.0 else 'Poor'} -
    -
    - -
    -

    Process Performance

    -

    Mean: {self.results['performance']['mean']:.4f}

    -

    Std Dev: {self.results['performance']['std_dev']:.4f}

    -

    Yield: {self.results['performance']['yield_percent']:.2f}%

    -

    DPMO: {self.results['performance']['dpmo']:.0f}

    -

    Defects: {self.results['performance']['total_defects']} out of {self.results['performance']['total_count']}

    -
    - -
    -

    Process Centering

    -

    Process Mean: {self.results['centering']['process_mean']:.4f}

    -

    Spec Midpoint: {self.results['centering']['spec_midpoint']:.4f}

    -

    Offset: {self.results['centering']['offset_from_midpoint']:.4f}

    -

    Status: {self.results['centering']['recommendation']}

    -
    - -
    -

    Visualizations

    - {pio.to_html(fig, include_plotlyjs='cdn')} -
    - -
    -

    Overall Assessment

    -

    {self.results['interpretation']['overall_assessment']}

    -
    - -
    -

    Capability Criteria Guide

    -
      -
    • Cp/Cpk ≥ 1.33: Excellent - Six Sigma capable
    • -
    • Cp/Cpk ≥ 1.00: Adequate - Meets minimum requirements
    • -
    • Cp/Cpk < 1.00: Poor - Process needs improvement
    • -
    -
    - - - """ - - with open(filename, 'w') as f: - f.write(html_content) - - print(f"Capability report saved as: {filename}") - return filename - diff --git a/process_capability_system/capability_tools.py b/process_capability_system/capability_tools.py deleted file mode 100644 index d6e7e0e..0000000 --- a/process_capability_system/capability_tools.py +++ /dev/null @@ -1,349 +0,0 @@ -""" -Process Capability Tool for LangGraph Agent -Single comprehensive tool that wraps ProcessCapabilityPipeline functionality. -""" -import pandas as pd -import numpy as np -import json -from typing import Optional -from langchain.tools import tool -from .capability_pipeline import ProcessCapabilityPipeline - - -@tool -def run_capability_analysis( - data_path: str, - usl: float, - lsl: float, - target: Optional[float] = None, - measurement_col: Optional[str] = None, - subgroup_col: Optional[str] = None, - date_col: Optional[str] = None -) -> str: - """ - Complete process capability analysis with automatic validation and comprehensive metrics. - - This tool performs the entire capability workflow: - 1. Validates data file and schema - 2. Auto-detects measurement column if not specified - 3. Checks data normality (required for Cp/Cpk validity) - 4. Calculates short-term capability (Cp, Cpk, CPU, CPL) - 5. Calculates long-term performance (Pp, Ppk, PPU, PPL) - 6. Analyzes process yield (DPMO, Sigma level, defect rate) - 7. Checks process centering vs target - 8. Generates comprehensive HTML report - - Args: - data_path: Path to CSV file containing process data - usl: Upper Specification Limit (REQUIRED) - lsl: Lower Specification Limit (REQUIRED) - target: Target/nominal value (defaults to midpoint between USL and LSL) - measurement_col: Column with measurement values (auto-detected if None) - subgroup_col: Column for rational subgroups (optional, for Cp vs Pp distinction) - date_col: Column for time sequence (optional) - - Returns: - JSON string with complete capability results including: - - Normality test results - - Capability indices (Cp, Cpk, CPU, CPL) - - Performance indices (Pp, Ppk, PPU, PPL) - - Process yield (DPMO, Sigma level, defects) - - Centering assessment - - Rating and acceptance status - - HTML report path - - Recommendations - """ - try: - # ===== STEP 1: LOAD AND VALIDATE DATA ===== - try: - data = pd.read_csv(data_path) - except FileNotFoundError: - return json.dumps({ - "status": "error", - "error_type": "file_not_found", - "message": f"File not found: {data_path}", - "suggestion": "Please check the file path and try again." - }) - except pd.errors.EmptyDataData: - return json.dumps({ - "status": "error", - "error_type": "empty_file", - "message": "File is empty", - "suggestion": "Please provide a CSV file with process data." - }) - except Exception as e: - return json.dumps({ - "status": "error", - "error_type": "read_error", - "message": f"Error reading file: {str(e)}", - "suggestion": "Make sure it's a valid CSV file." - }) - - # ===== STEP 2: SCHEMA VALIDATION ===== - if data.empty: - return json.dumps({ - "status": "error", - "error_type": "no_data", - "message": "CSV file contains no data rows", - "suggestion": "Ensure the file has data rows, not just headers." - }) - - available_columns = list(data.columns) - - # Auto-detect measurement column if not specified - if measurement_col is None: - numeric_cols = data.select_dtypes(include=[np.number]).columns.tolist() - if len(numeric_cols) == 0: - return json.dumps({ - "status": "error", - "error_type": "no_numeric_columns", - "message": "No numeric columns found for measurements", - "available_columns": available_columns, - "suggestion": "Ensure your CSV has at least one numeric column for measurements." - }) - measurement_col = numeric_cols[0] - - # Validate measurement column exists - if measurement_col not in data.columns: - return json.dumps({ - "status": "error", - "error_type": "column_not_found", - "message": f"Measurement column '{measurement_col}' not found", - "available_columns": available_columns, - "suggestion": "Specify measurement_col or ensure data has a numeric column." - }) - - # ===== STEP 3: VALIDATE SPECIFICATIONS ===== - if usl <= lsl: - return json.dumps({ - "status": "error", - "error_type": "invalid_specifications", - "message": f"USL ({usl}) must be greater than LSL ({lsl})", - "suggestion": "Check your specification limits and provide correct values." - }) - - # Set target to midpoint if not provided - if target is None: - target = (usl + lsl) / 2 - - # ===== STEP 4: CREATE PIPELINE ===== - pipeline = ProcessCapabilityPipeline( - data=data, - measurement_col=measurement_col, - subgroup_col=subgroup_col, - usl=usl, - lsl=lsl, - target=target, - date_col=date_col - ) - - # ===== STEP 5: VALIDATE DATA QUALITY ===== - quality_issues = pipeline.validate_data_quality() - - # Check for critical errors - has_critical_errors = any('ERROR' in issue for issue in quality_issues) - if has_critical_errors: - error_messages = [issue for issue in quality_issues if 'ERROR' in issue] - warning_messages = [issue for issue in quality_issues if 'WARNING' in issue] - return json.dumps({ - "status": "error", - "error_type": "data_quality_error", - "message": "Data quality issues detected", - "errors": error_messages, - "warnings": warning_messages, - "suggestion": "Fix the errors in your data and try again." - }) - - # ===== STEP 6: CHECK NORMALITY ===== - try: - normality_results = pipeline.check_normality() - is_normal = normality_results.get('is_normal', False) - - if not is_normal: - normality_warning = "Data is not normally distributed. Cp/Cpk results may not be valid. Consider data transformation or non-parametric methods." - else: - normality_warning = None - except Exception as e: - normality_results = {"error": str(e)} - is_normal = False - normality_warning = f"Normality check failed: {str(e)}" - - # ===== STEP 7: CALCULATE CAPABILITY INDICES ===== - try: - capability_results = pipeline.calculate_short_term_capability(check_normality=False) - cp = capability_results.get('Cp', 0) - cpk = capability_results.get('Cpk', 0) - cpu = capability_results.get('CPU', 0) - cpl = capability_results.get('CPL', 0) - except Exception as e: - return json.dumps({ - "status": "error", - "error_type": "calculation_error", - "message": f"Error calculating capability: {str(e)}", - "suggestion": "Check if your data is appropriate for capability analysis." - }) - - # ===== STEP 8: CALCULATE PERFORMANCE INDICES ===== - try: - performance_results = pipeline.calculate_long_term_capability() - pp = performance_results.get('Pp', 0) - ppk = performance_results.get('Ppk', 0) - ppu = performance_results.get('PPU', 0) - ppl = performance_results.get('PPL', 0) - except Exception as e: - pp = ppk = ppu = ppl = 0 - performance_error = str(e) - - # ===== STEP 9: ANALYZE YIELD ===== - try: - values = data[measurement_col].dropna() - above_usl = (values > usl).sum() - below_lsl = (values < lsl).sum() - total_defects = above_usl + below_lsl - dpmo = (total_defects / len(values)) * 1_000_000 - yield_pct = ((len(values) - total_defects) / len(values)) * 100 - - # Estimate sigma level from DPMO - if dpmo == 0: - sigma_level = 6.0 - elif dpmo >= 691462: - sigma_level = 1.0 - elif dpmo >= 308538: - sigma_level = 2.0 - elif dpmo >= 66807: - sigma_level = 3.0 - elif dpmo >= 6210: - sigma_level = 4.0 - elif dpmo >= 233: - sigma_level = 5.0 - else: - sigma_level = 6.0 - except Exception as e: - total_defects = dpmo = sigma_level = yield_pct = 0 - yield_error = str(e) - - # ===== STEP 10: CHECK CENTERING ===== - mean = values.mean() - offset_from_target = mean - target - is_centered = abs(cp - cpk) < 0.1 # If Cp ≈ Cpk, process is centered - - # ===== STEP 11: DETERMINE RATING AND ACCEPTANCE ===== - if cpk >= 1.67: - rating = "World-class (5σ)" - acceptance = "Excellent" - recommendation = f"Process capability is world-class (Cpk={cpk:.3f}). Less than 1 DPMO expected. Maintain current performance." - elif cpk >= 1.33: - rating = "Excellent (4σ)" - acceptance = "Excellent" - recommendation = f"Process capability is excellent (Cpk={cpk:.3f}). Approximately {dpmo:.0f} DPMO. Continue monitoring." - elif cpk >= 1.0: - rating = "Adequate (3σ)" - acceptance = "Acceptable" - recommendation = f"Process capability is adequate (Cpk={cpk:.3f}). Approximately {dpmo:.0f} DPMO. Consider improvement to reduce defects." - else: - rating = "Unacceptable" - acceptance = "Unacceptable" - recommendation = f"Process capability is unacceptable (Cpk={cpk:.3f}). High defect rate ({dpmo:.0f} DPMO). URGENT: Reduce variation or adjust centering." - - # Add centering recommendation if needed - if not is_centered: - recommendation += f" Process is off-center by {offset_from_target:.4f} from target. Adjust process mean to improve Cpk." - - # Add normality warning if needed - if normality_warning: - recommendation += f" WARNING: {normality_warning}" - - # ===== STEP 12: GENERATE HTML REPORT ===== - try: - # Run full analysis to populate pipeline.results for report generation - pipeline.generate_full_analysis() - html_report_path = pipeline.save_report(filename=data_path.replace('.csv', '_capability_report.html')) - except Exception as e: - html_report_path = None - html_error = str(e) - - # ===== STEP 13: GENERATE PLOT ===== - try: - plot_fig = pipeline.generate_plot() - plot_generated = True - except Exception as e: - plot_generated = False - plot_error = str(e) - - # ===== STEP 14: PREPARE RESPONSE ===== - warnings = [issue for issue in quality_issues if 'WARNING' in issue] - if normality_warning: - warnings.append(normality_warning) - - response = { - "status": "success", - "analysis_complete": True, - "data_info": { - "file": data_path, - "sample_size": len(values), - "mean": round(float(mean), 4), - "std_dev": round(float(values.std()), 4), - "columns_used": { - "measurement_col": measurement_col, - "subgroup_col": subgroup_col, - "date_col": date_col - } - }, - "specifications": { - "USL": float(usl), - "LSL": float(lsl), - "Target": float(target), - "tolerance": float(usl - lsl) - }, - "normality": { - "is_normal": is_normal, - "shapiro_p_value": round(float(normality_results.get('shapiro_p', 0)), 4) if 'shapiro_p' in normality_results else None, - "anderson_darling_stat": round(float(normality_results.get('anderson_stat', 0)), 4) if 'anderson_stat' in normality_results else None - }, - "capability_indices": { - "Cp": round(float(cp), 3), - "Cpk": round(float(cpk), 3), - "CPU": round(float(cpu), 3), - "CPL": round(float(cpl), 3), - "description": "Short-term capability (within-subgroup variation)" - }, - "performance_indices": { - "Pp": round(float(pp), 3), - "Ppk": round(float(ppk), 3), - "PPU": round(float(ppu), 3), - "PPL": round(float(ppl), 3), - "description": "Long-term performance (overall variation including shifts)" - }, - "process_yield": { - "defects": int(total_defects), - "above_USL": int(above_usl), - "below_LSL": int(below_lsl), - "DPMO": round(float(dpmo), 2), - "sigma_level": round(float(sigma_level), 2), - "yield_percent": round(float(yield_pct), 2) - }, - "centering": { - "is_centered": is_centered, - "offset_from_target": round(float(offset_from_target), 4), - "description": "Process centered" if is_centered else "Process off-center" - }, - "rating": rating, - "acceptance": acceptance, - "recommendations": recommendation, - "html_report": html_report_path if html_report_path else "Report generation failed", - "plot_generated": plot_generated - } - - # Add warnings if any - if warnings: - response["warnings"] = warnings - - return json.dumps(response, indent=2) - - except Exception as e: - return json.dumps({ - "status": "error", - "error_type": "unexpected_error", - "message": f"Unexpected error during capability analysis: {str(e)}", - "suggestion": "Please check your data format and specification limits, then try again." - }) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1ade9e7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,154 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "aspc" +version = "2.0.0" +description = "ASPC - production Statistical Process Control platform: correct core, streaming, TimescaleDB, Next.js dashboard." +readme = "README.md" +requires-python = ">=3.11" +license = { text = "MIT" } +authors = [{ name = "ASPC" }] +keywords = ["spc", "statistical-process-control", "msa", "gage-rr", "capability", "quality"] + +dependencies = [ + "numpy>=1.26", + "scipy>=1.11", + "pydantic>=2.5", + # Hartigan dip test for the multimodality STOP gate. The built-in fallback + # heuristic only catches well-separated mixtures, so ship the real test. + "diptest>=0.9", +] + +[project.optional-dependencies] +data = [ + "polars>=0.20", +] +render = [ + "plotly>=5.18", +] +apps = [ + "fastapi>=0.110", + "uvicorn[standard]>=0.27", + "python-multipart>=0.0.9", + "pyyaml>=6.0", + "python-dotenv>=1.0", + "requests>=2.31", + "python-jose[cryptography]>=3.3", + "passlib[bcrypt]>=1.7", + "bcrypt>=4.0", + "slowapi>=0.1.9", + "structlog>=24.1", + "prometheus-client>=0.20", + "redis>=5.0", + "openpyxl>=3.1", +] +stream = [ + "aiokafka>=0.10", + "aiomqtt>=2.0", +] +tsdb = [ + "sqlalchemy[asyncio]>=2.0", + "alembic>=1.13", + "asyncpg>=0.29", + "psycopg[binary]>=3.1", + "greenlet>=3.0", +] +dev = [ + "polars>=0.20", + "plotly>=5.18", + "fastapi>=0.110", + "uvicorn[standard]>=0.27", + "python-multipart>=0.0.9", + "pyyaml>=6.0", + "python-dotenv>=1.0", + "requests>=2.31", + "python-jose[cryptography]>=3.3", + "passlib[bcrypt]>=1.7", + "bcrypt>=4.0", + "slowapi>=0.1.9", + "structlog>=24.1", + "prometheus-client>=0.20", + "redis>=5.0", + "aiokafka>=0.10", + "aiomqtt>=2.0", + "sqlalchemy[asyncio]>=2.0", + "alembic>=1.13", + "asyncpg>=0.29", + "psycopg[binary]>=3.1", + "greenlet>=3.0", + "pytest>=7.4", + "pytest-asyncio>=0.23", + "httpx>=0.27", + "hypothesis>=6.98", + "ruff>=0.4", + "mypy>=1.9", +] +all = [ + "aspc[data,render,apps,stream,tsdb]", +] + +[project.scripts] +aspc = "apps.cli.main:main" +aspc-api = "apps.api.main:run" +aspc-stream-engine = "services.stream_engine.main:main" +aspc-mqtt-bridge = "services.mqtt_bridge.main:main" + +[tool.setuptools] +packages = [ + "spc_core", + "adapters", + "apps", + "apps.api", + "apps.cli", + "services", + "services.stream_engine", + "services.mqtt_bridge", + "sample_data", +] + +# resilience_data is a repo-local corpus (imported via pytest path), not an +# installable package — keep it out of setuptools so CI/Vercel installs work +# when the tree is absent from the git checkout. + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +asyncio_mode = "auto" +markers = [ + "integration: requires external services (Timescale/Redis); set ASPC_INTEGRATION=1", +] +addopts = "-q -p no:launch_testing -p no:launch_testing_ros -p no:launch_testing_ros_pytest_entrypoint" +filterwarnings = [ + "ignore::FutureWarning:scipy.*", +] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] +ignore = ["E501", "UP042", "B905"] + +[tool.ruff.lint.per-file-ignores] +"apps/api/main.py" = ["B008"] +"apps/api/deps.py" = ["B008"] +"apps/cli/main.py" = ["B008"] +"adapters/persistence_tsdb.py" = ["E402"] +"tests/**" = ["E402", "B011", "B017", "E731"] +"tests/load/**" = ["E731"] + +[tool.mypy] +python_version = "3.12" +ignore_missing_imports = true +warn_return_any = false +warn_unused_ignores = true +check_untyped_defs = true +packages = ["spc_core"] + +# Kept for optional native FastAPI experiments. Preferred slim path uses +# root Dockerfile.vercel (container) — see deploy/vercel/README.md. +[tool.vercel] +entrypoint = "apps.api.main:app" diff --git a/requirements.txt b/requirements.txt index 16cef29..9a71155 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,21 +1,24 @@ -# Core Data Science & Statistics -pandas>=2.0.0 -numpy>=1.24.0 -plotly>=5.14.0 -scipy>=1.10.0 +# ASPC dependencies — generated from pyproject.toml extras. +# Prefer: uv pip install -e ".[dev]" +# Or: uv sync +# +# Minimal core (library only): +numpy>=1.26 +scipy>=1.11 +pydantic>=2.5 -# Environment & Configuration -python-dotenv>=1.0.0 -pyyaml>=6.0.0 - -# AI/LLM Framework -langgraph -langchain -langchain-groq - -# Web Framework & API -fastapi>=0.104.0 -uvicorn[standard]>=0.24.0 -python-multipart>=0.0.6 -requests>=2.31.0 +# Apps (API / CLI) +fastapi>=0.110 +uvicorn[standard]>=0.27 +python-multipart>=0.0.9 +pyyaml>=6.0 +python-dotenv>=1.0 +requests>=2.31 +python-jose[cryptography]>=3.3 +bcrypt>=4.0 +slowapi>=0.1.9 +prometheus-client>=0.20 +redis>=5.0 +# Optional extras — install via uv pip install -e ".[data,render,stream,tsdb,dev]" +# polars, plotly, aiokafka, aiomqtt, sqlalchemy, alembic, asyncpg, psycopg, pytest, ruff, mypy diff --git a/resilience_data/MANIFEST.json b/resilience_data/MANIFEST.json new file mode 100644 index 0000000..7a72654 --- /dev/null +++ b/resilience_data/MANIFEST.json @@ -0,0 +1,1231 @@ +{ + "version": 1, + "cases": [ + { + "id": "imr_in_control_n50", + "path": "cases/spc/imr_in_control_n50.csv", + "entry": "establish", + "standards": [ + "AIAG_SPC", + "ISO_7870" + ], + "columns": { + "values": "measurement" + }, + "params": {}, + "expect": { + "stopped": false, + "frozen": true, + "chart_type": "I-MR", + "gates": { + "freeze": "ok", + "autocorrelation": "ok", + "multimodal": "ok" + }, + "n_signals": { + "lte": 5 + } + } + }, + { + "id": "xbar_r_25x5", + "path": "cases/spc/xbar_r_25x5.csv", + "entry": "establish", + "standards": [ + "AIAG_SPC", + "ISO_7870" + ], + "columns": { + "values": "measurement", + "subgroup_ids": "subgroup" + }, + "params": { + "chart_type": "Xbar-R" + }, + "expect": { + "stopped": false, + "frozen": true, + "chart_type": "Xbar-R", + "gates": { + "freeze": "ok" + } + } + }, + { + "id": "xbar_s_25x10", + "path": "cases/spc/xbar_s_25x10.csv", + "entry": "establish", + "standards": [ + "AIAG_SPC", + "ISO_7870" + ], + "columns": { + "values": "measurement", + "subgroup_ids": "subgroup" + }, + "params": { + "chart_type": "Xbar-S" + }, + "expect": { + "stopped": false, + "frozen": true, + "chart_type": "Xbar-S", + "gates": { + "freeze": "ok" + } + } + }, + { + "id": "p_in_control", + "path": "cases/spc/p_in_control.csv", + "entry": "establish", + "standards": [ + "AIAG_SPC", + "ISO_7870" + ], + "columns": { + "values": "defective", + "sample_sizes": "inspected" + }, + "params": { + "chart_type": "P" + }, + "expect": { + "stopped": false, + "frozen": true, + "chart_type": "P", + "n_plotted": 25, + "gates": { + "freeze": "ok", + "normality": "ok", + "multimodal": "__absent__" + }, + "notes": "Count data is binomial, so the dip test must not run: its tie-heavy ECDF over a handful of distinct integers reads as multimodal and used to STOP in-control attribute studies. multimodal '__absent__' asserts the skip." + } + }, + { + "id": "np_in_control", + "path": "cases/spc/np_in_control.csv", + "entry": "establish", + "standards": [ + "AIAG_SPC", + "ISO_7870" + ], + "columns": { + "values": "defectives", + "sample_sizes": "sample_size" + }, + "params": { + "chart_type": "NP" + }, + "expect": { + "stopped": false, + "frozen": true, + "chart_type": "NP", + "n_plotted": 25, + "gates": { + "freeze": "ok", + "normality": "ok", + "multimodal": "__absent__" + } + } + }, + { + "id": "c_in_control", + "path": "cases/spc/c_in_control.csv", + "entry": "establish", + "standards": [ + "AIAG_SPC", + "ISO_7870" + ], + "columns": { + "values": "defects" + }, + "params": { + "chart_type": "C" + }, + "expect": { + "stopped": false, + "frozen": true, + "chart_type": "C", + "n_plotted": 25, + "gates": { + "freeze": "ok", + "normality": "ok", + "multimodal": "__absent__" + } + } + }, + { + "id": "u_in_control", + "path": "cases/spc/u_in_control.csv", + "entry": "establish", + "standards": [ + "AIAG_SPC", + "ISO_7870" + ], + "columns": { + "values": "defects", + "opportunities": "units" + }, + "params": { + "chart_type": "U" + }, + "expect": { + "stopped": false, + "frozen": true, + "chart_type": "U", + "n_plotted": 25, + "gates": { + "freeze": "ok", + "normality": "ok", + "multimodal": "__absent__" + } + } + }, + { + "id": "imr_mean_shift", + "path": "cases/spc/imr_mean_shift.csv", + "entry": "establish", + "standards": [ + "AIAG_SPC" + ], + "columns": { + "values": "measurement" + }, + "params": {}, + "expect": { + "chart_type": "EWMA", + "chart_route": "EWMA", + "gates": { + "autocorrelation": "warn", + "multimodal": "__absent__", + "normality": "__absent__" + }, + "n_signals": { + "gte": 1 + }, + "rule_ids": { + "includes": [ + "EWMA1" + ] + }, + "frozen": true, + "stopped": false + } + }, + { + "id": "imr_single_spike", + "path": "cases/spc/imr_single_spike.csv", + "entry": "establish", + "standards": [ + "AIAG_SPC" + ], + "columns": { + "values": "measurement" + }, + "params": {}, + "expect": { + "chart_type": "I-MR", + "n_signals": { + "gte": 1 + }, + "rule_ids": { + "includes": [ + "1" + ] + }, + "frozen": true + } + }, + { + "id": "imr_trend", + "path": "cases/spc/imr_trend.csv", + "entry": "establish", + "standards": [ + "AIAG_SPC" + ], + "columns": { + "values": "measurement" + }, + "params": {}, + "expect": { + "chart_type": "EWMA", + "chart_route": "EWMA", + "gates": { + "autocorrelation": "warn", + "multimodal": "__absent__", + "normality": "__absent__" + }, + "n_signals": { + "gte": 1 + }, + "rule_ids": { + "includes": [ + "EWMA1" + ] + }, + "frozen": true + } + }, + { + "id": "xbar_r_variance_increase", + "path": "cases/spc/xbar_r_variance_increase.csv", + "entry": "establish", + "standards": [ + "AIAG_SPC" + ], + "columns": { + "values": "measurement", + "subgroup_ids": "subgroup" + }, + "params": { + "chart_type": "Xbar-R" + }, + "expect": { + "chart_type": "Xbar-R", + "frozen": true, + "stopped": false, + "secondary_name": "range", + "n_secondary_signals": { + "gte": 10 + }, + "secondary_rule_ids": { + "includes": [ + "1" + ] + }, + "notes": "A dispersion shift shows up on the R chart, so the assertion must be on secondary_signals; the Xbar chart alone barely reacts." + } + }, + { + "id": "imr_sustained_small_shift", + "path": "cases/spc/imr_sustained_small_shift.csv", + "entry": "establish", + "standards": [ + "AIAG_SPC", + "ISO_7870" + ], + "columns": { + "values": "measurement" + }, + "params": {}, + "expect": { + "chart_type": "I-MR", + "chart_route": "shewhart", + "ruleset_applied": "nelson", + "gates": { + "autocorrelation": "ok", + "normality": "ok" + }, + "rule_ids": { + "includes": [ + "2", + "6" + ], + "excludes": [ + "1" + ] + }, + "frozen": true, + "stopped": false, + "notes": "A 0.8-sigma sustained shift stays under the autocorrelation gate, so it remains on the Shewhart route. No point breaches 3 sigma, so excludes ['1'] is the point of the case: only the run rules catch this." + } + }, + { + "id": "imr_trend_nelson3", + "path": "cases/spc/imr_trend_nelson3.csv", + "entry": "analyze_control_chart", + "standards": [ + "AIAG_SPC", + "ISO_7870" + ], + "columns": { + "values": "measurement" + }, + "params": { + "ruleset": "nelson" + }, + "expect": { + "chart_type": "I-MR", + "ruleset_applied": "nelson", + "rule_ids": { + "includes": [ + "3", + "5", + "8" + ] + }, + "notes": "Nelson 3 (six monotonic points), plus the zone tests a strong drift necessarily trips on the way out: 5 (2 of 3 beyond 2 sigma) and 8 (8 in a row beyond 1 sigma). Asserted at the chart layer because a drift is autocorrelated by construction, so establish() correctly diverts it to EWMA and the Shewhart trend rule would never run." + } + }, + { + "id": "imr_alternating_nelson4", + "path": "cases/spc/imr_alternating_nelson4.csv", + "entry": "analyze_control_chart", + "standards": [ + "AIAG_SPC", + "ISO_7870" + ], + "columns": { + "values": "measurement" + }, + "params": { + "ruleset": "nelson" + }, + "expect": { + "chart_type": "I-MR", + "ruleset_applied": "nelson", + "rule_ids": { + "includes": [ + "4", + "7" + ] + }, + "notes": "Sawtooth from operator over-adjustment: Nelson 4 (14 alternating) plus Nelson 7 (15 within 1 sigma, because the sawtooth inflates the moving range and so the limits)." + } + }, + { + "id": "imr_we_full_ruleset", + "path": "cases/spc/imr_trend_nelson3.csv", + "entry": "analyze_control_chart", + "standards": [ + "WesternElectric", + "AIAG_SPC" + ], + "columns": { + "values": "measurement" + }, + "params": { + "ruleset": "western_electric" + }, + "expect": { + "ruleset_applied": "western_electric", + "rule_ids": { + "includes": [ + "WE1", + "WE2", + "WE3", + "WE4" + ], + "subset_of": [ + "WE1", + "WE2", + "WE3", + "WE4" + ] + }, + "notes": "Only case exercising the Western Electric ruleset. subset_of proves no Nelson-numbered rule leaks through when WE is selected." + } + }, + { + "id": "imr_mean_shift_phase2", + "path": "cases/spc/imr_mean_shift.csv", + "entry": "phase2_detect", + "standards": [ + "AIAG_SPC" + ], + "columns": { + "values": "measurement" + }, + "params": { + "split": 25 + }, + "expect": { + "n_signals": { + "gte": 1 + }, + "rule_ids": { + "includes": [ + "1" + ] + }, + "frozen": true + } + }, + { + "id": "normal_path", + "path": "cases/spc/normal_path.csv", + "entry": "establish", + "standards": [ + "AIAG_SPC" + ], + "columns": { + "values": "measurement" + }, + "params": {}, + "expect": { + "distribution_flag": "NORMAL", + "gates": { + "normality": "ok", + "freeze": "ok", + "autocorrelation": "ok" + }, + "frozen": true, + "stopped": false, + "chart_type": "I-MR" + } + }, + { + "id": "skewed_boxcox", + "path": "cases/spc/skewed_boxcox.csv", + "entry": "establish", + "standards": [ + "AIAG_SPC", + "Wheeler" + ], + "columns": { + "values": "measurement" + }, + "params": {}, + "expect": { + "distribution_flag": "TRANSFORMED", + "gates": { + "normality": "ok", + "multimodal": "ok", + "freeze": "ok" + }, + "frozen": true, + "stopped": false + } + }, + { + "id": "heavy_tail_wheeler", + "path": "cases/spc/heavy_tail_wheeler.csv", + "entry": "establish", + "standards": [ + "Wheeler" + ], + "columns": { + "values": "measurement" + }, + "params": {}, + "expect": { + "chart_route": "wheeler", + "distribution_flag": "NON_NORMAL_RAW", + "gates": { + "normality": "warn" + }, + "chart_type": "I-MR", + "frozen": true, + "ruleset_applied": "wheeler", + "rule_ids": { + "subset_of": [ + "1" + ] + }, + "notes": "subset_of proves Wheeler actually suppressed the zone/run tests; the chart_route label alone would pass even if all 8 Nelson rules still ran." + } + }, + { + "id": "heavy_tail_wheeler_subgroup", + "path": "cases/spc/heavy_tail_wheeler_subgroup.csv", + "entry": "establish", + "standards": [ + "Wheeler", + "AIAG_SPC" + ], + "columns": { + "values": "measurement", + "subgroup_ids": "subgroup" + }, + "params": {}, + "expect": { + "chart_route": "wheeler", + "distribution_flag": "NON_NORMAL_RAW", + "gates": { + "normality": "warn" + }, + "chart_type": "Xbar-R", + "frozen": true, + "ruleset_applied": "wheeler", + "rule_ids": { + "subset_of": [ + "1" + ] + } + } + }, + { + "id": "autocorrelated_ewma", + "path": "cases/spc/autocorrelated_ewma.csv", + "entry": "establish", + "standards": [ + "AIAG_SPC" + ], + "columns": { + "values": "measurement" + }, + "params": {}, + "expect": { + "chart_type": "EWMA", + "chart_route": "EWMA", + "gates": { + "autocorrelation": "warn", + "multimodal": "__absent__", + "normality": "__absent__", + "freeze": "ok" + }, + "frozen": true, + "stopped": false + } + }, + { + "id": "autocorrelated_cusum", + "path": "cases/spc/autocorrelated_ewma.csv", + "entry": "establish", + "standards": [ + "AIAG_SPC" + ], + "columns": { + "values": "measurement" + }, + "params": { + "autocorrelated_chart": "CUSUM" + }, + "expect": { + "chart_type": "CUSUM", + "chart_route": "CUSUM", + "gates": { + "autocorrelation": "warn", + "multimodal": "__absent__", + "normality": "__absent__", + "freeze": "ok" + }, + "frozen": true, + "stopped": false + } + }, + { + "id": "multimodal_stop", + "path": "cases/spc/multimodal_stop.csv", + "entry": "establish", + "standards": [ + "AIAG_SPC", + "Wheeler" + ], + "columns": { + "values": "measurement" + }, + "params": {}, + "expect": { + "stopped": true, + "frozen": false, + "gates": { + "multimodal": "stop", + "freeze": "blocked" + } + } + }, + { + "id": "gap_short_locf", + "path": "cases/cleaning/gap_short_locf.csv", + "entry": "classify_missing", + "standards": [ + "AIAG_SPC" + ], + "columns": { + "values": "measurement", + "reasons": "reason" + }, + "params": {}, + "expect": { + "flag_counts": { + "IMPUTED_LOCF": 2 + }, + "n_usable": { + "gte": 38 + } + } + }, + { + "id": "gap_long_hold", + "path": "cases/cleaning/gap_long_hold.csv", + "entry": "classify_missing", + "standards": [ + "AIAG_SPC" + ], + "columns": { + "values": "measurement", + "reasons": "reason" + }, + "params": {}, + "expect": { + "flag_counts": { + "MISSING_SENSOR": 5 + }, + "n_unusable": { + "gte": 5 + } + } + }, + { + "id": "reason_maintenance", + "path": "cases/cleaning/reason_maintenance.csv", + "entry": "classify_missing", + "standards": [ + "AIAG_SPC" + ], + "columns": { + "values": "measurement", + "reasons": "reason" + }, + "params": {}, + "expect": { + "flag_counts": { + "EXCLUDED_MAINTENANCE": 1 + } + } + }, + { + "id": "reason_human", + "path": "cases/cleaning/reason_human.csv", + "entry": "classify_missing", + "standards": [ + "AIAG_SPC" + ], + "columns": { + "values": "measurement", + "reasons": "reason" + }, + "params": {}, + "expect": { + "flag_counts": { + "MISSING_HUMAN": 1 + } + } + }, + { + "id": "reason_backup", + "path": "cases/cleaning/reason_backup.csv", + "entry": "classify_missing", + "standards": [ + "AIAG_SPC" + ], + "columns": { + "values": "measurement", + "reasons": "reason" + }, + "params": {}, + "expect": { + "flag_counts": { + "RESTORED_FROM_BACKUP": 1 + } + } + }, + { + "id": "reason_incomplete", + "path": "cases/cleaning/reason_incomplete.csv", + "entry": "classify_missing", + "standards": [ + "AIAG_SPC" + ], + "columns": { + "values": "measurement", + "reasons": "reason" + }, + "params": {}, + "expect": { + "flag_counts": { + "EXCLUDED_INCOMPLETE": 1 + } + } + }, + { + "id": "sensor_sentinel_range", + "path": "cases/cleaning/sensor_sentinel_range.csv", + "entry": "range_check", + "standards": [ + "AIAG_SPC" + ], + "columns": { + "values": "measurement" + }, + "params": { + "low": 0.0, + "high": 200.0 + }, + "expect": { + "n_invalid": 2 + } + }, + { + "id": "sensor_sentinel_establish", + "path": "cases/cleaning/sensor_sentinel_long.csv", + "entry": "establish", + "standards": [ + "AIAG_SPC" + ], + "columns": { + "values": "measurement" + }, + "params": { + "valid_range": [ + 0.0, + 200.0 + ] + }, + "expect": { + "gates": { + "range": "warn", + "missing": "warn", + "normality": "ok" + }, + "chart_route": "shewhart", + "distribution_flag": "NORMAL", + "rule_ids": { + "excludes": [ + "1" + ] + }, + "frozen": true, + "stopped": false, + "notes": "The whole point of range_check, asserted through the pipeline rather than in isolation. Without valid_range the two -999 sentinels both fire Nelson rule 1 AND drag the distribution to non-normal, diverting the study to the Wheeler route. With it they are reclassified MISSING_SENSOR and never reach the chart." + } + }, + { + "id": "gap_short_locf_establish", + "path": "cases/cleaning/gap_short_locf.csv", + "entry": "establish", + "standards": [ + "AIAG_SPC" + ], + "columns": { + "values": "measurement", + "missing_reasons": "reason" + }, + "params": {}, + "expect": { + "gates": { + "missing": "ok", + "freeze": "ok" + }, + "n_plotted": 40, + "frozen": true, + "stopped": false, + "notes": "Short gap: LOCF-filled points are usable, so all 40 points reach the chart and the missing gate stays ok. Pairs with gap_long_hold_establish." + } + }, + { + "id": "gap_long_hold_establish", + "path": "cases/cleaning/gap_long_hold.csv", + "entry": "establish", + "standards": [ + "AIAG_SPC" + ], + "columns": { + "values": "measurement", + "missing_reasons": "reason" + }, + "params": {}, + "expect": { + "gates": { + "missing": "warn", + "freeze": "ok" + }, + "n_plotted": 35, + "frozen": true, + "stopped": false, + "notes": "Long gap: the 5 missing points are held as MISSING_SENSOR, excluded from chart math (35 of 40 plotted) and the missing gate warns. This is the 'no silent imputation' contract." + } + }, + { + "id": "incomplete_subgroup_keep", + "path": "cases/spc/incomplete_subgroup.csv", + "entry": "analyze_control_chart", + "standards": [ + "AIAG_SPC" + ], + "columns": { + "values": "measurement", + "subgroup_ids": "subgroup" + }, + "params": { + "chart_type": "Xbar-R", + "exclude_incomplete": false + }, + "expect": { + "chart_type": "Xbar-R", + "n_plotted": 25 + } + }, + { + "id": "incomplete_subgroup_exclude", + "path": "cases/spc/incomplete_subgroup.csv", + "entry": "analyze_control_chart", + "standards": [ + "AIAG_SPC" + ], + "columns": { + "values": "measurement", + "subgroup_ids": "subgroup" + }, + "params": { + "chart_type": "Xbar-R", + "exclude_incomplete": true + }, + "expect": { + "chart_type": "Xbar-R", + "n_plotted": 24 + } + }, + { + "id": "empty_series", + "path": "cases/spc/empty_series.csv", + "entry": "establish", + "standards": [ + "AIAG_SPC" + ], + "columns": { + "values": "measurement" + }, + "params": {}, + "expect": { + "raises": "ValueError", + "raises_match": "requires at least 2 observations", + "notes": "raises_match guards against regressing to the old failure mode, where this crashed inside scipy.stats.boxcox with 'not enough values to unpack'." + } + }, + { + "id": "all_nan", + "path": "cases/spc/all_nan.csv", + "entry": "establish", + "standards": [ + "AIAG_SPC" + ], + "columns": { + "values": "measurement" + }, + "params": {}, + "expect": { + "raises": "ValueError", + "raises_match": "usable observations" + } + }, + { + "id": "constant_series", + "path": "cases/spc/constant_series.csv", + "entry": "ewma_chart", + "standards": [ + "AIAG_SPC" + ], + "columns": { + "values": "measurement" + }, + "params": {}, + "expect": { + "raises": "ValueError", + "raises_match": "positive process sigma" + } + }, + { + "id": "p_zero_n", + "path": "cases/spc/p_zero_n.csv", + "entry": "analyze_control_chart", + "standards": [ + "AIAG_SPC" + ], + "columns": { + "values": "defective", + "sample_sizes": "inspected" + }, + "params": { + "chart_type": "P" + }, + "expect": { + "raises": "ValueError", + "raises_match": "P chart sample sizes must be > 0" + } + }, + { + "id": "u_zero_opportunity", + "path": "cases/spc/u_zero_opportunity.csv", + "entry": "analyze_control_chart", + "standards": [ + "AIAG_SPC" + ], + "columns": { + "values": "defects", + "opportunities": "units" + }, + "params": { + "chart_type": "U" + }, + "expect": { + "raises": "ValueError", + "raises_match": "U chart opportunities must be > 0" + } + }, + { + "id": "gage_rr_excellent", + "path": "cases/msa/gage_rr_excellent.csv", + "entry": "gage_rr_anova", + "standards": [ + "AIAG_MSA4" + ], + "columns": {}, + "params": { + "tolerance": 10.0 + }, + "expect": { + "grr_percent": { + "lt": 10 + }, + "ndc_ok": true + } + }, + { + "id": "gage_rr_marginal", + "path": "cases/msa/gage_rr_marginal.csv", + "entry": "gage_rr_anova", + "standards": [ + "AIAG_MSA4" + ], + "columns": {}, + "params": { + "tolerance": 10.0 + }, + "expect": { + "grr_percent": { + "gte": 10, + "lte": 30 + } + } + }, + { + "id": "gage_rr_poor", + "path": "cases/msa/gage_rr_poor.csv", + "entry": "gage_rr_anova", + "standards": [ + "AIAG_MSA4" + ], + "columns": {}, + "params": { + "tolerance": 10.0 + }, + "expect": { + "grr_percent": { + "gt": 30 + } + } + }, + { + "id": "gage_rr_unbalanced", + "path": "cases/msa/gage_rr_unbalanced.csv", + "entry": "gage_rr_anova", + "standards": [ + "AIAG_MSA4" + ], + "columns": {}, + "params": { + "tolerance": 10.0 + }, + "expect": { + "anova_fallback": true + } + }, + { + "id": "ndc_fail", + "path": "cases/msa/ndc_fail.csv", + "entry": "gage_rr_anova", + "standards": [ + "AIAG_MSA4" + ], + "columns": {}, + "params": { + "tolerance": 10.0 + }, + "expect": { + "ndc": { + "lt": 5 + }, + "ndc_ok": false + } + }, + { + "id": "gage_rr_poor_establish", + "path": "cases/msa/gage_rr_poor.csv", + "entry": "establish", + "standards": [ + "AIAG_MSA4", + "AIAG_SPC" + ], + "columns": { + "values": "Measurement", + "msa_parts": "Part", + "msa_operators": "Operator", + "msa_measurements": "Measurement" + }, + "params": { + "msa_tolerance": 10.0 + }, + "expect": { + "stopped": true, + "frozen": false, + "gates": { + "msa": "stop", + "freeze": "blocked" + } + } + }, + { + "id": "gage_resolution_fail", + "path": "cases/msa/gage_rr_excellent.csv", + "entry": "gage_resolution_gate", + "standards": [ + "AIAG_MSA4" + ], + "columns": {}, + "params": { + "resolution": 2.0, + "tolerance": 10.0 + }, + "expect": { + "ok": false + } + }, + { + "id": "bias_significant", + "path": "cases/msa/bias_significant.csv", + "entry": "bias_study", + "standards": [ + "AIAG_MSA4" + ], + "columns": {}, + "params": {}, + "expect": { + "is_significant": true, + "mean_bias": { + "gt": 0.5 + } + } + }, + { + "id": "linearity_ok", + "path": "cases/msa/linearity_ok.csv", + "entry": "linearity_study", + "standards": [ + "AIAG_MSA4" + ], + "columns": {}, + "params": {}, + "expect": { + "is_linear": true + } + }, + { + "id": "stability_ok", + "path": "cases/msa/stability_ok.csv", + "entry": "stability_study", + "standards": [ + "AIAG_MSA4" + ], + "columns": {}, + "params": {}, + "expect": { + "is_stable": true + } + }, + { + "id": "cap_excellent", + "path": "cases/capability/cap_excellent.csv", + "entry": "capability_analysis", + "standards": [ + "SixSigma" + ], + "columns": { + "values": "measurement" + }, + "params": { + "usl": 10.5, + "lsl": 9.5 + }, + "expect": { + "cpk": { + "gte": 1.33 + }, + "method": "parametric" + } + }, + { + "id": "cap_off_center", + "path": "cases/capability/cap_off_center.csv", + "entry": "capability_analysis", + "standards": [ + "SixSigma" + ], + "columns": { + "values": "measurement" + }, + "params": { + "usl": 10.5, + "lsl": 9.5 + }, + "expect": { + "cpk_lt_cp": true, + "cpk": { + "lt": 1.33 + }, + "notes": "Off-centre means Cpk is penalised relative to Cp. Asserted as a relation plus a standard capability threshold, not as a hardcoded observed float." + } + }, + { + "id": "cap_high_variation", + "path": "cases/capability/cap_high_variation.csv", + "entry": "capability_analysis", + "standards": [ + "SixSigma" + ], + "columns": { + "values": "measurement" + }, + "params": { + "usl": 10.5, + "lsl": 9.5 + }, + "expect": { + "cpk": { + "lt": 1.0 + } + } + }, + { + "id": "cap_skewed", + "path": "cases/capability/cap_skewed.csv", + "entry": "capability_analysis", + "standards": [ + "SixSigma" + ], + "columns": { + "values": "measurement" + }, + "params": { + "usl": 20.0, + "lsl": 5.0 + }, + "expect": { + "method": "transformed" + } + }, + { + "id": "too_few_points_n10", + "path": "cases/spc/too_few_points_n10.csv", + "entry": "establish", + "standards": [ + "AIAG_SPC" + ], + "columns": { + "values": "measurement" + }, + "params": { + "min_subgroups": 25 + }, + "expect": { + "checklist_items": { + "min_subgroups": false + }, + "checklist_passed": false + } + } + ] +} diff --git a/resilience_data/README.md b/resilience_data/README.md new file mode 100644 index 0000000..9200459 --- /dev/null +++ b/resilience_data/README.md @@ -0,0 +1,109 @@ +# Resilience data catalog + +Standards-mapped CSV corpus for judging `spc_core` behavior. + +## Standards + +| Standard | Encoded as | +|----------|------------| +| AIAG SPC | ≥25 Phase I points/subgroups; chart matrix; Nelson 1–8, Western Electric WE1–WE4, Wheeler | +| AIAG MSA-4 | %GRR <10 / 10–30 / >30; NDC ≥ 5; bias / linearity / stability | +| ISO 7870 | Variable + attribute chart coverage | +| Wheeler / Burr | Non-normal robust path (beyond-3σ only; subgroups preserved) | +| Six Sigma | Cp/Cpk/Pp/Ppk, nonparametric when transform fails | + +## Layout + +``` +resilience_data/ + MANIFEST.json # case specs + expect blocks + generators/ # seeded NumPy builders + cases/{spc,cleaning,msa,capability}/ +scripts/resilience_report.py +tests/resilience/test_resilience_catalog.py +``` + +## Regenerate CSVs + +```bash +python -m resilience_data --force +``` + +CSV encoding: empty cells are missing (`None`). Never the literal string `"None"`. + +## Related: combinatorial dual-mode matrix + +Finite batch + in-process Phase II streaming coverage of `spc_core` contracts lives in [`combinatorial/`](../combinatorial/) (sparse for CI, exhaustive locally): + +```bash +python -m combinatorial report --mode sparse +# → combinatorial/out/JUDGMENT.md, COVERAGE.json, ENGINE_BEHAVIOR_REPORT.md +``` + +## Run judgment + +```bash +# Assert every MANIFEST expect block +pytest tests/resilience -q + +# Human-readable summary (writes resilience_data/JUDGMENT.md) +python scripts/resilience_report.py +``` + +## Adding a case + +1. Add a builder in `generators/` and register it in `generators/__init__.py` `CASE_BUILDERS`. +2. Add a MANIFEST entry with `id`, `path`, `entry`, `columns`, `params`, and an empty `expect`. +3. `python -m resilience_data --force` to write the CSV. +4. Calibrate: `python scripts/calibrate_resilience.py` dumps the observed output for every case. + Check it against the standards table, then freeze *bounds* into `expect` — see the two rules below. +5. Re-run `pytest tests/resilience`. + +A case charting a mean shift or drift belongs at the `analyze_control_chart` entry, not `establish`: +both patterns are autocorrelated by construction, so the pipeline diverts them to EWMA and the +Shewhart run rules never execute. `imr_sustained_small_shift` is the exception — a 0.8σ shift stays +under the autocorrelation gate, so it exercises the run rules through the full pipeline. + +## Expect schema + +Judgment outcomes: `PASS` | `FAIL` | `ERROR` | `XFAIL`. + +| Form | Meaning | +|------|---------| +| `"chart_type": "I-MR"` | exact match on an observed scalar | +| `{"cpk": {"lt": 1.33}}` | numeric bound — `lt` / `lte` / `gt` / `gte` / `eq` | +| `{"gates": {"normality": "warn"}}` | gate status; `"__absent__"` asserts the gate never ran | +| `{"rule_ids": {"includes": ["2"]}}` | the named run rule must have fired | +| `{"rule_ids": {"excludes": ["1"]}}` | the named rule must **not** have fired | +| `{"rule_ids": {"subset_of": ["1"]}}` | no rule outside this set may fire | +| `{"flag_counts": {"MISSING_SENSOR": 5}}` | exact count per `QualityFlag` | +| `"raises": "ValueError"` | the case must raise this exception type | +| `"raises_match": "at least 2 observations"` | substring the exception message must contain | + +Two rules keep the corpus honest, both learned from cases that passed while asserting nothing: + +**Bound, never freeze an observed float.** `{"cpk": {"lt": 1.282522727241782}}` is a recalibration +trap, not an assertion. Express the intent instead: a standard threshold (`lt: 1.33`) or a relation +(`cpk_lt_cp: true`). + +**Assert the mechanism, not just the count.** `n_signals >= 1` passes when the wrong rule fires, and +`raises: ValueError` passes when the engine crashes internally instead of rejecting bad input +cleanly. Pin the rule id and the message. Where a signal belongs on the dispersion chart — an R/S +chart variance shift — assert `secondary_rule_ids`, since the primary chart barely moves. + +`tests/resilience/test_resilience_catalog.py` enforces coverage: every `ChartType`, every +`QualityFlag`, every Nelson and Western Electric rule id, and all three rulesets must be asserted +by at least one case. + +## Known sensitivity limits + +`check_multimodal` uses Hartigan's dip test via the `diptest` dependency. If that package is +unavailable the code falls back to a histogram heuristic that only catches well-separated mixtures +(roughly ≥6σ apart), versus ~4σ for the real test. The fallback is deliberately conservative: this +gate issues a hard STOP that blocks go-live, so a false alarm on an in-control process is worse +than missing a subtle mixture. + +Neither test runs on attribute (count) data. The dip test assumes a continuous distribution, and +the tie-heavy ECDF of a handful of distinct integers reads as multimodal — in-control Poisson +counts produced p = 0.003. The `p/np/c/u_in_control` cases assert `"multimodal": "__absent__"` to +hold that line. diff --git a/resilience_data/__init__.py b/resilience_data/__init__.py new file mode 100644 index 0000000..39904f9 --- /dev/null +++ b/resilience_data/__init__.py @@ -0,0 +1,88 @@ +"""Standards-mapped resilience catalog for spc_core judgment. + +CSV encoding: empty cells are missing (None); never the literal string \"None\". +""" +from __future__ import annotations + +import csv +import json +import math +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parent +MANIFEST_PATH = ROOT / "MANIFEST.json" +CASES_DIR = ROOT / "cases" + +Columns = dict[str, list[Any]] + + +def load_manifest(path: Path | None = None) -> list[dict[str, Any]]: + """Load MANIFEST.json and return the list of case specs.""" + p = path or MANIFEST_PATH + data = json.loads(p.read_text(encoding="utf-8")) + if isinstance(data, dict) and "cases" in data: + return list(data["cases"]) + if isinstance(data, list): + return data + raise ValueError(f"Unexpected MANIFEST shape in {p}") + + +def write_csv(cols: Mapping[str, list[Any]], path: str | Path) -> Path: + """Write a column dict to CSV. None/NaN become empty cells.""" + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + keys = list(cols.keys()) + if not keys: + raise ValueError("empty columns") + n = len(cols[keys[0]]) + if any(len(cols[k]) != n for k in keys): + raise ValueError("column length mismatch") + + def _cell(v: Any) -> str: + if v is None: + return "" + if isinstance(v, float) and (math.isnan(v) or math.isinf(v)): + return "" + return str(v) + + with path.open("w", encoding="utf-8", newline="") as fh: + writer = csv.writer(fh) + writer.writerow(keys) + for i in range(n): + writer.writerow([_cell(cols[k][i]) for k in keys]) + return path + + +def read_csv(path: str | Path) -> Columns: + """Read CSV into a column dict. Empty cells become None.""" + path = Path(path) + with path.open(encoding="utf-8", newline="") as fh: + reader = csv.DictReader(fh) + if not reader.fieldnames: + return {} + cols: Columns = {k: [] for k in reader.fieldnames} + for row in reader: + for k in reader.fieldnames: + raw = row.get(k, "") + if raw is None or raw == "": + cols[k].append(None) + else: + cols[k].append(_coerce(raw)) + return cols + + +def _coerce(raw: str) -> Any: + """Best-effort numeric coerce; leave strings for Part/Operator/reason.""" + try: + if raw.isdigit() or (raw.startswith("-") and raw[1:].isdigit()): + return int(raw) + return float(raw) + except ValueError: + return raw + + +def case_csv_path(rel: str) -> Path: + """Resolve a MANIFEST path relative to the package root.""" + return ROOT / rel diff --git a/resilience_data/__main__.py b/resilience_data/__main__.py new file mode 100644 index 0000000..528bd85 --- /dev/null +++ b/resilience_data/__main__.py @@ -0,0 +1,48 @@ +"""CLI: ``python -m resilience_data [--force]`` — write committed CSVs under cases/.""" +from __future__ import annotations + +import argparse +from pathlib import Path + +from resilience_data import CASES_DIR, ROOT, write_csv +from resilience_data.generators import CASE_BUILDERS + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Generate deterministic resilience_data CSVs for spc_core judgment." + ) + parser.add_argument( + "--out", + type=Path, + default=CASES_DIR, + help="Output cases directory (default: resilience_data/cases)", + ) + parser.add_argument( + "--force", + action="store_true", + help="Overwrite existing CSVs", + ) + args = parser.parse_args(argv) + + written: list[Path] = [] + skipped = 0 + for _case_id, (rel, builder) in sorted(CASE_BUILDERS.items()): + path = args.out / rel + if path.exists() and not args.force: + skipped += 1 + continue + cols = builder() + written.append(write_csv(cols, path)) + + print(f"Wrote {len(written)} CSVs under {args.out} ({skipped} skipped; use --force)") + for p in written: + try: + print(f" {p.relative_to(ROOT)}") + except ValueError: + print(f" {p}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/resilience_data/cases/capability/cap_excellent.csv b/resilience_data/cases/capability/cap_excellent.csv new file mode 100644 index 0000000..018d179 --- /dev/null +++ b/resilience_data/cases/capability/cap_excellent.csv @@ -0,0 +1,201 @@ +measurement +9.915266486535009 +10.010066402249578 +10.090765090805023 +9.901080961764672 +9.95390948849635 +9.835948876815383 +10.082058197335808 +10.11655162059538 +10.050928458923266 +9.999447971529992 +10.16141815337176 +10.028248096577983 +9.946013770218716 +10.012240956550102 +9.93781509085878 +10.038886002145 +9.999087161899707 +9.855785098380268 +10.05953516179435 +9.84051466704788 +9.994152407504124 +10.037937773545481 +10.172574258177514 +9.996805338616962 +10.07578647247413 +10.028770451788992 +10.040411017708971 +10.042033435262349 +10.034947522650649 +10.072141520539738 +9.947239272968261 +9.922755339829084 +9.893896829788764 +9.981102553716148 +9.930508973943832 +9.954452225277558 +9.92144250628846 +9.874949777974765 +9.98298648910215 +9.947723034704273 +9.845437092008824 +9.97423476402954 +9.940386661924064 +10.052710882701556 +10.05509471869553 +9.983434147603703 +9.906827980644412 +9.969456416555259 +10.049495329018 +10.127323692727137 +10.024482476277102 +9.88661546386915 +10.13103363216752 +10.030657583156598 +9.940305854460586 +10.144096938944799 +9.876192470902792 +10.16171251482728 +9.990940402753626 +10.072342931393912 +10.08500081420937 +9.918988950506723 +9.94846809923333 +9.788342388550484 +9.921230168057484 +10.09626052716028 +9.955777747637653 +10.227612118321083 +10.058611957722835 +9.891131840859876 +10.015532653618331 +9.920563550652798 +9.966150096283096 +9.9782540516299 +9.967267607633536 +9.987272731591506 +9.947131199691626 +10.026772971077495 +9.875009009018742 +9.704171856916332 +10.0616751307086 +10.058731137450486 +10.044182300204584 +9.810921005708705 +9.787573272280792 +10.131263709638022 +9.966675953279497 +10.056094777219453 +10.040385094105305 +9.739951600914903 +10.139627753468075 +10.023027288669654 +10.088819980947916 +9.923405688551723 +10.002020963464895 +10.103681490751493 +10.022815373639082 +9.999365521422657 +10.01212308878671 +10.048533309629862 +10.040401371724974 +10.084557254172143 +10.090800960608165 +10.062996009991885 +10.034715542097262 +9.872011235484281 +9.967375591578737 +10.023964729644915 +9.920013696263982 +10.114480982117408 +10.035708447357326 +10.017093700807528 +9.89449039986555 +10.034131215729602 +9.741704546514388 +9.822189964926427 +9.94478910332114 +9.951056063787302 +9.919075929340057 +9.872925180982627 +9.959770690483674 +10.00035805276909 +9.964847411096295 +10.041176289857784 +9.920761631344414 +10.028565452853753 +10.099316883208953 +9.947787026315778 +9.935357686752544 +9.942046077130518 +9.878348151957477 +10.135167142570191 +9.981798075923917 +9.834934982937794 +10.080030238455986 +10.130607719218888 +9.956297727322218 +10.092250818920819 +9.988749436208266 +9.959371502678461 +9.837298644613366 +10.142214667267979 +10.038030509361052 +9.979606271376433 +10.02812645549907 +10.064888324085178 +9.940982370244635 +10.11957423103746 +10.095583107822115 +9.885915445428386 +9.799754357255212 +9.930250122736284 +9.970170610374034 +10.080130883255915 +10.075999041511608 +9.92329392881185 +10.022528232842179 +9.827527404337841 +10.200380955693182 +10.057193553074661 +10.095165647075309 +9.934832897398303 +9.950365393222182 +9.915414627670007 +10.019482704026318 +9.866231750271407 +9.971288479473376 +10.033841356724068 +10.01961828000622 +9.898852800300862 +10.103942382656323 +10.003011593445848 +9.95265767718493 +9.92238236443198 +10.06406151397838 +9.862060864784874 +10.033021711093387 +9.999988904591522 +10.031614664307497 +10.00719496842966 +9.936060501749377 +10.036447250047935 +9.974678043967327 +9.899656099932978 +9.982690421460772 +10.05197608597321 +9.941376624936527 +9.89684701132227 +9.849783396869077 +9.96893960917116 +9.929527564759649 +9.767710342917091 +9.86439734422895 +10.101100651318744 +10.144486184482227 +9.986814455281294 +9.922583299336686 +10.132066685350205 +9.945721290205721 +10.02400950077883 diff --git a/resilience_data/cases/capability/cap_high_variation.csv b/resilience_data/cases/capability/cap_high_variation.csv new file mode 100644 index 0000000..1d03e84 --- /dev/null +++ b/resilience_data/cases/capability/cap_high_variation.csv @@ -0,0 +1,201 @@ +measurement +10.295926443692334 +10.331781627577453 +9.839668461859093 +10.121575001869672 +10.40054315894734 +10.351465134515509 +9.54600729216684 +10.011682515901795 +10.035864980016544 +9.872957897651789 +10.63900379114388 +9.078105786773005 +10.953074811304528 +10.254986188345164 +9.975338371418795 +10.008053020379682 +9.821777455653354 +9.510367494404232 +10.834066979383158 +9.939120245107897 +9.421346261104132 +9.675841408279313 +10.480921254414868 +9.820638871923217 +11.328331562232895 +10.431151503347452 +10.019205414516872 +10.485124439289887 +9.30453394847714 +9.43393483223745 +10.083537463649085 +9.666514120170152 +10.359502302358196 +9.057965650397518 +9.615383295192405 +9.653037320906607 +10.79734670910259 +10.35402950143915 +10.289957170382126 +9.668644110259176 +9.493612455494645 +10.341107260960266 +9.995502311215283 +10.47494537038975 +9.871395189175042 +10.120082854164819 +9.517968976854533 +9.657106109026522 +10.253396367183521 +9.845294019476166 +9.633569519989694 +10.388235882403709 +10.075877503105811 +10.477600732525666 +11.122778899302938 +9.488256741379233 +10.65928499310836 +9.906659628369294 +10.402487887223216 +9.731787454552554 +10.007470337321582 +9.637960338171727 +9.383700005980517 +10.209229053765206 +9.84687135951333 +9.726564914445051 +10.145178082743962 +10.383803635504837 +10.966084453936702 +10.22295897730668 +9.627581343722301 +9.770148739157914 +10.842109898345917 +9.94582348208797 +10.524772512885141 +10.05872401473725 +10.273617677542537 +9.569355886796053 +10.728524020854634 +9.844208159687332 +9.818300740521902 +9.792025106591645 +9.452294308441957 +10.151767917871512 +10.41624453872866 +10.658622942230611 +10.171156798670973 +9.87143499372689 +9.947198439194088 +9.479066944861152 +9.619521452292929 +9.71276134295356 +10.024818730808754 +10.565982800287411 +10.556773954198672 +10.58716688806102 +10.140927594047806 +10.001319880024838 +9.328805427021729 +8.645755555769512 +10.069680288622687 +9.135374371498608 +9.724326756955112 +9.940812045617099 +9.735876230785397 +9.876238098407297 +10.050490826793594 +10.939357186589604 +10.048762380501676 +9.972302209415346 +9.504276731726764 +10.005326118309304 +9.47350693329238 +10.393425081722242 +10.035408612336568 +10.507366283835665 +10.382229002960097 +9.213211818629132 +10.238061003178752 +10.3899053609791 +10.056996329907163 +10.963686978299584 +9.976816865674692 +9.788311991231598 +9.047052994802376 +9.80151290108307 +9.969804610408593 +10.44144037233208 +9.167122665783236 +10.062335875739809 +9.959021575265284 +10.140039925866366 +10.353613272287026 +10.351654810081278 +9.950437647718259 +9.777901091685642 +10.260513517686272 +10.550665670450066 +9.743190442069672 +10.434214640910369 +11.05187741158694 +9.236747412250251 +10.557992682183091 +10.036940692211653 +9.746888382583656 +9.945137864950844 +9.839701599060124 +9.502786807439632 +9.902816777304873 +9.89728927938628 +9.538720079151037 +9.610734242689135 +9.531245552580748 +9.948358108701832 +10.519003461075199 +10.296490994830586 +9.437466875923088 +9.911299560034752 +11.027911614302596 +10.12838025873918 +10.023247507864857 +9.352406190344817 +10.533015742211738 +9.933690996425177 +10.44495833371928 +10.663811604738237 +9.008127529877537 +9.891092669959892 +9.645009949227584 +10.916251564467625 +10.406336686902673 +10.180630977038625 +9.873243297886827 +10.167384484308771 +9.71405133670718 +10.072951738305724 +9.302113241750515 +10.162241275867707 +10.162467491131927 +9.956501108033239 +10.258048477241704 +9.973656249656779 +9.445823099773378 +9.498391767169 +9.519642583472324 +9.390481052879052 +9.979921766850856 +9.919000935693944 +10.776408961300135 +9.893520867256724 +9.524126909920023 +10.056228743892724 +10.263225314299092 +9.56027872887508 +10.464949104486443 +9.902642159466051 +9.483495576027419 +10.383172921122098 +9.249155719141562 +9.754148813094881 diff --git a/resilience_data/cases/capability/cap_off_center.csv b/resilience_data/cases/capability/cap_off_center.csv new file mode 100644 index 0000000..42b4110 --- /dev/null +++ b/resilience_data/cases/capability/cap_off_center.csv @@ -0,0 +1,201 @@ +measurement +10.276008257301719 +10.115845567265705 +9.992615039064106 +10.315405157285534 +9.985465961560147 +10.320831918600915 +10.18653162485763 +10.363788370400306 +10.122111105498876 +10.160701254841598 +10.13350358880257 +10.254674564534564 +10.30346836962762 +10.307560237247143 +10.202146875351072 +10.3212755323733 +10.437018513100442 +10.275852519484987 +10.21450205172438 +10.37246292114915 +10.394941600375978 +10.277969305534748 +10.288493315859068 +10.433887351891661 +10.32506284912607 +10.109836246336057 +10.303314433941173 +10.32767587858823 +10.411552797865513 +10.31917878389706 +10.070780292712925 +10.561629043727713 +10.11007508055893 +10.283924817993737 +10.073636990001296 +10.067213617267456 +10.371847797376486 +10.374882570784651 +10.216910237446452 +10.10961954796539 +10.087606759527011 +10.347347865048372 +10.374908788348002 +10.33563255909455 +10.452406401087696 +10.368650157082529 +10.22936239891849 +10.241038343733978 +10.22118336996765 +10.145283861407021 +10.15694958163739 +10.334919669579232 +10.18822721674323 +10.53279189592409 +10.06523433578422 +10.25520643284871 +10.50791197591706 +10.144089310339687 +10.191479316243058 +10.566123792775482 +10.129264553959047 +10.232091871070471 +10.285999149533367 +10.42890209102174 +10.583979766583306 +10.151191367180317 +10.313938917833987 +10.302429579721421 +10.086450115553511 +10.1912931415006 +10.21364666944466 +10.402037192226217 +10.199456761351918 +10.550940434324175 +10.124970242049955 +10.209806083412815 +10.180998864704572 +10.232358423691789 +10.21252693212631 +10.32079296573509 +10.178916406903738 +10.095530136056388 +10.237082952120609 +10.18415169738161 +10.007408054778137 +10.458603174858355 +10.154979158281847 +10.24432431821125 +10.264002917042895 +10.095830265707436 +10.210341587403466 +10.152553958633172 +10.507329951395615 +10.287142682860653 +10.516171177900066 +10.198460613841647 +10.313955433129319 +10.160246607668457 +10.231301054205431 +10.250031657882012 +10.07526684615062 +10.36500860581934 +10.462614761200872 +10.231735416628387 +10.17129354657626 +10.108386716689688 +10.173218694005229 +10.348367830247474 +10.111285517300114 +10.100687536953988 +10.32644298467961 +10.155102362715752 +10.183260031190153 +10.27928010082335 +10.336763412275323 +9.963492465458671 +10.147342394298956 +10.35723324030518 +10.370599923711882 +10.225437128806172 +10.204895867741865 +10.13750546745307 +10.253793316030832 +10.18052997548604 +10.329225825754333 +10.383340362236435 +10.156354672368886 +10.267117294824494 +10.289926394606644 +10.353277348388891 +10.226097477164174 +10.151663182869346 +10.24286869891796 +10.135732629773484 +10.164187196681613 +10.298660629865367 +9.772284452665174 +10.228252614501066 +10.515397982856298 +10.089343085032665 +10.196654404948768 +10.411641132124206 +10.388739140751978 +10.351801145016065 +10.08860800259231 +10.0255800132424 +10.326191808656048 +10.514834002854462 +9.999974597618428 +10.187651966774698 +10.105546692347257 +10.233284246809257 +10.29094406911041 +10.430174644825751 +10.321467037902872 +10.203738403465408 +10.287763389414856 +10.184752673363956 +10.171620551157233 +10.394614072945153 +10.257627330755238 +10.190309175411382 +10.224614918644464 +10.231744220277202 +10.017697588770327 +10.048886020719893 +10.26550189448091 +10.247273700840436 +10.120492565689588 +10.26800481524021 +10.425517974690852 +10.012607007973267 +10.32575678343004 +10.392235359464594 +10.22219454621442 +10.09222688476948 +10.153870766364061 +10.379162418648562 +10.463078704418672 +10.217447358790194 +10.204966670836432 +10.211333813553575 +10.250548880075787 +10.277423438785046 +10.45854036604356 +10.207084480875729 +10.237489454951913 +10.310631388293144 +10.38128183454119 +10.184799668339576 +10.097949778774344 +10.214488122211142 +10.193864075023773 +10.211696654079347 +10.207050393879024 +10.295176173985954 +10.30148838445484 +10.16021471250619 +10.219278535009904 +10.218797553259522 diff --git a/resilience_data/cases/capability/cap_skewed.csv b/resilience_data/cases/capability/cap_skewed.csv new file mode 100644 index 0000000..40bafdf --- /dev/null +++ b/resilience_data/cases/capability/cap_skewed.csv @@ -0,0 +1,201 @@ +measurement +5.115078635735354 +6.232865583720788 +6.071102759987998 +6.332317615479299 +6.445857352667765 +5.478230146595117 +5.176956807489999 +5.406408419004466 +5.314659268227979 +5.780303600132309 +5.523683167942055 +7.629243179508824 +7.230901372291844 +9.602232425973149 +5.065480752454468 +6.5112799486708814 +7.4402441538616255 +5.3607158125071255 +6.8235108512546185 +6.036201360756589 +6.606130049917574 +5.649355876426637 +7.530020180338779 +5.275767000509642 +7.109475019516816 +6.806771898687034 +6.3105932811569385 +5.169956795372768 +6.5297571625803785 +8.151834916172042 +5.854026054550291 +5.161482211506196 +5.96900361066611 +5.393875025594629 +5.259900334506387 +5.052376225741017 +6.283499446841223 +6.405987303318028 +5.641171428972312 +5.203173910236618 +5.770105440392947 +6.0816264011939785 +5.230356034042656 +5.411061614771683 +11.63759466366296 +9.39590761519405 +6.764898427137889 +5.785264855589007 +10.13281916265241 +5.084505424723423 +6.335010593767986 +5.440946448018951 +6.181328533060515 +9.737515110913634 +5.367676837067083 +5.100827504543773 +5.411558676313807 +5.023843146470932 +7.022191322915203 +6.193287378303659 +6.347223553059317 +7.513917131842076 +6.809467161371923 +7.39006137399104 +7.2240247817143874 +6.929230376194262 +8.179698451238766 +6.37668185831074 +5.11697076696099 +8.504133432059641 +10.976379388699938 +7.126632263990686 +7.927972780966849 +9.124875134039561 +10.32688538082912 +6.374398754737319 +5.46078110473498 +6.897705660190918 +15.739272159090548 +5.800399993752091 +7.8518335068368454 +8.279007087681155 +5.609000904199479 +6.175651822225397 +5.103497624895228 +5.046441233304406 +5.752601934770099 +5.497315410416239 +5.764755227771317 +5.132166863675559 +5.486731455035023 +6.425532555100902 +5.101528490444601 +5.941546341712277 +5.770658580067151 +6.884728086232749 +7.841460096279635 +5.2967030712685474 +12.853740010318717 +5.667663094778122 +5.830175716958427 +9.493687192202657 +6.245374046369913 +7.3060563801811895 +7.7252201028948875 +6.454318233634825 +5.8708922066218525 +8.699835503510261 +5.045609310355092 +8.950446976669014 +6.073467737875131 +5.324515798461336 +5.317491537247404 +7.451784546471436 +6.907366553050782 +6.736911715889724 +7.542053220462744 +5.745256805487871 +7.619366366963524 +5.692799413028142 +5.388785026437645 +8.482799964320225 +7.846229421681165 +6.519254038550443 +5.092243501704972 +7.895679526400367 +7.688513510109969 +7.99113104990952 +5.514431119163351 +5.132426522700029 +7.439306695893007 +5.555578679204752 +5.969482130456588 +7.248558925247756 +5.806452869699108 +6.279817635419118 +8.954109014714767 +5.446700564180463 +6.1666754226274625 +10.37191379482163 +11.476024920091128 +7.6084152562115825 +8.81337249418219 +5.357960692959345 +5.922842857428466 +7.170945431275512 +7.045026073622884 +12.009647005968283 +8.162133614258458 +5.266199715809586 +7.354799864011799 +5.406516325468407 +7.260986809002987 +5.324359410347818 +5.045218849184031 +6.178060963121975 +6.782067445684777 +9.395959272227504 +7.820927848588983 +5.568784698342787 +5.154192541867484 +7.73068195645058 +7.475413540607306 +7.544164704024274 +5.170012508628206 +9.579020178009259 +6.578497986072275 +6.226421094754825 +7.263180285248441 +6.491794707151921 +6.97413792668847 +8.819051700030096 +5.775060057350711 +8.537943424398616 +5.011694464688584 +8.871524317119338 +5.043904427402288 +6.333356742496061 +7.200389710026136 +7.780029423629198 +7.241926877416283 +5.639093733965313 +9.46245026511096 +5.056313285801375 +6.711606120137889 +5.619913475284199 +16.601380606162554 +5.800101781683948 +8.639295019733588 +5.6014083928037985 +6.06448677372524 +5.073199017916596 +5.276145048843457 +9.109111099320902 +6.163177617018661 +5.607403546954406 +19.247265825671967 +5.757148980360016 +5.56390797629002 +8.871508929704568 diff --git a/resilience_data/cases/cleaning/gap_long_hold.csv b/resilience_data/cases/cleaning/gap_long_hold.csv new file mode 100644 index 0000000..e1988d5 --- /dev/null +++ b/resilience_data/cases/cleaning/gap_long_hold.csv @@ -0,0 +1,41 @@ +measurement,reason +101.81172035381532, +99.27094644069004, +98.91437351378316, +99.5980889459881, +98.84740757974713, +101.50822459795432, +100.87990037514332, +100.63763311083908, +101.40987119691744, +99.3986628564992, +, +, +, +, +, +101.4720750362343, +99.46668661983844, +100.19468086676983, +100.3442433434023, +100.48581867005892, +101.02290462567206, +100.58848129793942, +101.39965969008453, +99.18973930935839, +99.52788379538949, +100.81971652064084, +99.55756952913703, +99.44013853877951, +99.95450914846532, +100.352249874056, +99.50232291667129, +100.59315588693647, +101.38757431353294, +98.21550503627488, +97.71325192925605, +100.75295259961203, +99.20367250664755, +101.45633871261806, +102.86941962033423, +99.85824434682529, diff --git a/resilience_data/cases/cleaning/gap_short_locf.csv b/resilience_data/cases/cleaning/gap_short_locf.csv new file mode 100644 index 0000000..00527ef --- /dev/null +++ b/resilience_data/cases/cleaning/gap_short_locf.csv @@ -0,0 +1,41 @@ +measurement,reason +101.92895718933202, +99.39231748810909, +97.9283007643259, +99.19899030556164, +101.7269066549785, +100.28242255241071, +99.71836785906244, +100.18800972915824, +100.31042389189142, +101.00169034243198, +, +, +99.46913763395402, +99.94741450777055, +99.44899360650837, +100.06492880847459, +102.06257358997705, +98.31892211993704, +98.45518652925122, +99.31873050144473, +101.13131676780928, +100.26610502828466, +99.13661452169801, +100.05655015284061, +98.57681304558439, +98.7812972646498, +99.22794413762556, +100.51764419974747, +99.6850932909356, +100.06816294250764, +101.30388406341555, +99.58807881291968, +101.38687486640343, +99.00640364339702, +102.2560505203639, +98.53562040665642, +97.8500342320434, +100.21498990394223, +99.7893301053404, +100.78893794362733, diff --git a/resilience_data/cases/cleaning/reason_backup.csv b/resilience_data/cases/cleaning/reason_backup.csv new file mode 100644 index 0000000..78ffb70 --- /dev/null +++ b/resilience_data/cases/cleaning/reason_backup.csv @@ -0,0 +1,31 @@ +measurement,reason +99.64760365832828, +101.87331376546678, +101.88534134964799, +100.54553854520191, +100.40963353903655, +99.94069874761401, +100.63764779582564, +101.10084073554482, +100.10934342731083, +101.49915316414285, +100.31839312317759, +100.8573968172093, +98.17449584492466, +102.0665911181184, +99.67626995295221, +100.95173499800649,backup +99.17318549022909, +102.24510316173618, +99.76934597435009, +98.17283980258419, +99.05389848657552, +100.1267656465963, +98.89801758002368, +100.83933628286347, +100.01127656197802, +97.74353847492547, +98.81300401523814, +99.27574655763243, +99.96749128447878, +99.9682970930299, diff --git a/resilience_data/cases/cleaning/reason_human.csv b/resilience_data/cases/cleaning/reason_human.csv new file mode 100644 index 0000000..29f2e0a --- /dev/null +++ b/resilience_data/cases/cleaning/reason_human.csv @@ -0,0 +1,31 @@ +measurement,reason +100.93990152852653, +100.16645895202252, +102.24499828381337, +101.09603941208957, +100.05691587474064, +99.16083713425674, +99.17782486579198, +100.33259899420037, +,human +100.11209641208467, +98.6895943051657, +99.13220743624228, +99.59577842573883, +101.10562998138909, +100.45651425022272, +101.73774533644414, +100.92969131119527, +99.23609745034902, +100.30306659856497, +99.2437331088164, +102.2956709740645, +99.09511437894297, +100.50629773199167, +98.9277576464311, +100.32724818704378, +101.3035697319268, +100.7918692635676, +100.88170197970517, +101.13758976190935, +99.28724446367183, diff --git a/resilience_data/cases/cleaning/reason_incomplete.csv b/resilience_data/cases/cleaning/reason_incomplete.csv new file mode 100644 index 0000000..160bd2d --- /dev/null +++ b/resilience_data/cases/cleaning/reason_incomplete.csv @@ -0,0 +1,31 @@ +measurement,reason +100.69146044812926, +99.90817721829691, +101.38141268904114, +100.51265614904929, +99.79561632376755, +99.56423885444472, +98.83795559031326, +98.19296548889426, +99.95435807865213, +101.21655733977995, +99.46459247029902, +99.09004701343306, +99.62334636928725, +100.57142640110405, +100.78544111093622, +102.38148894112796, +99.46306501229971, +100.94955601249085, +100.39387243927693, +99.38287411584278, +,incomplete +99.94800600419212, +99.72172466987305, +100.60461954990797, +99.28222879716114, +100.51400751166449, +100.65944059794808, +100.31673120736954, +99.00105084314917, +99.31855699915724, diff --git a/resilience_data/cases/cleaning/reason_maintenance.csv b/resilience_data/cases/cleaning/reason_maintenance.csv new file mode 100644 index 0000000..92eb7b2 --- /dev/null +++ b/resilience_data/cases/cleaning/reason_maintenance.csv @@ -0,0 +1,31 @@ +measurement,reason +99.47161966083733, +99.65020447734054, +100.94268081801951, +101.63463424632722, +99.05977107751046, +102.25289238667479, +100.91803880192028, +99.16367668517742, +99.13766711596246, +99.90027022465077, +99.44856562421276, +98.4880316585158, +,maintenance +100.71794851695745, +98.97041369158565, +101.55958174550028, +102.12401451584164, +99.51133679025568, +101.41826132997917, +99.42339017421988, +101.99013584671177, +100.75894213908313, +98.22475806806808, +99.40856724614642, +99.69621831950914, +100.27652130286894, +101.44652724450322, +98.06766449726486, +99.90810254262628, +102.95555845022972, diff --git a/resilience_data/cases/cleaning/sensor_sentinel_long.csv b/resilience_data/cases/cleaning/sensor_sentinel_long.csv new file mode 100644 index 0000000..47ba862 --- /dev/null +++ b/resilience_data/cases/cleaning/sensor_sentinel_long.csv @@ -0,0 +1,81 @@ +measurement +99.72271075462878 +98.48773799179936 +98.6163551045723 +100.75272188470565 +99.39709053998632 +99.4265224346702 +100.7413301499553 +98.3138647558683 +100.2309240674894 +99.37431553628745 +101.87459156121525 +100.37562381651014 +99.28147921399734 +102.46596510554154 +101.32452427262342 +101.31988647133328 +98.98076090489445 +-999.0 +101.48335608162364 +101.38073234115758 +99.64554606469397 +101.37268169665744 +100.23300623149679 +101.19042372186213 +102.32402552307444 +98.5612318502539 +98.50952319888353 +99.48419136208081 +100.18637439028203 +100.15411148498232 +99.37584370331702 +100.68062370796804 +99.0119527512935 +99.47183675177345 +101.09332236042331 +99.69207735550525 +99.79308671540663 +98.14118936842495 +101.11092260047305 +100.48889681656422 +98.31928045340905 +99.20308400173091 +98.12418088734857 +98.3861882185616 +100.48924499620685 +99.78358855410464 +101.04220096439585 +99.69030516765434 +100.20869103425821 +99.1116394548444 +99.45527447014564 +100.50763263220836 +-999.0 +100.43515880533089 +100.11501983765098 +99.26629854175194 +100.11447782468707 +99.25727016422648 +102.278368280739 +99.9165991542862 +99.740969394676 +100.20013699397634 +99.6358469272779 +99.6327828569994 +101.08984562840112 +100.71239088914541 +100.77974432492148 +100.98495070070578 +98.98265015931509 +97.7717566803455 +100.86796253584495 +101.00053954969194 +98.90177665444051 +99.04264134524908 +100.82864034717751 +101.09931873479466 +99.66431027699265 +100.2882232767264 +99.88058927576574 +99.13958799279114 diff --git a/resilience_data/cases/cleaning/sensor_sentinel_range.csv b/resilience_data/cases/cleaning/sensor_sentinel_range.csv new file mode 100644 index 0000000..0b4a890 --- /dev/null +++ b/resilience_data/cases/cleaning/sensor_sentinel_range.csv @@ -0,0 +1,31 @@ +measurement +100.48822741522969 +98.42924972890694 +100.14316821507582 +99.17058007417552 +99.04351032317602 +-999.0 +99.21135293254177 +100.75852836631148 +99.48951536205479 +102.52441621028244 +101.5673965424631 +101.16891678208825 +100.89392208180755 +99.7419665778784 +100.62277751747952 +98.82329951746912 +99.99435896194164 +97.85114551011148 +-999.0 +99.52116924131874 +99.41801046767786 +99.45273283880624 +101.51619157507308 +101.54168745443927 +100.41419137560042 +101.94741897323884 +101.351977390647 +99.75106891136836 +100.424807496011 +100.90677242245918 diff --git a/resilience_data/cases/msa/bias_significant.csv b/resilience_data/cases/msa/bias_significant.csv new file mode 100644 index 0000000..b3946ac --- /dev/null +++ b/resilience_data/cases/msa/bias_significant.csv @@ -0,0 +1,31 @@ +Measurement,Reference +10.84256284522326,10.0 +10.872130671243161,10.0 +10.806147115390097,10.0 +10.889978793321573,10.0 +10.778849566582089,10.0 +10.733650019988131,10.0 +10.935648386798965,10.0 +10.722332583799737,10.0 +10.859027720377082,10.0 +10.894379329331738,10.0 +10.743585711953662,10.0 +10.952852541552808,10.0 +10.728928259448384,10.0 +10.714406946342592,10.0 +10.737671418689143,10.0 +10.830675708145941,10.0 +10.796460427897888,10.0 +10.632172092658791,10.0 +10.827656912695725,10.0 +10.852839848696231,10.0 +10.694803447513983,10.0 +10.745895106467565,10.0 +10.903071530251049,10.0 +10.82323014245231,10.0 +10.804139036961576,10.0 +11.012946110760396,10.0 +10.76642069796359,10.0 +10.794621278379623,10.0 +10.701494667630383,10.0 +10.873684677293705,10.0 diff --git a/resilience_data/cases/msa/gage_rr_excellent.csv b/resilience_data/cases/msa/gage_rr_excellent.csv new file mode 100644 index 0000000..60eac52 --- /dev/null +++ b/resilience_data/cases/msa/gage_rr_excellent.csv @@ -0,0 +1,91 @@ +Part,Operator,Measurement +P1,Op1,10.848139181499109 +P1,Op1,10.788558987984313 +P1,Op1,10.733809812729959 +P1,Op2,10.918487381319164 +P1,Op2,10.812631549874244 +P1,Op2,10.860869465640091 +P1,Op3,10.565871754999316 +P1,Op3,10.681227583940762 +P1,Op3,10.628564545424641 +P2,Op1,6.712772090979436 +P2,Op1,6.789483130606787 +P2,Op1,6.748404727885005 +P2,Op2,6.5904789505801675 +P2,Op2,6.748992503349488 +P2,Op2,6.761947166246228 +P2,Op3,6.687609622773035 +P2,Op3,6.683559262785523 +P2,Op3,6.482079062084615 +P3,Op1,6.343031692316313 +P3,Op1,6.124217345610211 +P3,Op1,6.259563231228917 +P3,Op2,6.373169055834241 +P3,Op2,6.315192990626623 +P3,Op2,6.169061835771615 +P3,Op3,6.229384678461958 +P3,Op3,6.139934744344882 +P3,Op3,6.134933581086356 +P4,Op1,7.9631171099170075 +P4,Op1,8.003145102225627 +P4,Op1,7.91305424607589 +P4,Op2,7.972866996956232 +P4,Op2,7.868257765260438 +P4,Op2,7.924536150041353 +P4,Op3,7.777158930105448 +P4,Op3,7.791897319216018 +P4,Op3,7.787446918566303 +P5,Op1,9.767367304052774 +P5,Op1,9.921351191808988 +P5,Op1,9.871059498106666 +P5,Op2,9.745533807911805 +P5,Op2,9.752114425647772 +P5,Op2,9.87858210534334 +P5,Op3,9.668622525511678 +P5,Op3,9.77132437265654 +P5,Op3,9.782411460772781 +P6,Op1,6.437891949665437 +P6,Op1,6.336710939291871 +P6,Op1,6.4806253693699025 +P6,Op2,6.484149876233229 +P6,Op2,6.494310283934975 +P6,Op2,6.371958397693409 +P6,Op3,6.260154103271062 +P6,Op3,6.374876890109369 +P6,Op3,6.3637174762569035 +P7,Op1,11.152827826418527 +P7,Op1,10.874269844512007 +P7,Op1,10.937999123959948 +P7,Op2,11.003894093737005 +P7,Op2,10.860474530646433 +P7,Op2,10.982343837186898 +P7,Op3,10.858790190407793 +P7,Op3,10.807713495776088 +P7,Op3,10.802845352388047 +P8,Op1,9.025050060113099 +P8,Op1,9.092535920304572 +P8,Op1,9.064686396519024 +P8,Op2,8.974242017821242 +P8,Op2,9.080122180449044 +P8,Op2,8.969495385258819 +P8,Op3,8.850979414874084 +P8,Op3,8.81208807209997 +P8,Op3,8.895228982169101 +P9,Op1,9.861637256385697 +P9,Op1,9.700137889891398 +P9,Op1,9.761831738271159 +P9,Op2,9.782178436344877 +P9,Op2,9.747815431592397 +P9,Op2,9.911783409902503 +P9,Op3,9.75376695109465 +P9,Op3,9.681986569286277 +P9,Op3,9.832858399847805 +P10,Op1,8.44119120394441 +P10,Op1,8.400721107469426 +P10,Op1,8.568479605040487 +P10,Op2,8.456701714175727 +P10,Op2,8.516628572640466 +P10,Op2,8.414957900828094 +P10,Op3,8.185834841556304 +P10,Op3,8.255498639028563 +P10,Op3,8.25906412361121 diff --git a/resilience_data/cases/msa/gage_rr_marginal.csv b/resilience_data/cases/msa/gage_rr_marginal.csv new file mode 100644 index 0000000..9808330 --- /dev/null +++ b/resilience_data/cases/msa/gage_rr_marginal.csv @@ -0,0 +1,91 @@ +Part,Operator,Measurement +P1,Op1,10.235043736715701 +P1,Op1,10.162284066786293 +P1,Op1,10.414946684488024 +P1,Op2,10.273920554939513 +P1,Op2,9.965807535472443 +P1,Op2,9.98073064310942 +P1,Op3,10.34879767193772 +P1,Op3,9.881985896110793 +P1,Op3,9.688773037575524 +P2,Op1,11.98517392340174 +P2,Op1,11.665509617470518 +P2,Op1,11.290577187541473 +P2,Op2,11.373016125165003 +P2,Op2,11.476952956680789 +P2,Op2,11.259271481118933 +P2,Op3,11.286014651144166 +P2,Op3,11.744745164446519 +P2,Op3,12.002928593381402 +P3,Op1,11.63470798711991 +P3,Op1,11.481568801655394 +P3,Op1,11.82014575313242 +P3,Op2,11.670371212438093 +P3,Op2,11.365197290733235 +P3,Op2,11.618600812656044 +P3,Op3,11.884584705269129 +P3,Op3,11.509635131431745 +P3,Op3,11.32766739969977 +P4,Op1,9.726909003614379 +P4,Op1,9.696877546348581 +P4,Op1,9.952257654584049 +P4,Op2,8.987792450189854 +P4,Op2,9.174682804724792 +P4,Op2,9.121716595616755 +P4,Op3,8.969483821976066 +P4,Op3,9.52761864240502 +P4,Op3,9.648029539594578 +P5,Op1,9.65578189343445 +P5,Op1,10.293113025226038 +P5,Op1,10.12399620387532 +P5,Op2,9.81618470629958 +P5,Op2,9.74848389008121 +P5,Op2,9.91467263198351 +P5,Op3,9.344899402800454 +P5,Op3,9.928672252138345 +P5,Op3,9.92532640472342 +P6,Op1,9.071805711421923 +P6,Op1,9.70623031518113 +P6,Op1,9.526994880551825 +P6,Op2,9.587130953155057 +P6,Op2,9.220955488045247 +P6,Op2,9.347201887041816 +P6,Op3,9.572051191546308 +P6,Op3,9.206317229727748 +P6,Op3,9.648774730805126 +P7,Op1,10.88716498873418 +P7,Op1,11.066398732395053 +P7,Op1,10.762102693390244 +P7,Op2,10.995067268046265 +P7,Op2,10.850767398591852 +P7,Op2,10.615799297647207 +P7,Op3,10.975315544205557 +P7,Op3,11.163654945485062 +P7,Op3,11.32308105112687 +P8,Op1,9.69563207821812 +P8,Op1,10.432644532836942 +P8,Op1,10.3072255419042 +P8,Op2,9.890099615066013 +P8,Op2,9.616258368628795 +P8,Op2,10.29541443316386 +P8,Op3,9.65625804310301 +P8,Op3,10.204863080856805 +P8,Op3,10.425339839997187 +P9,Op1,10.651344269297336 +P9,Op1,11.0404897035678 +P9,Op1,10.73848001045789 +P9,Op2,10.95501414789528 +P9,Op2,11.336110454686906 +P9,Op2,11.48886472423617 +P9,Op3,10.46488517463392 +P9,Op3,10.825834805782657 +P9,Op3,11.209383035498249 +P10,Op1,8.492004353063964 +P10,Op1,8.144555750719487 +P10,Op1,7.794346958158784 +P10,Op2,7.857884870098958 +P10,Op2,7.763759656913476 +P10,Op2,7.707623200491275 +P10,Op3,7.664925679657965 +P10,Op3,8.001444936005093 +P10,Op3,7.9776308971319345 diff --git a/resilience_data/cases/msa/gage_rr_poor.csv b/resilience_data/cases/msa/gage_rr_poor.csv new file mode 100644 index 0000000..a5d2f0d --- /dev/null +++ b/resilience_data/cases/msa/gage_rr_poor.csv @@ -0,0 +1,91 @@ +Part,Operator,Measurement +P1,Op1,9.7287462400744 +P1,Op1,11.14547330705512 +P1,Op1,7.902750788306445 +P1,Op2,6.8922689160580575 +P1,Op2,11.391151972123318 +P1,Op2,11.389385116967128 +P1,Op3,9.073312659121443 +P1,Op3,9.14845460486221 +P1,Op3,10.555820954338138 +P2,Op1,6.972158476862148 +P2,Op1,8.899082257733578 +P2,Op1,11.132704232371982 +P2,Op2,10.03496462903216 +P2,Op2,10.639242941909421 +P2,Op2,10.604355320427628 +P2,Op3,10.50544022003088 +P2,Op3,7.317330509881472 +P2,Op3,8.770883674701093 +P3,Op1,9.84650201270032 +P3,Op1,10.342061479341085 +P3,Op1,7.426324816366514 +P3,Op2,11.13482948143519 +P3,Op2,10.37370021161715 +P3,Op2,11.125208017253792 +P3,Op3,10.953142435691037 +P3,Op3,7.296432133407525 +P3,Op3,8.116421253463152 +P4,Op1,8.810392185697474 +P4,Op1,6.6632936392588675 +P4,Op1,7.0661107859018895 +P4,Op2,10.668797804261526 +P4,Op2,11.686387937160662 +P4,Op2,11.126644808415973 +P4,Op3,8.894307603325496 +P4,Op3,9.48287750801853 +P4,Op3,10.063289947895242 +P5,Op1,9.382496432932985 +P5,Op1,7.154346272713221 +P5,Op1,7.032501329711921 +P5,Op2,11.702224166982472 +P5,Op2,8.591564205465495 +P5,Op2,11.75333018278217 +P5,Op3,9.964154693570595 +P5,Op3,8.343243695056314 +P5,Op3,9.292266682385227 +P6,Op1,10.35241882885558 +P6,Op1,9.13456761822382 +P6,Op1,8.60939285124822 +P6,Op2,12.137149635608408 +P6,Op2,12.038609481899101 +P6,Op2,12.647090178759651 +P6,Op3,8.617699558614927 +P6,Op3,10.255896363855534 +P6,Op3,9.844565784218686 +P7,Op1,9.922445005579917 +P7,Op1,8.393258359956489 +P7,Op1,7.49751623603432 +P7,Op2,10.921759397672732 +P7,Op2,8.506208676503297 +P7,Op2,9.331076541811449 +P7,Op3,8.838873705180625 +P7,Op3,10.44982859994789 +P7,Op3,7.32769722422536 +P8,Op1,8.190892953015407 +P8,Op1,8.238024542699964 +P8,Op1,6.365642670074944 +P8,Op2,10.999353391548153 +P8,Op2,9.815441580269386 +P8,Op2,10.179342848438436 +P8,Op3,9.278211233573307 +P8,Op3,7.563881358663386 +P8,Op3,7.597868835733152 +P9,Op1,10.294131977879005 +P9,Op1,8.589796593247234 +P9,Op1,9.944536556252121 +P9,Op2,12.158621347473792 +P9,Op2,10.885976404205174 +P9,Op2,9.295939476324582 +P9,Op3,11.17422956559311 +P9,Op3,8.671570931008057 +P9,Op3,8.283662152927104 +P10,Op1,9.663477534972193 +P10,Op1,10.2462669984897 +P10,Op1,9.830338674831465 +P10,Op2,10.509583890453444 +P10,Op2,12.083121639723272 +P10,Op2,10.088305624093971 +P10,Op3,9.97132463716862 +P10,Op3,9.197019685188424 +P10,Op3,8.378376555703438 diff --git a/resilience_data/cases/msa/gage_rr_unbalanced.csv b/resilience_data/cases/msa/gage_rr_unbalanced.csv new file mode 100644 index 0000000..cfc6a81 --- /dev/null +++ b/resilience_data/cases/msa/gage_rr_unbalanced.csv @@ -0,0 +1,13 @@ +Part,Operator,Measurement +P1,Op1,10.843799722267452 +P1,Op1,9.553105234980258 +P1,Op1,10.52506371300749 +P1,Op2,10.066021633086736 +P2,Op1,10.283583284885218 +P2,Op1,9.258693662505012 +P2,Op2,9.441401712453423 +P2,Op2,10.037019389727494 +P2,Op2,10.52835145297477 +P3,Op1,9.908938795003763 +P3,Op2,9.977783866933416 +P3,Op2,10.160733492295794 diff --git a/resilience_data/cases/msa/linearity_ok.csv b/resilience_data/cases/msa/linearity_ok.csv new file mode 100644 index 0000000..d7aa26f --- /dev/null +++ b/resilience_data/cases/msa/linearity_ok.csv @@ -0,0 +1,31 @@ +Measurement,Reference +4.956356965782632,5.0 +4.841736252800165,5.0 +4.9876614813862465,5.0 +4.908732576282721,5.0 +4.858752268728027,5.0 +4.901068156282408,5.0 +7.438665177845815,7.5 +7.555049525308401,7.5 +7.60113384499975,7.5 +7.301505021930553,7.5 +7.40477078407175,7.5 +7.528343970205423,7.5 +10.248810298911078,10.0 +10.047867549428545,10.0 +9.956465240664816,10.0 +9.92804447818999,10.0 +10.036807946189485,10.0 +10.063378970282596,10.0 +12.553226782894841,12.5 +12.4316020517702,12.5 +12.575979088830493,12.5 +12.58621307948527,12.5 +12.48014036074819,12.5 +12.419488010785477,12.5 +14.96520603040214,15.0 +15.091829522164472,15.0 +15.055850496010319,15.0 +14.993668121959718,15.0 +15.2323904775551,15.0 +15.159136895481145,15.0 diff --git a/resilience_data/cases/msa/ndc_fail.csv b/resilience_data/cases/msa/ndc_fail.csv new file mode 100644 index 0000000..cb1333e --- /dev/null +++ b/resilience_data/cases/msa/ndc_fail.csv @@ -0,0 +1,91 @@ +Part,Operator,Measurement +P1,Op1,10.22535212159346 +P1,Op1,9.31411231407937 +P1,Op1,9.766571764157668 +P1,Op2,10.811660007502937 +P1,Op2,10.12075945029374 +P1,Op2,8.567927263396307 +P1,Op3,10.063032615317631 +P1,Op3,10.684731849470781 +P1,Op3,11.181845944185342 +P2,Op1,10.389180080946828 +P2,Op1,9.159927611040002 +P2,Op1,9.803529628008867 +P2,Op2,10.251381348428485 +P2,Op2,10.422443525130907 +P2,Op2,9.510183539362083 +P2,Op3,9.753426447277919 +P2,Op3,10.688912980385426 +P2,Op3,11.437339704779385 +P3,Op1,9.584279696118834 +P3,Op1,10.030682282421685 +P3,Op1,9.722745267057622 +P3,Op2,10.09853635448219 +P3,Op2,9.21863660108863 +P3,Op2,9.449689257109924 +P3,Op3,9.55346889435239 +P3,Op3,10.478330496750711 +P3,Op3,11.433542834398166 +P4,Op1,9.994134556461843 +P4,Op1,9.879581246822527 +P4,Op1,9.62185286278365 +P4,Op2,9.630137090284725 +P4,Op2,11.202160912905299 +P4,Op2,10.325612203655247 +P4,Op3,10.49596906190256 +P4,Op3,10.994941594090982 +P4,Op3,9.452707862214298 +P5,Op1,9.658808803471779 +P5,Op1,10.141976352362786 +P5,Op1,10.633298503365765 +P5,Op2,10.29404390896854 +P5,Op2,10.399656966019997 +P5,Op2,10.199655139327819 +P5,Op3,9.902772589479106 +P5,Op3,10.136815325934577 +P5,Op3,9.995314085271627 +P6,Op1,9.749535627099394 +P6,Op1,9.269087864807187 +P6,Op1,10.072720338992134 +P6,Op2,9.473949182399684 +P6,Op2,9.97653093239215 +P6,Op2,10.113006569761643 +P6,Op3,11.318907030226876 +P6,Op3,10.36739728725053 +P6,Op3,9.363640624010785 +P7,Op1,10.95763622249645 +P7,Op1,10.45202313865626 +P7,Op1,10.027421682780465 +P7,Op2,9.509367500988333 +P7,Op2,9.875146065527389 +P7,Op2,10.518412060403305 +P7,Op3,11.586728980774591 +P7,Op3,11.116520029125402 +P7,Op3,11.342103168989244 +P8,Op1,10.196903425181858 +P8,Op1,9.663411748610896 +P8,Op1,9.298650399172374 +P8,Op2,10.70374435816862 +P8,Op2,9.14576716185134 +P8,Op2,10.150673127654011 +P8,Op3,11.479961045374464 +P8,Op3,11.09988622146313 +P8,Op3,9.810107504002866 +P9,Op1,10.778948355828026 +P9,Op1,9.489362567529616 +P9,Op1,9.144851037899858 +P9,Op2,9.937225965637843 +P9,Op2,9.953525300509797 +P9,Op2,11.33358605013104 +P9,Op3,10.515781106702098 +P9,Op3,9.862952012092071 +P9,Op3,9.55125459310898 +P10,Op1,9.453642344129044 +P10,Op1,9.885185785142351 +P10,Op1,9.291191946684812 +P10,Op2,9.567224748147455 +P10,Op2,9.456506304187736 +P10,Op2,10.327598886655961 +P10,Op3,9.700618133056965 +P10,Op3,10.557503417771354 +P10,Op3,9.654125380936701 diff --git a/resilience_data/cases/msa/stability_ok.csv b/resilience_data/cases/msa/stability_ok.csv new file mode 100644 index 0000000..611b631 --- /dev/null +++ b/resilience_data/cases/msa/stability_ok.csv @@ -0,0 +1,41 @@ +Measurement +9.984284762176388 +10.180093450165554 +9.942183809808496 +10.046289631790584 +9.63285049228416 +9.907491862719656 +10.182756623506975 +9.788406188150198 +10.031612795081493 +10.180948134251429 +9.967616142327989 +10.144400486015147 +9.908873597702703 +9.739941298741009 +10.145913294506752 +10.153365026164039 +10.096141663823497 +9.945498112868902 +9.880111506075295 +9.871435324102595 +10.236204086535484 +9.829622630692871 +10.323802751266818 +10.185108929929847 +10.153768880531588 +9.757594765350781 +9.872653604706422 +10.031256201185943 +10.023191396000708 +10.158561672667169 +9.96115340069124 +9.936914421256091 +9.975220931116933 +9.886661093416716 +9.864849955487362 +9.9453234902694 +9.776273077900449 +9.849164023243802 +9.900272823298113 +9.952828368343798 diff --git a/resilience_data/cases/spc/all_nan.csv b/resilience_data/cases/spc/all_nan.csv new file mode 100644 index 0000000..0b0f93c --- /dev/null +++ b/resilience_data/cases/spc/all_nan.csv @@ -0,0 +1,11 @@ +measurement +"" +"" +"" +"" +"" +"" +"" +"" +"" +"" diff --git a/resilience_data/cases/spc/autocorrelated_ewma.csv b/resilience_data/cases/spc/autocorrelated_ewma.csv new file mode 100644 index 0000000..f4f9af2 --- /dev/null +++ b/resilience_data/cases/spc/autocorrelated_ewma.csv @@ -0,0 +1,81 @@ +measurement +97.49002246719188 +95.28002246285617 +96.02696090259202 +96.40497738520529 +96.2772329396285 +96.46904743856295 +96.67379380690585 +95.96229307741032 +95.79126381477946 +95.56282624647285 +94.47590459816885 +94.06849687848043 +93.9235534757697 +95.46055016716706 +96.08630959806635 +97.28433286968732 +97.65327440843757 +98.16363382660928 +98.83431633975995 +98.92264389688603 +99.09524246308574 +100.69925471379234 +100.12985281779378 +98.87729430936781 +99.83277548797457 +99.39935660500886 +99.90566713080126 +98.05740696206036 +97.2183592912202 +98.63499026522139 +98.75627908671062 +97.77146017048635 +98.64352090812723 +98.83802180242449 +98.79151565725937 +100.75665879990551 +99.13303333973005 +99.43239644687635 +100.40221799740053 +100.79817876359803 +99.74914668447943 +100.48749014911634 +98.6941717511507 +98.73311108857848 +99.93380229308383 +100.48978262296856 +100.90033451810454 +100.14594073754766 +98.70149375011651 +100.5189117547197 +100.23471671056059 +99.74507858016865 +101.31634278736803 +102.38918537760073 +102.42016468668344 +101.85271466951345 +101.29571179141222 +101.86192831461406 +101.54823938247016 +100.93136781558792 +102.15607308551333 +101.49408764527435 +98.89564588547918 +99.7141277438339 +98.58701210898506 +99.18040137425 +97.57956107067432 +97.52047616356823 +97.78125928949909 +97.48576468175965 +97.6703647727413 +97.30845328437168 +97.41468039669883 +99.1755974132192 +99.57537781739919 +100.82272248752595 +99.79959649773372 +99.69830183749937 +102.03051109000076 +101.33926190043833 diff --git a/resilience_data/cases/spc/c_in_control.csv b/resilience_data/cases/spc/c_in_control.csv new file mode 100644 index 0000000..e59627e --- /dev/null +++ b/resilience_data/cases/spc/c_in_control.csv @@ -0,0 +1,26 @@ +defects +1 +3 +2 +1 +2 +1 +4 +2 +1 +3 +3 +5 +3 +4 +3 +4 +3 +4 +7 +3 +6 +2 +5 +2 +2 diff --git a/resilience_data/cases/spc/constant_series.csv b/resilience_data/cases/spc/constant_series.csv new file mode 100644 index 0000000..5391747 --- /dev/null +++ b/resilience_data/cases/spc/constant_series.csv @@ -0,0 +1,41 @@ +measurement +10.0 +10.0 +10.0 +10.0 +10.0 +10.0 +10.0 +10.0 +10.0 +10.0 +10.0 +10.0 +10.0 +10.0 +10.0 +10.0 +10.0 +10.0 +10.0 +10.0 +10.0 +10.0 +10.0 +10.0 +10.0 +10.0 +10.0 +10.0 +10.0 +10.0 +10.0 +10.0 +10.0 +10.0 +10.0 +10.0 +10.0 +10.0 +10.0 +10.0 diff --git a/resilience_data/cases/spc/empty_series.csv b/resilience_data/cases/spc/empty_series.csv new file mode 100644 index 0000000..59d4c4f --- /dev/null +++ b/resilience_data/cases/spc/empty_series.csv @@ -0,0 +1 @@ +measurement diff --git a/resilience_data/cases/spc/heavy_tail_wheeler.csv b/resilience_data/cases/spc/heavy_tail_wheeler.csv new file mode 100644 index 0000000..fb0d66b --- /dev/null +++ b/resilience_data/cases/spc/heavy_tail_wheeler.csv @@ -0,0 +1,81 @@ +measurement +12.689388933503002 +16.63966551387759 +11.59456741331587 +8.825609302072541 +10.98202896840262 +9.936322575226663 +8.22353681154108 +17.33759663496631 +7.678630179500928 +9.07689947969352 +7.33371913397511 +11.368140841806252 +4.829562009663391 +11.193543462202328 +86.7690318731166 +10.294063895813343 +9.435449117608515 +7.748222478644212 +10.488108137246268 +6.592621718252733 +9.422854014758867 +0.8615717638057383 +11.054297005135174 +10.156221188987839 +6.81151481896696 +11.162490714356352 +10.510826315504767 +10.825431379964863 +10.579340955284477 +10.21973997339158 +10.972197493898944 +10.687050640640466 +8.947870999717258 +9.383162912791699 +9.322233248026667 +9.418520701750891 +10.62714666627266 +3.1531049624941634 +4.9823180151164905 +9.825198330901102 +9.237064628236501 +2.751457553075853 +12.286630383921391 +13.646882785780342 +4.806629446314172 +18.1820776564837 +6.765746785784129 +8.923654894176146 +10.258664283460252 +10.971718038084127 +9.688943097362623 +10.214881821704806 +9.21892087514069 +9.896539446632792 +10.49407775062064 +9.177898897247074 +10.138449913255036 +9.420326186942349 +11.324370046098151 +10.414235148168853 +11.392871091921203 +13.184231638981947 +9.092897922790968 +10.298946055774529 +3.4430562689744377 +9.618172679743882 +9.589682561549077 +7.370001709714149 +10.6047501589632 +5.1040002340669535 +8.945445520221295 +9.790246437668394 +10.413157598452578 +10.106462106839652 +7.3667015772029645 +9.821079941979821 +7.835810568307643 +12.381438556975322 +9.768374222652028 +18.504829499536438 diff --git a/resilience_data/cases/spc/heavy_tail_wheeler_subgroup.csv b/resilience_data/cases/spc/heavy_tail_wheeler_subgroup.csv new file mode 100644 index 0000000..1d3de7a --- /dev/null +++ b/resilience_data/cases/spc/heavy_tail_wheeler_subgroup.csv @@ -0,0 +1,126 @@ +measurement,subgroup +10.51969354315003,1 +10.936810801171257,1 +11.334148736198351,1 +9.052078574641135,1 +9.554408707987294,1 +4.913544285140615,2 +-16.60978461245814,2 +-1.7873238656426622,2 +9.180824945526851,2 +12.077633728457572,2 +214.03074205693235,3 +7.6923552567040465,3 +9.781849897266875,3 +10.376140792377205,3 +10.67812453604008,3 +9.237841218198762,4 +10.199254575209709,4 +9.242591864069436,4 +3.14166120202479,4 +8.825481880259655,4 +9.809740480912387,5 +6.566138750185557,5 +8.749510350063279,5 +14.69926291741304,5 +2.9592996006644965,5 +12.096741790951874,6 +33.43676788683833,6 +7.094110348451029,6 +11.061418216145952,6 +6.396428022086316,6 +10.506734176623109,7 +11.449221661433965,7 +10.607963609545433,7 +10.4031299914753,7 +10.04109729238801,7 +10.978428511752968,8 +10.991937678595745,8 +10.74871566342166,8 +12.09108883556708,8 +9.671753408609113,8 +10.00395471423832,9 +12.244234940461055,9 +11.135169510234357,9 +10.763319009423926,9 +8.444126742380979,9 +8.741668588317394,10 +13.865257136534703,10 +2.5535458325838825,10 +7.430433668061991,10 +9.34961849048688,10 +10.268165328230834,11 +10.232500092995897,11 +9.410501368524367,11 +9.361914406229925,11 +12.094547100226748,11 +10.397502191910514,12 +8.499077902094317,12 +5.433732952097399,12 +9.804646276817351,12 +8.533704481390778,12 +10.440271312128335,13 +10.880629580329726,13 +12.516296561988595,13 +11.083154149615389,13 +10.602076284295428,13 +-6.947588746879241,14 +13.394317110168297,14 +1.2509643517437468,14 +9.671323718341513,14 +8.478340278507266,14 +11.466309354732877,15 +12.504741683505777,15 +10.558056449750229,15 +8.823154408297865,15 +46.07440512761674,15 +9.811241951795864,16 +3.9301027383017297,16 +11.588513250754032,16 +11.190921068960686,16 +10.079206427976711,16 +11.274883277237294,17 +10.177492484770985,17 +10.814922379930607,17 +10.324836847066916,17 +11.711977304337326,17 +6.532300548674108,18 +10.269350385479935,18 +11.343250103526964,18 +5.918184301601211,18 +10.46019421333533,18 +6.945532464614888,19 +14.820996925436468,19 +8.512786396478738,19 +10.1673579834077,19 +-4.142895336507122,19 +10.016328099795125,20 +11.172610348577638,20 +10.78014747774275,20 +9.045946678063984,20 +12.121812848796573,20 +18.233673995378233,21 +9.533955686502122,21 +20.01861921837159,21 +10.356176740545509,21 +9.693513319787124,21 +9.090085922453063,22 +10.259115600231263,22 +9.94588507871571,22 +9.891975723852143,22 +10.061813892978241,22 +-10.29783302575169,23 +6.738833435679184,23 +9.206672486443727,23 +5.891221147777633,23 +10.062853529748278,23 +8.999836457905845,24 +9.683041961882857,24 +10.12382271906803,24 +10.38054235673076,24 +9.974078496280532,24 +8.621108444892561,25 +8.408439946234354,25 +10.508790814815587,25 +13.159584161864878,25 +9.935759855911432,25 diff --git a/resilience_data/cases/spc/imr_alternating_nelson4.csv b/resilience_data/cases/spc/imr_alternating_nelson4.csv new file mode 100644 index 0000000..7f05c06 --- /dev/null +++ b/resilience_data/cases/spc/imr_alternating_nelson4.csv @@ -0,0 +1,41 @@ +measurement +97.96885545936539 +101.77548019637977 +97.97852075791586 +102.08868638995544 +98.01743384755618 +101.9803839607879 +97.87019136322859 +102.00578855801561 +98.1243297379443 +101.95582263046309 +97.96840619574546 +101.94164236607705 +97.91557828818877 +102.04018092464445 +98.159531753464 +102.12264244461902 +97.94951618319949 +101.85428884449566 +98.01562921715437 +102.09537126427458 +98.04647887367645 +101.9541783575978 +98.08603807768338 +101.83323707262258 +98.10923016348066 +102.03421660410007 +98.09226886665421 +101.96891598622952 +97.92135384835645 +101.8249590489936 +98.25480976190318 +102.02422640549145 +98.051427746332 +102.04779938938914 +98.12098630134275 +102.05890844914366 +98.0270901340136 +101.9824798984341 +98.04608250873973 +102.07061316143763 diff --git a/resilience_data/cases/spc/imr_in_control_n50.csv b/resilience_data/cases/spc/imr_in_control_n50.csv new file mode 100644 index 0000000..ecaae1f --- /dev/null +++ b/resilience_data/cases/spc/imr_in_control_n50.csv @@ -0,0 +1,51 @@ +measurement +98.84245035287988 +100.28975580232775 +100.7808540692251 +100.54397364470859 +99.03861735875456 +101.07100866560158 +100.70145566011045 +100.70497345509882 +100.74506260266183 +101.10434723837231 +102.24297239573468 +99.38850687727692 +100.04721118309074 +101.75423468244375 +98.6620201337005 +100.32557446888715 +99.31088228401231 +99.98017819000808 +100.47475324599756 +98.06889858384295 +99.00752172193336 +98.5945289174062 +99.76890449150977 +99.31115291504038 +101.51510577985853 +99.39682845344906 +101.71368448755628 +99.59375080920512 +100.27140952079766 +100.03984020008687 +100.01151831825204 +98.87282199166762 +100.33471297537837 +100.38389154856475 +100.2378355139871 +100.62141167436006 +99.18075445348018 +99.7024207944696 +99.33843729529471 +98.29582967469723 +100.36753632089105 +99.36451120666038 +99.92227949308055 +102.24976783797992 +100.23031052894936 +100.10741590014419 +101.0739457822334 +101.2463539133011 +101.8128926970985 +99.47851602673872 diff --git a/resilience_data/cases/spc/imr_mean_shift.csv b/resilience_data/cases/spc/imr_mean_shift.csv new file mode 100644 index 0000000..263ed89 --- /dev/null +++ b/resilience_data/cases/spc/imr_mean_shift.csv @@ -0,0 +1,51 @@ +measurement +99.69256216179924 +99.16134310028794 +100.12563787425727 +99.33932271047779 +100.15831515895609 +102.43876182218403 +100.43963253154293 +100.77035284002963 +99.20556673784627 +99.09354495893737 +100.78974626023376 +99.03986406475036 +99.06782561031976 +100.41546944054761 +99.53614973127446 +100.62222806658201 +99.10180977385633 +100.88850981231012 +97.7435727581509 +99.10656004310327 +98.65264284774976 +100.18162461724202 +98.49534865151777 +101.00265427640532 +99.00692798273975 +104.96121094439867 +103.95821190172173 +105.03772144730411 +105.14152931380193 +103.40872688328422 +104.14229367951718 +104.74851125761576 +104.16193096962098 +105.29566818833003 +105.30769755037069 +103.72102055628483 +102.18535630752774 +103.96450614612375 +105.36682862096583 +103.69225194048259 +103.55833581491146 +103.63388710802921 +104.57916021095905 +105.40387195134313 +102.09486669448576 +104.73153862096103 +103.44892913564388 +102.51839536756063 +104.98013209188424 +103.72798972428455 diff --git a/resilience_data/cases/spc/imr_single_spike.csv b/resilience_data/cases/spc/imr_single_spike.csv new file mode 100644 index 0000000..d26cb69 --- /dev/null +++ b/resilience_data/cases/spc/imr_single_spike.csv @@ -0,0 +1,51 @@ +measurement +98.66973229943486 +100.93836275109486 +99.42316383453728 +100.21462248176519 +99.05488341883743 +102.220028451014 +99.03201504686156 +100.17497494923055 +99.67461865195496 +102.17483659276827 +100.3007042711939 +98.9858009426294 +99.59803262243511 +99.15999765519648 +99.0729894574982 +99.88918774641112 +98.58687683529706 +100.73823573814474 +98.19918289889159 +100.08455887137072 +100.76732268754711 +100.50903424857526 +101.8308545525569 +100.23837689696866 +101.05764672086335 +108.0 +101.87930019187762 +97.56211397880365 +100.2991366830643 +99.19286658519204 +100.89299512891418 +99.03213694402973 +99.93744488497008 +100.46747452091998 +99.11589564868389 +100.08778899319726 +100.62638404116656 +99.67782408139973 +99.43018543099822 +100.00344327474332 +101.90409988220866 +99.23223596994276 +100.96144846091872 +97.95693132819497 +99.03577378798985 +100.11321991255326 +100.85576185497312 +99.77204924584935 +100.2910124789767 +98.58019309645847 diff --git a/resilience_data/cases/spc/imr_sustained_small_shift.csv b/resilience_data/cases/spc/imr_sustained_small_shift.csv new file mode 100644 index 0000000..26b3d5d --- /dev/null +++ b/resilience_data/cases/spc/imr_sustained_small_shift.csv @@ -0,0 +1,61 @@ +measurement +100.09753232462425 +100.92555509259526 +100.35833341493786 +102.38522191446006 +99.91048918836269 +102.11314870827094 +100.88207166410805 +100.05492798514388 +100.12382703290366 +100.92426375285771 +99.91068427689423 +99.37126046377936 +99.54126666896538 +98.34858907373052 +100.30221099940066 +99.07951221754777 +99.16677848186822 +100.15286661541033 +101.57974379022816 +98.69702947484483 +99.47501380717915 +101.45727076154004 +101.26788875996289 +99.46475920696508 +101.03336141510812 +101.27252538949763 +100.47120449622601 +100.23785564592485 +99.79327199460958 +98.40681486770161 +101.37345825313143 +100.23072285017473 +101.614674604243 +101.00707962542735 +99.42797387342145 +100.45914524856262 +98.97806029149675 +102.14165672340091 +101.31225699748306 +100.37583538537716 +100.60916009469248 +101.0845819978275 +101.34895012798452 +100.95209804702542 +101.82341108898827 +100.32148473957032 +101.34920753340964 +100.38036362758557 +102.30986108356312 +101.32424756849828 +100.80380451442772 +101.05985500603632 +100.60503909944421 +100.74602739252869 +102.7857832914271 +101.3641967017413 +101.33234674946165 +99.79498341374445 +98.82826812296906 +101.66354003861487 diff --git a/resilience_data/cases/spc/imr_trend.csv b/resilience_data/cases/spc/imr_trend.csv new file mode 100644 index 0000000..3200fe8 --- /dev/null +++ b/resilience_data/cases/spc/imr_trend.csv @@ -0,0 +1,51 @@ +measurement +100.47816001823249 +100.30826964290377 +100.28220776301427 +99.77906431147132 +100.89646445142616 +101.48604474004881 +101.64475711031777 +101.19994240469752 +101.01597145322704 +101.62810955376145 +100.89562189229574 +101.5050394978504 +101.99172473001111 +102.09059101402818 +102.21805082648979 +101.80200414063242 +101.82388708451512 +102.63766377050962 +102.98694649253906 +102.74784077240852 +102.65299547880979 +103.26451351337394 +103.68621500201093 +103.4671994538009 +103.32518725746263 +104.34294382624869 +104.21611323573053 +103.97832813384485 +104.18736682018489 +104.0969386993879 +104.9408243412943 +104.4581348428295 +104.4565958680629 +104.93347271447013 +105.7834710406441 +104.28247714175842 +105.00024678521211 +105.83016093553671 +105.82281154500474 +105.91747658758094 +106.03604785992496 +106.058717028714 +106.4444977821715 +106.57688682009227 +106.53033937913266 +106.39095132019132 +106.6889560596795 +106.58038084499378 +107.3539100207249 +107.35540298441191 diff --git a/resilience_data/cases/spc/imr_trend_nelson3.csv b/resilience_data/cases/spc/imr_trend_nelson3.csv new file mode 100644 index 0000000..dc9a21c --- /dev/null +++ b/resilience_data/cases/spc/imr_trend_nelson3.csv @@ -0,0 +1,31 @@ +measurement +99.94241132759335 +100.58882119419377 +100.99888071436699 +101.47326629737111 +101.97241379747841 +102.46609995668065 +103.008853645231 +103.55273038529762 +103.97378710303033 +104.47661182174731 +105.02922608245483 +105.50245512632796 +106.06162889535877 +106.42444111168821 +107.05467728165891 +107.49688553212542 +107.97986279630554 +108.40282375323521 +109.01025173278433 +109.51229092211656 +110.05545176277111 +110.56213305991781 +110.9719101109542 +111.60568388742371 +111.9562611942672 +112.50725565313984 +112.94544507684158 +113.40039179228327 +113.96845213881903 +114.50255905318188 diff --git a/resilience_data/cases/spc/incomplete_subgroup.csv b/resilience_data/cases/spc/incomplete_subgroup.csv new file mode 100644 index 0000000..129a16a --- /dev/null +++ b/resilience_data/cases/spc/incomplete_subgroup.csv @@ -0,0 +1,123 @@ +measurement,subgroup +50.3301337772061,1 +50.267341321903025,1 +48.001915493756414,1 +49.80249483291909,1 +51.353146765229376,1 +48.47255880912069,2 +50.76010153415439,2 +48.837352647149004,2 +48.62186698029974,2 +50.939442818709864,2 +47.805682870117266,3 +49.17109387150341,3 +51.52490515344251,3 +49.35502098313387,3 +47.57110683144232,3 +48.55514455889358,4 +49.869998094396344,4 +51.361984645519094,4 +48.06639915600908,4 +49.9605066493877,4 +50.12055858018676,5 +51.51618345570197,5 +50.5896907155649,5 +49.74039369430762,5 +49.496462258404115,5 +50.870102410152725,6 +50.10999170526245,6 +49.525695334194936,6 +49.08449395495797,6 +50.65465247853087,6 +49.43980054965894,7 +48.98137290154473,7 +50.85764788618237,7 +50.3829160683211,7 +48.91027576511383,7 +50.64003427052102,8 +51.205269665218324,8 +50.284986279687075,8 +51.46774188720335,8 +48.16860101690539,8 +52.317668551171586,9 +49.98710816327842,9 +50.98278118874582,9 +49.11260236725434,9 +48.36288977420204,9 +50.675131850803496,10 +50.1317723694416,10 +50.62355615246179,10 +48.352323598875614,10 +50.87388797375164,10 +50.62985531790928,11 +49.39650379986126,11 +49.788506012342076,11 +51.08836667965123,11 +50.052048889887125,11 +49.21513371130527,12 +51.126451462369175,12 +49.1158685420286,12 +48.02634174302713,12 +52.523179809474804,12 +48.95049739287069,13 +51.44443909450195,13 +51.042731122287826,13 +48.88073799127893,13 +51.12699559413469,13 +50.09448833166974,14 +50.05173469208042,14 +49.00613180932144,14 +50.90443122643183,14 +51.350723217318105,14 +48.63013315304089,15 +51.888303888612086,15 +50.77832903778478,15 +47.162283590506426,15 +46.8301252109007,15 +49.76670318970286,16 +49.45078535687172,16 +49.98296717222831,16 +49.31694467507582,16 +51.206142482942134,16 +48.871923310772964,17 +49.42902755951282,17 +50.98233552052349,17 +49.571752232747464,17 +49.0710845008589,17 +49.657335862523276,18 +49.802098194412565,18 +49.75277098436943,18 +48.87133730703353,18 +49.83877872061986,18 +50.042382568589375,19 +49.400815585754955,19 +50.77541730468754,19 +49.110735921299245,19 +48.9038523093877,19 +50.158047611119684,20 +47.86206190786699,20 +49.637142460113274,20 +49.489753041708305,20 +49.20822840977558,20 +51.70562833794341,21 +50.34242212741619,21 +49.526670344881886,21 +49.72178849213592,21 +49.916107029266215,21 +51.32054857990143,22 +51.11346520433953,22 +49.891600184838154,22 +49.41197268436229,22 +50.77656504202696,22 +48.354234253567284,23 +51.50202814642219,23 +50.24305620039547,23 +49.56015997836358,23 +50.610024518132825,23 +50.25371105499226,24 +48.17950379460994,24 +52.470636146765955,24 +48.275239584105805,24 +48.57735867704003,24 +48.32077735978276,25 +50.11660358087641,25 diff --git a/resilience_data/cases/spc/multimodal_stop.csv b/resilience_data/cases/spc/multimodal_stop.csv new file mode 100644 index 0000000..7f676be --- /dev/null +++ b/resilience_data/cases/spc/multimodal_stop.csv @@ -0,0 +1,101 @@ +measurement +18.941878581103715 +20.970331479436634 +-1.5038153585345895 +-0.05766685713477117 +20.044087860966005 +20.308799145651445 +19.44149031521496 +19.967939102673817 +20.81793208292832 +20.828390986017265 +20.273122227248034 +-0.061415638511272894 +0.5899836127931857 +0.048919752534854984 +0.6107926197383902 +-0.6760835775178365 +20.57111602679655 +19.34935547375584 +19.630225292156563 +20.039024788628787 +20.595672778644833 +19.578139997949023 +0.6885663038747941 +0.09005876808875624 +-0.3163133611104873 +-1.13654729205821 +19.2210820624761 +-0.7571553461987588 +19.93993710162612 +20.654418063833486 +0.5318301463635309 +19.137196896800095 +0.4452009174855592 +0.3306626473184839 +19.289593902630028 +20.547687717041224 +-0.3015000149422857 +19.78632173015465 +-0.747629851847423 +19.68229629634074 +20.29044657330995 +19.719583032767375 +-0.19467719351125945 +0.45471478857835124 +19.56166693228284 +19.815259193296 +-0.23843143541605805 +20.775433365899286 +0.712427412645121 +21.171911072721336 +0.033455687826108714 +0.408570926541585 +0.8477088259144061 +19.7218820634632 +20.5707847314524 +20.541489161023552 +0.0019119397473911196 +-0.6768443736077588 +0.05747656645002724 +-0.19698312563957712 +0.6618766509960935 +19.959606061302644 +20.047276124369485 +19.7422662269167 +-0.780991885434894 +19.624523467710777 +-0.14380844211947744 +19.79452433510714 +0.15712523065501294 +0.1637213301664665 +19.760399155795156 +0.21295773225543588 +-1.3687152896097678 +-0.5888869665026262 +-1.5803336791378317 +20.73870202983106 +1.1615166757278452 +-0.7660159383420145 +-0.45201303976403795 +20.381143712832 +19.12829816770643 +0.8473228668057182 +0.7380287131600807 +-0.24865406152204694 +0.24041378155997165 +0.17652485737851323 +0.4434360100065444 +20.38353171159609 +19.743442919645545 +0.03467498256251172 +20.080397613793064 +20.575672127480797 +-1.080805008346061 +0.2900634485286484 +19.789249147603417 +20.39055401314817 +20.58497616649455 +-0.46460662306925266 +20.39247776991679 +20.111559419177468 diff --git a/resilience_data/cases/spc/normal_path.csv b/resilience_data/cases/spc/normal_path.csv new file mode 100644 index 0000000..e2e6a6d --- /dev/null +++ b/resilience_data/cases/spc/normal_path.csv @@ -0,0 +1,61 @@ +measurement +98.20443653611828 +99.52950261553688 +100.02188094103259 +101.04016320424071 +98.98210253294317 +99.89877581895769 +99.79593173128073 +99.82784645356602 +99.35174734887306 +100.27883818581137 +100.21369469660407 +100.21300369012303 +100.13373224472791 +99.8299680938478 +97.82961581491881 +100.00675783233243 +101.29059273731013 +97.80198997872517 +101.58616771749841 +98.82221251950452 +99.1220666755065 +101.27767845530374 +100.13400245696106 +99.5581363090923 +98.77959824331398 +99.32220799461689 +101.23713339329703 +100.40708368198612 +100.27813602915478 +98.68329408503526 +100.29091256153447 +98.43873388888575 +100.87916955418231 +100.15918602294414 +100.75102696356412 +101.09449061398689 +97.77768840485949 +100.01238130943726 +99.72010365049655 +99.73935069024054 +99.18484751940332 +100.51490623081699 +99.20155231856535 +100.47447027744877 +99.20263681621142 +98.90812494476515 +98.80385902332658 +98.46515800410212 +99.70493398526476 +100.49550449683122 +100.68441310298579 +99.5576145543137 +99.09073283711125 +102.0229686388888 +99.61580847631208 +100.98163142522387 +101.49069798396879 +99.58462115698039 +99.97045378499028 +99.98414747986052 diff --git a/resilience_data/cases/spc/np_in_control.csv b/resilience_data/cases/spc/np_in_control.csv new file mode 100644 index 0000000..9afeafa --- /dev/null +++ b/resilience_data/cases/spc/np_in_control.csv @@ -0,0 +1,26 @@ +defectives,sample_size +5,100 +9,100 +2,100 +4,100 +3,100 +2,100 +6,100 +4,100 +4,100 +3,100 +4,100 +5,100 +2,100 +3,100 +3,100 +4,100 +4,100 +4,100 +4,100 +5,100 +5,100 +4,100 +6,100 +3,100 +3,100 diff --git a/resilience_data/cases/spc/p_in_control.csv b/resilience_data/cases/spc/p_in_control.csv new file mode 100644 index 0000000..5e9c6bf --- /dev/null +++ b/resilience_data/cases/spc/p_in_control.csv @@ -0,0 +1,26 @@ +defective,inspected +7,108 +4,113 +4,93 +4,107 +3,90 +6,88 +5,87 +4,85 +4,106 +6,95 +1,87 +10,110 +4,92 +3,109 +4,94 +6,96 +8,118 +8,105 +6,88 +5,112 +3,94 +5,82 +4,94 +5,101 +8,110 diff --git a/resilience_data/cases/spc/p_zero_n.csv b/resilience_data/cases/spc/p_zero_n.csv new file mode 100644 index 0000000..0173bfc --- /dev/null +++ b/resilience_data/cases/spc/p_zero_n.csv @@ -0,0 +1,4 @@ +defective,inspected +1,100 +2,0 +0,80 diff --git a/resilience_data/cases/spc/skewed_boxcox.csv b/resilience_data/cases/spc/skewed_boxcox.csv new file mode 100644 index 0000000..fcabef7 --- /dev/null +++ b/resilience_data/cases/spc/skewed_boxcox.csv @@ -0,0 +1,81 @@ +measurement +0.23777016358877529 +0.6863291831238797 +1.0176588588948212 +2.2982100099494978 +0.44294132202061626 +0.9222127397052523 +0.849374897311688 +0.8713401614059505 +0.5953521969547421 +1.2499087499993948 +1.1864382566087859 +1.1857825686396126 +1.1129184462311892 +0.8728203535331489 +0.17616973450815732 +1.005420906092341 +2.8080047839502593 +0.17231897444991082 +3.5570593349271893 +0.3897577119222998 +0.4954213533554997 +2.77914343756984 +1.113159051578996 +0.7022323458476624 +0.37669425873468954 +0.5814480759482653 +2.690445288405498 +1.38495402700321 +1.249206841758515 +0.34876228105505724 +1.2620407444647048 +0.2867877485572447 +2.0204810837825535 +1.1358131411617618 +1.8236164151985925 +2.40029702758425 +0.16900123166747347 +1.009954264899127 +0.7993814166458695 +0.8117852471057577 +0.5209391782339491 +1.5097211798235228 +0.5279476514628823 +1.4616650366212667 +0.5284058966086641 +0.41748776346447014 +0.38407678657767075 +0.29291477085962325 +0.7897389653044947 +1.4864691316748295 +1.7289779998036128 +0.7019392925469917 +0.4831569497886844 +5.044885264410807 +0.7353908010600038 +2.1930760342582265 +3.2955016005073925 +0.7172699037520561 +0.9766401931387662 +0.987398061780922 +0.7113247067301084 +0.5418731336379182 +1.4287051203967476 +2.652715084975832 +5.303565563315438 +0.7477922864934096 +0.8069464883818066 +0.5271707205358312 +0.26241145321796133 +2.2297210689942477 +1.08518287243424 +2.9327955494401965 +2.4082180613784923 +1.3447809500777526 +1.1526710249154188 +0.6620368835596195 +1.8190646669782935 +2.5509065882732767 +1.5708497073682992 +0.31812768881639397 diff --git a/resilience_data/cases/spc/too_few_points_n10.csv b/resilience_data/cases/spc/too_few_points_n10.csv new file mode 100644 index 0000000..6b67974 --- /dev/null +++ b/resilience_data/cases/spc/too_few_points_n10.csv @@ -0,0 +1,11 @@ +measurement +99.9298762196552 +101.24787672843172 +99.39161029448239 +99.57680736690723 +101.98114087360024 +100.94270248931453 +99.69807171830828 +100.80259473598623 +99.91444433891604 +100.07656550992391 diff --git a/resilience_data/cases/spc/u_in_control.csv b/resilience_data/cases/spc/u_in_control.csv new file mode 100644 index 0000000..00117b2 --- /dev/null +++ b/resilience_data/cases/spc/u_in_control.csv @@ -0,0 +1,26 @@ +defects,units +3,6 +6,11 +8,14 +5,9 +6,13 +6,10 +8,7 +7,12 +3,10 +10,9 +6,9 +4,5 +2,5 +4,9 +2,6 +4,14 +9,14 +1,6 +5,9 +8,13 +8,8 +1,7 +5,8 +7,7 +9,12 diff --git a/resilience_data/cases/spc/u_zero_opportunity.csv b/resilience_data/cases/spc/u_zero_opportunity.csv new file mode 100644 index 0000000..8200cba --- /dev/null +++ b/resilience_data/cases/spc/u_zero_opportunity.csv @@ -0,0 +1,4 @@ +defects,units +1,10 +2,0 +0,8 diff --git a/resilience_data/cases/spc/xbar_r_25x5.csv b/resilience_data/cases/spc/xbar_r_25x5.csv new file mode 100644 index 0000000..5ebef48 --- /dev/null +++ b/resilience_data/cases/spc/xbar_r_25x5.csv @@ -0,0 +1,126 @@ +measurement,subgroup +49.05181700004438,1 +47.558449421801754,1 +50.72396209630972,1 +50.89315343585589,1 +49.62837584016047,1 +50.440785647534646,2 +52.05247314977317,2 +51.27295740807897,2 +50.84916682496729,2 +50.825299266206535,2 +48.963719055497165,3 +51.15682007800827,3 +48.021844621190155,3 +49.60148982467761,3 +49.47524739087906,3 +47.92577755298479,4 +49.86759333559826,4 +51.972146091961285,4 +49.59184719484986,4 +48.5508759916888,4 +49.890735537792274,5 +51.024709179801185,5 +50.23186173878046,5 +49.996801140244195,5 +48.30893328991087,5 +50.60121889094651,6 +50.58287730414044,6 +51.6095194468607,6 +51.78686392834514,6 +50.82629957612126,6 +48.02909974257666,7 +48.277527797341676,7 +48.682425853950726,7 +50.35390994933914,7 +49.05530467753333,7 +51.57144519329164,8 +50.29262631289049,8 +50.43772087484068,8 +50.90060972565819,8 +50.40829748171349,8 +48.30731958977686,9 +52.94231501589649,9 +51.75558668679417,9 +51.8277544055849,9 +49.10302084039339,9 +47.783002669096675,10 +48.192038345335504,10 +48.735844143235106,10 +51.05856735940201,10 +51.0803832929696,10 +51.75007149739072,11 +52.570939947163524,11 +50.62508913036967,11 +50.42547418983328,11 +49.564397201310456,11 +49.596244766210326,12 +48.610659549603255,12 +50.612960152136786,12 +50.381805247944726,12 +49.51376205793821,12 +49.027297378338226,13 +50.54834171402327,13 +51.5673920777603,13 +50.09779774354923,13 +50.34052285279006,13 +49.34220027343557,14 +49.7017689800837,14 +49.62019732535256,14 +49.65790562749803,14 +49.28330626526901,14 +50.47566005378558,15 +51.55967515350178,15 +48.86338937914685,15 +51.67904808051551,15 +49.51639706868281,15 +49.55949346015733,16 +47.41224093076185,16 +47.09422915662235,16 +50.88260365027424,16 +48.6906999065766,16 +50.966369983225455,17 +50.060751049916306,17 +50.300567773302696,17 +51.59996571803507,17 +49.9131739062722,17 +46.08066844771906,18 +47.91364954203747,18 +51.167158447233206,18 +52.39601633088736,18 +49.88485871450126,18 +49.157361204611036,19 +48.3293416587512,19 +50.65526470733961,19 +47.84253561076131,19 +48.50502668354135,19 +50.163119640981165,20 +50.122321229928986,20 +49.68942284106273,20 +51.27606463896641,20 +48.50790698202419,20 +48.57100523837772,21 +49.49797008771891,21 +50.07816956649483,21 +49.594680824814375,21 +52.39123215595172,21 +49.2753500858364,22 +50.76241328317778,22 +49.296321745418766,22 +50.3848409141812,22 +49.46853215215293,22 +49.52836828615714,23 +51.94839277654932,23 +49.59556550605888,23 +52.06056512312086,23 +49.11256689687508,23 +49.43744500273217,24 +51.331332553362465,24 +47.02440472384512,24 +47.9913741666502,24 +50.0215646810461,24 +50.572283073480946,25 +50.6874493371824,25 +49.60876194407004,25 +48.26565433840378,25 +51.001328270621975,25 diff --git a/resilience_data/cases/spc/xbar_r_variance_increase.csv b/resilience_data/cases/spc/xbar_r_variance_increase.csv new file mode 100644 index 0000000..fabc8d1 --- /dev/null +++ b/resilience_data/cases/spc/xbar_r_variance_increase.csv @@ -0,0 +1,151 @@ +measurement,subgroup +48.9776621609388,1 +50.25515059485921,1 +48.92881411251795,1 +51.059022358369646,1 +51.435896325694024,1 +50.24004641872865,2 +49.543592379614935,2 +50.17430030804879,2 +49.692754945072494,2 +49.20225048308779,2 +49.498666450737105,3 +50.14767170741176,3 +51.021652874277805,3 +50.500193175713626,3 +49.04529682142485,3 +47.82597584273605,4 +48.80560020987398,4 +49.322013483969485,4 +51.08409865095724,4 +49.708705515059144,4 +48.02477579620012,5 +51.38973384794316,5 +51.31507912250464,5 +50.14209652397966,5 +48.369462060508056,5 +49.421405617038225,6 +50.17519446419497,6 +50.276877584875486,6 +49.03808835515839,6 +49.16334208954113,6 +51.873398585075826,7 +47.90252946820478,7 +51.8537597827752,7 +48.77496283445695,7 +51.51707735063685,7 +50.60176719949687,8 +50.66850085800536,8 +50.16872669761821,8 +51.997141021015445,8 +50.58946477180282,8 +49.97585413575012,9 +48.345380687172586,9 +50.54737218643633,9 +50.40884915940848,9 +49.521398817342764,9 +48.753301227812564,10 +50.135415994253236,10 +50.33968704332775,10 +49.666571314235604,10 +51.899350371168296,10 +50.741397216408586,11 +48.20541438842702,11 +48.94331272985596,11 +52.47552250101032,11 +50.51836212943491,11 +50.8217478301076,12 +49.31354533955715,12 +49.882471701490665,12 +49.6265748088663,12 +48.525987503307746,12 +48.147917101244346,13 +49.54389930501514,13 +49.70167274859787,13 +48.837118373752226,13 +49.89337015554079,13 +49.10351857082017,14 +52.81428269864524,14 +48.69936476656387,14 +53.16281743480568,14 +50.09241046842621,14 +50.454096950674675,15 +49.85302722428572,15 +50.37194811810977,15 +50.89090036139786,15 +49.9883162287498,15 +49.896232166767014,16 +50.347898754815894,16 +50.42393610573682,16 +51.381387470887105,16 +52.34550474161024,16 +49.16415845805819,17 +50.41953296764666,17 +49.93157802830202,17 +49.31106575689522,17 +49.156793862549584,17 +48.83214363333972,18 +49.20102765827719,18 +49.311299612422026,18 +52.17958529158559,18 +52.72895089434077,18 +50.349829797576305,19 +52.27171674239491,19 +47.872407238158715,19 +51.838328582370586,19 +49.02108659208727,19 +50.12540547296997,20 +49.023069035030844,20 +50.187377243889756,20 +51.14604863100095,20 +49.49671222103489,20 +52.810956887863234,21 +55.672293966847676,21 +55.44064057778196,21 +53.61008433316331,21 +52.83089722590924,21 +50.61079354860957,22 +50.801250907917215,22 +48.589114223285854,22 +45.25831300535019,22 +48.41543663204359,22 +48.383438247387296,23 +57.1855523149623,23 +51.42361322524455,23 +54.542818810097295,23 +47.35438950022151,23 +48.877972284845896,24 +50.412026653981904,24 +49.917260941630495,24 +53.17959547343894,24 +45.18236832705315,24 +53.12699844219252,25 +53.06179465914873,25 +49.539119353892815,25 +47.36240085347315,25 +51.63854413956537,25 +51.006889516895804,26 +45.324600120930626,26 +53.884488025654406,26 +45.09983038328049,26 +48.61047088039648,26 +55.905271292113824,27 +50.3499261864196,27 +44.758275340555784,27 +55.60531349642412,27 +42.50376277719009,27 +52.03658016475981,28 +48.33559169052705,28 +56.93965616794066,28 +53.64168963998954,28 +53.20945868818949,28 +49.2830760366623,29 +51.68041803479592,29 +45.116448844838615,29 +45.77832420868188,29 +51.595755178582976,29 +53.06579741169159,30 +43.57984218048489,30 +48.82528460648872,30 +50.197659114920775,30 +55.10554583190509,30 diff --git a/resilience_data/cases/spc/xbar_s_25x10.csv b/resilience_data/cases/spc/xbar_s_25x10.csv new file mode 100644 index 0000000..cc363f8 --- /dev/null +++ b/resilience_data/cases/spc/xbar_s_25x10.csv @@ -0,0 +1,251 @@ +measurement,subgroup +48.61094042345586,1 +50.3477069627933,1 +50.937024883070116,1 +50.6527683736503,1 +48.846340830505476,1 +51.285210398721894,1 +50.84174679213254,1 +50.84596814611859,1 +50.8940751231942,1 +51.32521668604678,1 +52.691566874881616,2 +49.26620825273231,2 +50.05665341970889,2 +52.1050816189325,2 +48.3944241604406,2 +50.39068936266459,2 +49.17305874081477,2 +49.97621382800969,2 +50.56970389519708,2 +47.682678300611535,2 +48.80902606632004,3 +48.313434700887434,3 +49.72268538981173,3 +49.17338349804845,3 +51.818126935830236,3 +49.27619414413887,3 +52.056421385067544,3 +49.51250097104614,3 +50.32569142495719,3 +50.047808240104246,3 +50.013821981902446,4 +48.64738639000114,4 +50.40165557045403,4 +50.46066985827769,4 +50.285402616784516,4 +50.74569400923208,4 +49.01690534417621,4 +49.64290495336353,4 +49.20612475435365,4 +47.954995609636676,4 +50.44104358506926,5 +49.23741344799245,5 +49.906735391696664,5 +52.69972140557589,5 +50.276372634739225,5 +50.12889908017302,5 +51.288734938680065,5 +51.495624695961325,5 +52.1754712365182,5 +49.37421923208646,5 +52.15512193031664,6 +49.84237064222204,6 +48.61012784839921,6 +48.88533042037598,6 +51.329416253629184,6 +50.91504987598143,6 +51.539028618236905,6 +48.89381064483401,6 +49.592252055904204,6 +48.56273946194157,6 +47.62929711317827,7 +49.978323829194835,7 +51.89923354931054,7 +51.33450455009566,7 +49.06737767277031,7 +51.400963738787325,7 +49.307866108846085,7 +50.37453417501234,7 +50.992387025717036,7 +49.51089724672923,7 +48.93843508124575,8 +50.77953518180321,8 +49.787356306401925,8 +49.337796969603296,8 +50.76115530678504,8 +49.742035183722216,8 +50.45943896489827,8 +48.84126936336972,8 +50.86883266886428,8 +48.8640015499134,8 +49.53721756446819,9 +47.75403319599695,9 +51.39077545515232,9 +50.11235825029557,9 +50.55532420117994,9 +47.95694993099391,9 +50.184251545514435,9 +49.52674462057497,9 +51.43643647972988,9 +48.115396169554494,9 +50.4234314264108,10 +51.26669268414952,10 +48.569215966582746,10 +50.130948599105,10 +48.69878714995924,10 +50.05182272201383,10 +50.39977417216091,10 +51.58033982695926,10 +50.2017940294372,10 +49.172440558142725,10 +50.69107445149839,11 +50.0030690629402,11 +51.29619987366407,11 +49.27404554427843,11 +51.604037564995416,11 +50.20268607868329,11 +50.29119232719081,11 +51.20456495194362,11 +49.364286693044875,11 +49.38701817806126,11 +50.60962721976376,12 +49.899293606141555,12 +50.05640262257907,12 +49.51411040970323,12 +49.63073516680214,12 +51.03505728254882,12 +52.35299741645398,12 +48.34437822486723,12 +51.817643041718256,12 +50.886754280106494,12 +51.44675587133195,13 +50.21464881660201,13 +49.11804542631908,13 +49.7591170788714,13 +48.886695742724775,13 +47.99399133678957,13 +49.90642540588509,13 +50.31088117364442,13 +49.6625061068912,13 +50.22761723554641,13 +48.142664278434395,14 +51.26723955035839,14 +49.80978024112477,14 +50.04864332336511,14 +50.858296384943394,14 +49.32282242575139,14 +53.113706220315876,14 +51.49829190370795,14 +51.478150850930206,14 +50.18166219557683,14 +49.14680897995479,15 +50.60044751459292,15 +49.12548887370988,15 +50.27996977808769,15 +50.28195928010754,15 +51.482473382638034,15 +50.529229477566076,15 +50.179197566637605,15 +51.66478177534613,15 +49.86091264655094,15 +49.36183681104994,16 +49.38762181915451,16 +51.749340263141924,16 +50.03825359201121,16 +49.311593521587,16 +47.651048935464615,16 +48.747764806226876,16 +50.375188966536435,16 +50.70287752654106,16 +51.99688856233803,16 +49.164428043654766,17 +50.59791962949481,17 +51.55514917473464,17 +47.257195972319124,17 +52.77342762708819,17 +48.63715099809657,17 +48.6360449019382,17 +51.120548146283376,17 +50.49689657738199,17 +51.45861556215486,17 +50.78126814404716,18 +50.363577125181116,18 +49.84932485276341,18 +49.06987390963948,18 +50.70777320203158,18 +47.69419627775529,18 +49.7792858654112,18 +50.937243831928335,18 +48.61797550586213,18 +51.01039780126642,18 +51.17704066878174,19 +50.092781267077804,19 +49.81779314040188,19 +50.66557786332387,19 +50.771485308152656,19 +49.70071106655972,19 +52.47456714018488,19 +51.82905100822243,19 +51.208470048303965,19 +51.681085822047855,19 +51.573530768512,20 +48.34343314348072,20 +53.055306890863065,20 +48.26706641663152,20 +50.25494076315415,20 +48.67158637002098,20 +50.76350911654364,20 +50.53282477776003,20 +50.29470188984262,20 +46.5535533711739,20 +50.96964973171526,21 +50.30632942354477,21 +47.53599086987613,21 +51.818222513506974,21 +49.82002030491629,21 +49.09085314388798,21 +50.522105462425216,21 +49.356844685481946,21 +50.473937875304884,21 +49.56591599656475,21 +48.92209126197917,22 +48.62749263986555,22 +50.971866608733265,22 +50.124324665528185,22 +49.09360409220666,22 +49.301374128603044,22 +48.9431306775998,22 +52.59303224389725,22 +50.46118609442796,22 +50.221050876841836,22 +51.83434251944901,23 +49.22395553531472,23 +48.684912451975585,23 +49.394868931175964,23 +50.20775771592048,23 +49.7552481816352,23 +49.584684651129606,23 +50.18183827712378,23 +49.46224257585607,23 +48.67043418324612,23 +50.8855564338398,24 +49.66186330811064,24 +49.627188255150934,24 +50.59989724637326,24 +50.48146786003095,24 +48.6234638478185,24 +49.08647269584534,24 +48.28647829845042,24 +48.5176663709395,24 +50.33416337610616,24 +49.3741547304701,25 +48.82664493756216,25 +48.358185278231474,25 +51.484876162973805,25 +49.45405095336065,25 +50.237961655944076,25 +49.858115913528266,25 +48.80761732378116,25 +50.47246535665413,25 +50.10215830940247,25 diff --git a/resilience_data/generators/__init__.py b/resilience_data/generators/__init__.py new file mode 100644 index 0000000..2e0e96d --- /dev/null +++ b/resilience_data/generators/__init__.py @@ -0,0 +1,113 @@ +"""Catalog of case builders keyed by MANIFEST id.""" +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from . import capability as cap +from . import cleaning as cln +from . import msa, spc + +Columns = dict[str, list[Any]] +Builder = Callable[[], Columns] + +# id → (relative path under cases/, builder) +CASE_BUILDERS: dict[str, tuple[str, Builder]] = { + # A. Happy path + "imr_in_control_n50": ("spc/imr_in_control_n50.csv", spc.imr_in_control), + "xbar_r_25x5": ("spc/xbar_r_25x5.csv", spc.xbar_r), + "xbar_s_25x10": ("spc/xbar_s_25x10.csv", lambda: spc.xbar_s()), + "p_in_control": ("spc/p_in_control.csv", spc.attribute_p), + "np_in_control": ("spc/np_in_control.csv", spc.attribute_np), + "c_in_control": ("spc/c_in_control.csv", spc.attribute_c), + "u_in_control": ("spc/u_in_control.csv", spc.attribute_u), + # B. OOC + "imr_mean_shift": ("spc/imr_mean_shift.csv", spc.imr_mean_shift), + "imr_single_spike": ("spc/imr_single_spike.csv", spc.imr_single_spike), + "imr_trend": ("spc/imr_trend.csv", spc.imr_trend), + "xbar_r_variance_increase": ( + "spc/xbar_r_variance_increase.csv", + spc.xbar_r_variance_increase, + ), + # B2. Run-rule patterns (Nelson 2-8 / Western Electric) + "imr_sustained_small_shift": ( + "spc/imr_sustained_small_shift.csv", + spc.imr_sustained_small_shift, + ), + "imr_trend_nelson3": ("spc/imr_trend_nelson3.csv", spc.imr_trend_nelson3), + "imr_alternating_nelson4": ( + "spc/imr_alternating_nelson4.csv", + spc.imr_alternating_nelson4, + ), + # C. Distribution routes + "normal_path": ("spc/normal_path.csv", spc.normal_path), + "skewed_boxcox": ("spc/skewed_boxcox.csv", spc.skewed_boxcox), + "heavy_tail_wheeler": ("spc/heavy_tail_wheeler.csv", spc.heavy_tail_wheeler), + "heavy_tail_wheeler_subgroup": ( + "spc/heavy_tail_wheeler_subgroup.csv", + spc.heavy_tail_wheeler_subgroup, + ), + "autocorrelated_ewma": ("spc/autocorrelated_ewma.csv", spc.autocorrelated_ewma), + "multimodal_stop": ("spc/multimodal_stop.csv", spc.multimodal_stop), + # D. Cleaning + "gap_short_locf": ("cleaning/gap_short_locf.csv", cln.gap_short_locf), + "gap_long_hold": ("cleaning/gap_long_hold.csv", cln.gap_long_hold), + "reason_maintenance": ("cleaning/reason_maintenance.csv", cln.reason_maintenance), + "reason_human": ("cleaning/reason_human.csv", cln.reason_human), + "reason_backup": ("cleaning/reason_backup.csv", cln.reason_backup), + "reason_incomplete": ("cleaning/reason_incomplete.csv", cln.reason_incomplete), + "sensor_sentinel_range": ( + "cleaning/sensor_sentinel_range.csv", + cln.sensor_sentinel_range, + ), + "sensor_sentinel_long": ( + "cleaning/sensor_sentinel_long.csv", + cln.sensor_sentinel_long, + ), + "incomplete_subgroup": ("spc/incomplete_subgroup.csv", spc.incomplete_subgroup), + "empty_series": ("spc/empty_series.csv", spc.empty_series), + "all_nan": ("spc/all_nan.csv", spc.all_nan), + "constant_series": ("spc/constant_series.csv", spc.constant_series), + "p_zero_n": ("spc/p_zero_n.csv", spc.p_zero_n), + "u_zero_opportunity": ("spc/u_zero_opportunity.csv", spc.u_zero_opportunity), + # E. MSA + "gage_rr_excellent": ( + "msa/gage_rr_excellent.csv", + lambda: msa.gage_rr(quality="excellent"), + ), + "gage_rr_marginal": ( + "msa/gage_rr_marginal.csv", + lambda: msa.gage_rr(quality="marginal", seed=11), + ), + "gage_rr_poor": ( + "msa/gage_rr_poor.csv", + lambda: msa.gage_rr(quality="poor", seed=303), + ), + "gage_rr_unbalanced": ("msa/gage_rr_unbalanced.csv", msa.gage_rr_unbalanced), + "ndc_fail": ( + "msa/ndc_fail.csv", + lambda: msa.gage_rr(quality="ndc_fail", seed=304), + ), + "bias_significant": ("msa/bias_significant.csv", msa.bias_significant), + "linearity_ok": ("msa/linearity_ok.csv", msa.linearity_ok), + "stability_ok": ("msa/stability_ok.csv", msa.stability_ok), + # F. Capability + "cap_excellent": ( + "capability/cap_excellent.csv", + lambda: cap.capability(kind="excellent"), + ), + "cap_off_center": ( + "capability/cap_off_center.csv", + lambda: cap.capability(kind="off_center", seed=402), + ), + "cap_high_variation": ( + "capability/cap_high_variation.csv", + lambda: cap.capability(kind="high_variation", seed=403), + ), + "cap_skewed": ( + "capability/cap_skewed.csv", + lambda: cap.capability(kind="skewed", seed=404), + ), + # G. Phase I insufficiency + "too_few_points_n10": ("spc/too_few_points_n10.csv", spc.too_few_points), +} diff --git a/resilience_data/generators/capability.py b/resilience_data/generators/capability.py new file mode 100644 index 0000000..4528b11 --- /dev/null +++ b/resilience_data/generators/capability.py @@ -0,0 +1,31 @@ +"""Capability generators.""" +from __future__ import annotations + +from typing import Any + +import numpy as np + +Columns = dict[str, list[Any]] + + +def _rng(seed: int) -> np.random.Generator: + return np.random.default_rng(seed) + + +def capability(*, kind: str = "excellent", n: int = 200, seed: int = 401) -> Columns: + """Capability datasets. + + Specs used by the suite: USL=10.5, LSL=9.5 unless noted otherwise. + """ + rng = _rng(seed) + if kind == "excellent": + values = 10.0 + rng.normal(0.0, 0.10, n) + elif kind == "off_center": + values = 10.25 + rng.normal(0.0, 0.12, n) + elif kind == "high_variation": + values = 10.0 + rng.normal(0.0, 0.45, n) + elif kind == "skewed": + values = rng.exponential(2.0, n) + 5.0 + else: + raise ValueError(f"unknown kind {kind!r}") + return {"measurement": [float(v) for v in values]} diff --git a/resilience_data/generators/cleaning.py b/resilience_data/generators/cleaning.py new file mode 100644 index 0000000..c69ea10 --- /dev/null +++ b/resilience_data/generators/cleaning.py @@ -0,0 +1,79 @@ +"""Missing-value / range / reason generators.""" +from __future__ import annotations + +from typing import Any + +import numpy as np + +Columns = dict[str, list[Any]] + + +def _base(n: int = 40, seed: int = 201) -> list[float]: + rng = np.random.default_rng(seed) + return [float(v) for v in 100.0 + rng.normal(0.0, 1.0, n)] + + +def gap_short_locf(*, seed: int = 201) -> Columns: + values: list[float | None] = _base(40, seed) + values[10] = None + values[11] = None + return {"measurement": values, "reason": [None] * len(values)} + + +def gap_long_hold(*, seed: int = 202) -> Columns: + values: list[float | None] = _base(40, seed) + for i in range(10, 15): + values[i] = None + return {"measurement": values, "reason": [None] * len(values)} + + +def reason_maintenance(*, seed: int = 203) -> Columns: + values: list[float | None] = _base(30, seed) + reasons: list[str | None] = [None] * len(values) + values[12] = None + reasons[12] = "maintenance" + return {"measurement": values, "reason": reasons} + + +def reason_human(*, seed: int = 204) -> Columns: + values: list[float | None] = _base(30, seed) + reasons: list[str | None] = [None] * len(values) + values[8] = None + reasons[8] = "human" + return {"measurement": values, "reason": reasons} + + +def reason_backup(*, seed: int = 205) -> Columns: + values: list[float | None] = _base(30, seed) + reasons: list[str | None] = [None] * len(values) + reasons[15] = "backup" + return {"measurement": values, "reason": reasons} + + +def reason_incomplete(*, seed: int = 206) -> Columns: + values: list[float | None] = _base(30, seed) + reasons: list[str | None] = [None] * len(values) + values[20] = None + reasons[20] = "incomplete" + return {"measurement": values, "reason": reasons} + + +def sensor_sentinel_range(*, seed: int = 207) -> Columns: + values = _base(30, seed) + values[5] = -999.0 + values[18] = -999.0 + return {"measurement": values} + + +def sensor_sentinel_long(*, n: int = 80, seed: int = 208) -> Columns: + """Sentinels in a long in-control series, for the full pipeline rather than range_check. + + Longer than ``sensor_sentinel_range`` on purpose: at n=30, dropping two points moves + the lag-1 ACF estimate enough to trip the autocorrelation gate and divert the case to + EWMA, which would confound the thing being asserted (that a -999 sentinel never + reaches the chart as an out-of-control signal). + """ + values = _base(n, seed) + values[17] = -999.0 + values[52] = -999.0 + return {"measurement": values} diff --git a/resilience_data/generators/msa.py b/resilience_data/generators/msa.py new file mode 100644 index 0000000..dfc5a91 --- /dev/null +++ b/resilience_data/generators/msa.py @@ -0,0 +1,103 @@ +"""MSA generators — Gage R&R, bias, linearity, stability, NDC/resolution stress.""" +from __future__ import annotations + +from typing import Any + +import numpy as np + +Columns = dict[str, list[Any]] + + +def _rng(seed: int) -> np.random.Generator: + return np.random.default_rng(seed) + + +def gage_rr( + *, + quality: str = "excellent", + n_parts: int = 10, + n_operators: int = 3, + n_trials: int = 3, + seed: int = 301, +) -> Columns: + """Balanced Gage R&R. + + excellent: %GRR typically < 10 of tolerance 10 + marginal: %GRR typically 10–30 + poor: %GRR typically > 30 + ndc_fail: very low part variation → NDC < 5 + """ + rng = _rng(seed) + if quality == "excellent": + part_sd, op_sd, repeat_sd = 2.0, 0.05, 0.08 + elif quality == "marginal": + # Tuned so %GRR of tolerance 10 lands in the AIAG 10–30 conditional band. + part_sd, op_sd, repeat_sd = 1.2, 0.15, 0.3 + elif quality == "poor": + part_sd, op_sd, repeat_sd = 0.3, 0.8, 1.2 + elif quality == "ndc_fail": + part_sd, op_sd, repeat_sd = 0.05, 0.4, 0.5 + else: + raise ValueError(f"unknown quality {quality!r}") + + part_effects = rng.normal(0.0, part_sd, n_parts) + op_effects = rng.normal(0.0, op_sd, n_operators) + parts: list[str] = [] + operators: list[str] = [] + measurements: list[float] = [] + for pi in range(n_parts): + for oi in range(n_operators): + for _ in range(n_trials): + noise = float(rng.normal(0.0, repeat_sd)) + y = 10.0 + part_effects[pi] + op_effects[oi] + noise + parts.append(f"P{pi + 1}") + operators.append(f"Op{oi + 1}") + measurements.append(float(y)) + return {"Part": parts, "Operator": operators, "Measurement": measurements} + + +def gage_rr_unbalanced(*, seed: int = 305) -> Columns: + """Deliberately unbalanced cells so ANOVA falls back to range.""" + rng = _rng(seed) + parts: list[str] = [] + operators: list[str] = [] + measurements: list[float] = [] + plan = { + ("P1", "Op1"): 3, + ("P1", "Op2"): 1, + ("P2", "Op1"): 2, + ("P2", "Op2"): 3, + ("P3", "Op1"): 1, + ("P3", "Op2"): 2, + } + for (part, op), n in plan.items(): + for _ in range(n): + parts.append(part) + operators.append(op) + measurements.append(float(10.0 + rng.normal(0.0, 0.5))) + return {"Part": parts, "Operator": operators, "Measurement": measurements} + + +def bias_significant(*, n: int = 30, reference: float = 10.0, seed: int = 311) -> Columns: + rng = _rng(seed) + meas = [float(reference + 0.8 + rng.normal(0.0, 0.1)) for _ in range(n)] + return {"Measurement": meas, "Reference": [reference] * n} + + +def linearity_ok(*, n_refs: int = 5, n_reps: int = 6, seed: int = 312) -> Columns: + rng = _rng(seed) + refs_levels = np.linspace(5.0, 15.0, n_refs) + measurements: list[float] = [] + references: list[float] = [] + for ref in refs_levels: + for _ in range(n_reps): + bias = 0.02 * (ref - 10.0) + measurements.append(float(ref + bias + rng.normal(0.0, 0.08))) + references.append(float(ref)) + return {"Measurement": measurements, "Reference": references} + + +def stability_ok(*, n: int = 40, seed: int = 313) -> Columns: + rng = _rng(seed) + values = 10.0 + rng.normal(0.0, 0.15, n) + return {"Measurement": [float(v) for v in values]} diff --git a/resilience_data/generators/spc.py b/resilience_data/generators/spc.py new file mode 100644 index 0000000..73861f3 --- /dev/null +++ b/resilience_data/generators/spc.py @@ -0,0 +1,218 @@ +"""SPC chart / OOC / distribution / Phase I size generators.""" +from __future__ import annotations + +from typing import Any + +import numpy as np + +Columns = dict[str, list[Any]] + + +def _rng(seed: int) -> np.random.Generator: + return np.random.default_rng(seed) + + +def imr_in_control(*, n: int = 50, seed: int = 100) -> Columns: + rng = _rng(seed) + values = 100.0 + rng.normal(0.0, 1.0, n) + return {"measurement": [float(v) for v in values]} + + +def xbar_r(*, n_subgroups: int = 25, size: int = 5, seed: int = 101) -> Columns: + rng = _rng(seed) + measurements: list[float] = [] + subgroups: list[int] = [] + for sid in range(1, n_subgroups + 1): + for v in 50.0 + rng.normal(0.0, 1.2, size): + measurements.append(float(v)) + subgroups.append(sid) + return {"measurement": measurements, "subgroup": subgroups} + + +def xbar_s(*, n_subgroups: int = 25, size: int = 10, seed: int = 100) -> Columns: + return xbar_r(n_subgroups=n_subgroups, size=size, seed=seed) + + +def attribute_p(*, n: int = 25, seed: int = 104) -> Columns: + rng = _rng(seed) + inspected = [int(x) for x in rng.integers(80, 120, n)] + defective = [int(rng.binomial(k, 0.05)) for k in inspected] + return {"defective": defective, "inspected": inspected} + + +def attribute_np(*, n: int = 25, sample_size: int = 100, seed: int = 105) -> Columns: + rng = _rng(seed) + defectives = [int(x) for x in rng.binomial(sample_size, 0.04, n)] + return {"defectives": defectives, "sample_size": [sample_size] * n} + + +def attribute_c(*, n: int = 25, seed: int = 106) -> Columns: + rng = _rng(seed) + return {"defects": [int(x) for x in rng.poisson(3.0, n)]} + + +def attribute_u(*, n: int = 25, seed: int = 107) -> Columns: + rng = _rng(seed) + units = [int(x) for x in rng.integers(5, 15, n)] + defects = [int(rng.poisson(0.6 * u)) for u in units] + return {"defects": defects, "units": units} + + +def imr_mean_shift(*, n: int = 50, seed: int = 111) -> Columns: + rng = _rng(seed) + values = 100.0 + rng.normal(0.0, 1.0, n) + values[n // 2 :] += 4.0 + return {"measurement": [float(v) for v in values]} + + +def imr_single_spike(*, n: int = 50, seed: int = 112) -> Columns: + rng = _rng(seed) + values = 100.0 + rng.normal(0.0, 1.0, n) + values[n // 2] = 100.0 + 8.0 + return {"measurement": [float(v) for v in values]} + + +def imr_trend(*, n: int = 50, seed: int = 113) -> Columns: + rng = _rng(seed) + t = np.arange(n, dtype=float) + values = 100.0 + 0.15 * t + rng.normal(0.0, 0.3, n) + return {"measurement": [float(v) for v in values]} + + +def xbar_r_variance_increase( + *, n_subgroups: int = 30, size: int = 5, seed: int = 114 +) -> Columns: + rng = _rng(seed) + measurements: list[float] = [] + subgroups: list[int] = [] + for sid in range(1, n_subgroups + 1): + sd = 1.2 if sid <= 20 else 4.0 + for v in 50.0 + rng.normal(0.0, sd, size): + measurements.append(float(v)) + subgroups.append(sid) + return {"measurement": measurements, "subgroup": subgroups} + + +def imr_sustained_small_shift( + *, n: int = 60, shift: float = 0.8, seed: int = 163 +) -> Columns: + """A sustained sub-sigma shift: too small for rule 1, big enough for the run rules. + + A large step shift induces enough lag-1 autocorrelation that ``establish`` routes it + to EWMA (see ``imr_mean_shift``), which means the Shewhart run rules never run. A + ~0.8-sigma shift keeps lag-1 ACF under the 0.2 gate threshold, so this case stays on + the Shewhart route and exercises Nelson rule 2 — the classic reason run rules exist. + """ + rng = _rng(seed) + values = 100.0 + rng.normal(0.0, 1.0, n) + values[n // 2 :] += shift + return {"measurement": [float(v) for v in values]} + + +def imr_trend_nelson3(*, n: int = 30, slope: float = 0.5, seed: int = 141) -> Columns: + """Steady drift with tiny noise so six-in-a-row monotonic (Nelson 3) fires. + + Charted directly via ``analyze_control_chart`` rather than ``establish``: a drift is + autocorrelated by construction, so the pipeline correctly diverts it to EWMA. The + Shewhart trend rule still has to work, so it is asserted at the chart layer. + """ + rng = _rng(seed) + t = np.arange(n, dtype=float) + values = 100.0 + slope * t + rng.normal(0.0, 0.05, n) + return {"measurement": [float(v) for v in values]} + + +def imr_alternating_nelson4(*, n: int = 40, swing: float = 2.0, seed: int = 142) -> Columns: + """Sawtooth from operator over-adjustment (tampering) — Nelson rule 4, 14 alternating.""" + rng = _rng(seed) + base = np.array([swing if i % 2 else -swing for i in range(n)], dtype=float) + values = 100.0 + base + rng.normal(0.0, 0.1, n) + return {"measurement": [float(v) for v in values]} + + +def normal_path(*, n: int = 60, seed: int = 122) -> Columns: + return imr_in_control(n=n, seed=seed) + + +def skewed_boxcox(*, n: int = 80, seed: int = 122) -> Columns: + rng = _rng(seed) + # Lognormal — right-skewed, typically unimodal, Box-Cox / log recoverable. + values = np.exp(rng.normal(0.0, 0.8, n)) + return {"measurement": [float(v) for v in values]} + + +def heavy_tail_wheeler(*, n: int = 80, seed: int = 123) -> Columns: + rng = _rng(seed) + # Cauchy — heavy tails that resist Box-Cox / Yeo-Johnson normalization. + values = 10.0 + rng.standard_cauchy(n) + return {"measurement": [float(v) for v in values]} + + +def heavy_tail_wheeler_subgroup( + *, n_subgroups: int = 25, size: int = 5, seed: int = 124 +) -> Columns: + rng = _rng(seed) + measurements: list[float] = [] + subgroups: list[int] = [] + for sid in range(1, n_subgroups + 1): + for v in 10.0 + rng.standard_cauchy(size): + measurements.append(float(v)) + subgroups.append(sid) + return {"measurement": measurements, "subgroup": subgroups} + + +def autocorrelated_ewma(*, n: int = 80, seed: int = 125) -> Columns: + rng = _rng(seed) + # AR(1) with phi=0.8 → strong lag-1 ACF. + phi = 0.8 + eps = rng.normal(0.0, 1.0, n) + x = np.zeros(n) + x[0] = eps[0] + for i in range(1, n): + x[i] = phi * x[i - 1] + eps[i] + return {"measurement": [float(v) for v in (100.0 + x)]} + + +def multimodal_stop(*, n: int = 100, seed: int = 126) -> Columns: + rng = _rng(seed) + # Two well-separated clusters so the no-diptest heuristic fires. + a = rng.normal(0.0, 0.5, n // 2) + b = rng.normal(20.0, 0.5, n - n // 2) + values = np.concatenate([a, b]) + rng.shuffle(values) + return {"measurement": [float(v) for v in values]} + + +def too_few_points(*, n: int = 10, seed: int = 131) -> Columns: + return imr_in_control(n=n, seed=seed) + + +def incomplete_subgroup(*, n_complete: int = 24, size: int = 5, seed: int = 132) -> Columns: + """24 full subgroups of 5, then a ragged last subgroup of 2.""" + base = xbar_r(n_subgroups=n_complete, size=size, seed=seed) + rng = _rng(seed + 1) + last_id = n_complete + 1 + for v in 50.0 + rng.normal(0.0, 1.2, 2): + base["measurement"].append(float(v)) + base["subgroup"].append(last_id) + return base + + +def constant_series(*, n: int = 40) -> Columns: + return {"measurement": [10.0] * n} + + +def p_zero_n() -> Columns: + return {"defective": [1, 2, 0], "inspected": [100, 0, 80]} + + +def u_zero_opportunity() -> Columns: + return {"defects": [1, 2, 0], "units": [10, 0, 8]} + + +def empty_series() -> Columns: + return {"measurement": []} + + +def all_nan(*, n: int = 10) -> Columns: + return {"measurement": [None] * n} diff --git a/resilience_data/runner.py b/resilience_data/runner.py new file mode 100644 index 0000000..702ab84 --- /dev/null +++ b/resilience_data/runner.py @@ -0,0 +1,430 @@ +"""Shared runner: execute one MANIFEST case against spc_core and compare expect.""" +from __future__ import annotations + +from typing import Any + +from resilience_data import case_csv_path, read_csv +from spc_core import ( + ChartType, + Phase2Evaluator, + analyze_control_chart, + bias_study, + capability_analysis, + classify_missing, + establish, + gage_rr_anova, + linearity_study, + ndc_gate, + phase1_checklist, + range_check, + stability_study, +) +from spc_core.ewma import ewma_chart +from spc_core.msa import gage_resolution_gate + + +def _col(cols: dict[str, list], name: str | None) -> list | None: + if name is None: + return None + return cols[name] + + +def _as_float_list(values: list) -> list[float | None]: + out: list[float | None] = [] + for v in values: + if v is None: + out.append(None) + else: + out.append(float(v)) + return out + + +def _chart_type(raw: str | None) -> ChartType | None: + if raw is None: + return None + return ChartType(raw) + + +def _rule_ids(signals) -> list[str]: + """Sorted unique rule ids, so a case can assert *which* rule fired, not just how many.""" + return sorted({s.rule_id for s in signals}) + + +def _chart_observed(result) -> dict[str, Any]: + """Signal/rule facts shared by every chart-producing entry point.""" + return { + "chart_type": result.chart_type.value, + "ruleset_applied": result.ruleset_applied, + "n_plotted": len(result.plotted_values), + "n_signals": len(result.signals), + "rule_ids": _rule_ids(result.signals), + "n_secondary_signals": len(result.secondary_signals), + "secondary_rule_ids": _rule_ids(result.secondary_signals), + "secondary_name": result.secondary_name, + } + + +def run_case(spec: dict[str, Any]) -> dict[str, Any]: + """Execute one case. Returns a judgment dict with status and details.""" + case_id = spec["id"] + entry = spec["entry"] + expect = spec.get("expect") or {} + cols_map = spec.get("columns") or {} + params = dict(spec.get("params") or {}) + xfail = bool(expect.get("xfail", False)) + + try: + cols = read_csv(case_csv_path(spec["path"])) if spec.get("path") else {} + observed = _dispatch(entry, cols, cols_map, params) + mismatches = _compare(expect, observed) + if mismatches: + status = "XFAIL" if xfail else "FAIL" + return { + "id": case_id, + "status": status, + "mismatches": mismatches, + "observed": observed, + "xfail_reason": expect.get("xfail_reason"), + } + if xfail: + return { + "id": case_id, + "status": "FAIL", + "mismatches": ["marked xfail but all expects matched"], + "observed": observed, + } + return {"id": case_id, "status": "PASS", "observed": observed, "mismatches": []} + except Exception as exc: # noqa: BLE001 — judgment must catch all + expected_raises = expect.get("raises") + if expected_raises and type(exc).__name__ == expected_raises: + observed = {"raises": type(exc).__name__, "message": str(exc)} + # An exception *type* alone is too weak: a domain guard and an internal + # crash both surface as ValueError. Require the message to prove which. + wanted_msg = expect.get("raises_match") + if wanted_msg and wanted_msg not in str(exc): + return { + "id": case_id, + "status": "XFAIL" if xfail else "FAIL", + "mismatches": [ + f"raises_match: {wanted_msg!r} not in message {str(exc)!r}" + ], + "observed": observed, + "xfail_reason": expect.get("xfail_reason"), + } + return { + "id": case_id, + "status": "PASS", + "observed": observed, + "mismatches": [], + } + if xfail and expected_raises is None: + return { + "id": case_id, + "status": "XFAIL", + "mismatches": [f"unexpected {type(exc).__name__}: {exc}"], + "observed": {"raises": type(exc).__name__, "message": str(exc)}, + "xfail_reason": expect.get("xfail_reason"), + } + return { + "id": case_id, + "status": "ERROR", + "mismatches": [f"{type(exc).__name__}: {exc}"], + "observed": {"raises": type(exc).__name__, "message": str(exc)}, + } + + +def _dispatch( + entry: str, + cols: dict[str, list], + cols_map: dict[str, str], + params: dict[str, Any], +) -> dict[str, Any]: + if entry == "establish": + values = _as_float_list(_col(cols, cols_map.get("values", "measurement")) or []) + subgroup_ids = _col(cols, cols_map.get("subgroup_ids")) + sample_sizes = _col(cols, cols_map.get("sample_sizes")) + opportunities = _col(cols, cols_map.get("opportunities")) + reasons = _col(cols, cols_map.get("missing_reasons")) + msa_parts = _col(cols, cols_map.get("msa_parts")) + msa_operators = _col(cols, cols_map.get("msa_operators")) + msa_measurements = _col(cols, cols_map.get("msa_measurements")) + + # Optional companion MSA CSV columns living in the same file. + if msa_measurements is None and "Measurement" in cols and "Part" in cols: + msa_parts = cols["Part"] + msa_operators = cols["Operator"] + msa_measurements = cols["Measurement"] + + pipe = establish( + values, + subgroup_ids=subgroup_ids, + sample_sizes=sample_sizes, + opportunities=opportunities, + chart_type=_chart_type(params.get("chart_type")), + ruleset=params.get("ruleset", "nelson"), + missing_reasons=reasons, + msa_parts=msa_parts, + msa_operators=msa_operators, + msa_measurements=msa_measurements, + msa_tolerance=params.get("msa_tolerance"), + gage_resolution=params.get("gage_resolution"), + autocorrelated_chart=params.get("autocorrelated_chart", "EWMA"), + force_wheeler=bool(params.get("force_wheeler", False)), + valid_range=( + tuple(params["valid_range"]) if params.get("valid_range") else None + ), + ) + checklist = phase1_checklist( + pipe, + min_subgroups=int(params.get("min_subgroups", 25)), + phase2_enabled=bool(params.get("phase2_enabled", False)), + ) + gates = {g.step: g.status for g in pipe.gates} + return { + **_chart_observed(pipe.chart), + "stopped": pipe.stopped, + "frozen": pipe.frozen, + "gates": gates, + "chart_route": pipe.chart_route, + "distribution_flag": pipe.chart.distribution_flag.value, + "checklist_passed": checklist["passed"], + "checklist_items": { + i["item"]: i["passed"] for i in checklist["items"] + }, + "msa_grr_percent": ( + pipe.msa.grr_percent if pipe.msa is not None else None + ), + "msa_ndc": pipe.msa.ndc if pipe.msa is not None else None, + "raises": None, + } + + if entry == "analyze_control_chart": + values = _as_float_list(_col(cols, cols_map.get("values", "measurement")) or []) + subgroup_ids = _col(cols, cols_map.get("subgroup_ids")) + sample_sizes = _col(cols, cols_map.get("sample_sizes")) + opportunities = _col(cols, cols_map.get("opportunities")) + result = analyze_control_chart( + [v for v in values if v is not None], + subgroup_ids=subgroup_ids, + sample_sizes=sample_sizes, + opportunities=opportunities, + chart_type=_chart_type(params.get("chart_type")), + ruleset=params.get("ruleset", "nelson"), + exclude_incomplete=bool(params.get("exclude_incomplete", False)), + ) + return {**_chart_observed(result), "raises": None} + + if entry == "classify_missing": + values = _as_float_list(_col(cols, cols_map.get("values", "measurement")) or []) + reasons = _col(cols, cols_map.get("reasons", "reason")) + result = classify_missing(values, reasons=reasons) + flag_counts: dict[str, int] = {} + for f in result.flags: + flag_counts[f.value] = flag_counts.get(f.value, 0) + 1 + return { + "flag_counts": flag_counts, + "n_usable": sum(1 for u in result.usable if u), + "n_unusable": sum(1 for u in result.usable if not u), + "raises": None, + } + + if entry == "range_check": + values = _as_float_list(_col(cols, cols_map.get("values", "measurement")) or []) + low = float(params["low"]) + high = float(params["high"]) + valid = range_check(values, low, high) + return { + "n_invalid": sum(1 for v in valid if not v), + "n_valid": sum(1 for v in valid if v), + "raises": None, + } + + if entry == "gage_rr_anova": + parts = _col(cols, cols_map.get("parts", "Part")) or [] + operators = _col(cols, cols_map.get("operators", "Operator")) or [] + measurements = _col(cols, cols_map.get("measurements", "Measurement")) or [] + tolerance = params.get("tolerance", 10.0) + result = gage_rr_anova(parts, operators, measurements, tolerance=tolerance) + ndc_ok, _ = ndc_gate(result.ndc) + detail = result.detail or {} + return { + "grr_percent": result.grr_percent, + "ndc": result.ndc, + "ndc_ok": ndc_ok, + "method": result.method, + "anova_fallback": bool(detail.get("anova_skipped")), + "raises": None, + } + + if entry == "gage_resolution_gate": + ok, reason = gage_resolution_gate( + float(params["resolution"]), float(params["tolerance"]) + ) + return {"ok": ok, "reason": reason, "raises": None} + + if entry == "bias_study": + meas = _col(cols, cols_map.get("measurements", "Measurement")) or [] + refs = _col(cols, cols_map.get("references", "Reference")) or [] + result = bias_study(meas, refs) + return { + "mean_bias": result.mean_bias, + "is_significant": result.is_significant, + "raises": None, + } + + if entry == "linearity_study": + meas = _col(cols, cols_map.get("measurements", "Measurement")) or [] + refs = _col(cols, cols_map.get("references", "Reference")) or [] + result = linearity_study(meas, refs) + return { + "slope": result.slope, + "is_linear": result.is_linear, + "raises": None, + } + + if entry == "stability_study": + meas = _col(cols, cols_map.get("measurements", "Measurement")) or [] + result = stability_study(meas) + return { + "out_of_control_points": result.out_of_control_points, + "is_stable": result.is_stable, + "raises": None, + } + + if entry == "capability_analysis": + values = _as_float_list(_col(cols, cols_map.get("values", "measurement")) or []) + result = capability_analysis( + [v for v in values if v is not None], + usl=float(params["usl"]), + lsl=float(params["lsl"]), + target=params.get("target"), + force_method=params.get("force_method"), + ) + return { + "method": result.method, + "cp": result.cp, + "cpk": result.cpk, + "pp": result.pp, + "ppk": result.ppk, + # Relative form, so an off-centre case asserts "Cpk penalised vs Cp" + # instead of hardcoding an observed float. + "cpk_lt_cp": ( + None + if result.cp is None or result.cpk is None + else bool(result.cpk < result.cp) + ), + "raises": None, + } + + if entry == "ewma_chart": + values = _as_float_list(_col(cols, cols_map.get("values", "measurement")) or []) + result = ewma_chart([v for v in values if v is not None]) + return { + "chart_type": result.limits.chart_type.value, + "raises": None, + } + + if entry == "phase2_detect": + # Establish on first half, evaluate second half. + values = [ + v + for v in _as_float_list(_col(cols, cols_map.get("values", "measurement")) or []) + if v is not None + ] + split = int(params.get("split", len(values) // 2)) + pipe = establish(values[:split], chart_type=_chart_type(params.get("chart_type"))) + ruleset = "wheeler" if pipe.chart_route == "wheeler" else "nelson" + ev = Phase2Evaluator(pipe.chart.limits, ruleset=ruleset) + signals = [] + for v in values[split:]: + signals.extend(ev.observe(v)) + return { + "n_signals": len(signals), + "rule_ids": _rule_ids(signals), + "ruleset_applied": ruleset, + "frozen": pipe.frozen, + "raises": None, + } + + raise ValueError(f"Unknown entry {entry!r}") + + +def _compare(expect: dict[str, Any], observed: dict[str, Any]) -> list[str]: + """Return list of mismatch strings. Ignores meta keys.""" + mismatches: list[str] = [] + skip = {"xfail", "xfail_reason", "raises", "raises_match", "notes"} + list_ops = ("includes", "excludes", "subset_of") + if expect.get("raises"): + # Handled in run_case exception path; if we got here, no raise occurred. + mismatches.append( + f"expected raises={expect['raises']!r} but got result {observed}" + ) + return mismatches + + for key, wanted in expect.items(): + if key in skip: + continue + actual = observed.get(key) + if isinstance(wanted, dict) and any(k in wanted for k in list_ops): + actual_list = list(actual or []) + for item in wanted.get("includes", []): + if item not in actual_list: + mismatches.append( + f"{key}: expected to include {item!r}, got {actual_list!r}" + ) + for item in wanted.get("excludes", []): + if item in actual_list: + mismatches.append( + f"{key}: expected NOT to include {item!r}, got {actual_list!r}" + ) + if "subset_of" in wanted: + allowed = set(wanted["subset_of"]) + extra = sorted(x for x in actual_list if x not in allowed) + if extra: + mismatches.append( + f"{key}: {extra!r} not allowed; expected subset of " + f"{sorted(allowed)!r}" + ) + elif isinstance(wanted, dict) and any( + k in wanted for k in ("lt", "lte", "gt", "gte", "eq") + ): + if actual is None: + mismatches.append(f"{key}: expected bound {wanted}, got None") + continue + try: + val = float(actual) + except (TypeError, ValueError): + mismatches.append(f"{key}: expected numeric, got {actual!r}") + continue + if "lt" in wanted and not (val < float(wanted["lt"])): + mismatches.append(f"{key}: {val} not < {wanted['lt']}") + if "lte" in wanted and not (val <= float(wanted["lte"])): + mismatches.append(f"{key}: {val} not <= {wanted['lte']}") + if "gt" in wanted and not (val > float(wanted["gt"])): + mismatches.append(f"{key}: {val} not > {wanted['gt']}") + if "gte" in wanted and not (val >= float(wanted["gte"])): + mismatches.append(f"{key}: {val} not >= {wanted['gte']}") + if "eq" in wanted and val != float(wanted["eq"]): + mismatches.append(f"{key}: {val} != {wanted['eq']}") + elif isinstance(wanted, dict) and key == "gates": + actual_gates = actual or {} + for step, status in wanted.items(): + if status == "__absent__": + if step in actual_gates: + mismatches.append( + f"gates.{step}: expected absent, got {actual_gates[step]!r}" + ) + elif actual_gates.get(step) != status: + mismatches.append( + f"gates.{step}: expected {status!r}, got {actual_gates.get(step)!r}" + ) + elif isinstance(wanted, dict) and key in ("flag_counts", "checklist_items"): + actual_map = actual or {} + for k, v in wanted.items(): + if actual_map.get(k) != v: + mismatches.append( + f"{key}.{k}: expected {v!r}, got {actual_map.get(k)!r}" + ) + elif wanted != actual: + mismatches.append(f"{key}: expected {wanted!r}, got {actual!r}") + return mismatches diff --git a/sample_data/__init__.py b/sample_data/__init__.py new file mode 100644 index 0000000..1b3fa39 --- /dev/null +++ b/sample_data/__init__.py @@ -0,0 +1,256 @@ +"""Deterministic synthetic SPC / MSA / capability datasets. + +Used by pytest fixtures and ``python -m sample_data --out examples/data``. +All generators are numpy-seeded so outputs are reproducible across runs. +""" +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +import numpy as np + +Columns = dict[str, list[Any]] + + +def _rng(seed: int) -> np.random.Generator: + return np.random.default_rng(seed) + + +def spc_individual(*, in_control: bool = True, n: int = 50, seed: int = 42) -> Columns: + """I-MR series around 100. Out-of-control injects a late mean shift + spike.""" + rng = _rng(seed if in_control else seed + 1) + values = 100.0 + rng.normal(0.0, 1.0, n) + if not in_control: + values[n // 2 :] += 4.0 + values[-3] = 100.0 + 8.0 + return {"measurement": [float(v) for v in values]} + + +def spc_subgroup(*, n_subgroups: int = 25, size: int = 5, seed: int = 7) -> Columns: + """Xbar-R data: ``measurement`` + ``subgroup`` with fixed subgroup size.""" + rng = _rng(seed) + measurements: list[float] = [] + subgroups: list[int] = [] + for sid in range(1, n_subgroups + 1): + for v in 50.0 + rng.normal(0.0, 1.2, size): + measurements.append(float(v)) + subgroups.append(sid) + return {"measurement": measurements, "subgroup": subgroups} + + +def attribute_c(*, n: int = 25, seed: int = 11) -> Columns: + rng = _rng(seed) + return {"defects": [int(x) for x in rng.poisson(3.0, n)]} + + +def attribute_p(*, n: int = 25, seed: int = 12) -> Columns: + rng = _rng(seed) + inspected = [int(x) for x in rng.integers(80, 120, n)] + defective = [int(rng.binomial(k, 0.05)) for k in inspected] + return {"defective": defective, "inspected": inspected} + + +def attribute_np(*, n: int = 25, sample_size: int = 100, seed: int = 13) -> Columns: + rng = _rng(seed) + defectives = [int(x) for x in rng.binomial(sample_size, 0.04, n)] + return {"defectives": defectives, "sample_size": [sample_size] * n} + + +def attribute_u(*, n: int = 25, seed: int = 14) -> Columns: + rng = _rng(seed) + units = [int(x) for x in rng.integers(5, 15, n)] + defects = [int(rng.poisson(0.6 * u)) for u in units] + return {"defects": defects, "units": units} + + +def msa_gage_rr( + *, + quality: str = "excellent", + n_parts: int = 10, + n_operators: int = 3, + n_trials: int = 3, + seed: int = 21, +) -> Columns: + """Balanced Gage R&R study. + + excellent: high part variation, low gage noise → grr_percent < 30 + poor: gage noise dominates part variation + """ + if quality not in ("excellent", "poor"): + raise ValueError("quality must be 'excellent' or 'poor'") + rng = _rng(seed if quality == "excellent" else seed + 99) + + if quality == "excellent": + part_sd, op_sd, repeat_sd = 2.0, 0.05, 0.08 + else: + part_sd, op_sd, repeat_sd = 0.3, 0.8, 1.2 + + part_effects = rng.normal(0.0, part_sd, n_parts) + op_effects = rng.normal(0.0, op_sd, n_operators) + parts: list[str] = [] + operators: list[str] = [] + measurements: list[float] = [] + for pi in range(n_parts): + for oi in range(n_operators): + for _ in range(n_trials): + noise = float(rng.normal(0.0, repeat_sd)) + y = 10.0 + part_effects[pi] + op_effects[oi] + noise + parts.append(f"P{pi + 1}") + operators.append(f"Op{oi + 1}") + measurements.append(float(y)) + return {"Part": parts, "Operator": operators, "Measurement": measurements} + + +def msa_bias(*, n: int = 30, reference: float = 10.0, seed: int = 31) -> Columns: + rng = _rng(seed) + refs = [reference] * n + # Small positive bias + noise + meas = [float(reference + 0.05 + rng.normal(0.0, 0.1)) for _ in range(n)] + return {"Measurement": meas, "Reference": refs} + + +def msa_linearity(*, n_refs: int = 5, n_reps: int = 6, seed: int = 32) -> Columns: + rng = _rng(seed) + refs_levels = np.linspace(5.0, 15.0, n_refs) + measurements: list[float] = [] + references: list[float] = [] + for ref in refs_levels: + for _ in range(n_reps): + # Near-zero slope bias with small noise → high R^2 linearity residual model + bias = 0.02 * (ref - 10.0) + measurements.append(float(ref + bias + rng.normal(0.0, 0.08))) + references.append(float(ref)) + return {"Measurement": measurements, "Reference": references} + + +def msa_stability(*, n: int = 40, seed: int = 33) -> Columns: + rng = _rng(seed) + values = 10.0 + rng.normal(0.0, 0.15, n) + return {"Measurement": [float(v) for v in values]} + + +def capability(*, kind: str = "excellent", n: int = 200, seed: int = 41) -> Columns: + """Capability datasets keyed by kind. + + excellent: mean≈10, σ≈0.1 → Cpk > 1.0 for USL=10.5 / LSL=9.5 + skewed: exponential (non-normal) + off_center: mean shifted toward USL + high_variation: large σ relative to specs + """ + rng = _rng(seed) + if kind == "excellent": + values = 10.0 + rng.normal(0.0, 0.10, n) + elif kind == "skewed": + values = rng.exponential(2.0, n) + 5.0 + elif kind == "off_center": + values = 10.25 + rng.normal(0.0, 0.12, n) + elif kind == "high_variation": + values = 10.0 + rng.normal(0.0, 0.45, n) + else: + raise ValueError( + f"Unknown capability kind '{kind}'. " + "Use excellent|skewed|off_center|high_variation" + ) + return {"measurement": [float(v) for v in values]} + + +# Canonical names used by CLI demos and ``python -m sample_data`` +DATASET_CATALOG: dict[str, Columns] = { + "spc_individual_in_control": spc_individual(in_control=True), + "spc_individual_out_of_control": spc_individual(in_control=False), + "spc_subgroup_data": spc_subgroup(), + "spc_c_chart_data": attribute_c(), + "spc_p_chart_data": attribute_p(), + "spc_np_chart_data": attribute_np(), + "spc_u_chart_data": attribute_u(), + "msa_gage_rr_excellent": msa_gage_rr(quality="excellent"), + "msa_gage_rr_poor": msa_gage_rr(quality="poor"), + "msa_bias_study": msa_bias(), + "msa_linearity_study": msa_linearity(), + "msa_stability_study": msa_stability(), + "capability_excellent": capability(kind="excellent"), + "capability_skewed_data": capability(kind="skewed"), + "capability_off_center": capability(kind="off_center"), + "capability_high_variation": capability(kind="high_variation"), +} + + +def get_dataset(name: str) -> Columns: + """Return a fresh copy of a named dataset from the catalog.""" + if name not in DATASET_CATALOG: + # Rebuild on demand for names that map to generators + builders = { + "spc_individual_in_control": lambda: spc_individual(in_control=True), + "spc_individual_out_of_control": lambda: spc_individual(in_control=False), + "spc_subgroup_data": spc_subgroup, + "spc_c_chart_data": attribute_c, + "spc_p_chart_data": attribute_p, + "spc_np_chart_data": attribute_np, + "spc_u_chart_data": attribute_u, + "msa_gage_rr_excellent": lambda: msa_gage_rr(quality="excellent"), + "msa_gage_rr_poor": lambda: msa_gage_rr(quality="poor"), + "msa_bias_study": msa_bias, + "msa_linearity_study": msa_linearity, + "msa_stability_study": msa_stability, + "capability_excellent": lambda: capability(kind="excellent"), + "capability_skewed_data": lambda: capability(kind="skewed"), + "capability_off_center": lambda: capability(kind="off_center"), + "capability_high_variation": lambda: capability(kind="high_variation"), + } + if name not in builders: + raise KeyError(f"Unknown dataset '{name}'. Available: {list(builders)}") + cols = builders[name]() + else: + cols = DATASET_CATALOG[name] + return {k: list(v) for k, v in cols.items()} + + +def write_csv(cols: Mapping[str, list[Any]], path: str | Path) -> Path: + """Write a column dict to CSV and return the path.""" + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + keys = list(cols.keys()) + if not keys: + raise ValueError("empty columns") + n = len(cols[keys[0]]) + if any(len(cols[k]) != n for k in keys): + raise ValueError("column length mismatch") + lines = [",".join(keys)] + for i in range(n): + row = [] + for k in keys: + v = cols[k][i] + row.append(str(v)) + lines.append(",".join(row)) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return path + + +def write_all(out_dir: str | Path) -> list[Path]: + """Write every catalog dataset as ``{name}.csv`` under ``out_dir``.""" + out = Path(out_dir) + written: list[Path] = [] + for name in sorted( + { + "spc_individual_in_control", + "spc_individual_out_of_control", + "spc_subgroup_data", + "spc_c_chart_data", + "spc_p_chart_data", + "spc_np_chart_data", + "spc_u_chart_data", + "msa_gage_rr_excellent", + "msa_gage_rr_poor", + "msa_bias_study", + "msa_linearity_study", + "msa_stability_study", + "capability_excellent", + "capability_skewed_data", + "capability_off_center", + "capability_high_variation", + } + ): + written.append(write_csv(get_dataset(name), out / f"{name}.csv")) + return written diff --git a/sample_data/__main__.py b/sample_data/__main__.py new file mode 100644 index 0000000..10e126d --- /dev/null +++ b/sample_data/__main__.py @@ -0,0 +1,29 @@ +"""CLI: ``python -m sample_data --out examples/data``.""" +from __future__ import annotations + +import argparse +from pathlib import Path + +from . import write_all + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Write deterministic ASPC demo CSVs for CLI / manual testing." + ) + parser.add_argument( + "--out", + type=Path, + default=Path("examples/data"), + help="Output directory (default: examples/data)", + ) + args = parser.parse_args(argv) + paths = write_all(args.out) + print(f"Wrote {len(paths)} CSVs to {args.out.resolve()}") + for p in paths: + print(f" {p.name}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/calibrate_resilience.py b/scripts/calibrate_resilience.py new file mode 100644 index 0000000..9a6eba3 --- /dev/null +++ b/scripts/calibrate_resilience.py @@ -0,0 +1,44 @@ +"""Empirically dump observed spc_core outcomes for each resilience case. + +Usage: + PYTHONPATH=. python3 scripts/calibrate_resilience.py > /tmp/calibrate.json +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from resilience_data import load_manifest # noqa: E402 +from resilience_data.runner import run_case # noqa: E402 + + +def main() -> int: + cases = load_manifest() + rows = [] + for spec in cases: + # Strip expect so we always capture raw observation (raises become ERROR). + probe = {**spec, "expect": {}} + result = run_case(probe) + rows.append( + { + "id": spec["id"], + "entry": spec["entry"], + "status": result["status"], + "observed": result.get("observed"), + "mismatches": result.get("mismatches"), + } + ) + print(f"# {spec['id']}: {result['status']}", file=sys.stderr) + if result.get("mismatches"): + print(f" {result['mismatches']}", file=sys.stderr) + json.dump(rows, sys.stdout, indent=2, default=str) + print() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/manufacturing_sim.py b/scripts/manufacturing_sim.py new file mode 100755 index 0000000..a50ac26 --- /dev/null +++ b/scripts/manufacturing_sim.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Manufacturing-like MQTT publisher for ASPC Phase II live testing. + +Publishes JSON observations to ``sensors/{stream_key}`` for the mqtt-bridge: + + {"key": "line-1", "value": 100.2, "timestamp": "...", "machine_id": "CNC-01"} + +Phases (default): + warmup — stable process mean~100 σ~1 + production — continued in-control + shift — mean +4 (process shift) + spike — one extreme outlier + +Example: + python scripts/manufacturing_sim.py --host localhost --stream-key line-1 +""" +from __future__ import annotations + +import argparse +import json +import sys +import time +from collections.abc import Iterator +from datetime import UTC, datetime + +import numpy as np + + +def _now_iso() -> str: + return datetime.now(UTC).isoformat() + + +def generate_phases( + *, + seed: int = 42, + mean: float = 100.0, + sigma: float = 1.0, + warmup: int = 20, + production: int = 40, + shift: int = 25, + shift_delta: float = 4.0, + spike: bool = True, +) -> Iterator[tuple[str, float]]: + """Yield (phase_name, value) for a manufacturing-style sequence.""" + rng = np.random.default_rng(seed) + for _ in range(warmup): + yield "warmup", float(mean + rng.normal(0.0, sigma)) + for _ in range(production): + yield "production", float(mean + rng.normal(0.0, sigma)) + for _ in range(shift): + yield "shift", float(mean + shift_delta + rng.normal(0.0, sigma)) + if spike: + yield "spike", float(mean + 8.0 * sigma) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="ASPC manufacturing MQTT stream simulator") + parser.add_argument("--host", default="localhost", help="MQTT broker host") + parser.add_argument("--port", type=int, default=1883) + parser.add_argument("--stream-key", default="line-1") + parser.add_argument("--topic-prefix", default="sensors") + parser.add_argument("--rate", type=float, default=2.0, help="Points per second") + parser.add_argument("--mean", type=float, default=100.0) + parser.add_argument("--sigma", type=float, default=1.0) + parser.add_argument("--warmup", type=int, default=20) + parser.add_argument("--production", type=int, default=40) + parser.add_argument("--shift", type=int, default=25) + parser.add_argument("--shift-delta", type=float, default=4.0) + parser.add_argument("--no-spike", action="store_true") + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--machine-id", default="CNC-01") + parser.add_argument("--dry-run", action="store_true", help="Print only, do not publish") + args = parser.parse_args(argv) + + topic = f"{args.topic_prefix.rstrip('/')}/{args.stream_key}" + delay = 1.0 / args.rate if args.rate > 0 else 0.0 + + client = None + if not args.dry_run: + try: + import paho.mqtt.client as mqtt + except ImportError: + print( + "paho-mqtt required. Install with: uv pip install paho-mqtt", + file=sys.stderr, + ) + return 1 + try: + client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) + except AttributeError: + client = mqtt.Client() + client.connect(args.host, args.port, keepalive=60) + client.loop_start() + + counts: dict[str, int] = {} + total = 0 + print( + f"Publishing to mqtt://{args.host}:{args.port}/{topic} " + f"at {args.rate} Hz (stream_key={args.stream_key})", + flush=True, + ) + + try: + for phase, value in generate_phases( + seed=args.seed, + mean=args.mean, + sigma=args.sigma, + warmup=args.warmup, + production=args.production, + shift=args.shift, + shift_delta=args.shift_delta, + spike=not args.no_spike, + ): + payload = { + "key": args.stream_key, + "value": value, + "timestamp": _now_iso(), + "machine_id": args.machine_id, + "phase": phase, + } + body = json.dumps(payload) + if args.dry_run: + print(f"[{phase}] {body}") + else: + assert client is not None + client.publish(topic, body, qos=0) + print(f"[{phase}] value={value:.3f}", flush=True) + counts[phase] = counts.get(phase, 0) + 1 + total += 1 + if delay: + time.sleep(delay) + finally: + if client is not None: + client.loop_stop() + client.disconnect() + + print(f"Done. published={total} by_phase={counts}", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ops_digest.py b/scripts/ops_digest.py new file mode 100644 index 0000000..8ad1e9c --- /dev/null +++ b/scripts/ops_digest.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Optional ops digest — print multi-stream summary (stdout / JSON). + +Wire to cron or SMTP later; for now this is the in-app companion CLI. + +Usage: + ASPC_DEV_INSECURE=1 python scripts/ops_digest.py +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from adapters.factory import get_repository # noqa: E402 +from adapters.protocols import StreamingOpsRepository # noqa: E402 +from apps.config import get_config # noqa: E402 + + +def main() -> int: + cfg = get_config() + repo = get_repository(cfg) + streams = [] + if isinstance(repo, StreamingOpsRepository): + streams = repo.list_streams(active_only=False) + runs = repo.list_runs(limit=20) if hasattr(repo, "list_runs") else [] + payload = { + "streams_total": len(streams), + "streams_active": sum(1 for s in streams if s.get("active")), + "recent_runs": len(runs), + "streams": [ + { + "stream_key": s.get("stream_key"), + "active": s.get("active"), + "limits_version": s.get("limits_version"), + } + for s in streams + ], + } + print(json.dumps(payload, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/resilience_report.py b/scripts/resilience_report.py new file mode 100644 index 0000000..6199df6 --- /dev/null +++ b/scripts/resilience_report.py @@ -0,0 +1,67 @@ +"""Standalone judgment report for the resilience catalog. + +Writes ``resilience_data/JUDGMENT.md`` without leaving pytest with a dirty tree. + +Usage: + .venv/bin/python scripts/resilience_report.py +""" +from __future__ import annotations + +import sys +from collections import Counter +from datetime import UTC, datetime +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from resilience_data import ROOT as DATA_ROOT # noqa: E402 +from resilience_data import load_manifest # noqa: E402 +from resilience_data.runner import run_case # noqa: E402 + +OUT = DATA_ROOT / "JUDGMENT.md" + + +def main() -> int: + cases = load_manifest() + rows = [run_case(spec) for spec in cases] + counts = Counter(r["status"] for r in rows) + + lines = [ + "# Resilience judgment report", + "", + f"Generated: {datetime.now(UTC).isoformat()}", + f"Cases: {len(rows)}", + "", + "| Status | Count |", + "|--------|------:|", + ] + for status in ("PASS", "FAIL", "ERROR", "XFAIL"): + lines.append(f"| {status} | {counts.get(status, 0)} |") + lines += ["", "## Per-case results", ""] + lines += ["| id | status | notes |", "|----|--------|-------|"] + for r in rows: + notes = "" + if r["status"] != "PASS": + notes = "; ".join(r.get("mismatches") or [])[:120] + if r.get("xfail_reason"): + notes = f"xfail: {r['xfail_reason']}; {notes}" + lines.append(f"| `{r['id']}` | {r['status']} | {notes} |") + + fails = [r for r in rows if r["status"] in ("FAIL", "ERROR")] + if fails: + lines += ["", "## Failures detail", ""] + for r in fails: + lines.append(f"### `{r['id']}` — {r['status']}") + lines.append("") + for m in r.get("mismatches") or []: + lines.append(f"- {m}") + lines.append("") + + OUT.write_text("\n".join(lines) + "\n", encoding="utf-8") + print(f"Wrote {OUT} — {dict(counts)}") + return 0 if counts.get("FAIL", 0) == 0 and counts.get("ERROR", 0) == 0 else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_live_protocol.py b/scripts/run_live_protocol.py new file mode 100755 index 0000000..a147830 --- /dev/null +++ b/scripts/run_live_protocol.py @@ -0,0 +1,333 @@ +#!/usr/bin/env python3 +"""End-to-end manufacturing live protocol against the Compose stack. + +Assumes Docker Compose services are up (API :8000, Mosquitto :1883, Redis :6379). +Uses Compose credentials (API key demokey), not the host sqlite .env. +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import threading +import time +from datetime import UTC, datetime +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +REPORT = ROOT / "var" / "reports" / "live_protocol_result.md" +SAMPLES = ROOT / "examples" / "data" +CSV = SAMPLES / "spc_individual_in_control.csv" + +API = os.getenv("ASPC_PROTOCOL_API", "http://localhost:8000") +API_KEY = os.getenv("ASPC_PROTOCOL_API_KEY", "demokey") +PASSWORD = os.getenv("ASPC_PROTOCOL_PASSWORD", "admin") +STREAM_KEY = os.getenv("ASPC_PROTOCOL_STREAM", "line-1") +MQTT_HOST = os.getenv("ASPC_PROTOCOL_MQTT_HOST", "localhost") +REDIS_URL = os.getenv("ASPC_PROTOCOL_REDIS_URL", "redis://localhost:6379/0") + + +def _log(msg: str) -> None: + print(msg, flush=True) + + +def wait_health(timeout: float = 180.0) -> None: + import urllib.request + + deadline = time.time() + timeout + last_err = "" + while time.time() < deadline: + try: + with urllib.request.urlopen(f"{API}/health", timeout=3) as r: + body = json.loads(r.read().decode()) + if body.get("status") == "healthy": + _log(f"API healthy at {API}") + return + except Exception as exc: # noqa: BLE001 + last_err = str(exc) + time.sleep(2) + raise RuntimeError(f"API not healthy within {timeout}s: {last_err}") + + +def get_token() -> str: + import urllib.parse + import urllib.request + + data = urllib.parse.urlencode({"username": "operator", "password": PASSWORD}).encode() + req = urllib.request.Request( + f"{API}/auth/token", + data=data, + method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + with urllib.request.urlopen(req, timeout=30) as r: + body = json.loads(r.read().decode()) + token = body.get("access_token") + if not token: + raise RuntimeError(f"No access_token: {body}") + return token + + +def multipart_analyze(token: str, path: Path) -> dict: + """POST /analyze/control-chart with file upload (stdlib multipart).""" + import urllib.request + import uuid + + boundary = f"----aspc{uuid.uuid4().hex}" + file_bytes = path.read_bytes() + parts: list[bytes] = [] + parts.append( + ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="file"; filename="{path.name}"\r\n' + f"Content-Type: text/csv\r\n\r\n" + ).encode() + + file_bytes + + b"\r\n" + ) + parts.append(f"--{boundary}--\r\n".encode()) + body = b"".join(parts) + req = urllib.request.Request( + f"{API}/analyze/control-chart", + data=body, + method="POST", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": f"multipart/form-data; boundary={boundary}", + }, + ) + with urllib.request.urlopen(req, timeout=120) as r: + return json.loads(r.read().decode()) + + +def api_json(method: str, path: str, token: str, payload: dict | None = None, api_key: bool = False) -> dict: + import urllib.request + + data = None if payload is None else json.dumps(payload).encode() + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + if api_key: + headers["X-API-Key"] = API_KEY + req = urllib.request.Request(f"{API}{path}", data=data, method=method, headers=headers) + with urllib.request.urlopen(req, timeout=60) as r: + raw = r.read().decode() + return json.loads(raw) if raw else {} + + +class RedisCollector: + def __init__(self, stream_key: str): + self.channel = f"spc:live:{stream_key}" + self.messages: list[dict] = [] + self.ooc_count = 0 + self._stop = threading.Event() + self._thread: threading.Thread | None = None + self.error: str | None = None + + def start(self) -> None: + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + + def stop(self) -> None: + self._stop.set() + if self._thread: + self._thread.join(timeout=5) + + def _run(self) -> None: + try: + import redis + except ImportError: + self.error = "redis package not installed" + return + try: + client = redis.Redis.from_url(REDIS_URL, decode_responses=True) + pubsub = client.pubsub(ignore_subscribe_messages=True) + pubsub.subscribe(self.channel) + _log(f"Subscribed Redis {self.channel}") + while not self._stop.is_set(): + msg = pubsub.get_message(timeout=1.0) + if not msg or msg.get("type") != "message": + continue + try: + payload = json.loads(msg["data"]) + except json.JSONDecodeError: + continue + self.messages.append(payload) + if payload.get("ooc") or payload.get("signals"): + self.ooc_count += 1 + _log( + f"OOC redis: value={payload.get('value')} " + f"signals={payload.get('signals')}" + ) + pubsub.unsubscribe(self.channel) + pubsub.close() + client.close() + except Exception as exc: # noqa: BLE001 + self.error = str(exc) + + +def ensure_samples() -> None: + SAMPLES.mkdir(parents=True, exist_ok=True) + if CSV.exists(): + return + _log("Generating sample_data CSVs…") + subprocess.check_call( + [sys.executable, "-m", "sample_data", "--out", str(SAMPLES)], + cwd=str(ROOT), + ) + + +def run_simulator() -> None: + sim = ROOT / "scripts" / "manufacturing_sim.py" + cmd = [ + sys.executable, + str(sim), + "--host", + MQTT_HOST, + "--stream-key", + STREAM_KEY, + "--rate", + "2.0", + "--warmup", + "15", + "--production", + "25", + "--shift", + "20", + ] + _log("Starting manufacturing simulator…") + subprocess.check_call(cmd, cwd=str(ROOT)) + + +def write_report(result: dict) -> None: + REPORT.parent.mkdir(parents=True, exist_ok=True) + status = "PASS" if result["passed"] else "FAIL" + lines = [ + f"# Live manufacturing protocol — {status}", + "", + f"- Time (UTC): `{result['finished_at']}`", + f"- API: `{API}`", + f"- Stream key: `{STREAM_KEY}`", + f"- Limits version: `{result.get('limits_version')}`", + f"- Phase I run_id: `{result.get('run_id')}`", + f"- Redis messages: `{result.get('redis_messages')}`", + f"- Redis OOC events: `{result.get('redis_ooc')}`", + f"- Stream active: `{result.get('stream_active')}`", + f"- Sample limits in live payload: `{result.get('limits_in_payload')}`", + "", + "## Notes", + "", + result.get("notes", ""), + "", + "## Watch", + "", + "1. Open http://localhost:3000/login (password `admin`)", + "2. Go to **Live** → stream key `line-1` → Connect", + "3. Re-run simulator: " + "`python scripts/manufacturing_sim.py --host localhost --stream-key line-1`", + "", + ] + REPORT.write_text("\n".join(lines), encoding="utf-8") + _log(f"Wrote {REPORT}") + + +def main() -> int: + ensure_samples() + wait_health() + token = get_token() + _log("Phase I analyze…") + analyze = multipart_analyze(token, CSV) + report = analyze.get("report") or {} + limits = report.get("limits") or {} + limits_version = ( + (analyze.get("checklist") or {}).get("limits_version") + or limits.get("version") + ) + if not limits_version: + run = api_json("GET", f"/runs/{analyze['run_id']}", token) + limits_version = run.get("limits_version") + if not limits_version: + raise RuntimeError( + f"Could not find limits_version in analyze response. " + f"keys={list(analyze.keys())} checklist={analyze.get('checklist')}" + ) + + _log(f"limits_version={limits_version} run_id={analyze.get('run_id')}") + + _log("Register stream…") + api_json( + "POST", + "/streams/register", + token, + {"stream_key": STREAM_KEY, "chart_type": "I-MR", "ruleset": "nelson"}, + api_key=True, + ) + _log("Go-live…") + api_json( + "POST", + f"/streams/{STREAM_KEY}/go-live", + token, + {"limits_version": limits_version, "ruleset": "nelson"}, + api_key=True, + ) + + streams = api_json("GET", "/streams?active_only=true", token) + active = any(s.get("stream_key") == STREAM_KEY for s in streams.get("streams") or []) + _log(f"Active streams: {streams.get('streams')}") + + collector = RedisCollector(STREAM_KEY) + collector.start() + time.sleep(1.0) + + try: + run_simulator() + # Allow bridge/engine lag + time.sleep(5.0) + finally: + collector.stop() + + limits_in_payload = False + for m in collector.messages: + if m.get("ucl") is not None and m.get("center") is not None: + limits_in_payload = True + break + + passed = ( + active + and collector.error is None + and len(collector.messages) >= 10 + and collector.ooc_count >= 1 + and limits_in_payload + ) + notes = [] + if collector.error: + notes.append(f"Redis collector error: {collector.error}") + if len(collector.messages) < 10: + notes.append(f"Too few Redis messages: {len(collector.messages)}") + if collector.ooc_count < 1: + notes.append("No OOC observed on Redis — check mqtt-bridge / stream-engine logs") + if not limits_in_payload: + notes.append("Live payload missing ucl/center — rebuild stream-engine image") + if not active: + notes.append("Stream not listed as active") + + result = { + "passed": passed, + "finished_at": datetime.now(UTC).isoformat(), + "limits_version": limits_version, + "run_id": analyze.get("run_id"), + "redis_messages": len(collector.messages), + "redis_ooc": collector.ooc_count, + "stream_active": active, + "limits_in_payload": limits_in_payload, + "notes": "; ".join(notes) if notes else "All checks passed.", + } + write_report(result) + _log(f"PROTOCOL {'PASS' if passed else 'FAIL'}: {result['notes']}") + return 0 if passed else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_live_protocol.sh b/scripts/run_live_protocol.sh new file mode 100755 index 0000000..7acb28f --- /dev/null +++ b/scripts/run_live_protocol.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Bring up Compose (if needed) and run the manufacturing live protocol. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +COMPOSE_FILE="$ROOT/deploy/compose/docker-compose.yml" +cd "$ROOT" + +echo "==> Docker Compose up (build)" +docker compose -f "$COMPOSE_FILE" up -d --build + +echo "==> Waiting for service health" +for i in $(seq 1 90); do + if curl -sf http://localhost:8000/health >/dev/null 2>&1; then + echo "API is up" + break + fi + if [[ "$i" -eq 90 ]]; then + echo "API failed to become healthy" >&2 + docker compose -f "$COMPOSE_FILE" ps + docker compose -f "$COMPOSE_FILE" logs --tail=80 api stream-engine mqtt-bridge || true + exit 1 + fi + sleep 2 +done + +# Host-side deps for simulator + redis verify +if [[ -f "$ROOT/.venv/bin/activate" ]]; then + # shellcheck disable=SC1091 + source "$ROOT/.venv/bin/activate" +fi +python3 -c "import paho.mqtt.client" 2>/dev/null || uv pip install paho-mqtt +python3 -c "import redis" 2>/dev/null || uv pip install redis + +export ASPC_PROTOCOL_API="${ASPC_PROTOCOL_API:-http://localhost:8000}" +export ASPC_PROTOCOL_API_KEY="${ASPC_PROTOCOL_API_KEY:-demokey}" +export ASPC_PROTOCOL_PASSWORD="${ASPC_PROTOCOL_PASSWORD:-admin}" +export ASPC_PROTOCOL_STREAM="${ASPC_PROTOCOL_STREAM:-line-1}" +export ASPC_PROTOCOL_MQTT_HOST="${ASPC_PROTOCOL_MQTT_HOST:-localhost}" +export ASPC_PROTOCOL_REDIS_URL="${ASPC_PROTOCOL_REDIS_URL:-redis://localhost:6379/0}" + +echo "==> Running live protocol" +python3 "$ROOT/scripts/run_live_protocol.py" +status=$? + +echo "" +echo "============================================================" +echo " Stack left running for you to watch:" +echo " Dashboard: http://localhost:3000/login (password: admin)" +echo " Live page: http://localhost:3000/live → Connect 'line-1'" +echo " API docs: http://localhost:8000/docs" +echo " Report: $ROOT/var/reports/live_protocol_result.md" +echo " Re-sim: python scripts/manufacturing_sim.py --host localhost --stream-key line-1" +echo "============================================================" +exit "$status" diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..46c5813 --- /dev/null +++ b/services/__init__.py @@ -0,0 +1 @@ +"""Background services (stream engine, MQTT bridge).""" diff --git a/services/mqtt_bridge/__init__.py b/services/mqtt_bridge/__init__.py new file mode 100644 index 0000000..6755fd0 --- /dev/null +++ b/services/mqtt_bridge/__init__.py @@ -0,0 +1 @@ +"""MQTT → Redpanda bridge for edge sensor ingestion.""" diff --git a/services/mqtt_bridge/main.py b/services/mqtt_bridge/main.py new file mode 100644 index 0000000..0ac6326 --- /dev/null +++ b/services/mqtt_bridge/main.py @@ -0,0 +1,179 @@ +"""CLI entry: MQTT → Kafka bridge for ASPC live measurements.""" +from __future__ import annotations + +import argparse +import json +import logging +import os +import signal +import sys +from datetime import UTC, datetime +from typing import Any + +logger = logging.getLogger("aspc.mqtt_bridge") + + +def _produce_kafka(bootstrap: str, topic: str, messages): + """Lazy-import aiokafka producer wrapped for sync use, or kafka-python.""" + batch_size = int(os.getenv("ASPC_KAFKA_BATCH_FLUSH", "50")) + try: + from kafka import KafkaProducer # type: ignore[import-untyped] + + producer = KafkaProducer( + bootstrap_servers=bootstrap.split(","), + value_serializer=lambda v: json.dumps(v, default=str).encode("utf-8"), + key_serializer=lambda v: v.encode("utf-8") if v else None, + linger_ms=50, + batch_size=16384, + ) + pending = {"n": 0} + + def send(key: str, payload: dict[str, Any]) -> None: + producer.send(topic, key=key, value=payload) + pending["n"] += 1 + if pending["n"] >= batch_size: + producer.flush() + logger.debug("Kafka flush after %s messages", pending["n"]) + pending["n"] = 0 + + def close() -> None: + if pending["n"]: + producer.flush() + producer.close() + + return send, close + except ImportError: + pass + + try: + import asyncio + + from aiokafka import AIOKafkaProducer + except ImportError as exc: + raise ImportError( + "mqtt_bridge requires kafka-python or aiokafka. " + "Install with: pip install 'aspc[stream]' (or pip install kafka-python)" + ) from exc + + loop = asyncio.new_event_loop() + producer = AIOKafkaProducer(bootstrap_servers=bootstrap, linger_ms=50) + loop.run_until_complete(producer.start()) + pending = {"n": 0} + + def send(key: str, payload: dict[str, Any]) -> None: + data = json.dumps(payload, default=str).encode("utf-8") + loop.run_until_complete( + producer.send(topic, value=data, key=key.encode("utf-8")) + ) + pending["n"] += 1 + if pending["n"] >= batch_size: + loop.run_until_complete(producer.flush()) + logger.debug("Kafka flush after %s messages", pending["n"]) + pending["n"] = 0 + + def close() -> None: + if pending["n"]: + loop.run_until_complete(producer.flush()) + loop.run_until_complete(producer.stop()) + loop.close() + + return send, close + + +def _normalise(msg: dict[str, Any]) -> dict[str, Any]: + key = str(msg.get("key") or msg.get("stream_key") or "default") + value = msg.get("value") + if value is None: + raise ValueError(f"MQTT payload missing value: {msg!r}") + if isinstance(value, (list, tuple)): + value = [float(v) for v in value] + else: + value = float(value) + ts = msg.get("timestamp") or msg.get("ts") + if isinstance(ts, datetime): + ts = ts.isoformat() + elif ts is None: + ts = datetime.now(UTC).isoformat() + return {"key": key, "value": value, "timestamp": ts} + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="aspc-mqtt-bridge", + description="Bridge MQTT sensor topics into the ASPC Kafka measurement topic", + ) + parser.add_argument("--mqtt-host", default=os.getenv("ASPC_MQTT_HOST", "localhost")) + parser.add_argument("--mqtt-port", type=int, default=int(os.getenv("ASPC_MQTT_PORT", "1883"))) + parser.add_argument("--mqtt-topic", default=os.getenv("ASPC_MQTT_TOPIC", "sensors/#")) + parser.add_argument("--mqtt-user", default=os.getenv("ASPC_MQTT_USER")) + parser.add_argument("--mqtt-password", default=os.getenv("ASPC_MQTT_PASSWORD")) + parser.add_argument( + "--bootstrap", + default=os.getenv("ASPC_KAFKA_BOOTSTRAP", "localhost:9092"), + ) + parser.add_argument( + "--kafka-topic", + default=os.getenv("ASPC_KAFKA_TOPIC", "spc.measurements"), + ) + parser.add_argument("-v", "--verbose", action="store_true") + args = parser.parse_args(argv) + + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + + from adapters.stream_sources import MQTTSource + + stop = False + + def _stop(*_a) -> None: + nonlocal stop + stop = True + logger.info("Shutdown requested") + + signal.signal(signal.SIGINT, _stop) + signal.signal(signal.SIGTERM, _stop) + + try: + send, close = _produce_kafka(args.bootstrap, args.kafka_topic, None) + except ImportError as exc: + logger.error("%s", exc) + return 1 + + source = MQTTSource( + args.mqtt_host, + args.mqtt_topic, + port=args.mqtt_port, + username=args.mqtt_user, + password=args.mqtt_password, + ) + + logger.info( + "Bridging MQTT %s:%s/%s → Kafka %s/%s", + args.mqtt_host, + args.mqtt_port, + args.mqtt_topic, + args.bootstrap, + args.kafka_topic, + ) + try: + for msg in source.iter_sync(): + if stop: + break + try: + payload = _normalise(msg) + send(payload["key"], payload) + logger.debug("Forwarded %s", payload) + except Exception: # noqa: BLE001 + logger.exception("Failed to forward MQTT message %s", msg) + except ImportError as exc: + logger.error("%s", exc) + return 1 + finally: + close() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/services/stream_engine/__init__.py b/services/stream_engine/__init__.py new file mode 100644 index 0000000..ab3434b --- /dev/null +++ b/services/stream_engine/__init__.py @@ -0,0 +1 @@ +"""Keyed SPC stream engine — Kafka consumer with frozen Phase II evaluation.""" diff --git a/services/stream_engine/main.py b/services/stream_engine/main.py new file mode 100644 index 0000000..06c551e --- /dev/null +++ b/services/stream_engine/main.py @@ -0,0 +1,152 @@ +"""CLI entry: consume Kafka measurements and run the Phase II StreamEngine.""" +from __future__ import annotations + +import argparse +import logging +import signal +import sys + +from adapters.factory import get_repository, require_streaming_repository +from adapters.stream_engine import StreamEngine +from adapters.stream_sources import KafkaSource +from apps.config import get_config + +logger = logging.getLogger("aspc.stream_engine") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="aspc-stream-engine", + description="ASPC Phase II stream engine — Kafka → evaluate → Timescale/Redis", + ) + parser.add_argument("--bootstrap", default=None, help="Kafka bootstrap servers") + parser.add_argument("--topic", default=None, help="Kafka topic") + parser.add_argument("--group-id", default="aspc-stream-engine") + parser.add_argument("--redis-url", default=None) + parser.add_argument( + "--preload", + action="append", + default=[], + metavar="STREAM_KEY:LIMITS_VERSION", + help="Pre-register stream_key with frozen limits version (repeatable)", + ) + parser.add_argument("-v", "--verbose", action="store_true") + args = parser.parse_args(argv) + + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + + cfg = get_config() + bootstrap = args.bootstrap or cfg.kafka_bootstrap + topic = args.topic or cfg.kafka_topic + redis_url = args.redis_url or cfg.redis_url + + backend = cfg.persistence_backend + if backend == "sqlite" and cfg.timescale_dsn: + backend = "timescale" + repo = get_repository(cfg, backend=backend if backend != "sqlite" else cfg.persistence_backend) + try: + require_streaming_repository(repo) + except TypeError as exc: + logger.error("%s", exc) + return 2 + + redis_url = args.redis_url or cfg.redis_url + tenant = cfg.default_tenant_id if cfg.redis_tenant_prefix or cfg.default_tenant_id else None + engine = StreamEngine( + repo, + redis_url=redis_url, + webhook_url=cfg.webhook_url, + webhook_secret=cfg.webhook_secret, + tenant_id=tenant, + ) + + # Preload from CLI and/or active stream registry + for spec in args.preload: + if ":" not in spec: + logger.error("Invalid --preload %r (expected STREAM_KEY:LIMITS_VERSION)", spec) + return 2 + key, version = spec.split(":", 1) + engine.load_limits(key.strip(), version.strip(), ruleset=cfg.ruleset) + logger.info("Preloaded stream %s @ limits %s", key, version) + + if hasattr(repo, "list_streams"): + for row in repo.list_streams(active_only=True): + key = row["stream_key"] + version = row.get("limits_version") + if not version: + continue + if key in engine.registered_keys(): + continue + try: + engine.load_limits(key, version, ruleset=row.get("ruleset") or cfg.ruleset) + logger.info("Loaded registered stream %s @ %s", key, version) + except KeyError as exc: + logger.warning("Skip stream %s: %s", key, exc) + + source = KafkaSource( + bootstrap, + topic, + group_id=args.group_id, + ) + + stop = False + + def _stop(*_args) -> None: + nonlocal stop + stop = True + logger.info("Shutdown requested") + + signal.signal(signal.SIGINT, _stop) + signal.signal(signal.SIGTERM, _stop) + + logger.info("Consuming %s from %s", topic, bootstrap) + try: + for msg in source.iter_sync(): + if stop: + break + key = str(msg.get("key") or "") + if key and key not in engine.registered_keys(): + # Lazily attach from registry if present + if hasattr(repo, "get_stream"): + row = repo.get_stream(key) + if row and row.get("limits_version"): + try: + engine.load_limits( + key, + row["limits_version"], + ruleset=row.get("ruleset") or cfg.ruleset, + ) + except KeyError: + logger.warning("No limits for stream %s; dropping observation", key) + continue + else: + logger.debug("Unregistered stream %s; dropping", key) + continue + else: + logger.debug("Unregistered stream %s; dropping", key) + continue + try: + signals = engine.handle_message(msg) + if signals: + logger.warning( + "OOC %s value=%s rules=%s", + key, + msg.get("value"), + [s.rule_id for s in signals], + ) + except ValueError as exc: + # Chart/payload mismatch (e.g. scalar on Xbar) — do not hide as a generic failure. + logger.error("Rejecting observation for %s: %s (msg=%s)", key, exc, msg) + except Exception: # noqa: BLE001 + logger.exception("Failed handling message %s", msg) + except ImportError as exc: + logger.error("%s", exc) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/spc b/spc deleted file mode 100755 index aaf43ce..0000000 --- a/spc +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash -# SPC Quality AI - Command Line Interface Wrapper -# This script provides the ./spc command interface mentioned in the README - -# Get the directory where this script is located -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# Activate the virtual environment -if [ -f "$SCRIPT_DIR/aspcvenv/bin/activate" ]; then - source "$SCRIPT_DIR/aspcvenv/bin/activate" -else - echo "Error: Virtual environment not found at $SCRIPT_DIR/aspcvenv" - echo "Please ensure the aspcvenv virtual environment is properly set up." - exit 1 -fi - -# Run the CLI with all passed arguments -python "$SCRIPT_DIR/spc_cli.py" "$@" diff --git a/spc_cli.py b/spc_cli.py deleted file mode 100755 index 0887c1b..0000000 --- a/spc_cli.py +++ /dev/null @@ -1,261 +0,0 @@ -#!/usr/bin/env python3 -""" -SPC & Quality Management System - CLI Tool -Command-line interface for interacting with the SPC API -""" -import argparse -import requests -import json -import sys -from pathlib import Path -from typing import Optional -import time - - -class SPCClient: - """Client for SPC API""" - - def __init__(self, base_url: str = "http://localhost:8000"): - self.base_url = base_url.rstrip('/') - - def health_check(self) -> dict: - """Check API health status""" - try: - response = requests.get(f"{self.base_url}/health", timeout=5) - response.raise_for_status() - return response.json() - except requests.RequestException as e: - return {"status": "error", "message": str(e)} - - def chat( - self, - agent: str, - message: str, - file_path: Optional[str] = None, - thread_id: str = "cli_session", - user_id: str = "cli_user", - timeout: int = 120 - ) -> dict: - """Send a message to an agent""" - url = f"{self.base_url}/chat/{agent}" - - data = { - "message": message, - "thread_id": thread_id, - "user_id": user_id - } - - files = None - if file_path: - file_path = Path(file_path) - if not file_path.exists(): - return {"status": "error", "message": f"File not found: {file_path}"} - - files = {"file": (file_path.name, open(file_path, "rb"), "text/csv")} - - try: - start_time = time.time() - response = requests.post(url, data=data, files=files, timeout=timeout) - elapsed_time = time.time() - start_time - - if files: - files["file"][1].close() - - response.raise_for_status() - result = response.json() - result["elapsed_time"] = round(elapsed_time, 2) - return result - - except requests.Timeout: - return {"status": "error", "message": f"Request timed out after {timeout} seconds"} - except requests.RequestException as e: - return {"status": "error", "message": str(e)} - - def list_agents(self) -> dict: - """List available agents""" - try: - response = requests.get(f"{self.base_url}/", timeout=5) - response.raise_for_status() - return response.json() - except requests.RequestException as e: - return {"status": "error", "message": str(e)} - - -def print_response(result: dict, verbose: bool = False): - """Pretty print API response""" - if result.get("status") == "error": - print(f"\n[ERROR] {result.get('message', 'Unknown error')}\n") - return - - print("\n" + "="*80) - print("[SUCCESS]") - print("="*80) - - if "response" in result: - print("\nAgent Response:") - print("-" * 80) - print(result["response"]) - print("-" * 80) - - if verbose: - print("\nMetadata:") - for key, value in result.items(): - if key != "response": - print(f" {key}: {value}") - - if "elapsed_time" in result: - print(f"\nResponse time: {result['elapsed_time']} seconds") - - print() - - -def main(): - parser = argparse.ArgumentParser( - description="SPC & Quality Management System - CLI Tool", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - # Check API health - %(prog)s health - - # List available agents - %(prog)s list - - # Chat with Control Charts agent - %(prog)s chat control-charts "Analyze my data" -f data.csv - - # MSA analysis - %(prog)s chat msa "Run Gage R&R study" -f msa_data.csv -t session1 -u user1 - - # Capability analysis with verbose output - %(prog)s chat capability "Assess process capability" -f data.csv -v - -Available Agents: - - control-charts : Control chart analysis and SPC - - msa : Measurement System Analysis (Gage R&R, Bias, Linearity, Stability) - - capability : Process Capability Analysis (Cp, Cpk, Pp, Ppk) - """ - ) - - parser.add_argument( - "--url", - default="http://localhost:8000", - help="API base URL (default: http://localhost:8000)" - ) - - subparsers = parser.add_subparsers(dest="command", help="Available commands") - - # Health command - health_parser = subparsers.add_parser("health", help="Check API health status") - - # List command - list_parser = subparsers.add_parser("list", help="List available agents") - - # Chat command - chat_parser = subparsers.add_parser("chat", help="Chat with an agent") - chat_parser.add_argument( - "agent", - choices=["control-charts", "msa", "capability"], - help="Agent to chat with" - ) - chat_parser.add_argument( - "message", - help="Message to send to the agent" - ) - chat_parser.add_argument( - "-f", "--file", - help="CSV file to upload (optional)" - ) - chat_parser.add_argument( - "-t", "--thread-id", - default="cli_session", - help="Thread ID for conversation continuity (default: cli_session)" - ) - chat_parser.add_argument( - "-u", "--user-id", - default="cli_user", - help="User ID for tracking (default: cli_user)" - ) - chat_parser.add_argument( - "--timeout", - type=int, - default=120, - help="Request timeout in seconds (default: 120)" - ) - chat_parser.add_argument( - "-v", "--verbose", - action="store_true", - help="Show verbose output with metadata" - ) - chat_parser.add_argument( - "-o", "--output", - help="Save response to file (JSON format)" - ) - - args = parser.parse_args() - - if not args.command: - parser.print_help() - sys.exit(1) - - client = SPCClient(base_url=args.url) - - if args.command == "health": - result = client.health_check() - if result.get("status") == "healthy": - print("\n[OK] API is healthy and ready!") - if "agents" in result: - print("\nAvailable agents:") - for agent, status in result["agents"].items(): - print(f" - {agent}: {status}") - print() - else: - print(f"\n[FAIL] API health check failed: {result.get('message', 'Unknown error')}\n") - sys.exit(1) - - elif args.command == "list": - result = client.list_agents() - if "endpoints" in result: - print("\nAvailable Agents and Endpoints:") - print("="*80) - for agent_id, endpoint in result["endpoints"].items(): - print(f"\nAgent: {agent_id}") - print(f" Endpoint: {endpoint}") - print("\n" + "="*80 + "\n") - else: - print(f"\n[FAIL] Failed to list agents: {result.get('message', 'Unknown error')}\n") - sys.exit(1) - - elif args.command == "chat": - print(f"\nChatting with {args.agent} agent...") - if args.file: - print(f"File: {args.file}") - print(f"Message: {args.message}") - print(f"Thread ID: {args.thread_id}") - - result = client.chat( - agent=args.agent, - message=args.message, - file_path=args.file, - thread_id=args.thread_id, - user_id=args.user_id, - timeout=args.timeout - ) - - print_response(result, verbose=args.verbose) - - # Save to file if requested - if args.output and result.get("status") != "error": - output_path = Path(args.output) - with open(output_path, 'w') as f: - json.dump(result, f, indent=2) - print(f"Response saved to: {output_path}\n") - - # Exit with error code if request failed - if result.get("status") == "error": - sys.exit(1) - - -if __name__ == "__main__": - main() - diff --git a/spc_core/__init__.py b/spc_core/__init__.py new file mode 100644 index 0000000..6f8f165 --- /dev/null +++ b/spc_core/__init__.py @@ -0,0 +1,143 @@ +"""ASPC statistical process control core library. + +Pure computation — no I/O, no FastAPI, no LLM. Import what you need: + + from spc_core import analyze_control_chart, capability_analysis, gage_rr_anova + from spc_core import establish, phase1_checklist +""" +from .capability import ( + CapabilityResult, + capability_analysis, + dpmo_to_sigma, + nonparametric_capability, + parametric_capability, + sigma_to_dpmo, +) +from .charts import ControlChartResult, analyze_control_chart, select_chart_type +from .cleaning import CleaningResult, classify_missing, range_check +from .cusum import CUSUMResult, cusum_chart +from .evaluator import Phase2Evaluator, evaluate_batch +from .ewma import EWMAResult, ewma_chart +from .ingest import ColumnMap, IngestedFrame, detect_columns, ingest, validate_frame +from .limits import ( + c_limits, + imr_limits, + np_limits, + p_limits, + u_limits, + xbar_r_limits, + xbar_s_limits, +) +from .models import ( + ChartType, + ControlLimits, + DataType, + DistributionFlag, + LimitSet, + Phase, + QualityFlag, + Signal, + SPCRecord, +) +from .msa import ( + BiasResult, + GageRRResult, + LinearityResult, + StabilityResult, + bias_study, + gage_resolution_gate, + gage_rr_anova, + gage_rr_range, + linearity_study, + ndc_gate, + stability_study, +) +from .msa_stream import CalibrationAlert, ContinuousMSA, ContinuousMSAState +from .multimodal import MultimodalResult, check_multimodal +from .normality import ( + AutocorrelationResult, + NormalityResult, + TransformResult, + apply_transform, + check_autocorrelation, + check_normality, +) +from .pipeline import Gate, PipelineResult, checklist_ready_for_golive, establish, phase1_checklist +from .report import CapabilityReport, MSAReport, SPCReport +from .rules import RuleEngine, evaluate_series + +__version__ = "2.0.0" + +__all__ = [ + "AutocorrelationResult", + "BiasResult", + "CUSUMResult", + "CalibrationAlert", + "CapabilityReport", + "CapabilityResult", + "ChartType", + "CleaningResult", + "ColumnMap", + "ContinuousMSA", + "ContinuousMSAState", + "ControlChartResult", + "ControlLimits", + "DataType", + "DistributionFlag", + "EWMAResult", + "Gate", + "GageRRResult", + "IngestedFrame", + "LimitSet", + "LinearityResult", + "MSAReport", + "MultimodalResult", + "NormalityResult", + "Phase", + "Phase2Evaluator", + "PipelineResult", + "QualityFlag", + "RuleEngine", + "SPCRecord", + "SPCReport", + "Signal", + "StabilityResult", + "TransformResult", + "analyze_control_chart", + "apply_transform", + "bias_study", + "c_limits", + "capability_analysis", + "check_autocorrelation", + "check_multimodal", + "check_normality", + "classify_missing", + "cusum_chart", + "detect_columns", + "dpmo_to_sigma", + "establish", + "evaluate_batch", + "evaluate_series", + "ewma_chart", + "gage_resolution_gate", + "gage_rr_anova", + "gage_rr_range", + "imr_limits", + "ingest", + "linearity_study", + "ndc_gate", + "nonparametric_capability", + "np_limits", + "p_limits", + "parametric_capability", + "phase1_checklist", + "checklist_ready_for_golive", + "range_check", + "select_chart_type", + "sigma_to_dpmo", + "stability_study", + "u_limits", + "validate_frame", + "xbar_r_limits", + "xbar_s_limits", +] diff --git a/spc_core/capability.py b/spc_core/capability.py new file mode 100644 index 0000000..d4baa68 --- /dev/null +++ b/spc_core/capability.py @@ -0,0 +1,295 @@ +"""Process capability / performance. + +Corrections vs the legacy code: +* DPMO <-> sigma level is computed analytically with the normal quantile function, not + read off a hardcoded bucket table. +* When data is non-normal and cannot be transformed, a percentile-based (ISO 22514 + "Cnpk") path is provided instead of silently reporting parametric Cp/Cpk. +* Pure numpy/scipy; subgroup within-variation uses the same constants as the charts. +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +import numpy as np +from scipy import stats + +from . import constants as k + + +def _clean(values) -> np.ndarray: + arr = np.asarray(values, dtype=float) + return arr[~np.isnan(arr)] + + +def dpmo_to_sigma(dpmo: float, shift: float = 1.5) -> float: + """Convert DPMO to a (short-term) sigma level analytically. + + Z_bench (long-term) = Phi^-1(1 - dpmo/1e6). The conventional "process sigma level" + adds the 1.5-sigma shift. + """ + dpmo = max(min(dpmo, 999999.0), 0.0) + if dpmo <= 0: + z = 6.0 + else: + z = float(stats.norm.ppf(1.0 - dpmo / 1_000_000.0)) + return z + shift + + +def sigma_to_dpmo(z_bench: float) -> float: + """Inverse of the long-term relationship: DPMO expected for a given Z_bench.""" + return float((1.0 - stats.norm.cdf(z_bench)) * 1_000_000.0) + + +def _sigma_within(values: np.ndarray, subgroups: list[np.ndarray] | None) -> float: + if subgroups: + n = int(np.median([len(s) for s in subgroups])) + if n >= 2: + if n <= 8: + r_bar = float(np.mean([s.max() - s.min() for s in subgroups])) + return r_bar / k.d2(n) + s_bar = float(np.mean([s.std(ddof=1) for s in subgroups])) + return s_bar / k.c4(n) + return float(values.std(ddof=1)) + + +@dataclass +class CapabilityResult: + n: int + mean: float + usl: float + lsl: float + target: float + method: str # "parametric" | "nonparametric" | "transformed" + sigma_within: float + sigma_overall: float + cp: float | None = None + cpk: float | None = None + cpu: float | None = None + cpl: float | None = None + cpm: float | None = None + pp: float | None = None + ppk: float | None = None + ppu: float | None = None + ppl: float | None = None + observed_dpmo: float = 0.0 + expected_dpmo: float | None = None + z_bench: float | None = None + sigma_level: float | None = None + yield_pct: float = 100.0 + is_centered: bool = True + offset_from_target: float = 0.0 + rating: str = "" + notes: dict = field(default_factory=dict) + + +def _rate(cpk: float | None) -> str: + if cpk is None: + return "Unknown" + if cpk >= 1.67: + return "World-class (>=1.67)" + if cpk >= 1.33: + return "Capable (>=1.33)" + if cpk >= 1.0: + return "Marginal (>=1.00)" + return "Not capable (<1.00)" + + +def parametric_capability(values, usl, lsl, target=None, subgroups=None) -> CapabilityResult: + arr = _clean(values) + if usl is None or lsl is None or usl <= lsl: + raise ValueError("Valid USL > LSL required for capability analysis") + if target is None: + target = (usl + lsl) / 2.0 + + mean = float(arr.mean()) + sig_w = _sigma_within(arr, subgroups) + sig_o = float(arr.std(ddof=1)) + spec_width = usl - lsl + + def _indices(sigma): + cp = spec_width / (6 * sigma) if sigma > 0 else None + cpu = (usl - mean) / (3 * sigma) if sigma > 0 else None + cpl = (mean - lsl) / (3 * sigma) if sigma > 0 else None + cpk = min(cpu, cpl) if sigma > 0 and cpu is not None and cpl is not None else None + return cp, cpk, cpu, cpl + + cp, cpk, cpu, cpl = _indices(sig_w) + pp, ppk, ppu, ppl = _indices(sig_o) + + cpm = None + if sig_w > 0: + tau = np.sqrt(sig_w ** 2 + (mean - target) ** 2) + cpm = spec_width / (6 * tau) if tau > 0 else None + + return _finish(arr, usl, lsl, target, mean, sig_w, sig_o, "parametric", + cp, cpk, cpu, cpl, pp, ppk, ppu, ppl, cpm) + + +def nonparametric_capability(values, usl, lsl, target=None, subgroups=None) -> CapabilityResult: + """Percentile (ISO 22514 / Cnpk) capability for non-normal data. + + Uses the 0.135 / 50 / 99.865 percentiles so the spread matches the +/-3 sigma + coverage of a normal distribution without assuming normality. + """ + arr = _clean(values) + if usl is None or lsl is None or usl <= lsl: + raise ValueError("Valid USL > LSL required for capability analysis") + if target is None: + target = (usl + lsl) / 2.0 + + p00135, p50, p99865 = np.percentile(arr, [0.135, 50.0, 99.865]) + spread = p99865 - p00135 + mean = float(arr.mean()) + sig_o = float(arr.std(ddof=1)) + sig_w = _sigma_within(arr, subgroups) + + pp = (usl - lsl) / spread if spread > 0 else None + ppu = (usl - p50) / (p99865 - p50) if (p99865 - p50) > 0 else None + ppl = (p50 - lsl) / (p50 - p00135) if (p50 - p00135) > 0 else None + ppk = min(ppu, ppl) if (ppu is not None and ppl is not None) else None + + res = _finish(arr, usl, lsl, target, mean, sig_w, sig_o, "nonparametric", + None, None, None, None, pp, ppk, ppu, ppl, None) + res.notes["percentiles"] = {"p0.135": float(p00135), "median": float(p50), "p99.865": float(p99865)} + return res + + +def _finish(arr, usl, lsl, target, mean, sig_w, sig_o, method, + cp, cpk, cpu, cpl, pp, ppk, ppu, ppl, cpm) -> CapabilityResult: + n = int(arr.size) + above = int(np.sum(arr > usl)) + below = int(np.sum(arr < lsl)) + defects = above + below + observed_dpmo = (defects / n) * 1_000_000.0 if n else 0.0 + yield_pct = ((n - defects) / n) * 100.0 if n else 100.0 + + z_bench = None + expected_dpmo = None + sigma_level = None + if sig_o > 0: + z_usl = (usl - mean) / sig_o + z_lsl = (mean - lsl) / sig_o + z_bench = float(min(z_usl, z_lsl)) + expected_dpmo = sigma_to_dpmo(z_bench) + # Sigma level from expected (parametric) DPMO — consistent with z_bench. + # Guard zero-defect inflation: when observed_dpmo==0 on small n, do not claim 7.5σ. + if n >= 30 or defects > 0: + sigma_level = dpmo_to_sigma(expected_dpmo) + else: + sigma_level = z_bench + 1.5 + # Cap optimistic claims on tiny samples with zero defects. + if n < 30 and defects == 0: + sigma_level = min(sigma_level, 4.5) + elif observed_dpmo > 0: + sigma_level = dpmo_to_sigma(observed_dpmo) + offset = mean - target + key_cpk = cpk if cpk is not None else ppk + is_centered = (abs(cp - cpk) < 0.1) if (cp is not None and cpk is not None) else abs(offset) < ( + (usl - lsl) * 0.125 + ) + + return CapabilityResult( + n=n, mean=mean, usl=usl, lsl=lsl, target=target, method=method, + sigma_within=sig_w, sigma_overall=sig_o, + cp=cp, cpk=cpk, cpu=cpu, cpl=cpl, cpm=cpm, + pp=pp, ppk=ppk, ppu=ppu, ppl=ppl, + observed_dpmo=observed_dpmo, expected_dpmo=expected_dpmo, z_bench=z_bench, + sigma_level=sigma_level, yield_pct=yield_pct, + is_centered=bool(is_centered), offset_from_target=float(offset), + rating=_rate(key_cpk), + ) + + +def capability_analysis(values, usl, lsl, target=None, subgroups=None, + force_method: str | None = None) -> CapabilityResult: + """Full capability decision: normality -> transform -> parametric or non-parametric. + + Returns a :class:`CapabilityResult`; the chosen ``method`` records the path taken. + """ + from .normality import apply_transform, check_normality + + arr = _clean(values) + if force_method == "nonparametric": + return nonparametric_capability(arr, usl, lsl, target, subgroups) + if force_method == "parametric": + return parametric_capability(arr, usl, lsl, target, subgroups) + + norm = check_normality(arr) + if norm.is_normal: + res = parametric_capability(arr, usl, lsl, target, subgroups) + res.notes["normality"] = {"is_normal": True, "path": "raw"} + return res + + # Try to normalize; if successful, compute parametric capability on the + # transformed scale with correspondingly transformed specification limits. + tr = apply_transform(arr, method="auto") + transform_error: str | None = None + if tr.became_normal and tr.applied != "NONE": + try: + usl_t, lsl_t, target_t = _transform_specs(usl, lsl, target, tr) + res = parametric_capability(tr.values, usl_t, lsl_t, target_t, subgroups=None) + res.method = "transformed" + res.notes["normality"] = { + "is_normal": False, + "transform_applied": tr.applied, + "transform_label": tr.label, + "transform_lambda": tr.lam, + "path": "transformed_parametric", + } + return res + except Exception as exc: # noqa: BLE001 — fall through to nonparametric + transform_error = f"{type(exc).__name__}: {exc}" + + res = nonparametric_capability(arr, usl, lsl, target, subgroups) + notes: dict = { + "is_normal": False, + "transform_tried": tr.applied, + "transform_became_normal": tr.became_normal, + "path": "nonparametric", + } + if transform_error is not None: + notes["transform_error"] = transform_error + notes["path"] = "nonparametric_after_transform_error" + res.notes["normality"] = notes + return res + + +def _transform_specs(usl, lsl, target, tr): + """Apply the same transform used on data to the specification limits.""" + + if tr.applied == "LOG": + if min(usl, lsl, target if target is not None else usl) <= 0: + raise ValueError("Log transform requires positive specs") + if "log(x+1)" in (tr.label or ""): + return np.log1p(usl), np.log1p(lsl), np.log1p(target) if target is not None else None + return np.log(usl), np.log(lsl), np.log(target) if target is not None else None + + if tr.applied == "BOXCOX": + + lam = tr.lam + def _bc(x): + if abs(lam) < 1e-12: + return np.log(x) + return (x ** lam - 1.0) / lam + return float(_bc(usl)), float(_bc(lsl)), float(_bc(target)) if target is not None else None + + if tr.applied == "YEO-JOHNSON": + + # yeojohnson on a scalar needs the fitted lambda. + def _yj(x, lam): + x = float(x) + if lam == 0 and x >= 0: + return np.log1p(x) + if x >= 0: + return ((x + 1) ** lam - 1) / lam + if lam == 2: + return -np.log1p(-x) + return -((1 - x) ** (2 - lam) - 1) / (2 - lam) + return ( + float(_yj(usl, tr.lam)), + float(_yj(lsl, tr.lam)), + float(_yj(target, tr.lam)) if target is not None else None, + ) + + raise ValueError(f"Cannot transform specs for applied={tr.applied}") diff --git a/spc_core/charts.py b/spc_core/charts.py new file mode 100644 index 0000000..de79a64 --- /dev/null +++ b/spc_core/charts.py @@ -0,0 +1,323 @@ +"""Control-chart selection and the batch analysis orchestrator. + +Selection matrix (continuous data): + n == 1 -> I-MR + 2 <= n <= 8 -> Xbar-R + n >= 9 -> Xbar-S + +Attribute data: + defectives, variable n -> P + defectives, fixed n -> NP + counts, variable area -> U + counts, fixed area -> C + +Also supports EWMA and CUSUM (explicit chart_type or pipeline routing). +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +import numpy as np + +from . import limits as L +from . import rules as R +from .cusum import cusum_chart +from .evaluator import Phase2Evaluator +from .ewma import ewma_chart +from .models import ( + ChartType, + ControlLimits, + DataType, + DistributionFlag, + LimitSet, + Phase, + QualityFlag, + Signal, + SPCRecord, +) + + +def detect_data_type(values, sample_size_col_present=False, opportunity_col_present=False, + subgroup_present=False) -> DataType: + arr = np.asarray(values, dtype=float) + arr = arr[~np.isnan(arr)] + if arr.size == 0: + raise ValueError("No numeric values to analyze") + + is_binary = bool(np.all(np.isin(arr, [0, 1]))) + is_integer = bool(np.all(arr == np.floor(arr))) + is_non_negative = bool(np.all(arr >= 0)) + + if sample_size_col_present or opportunity_col_present or is_binary: + return DataType.ATTRIBUTE + if is_integer and is_non_negative and arr.max() < 50 and not subgroup_present: + return DataType.ATTRIBUTE + if not is_integer: + return DataType.CONTINUOUS + unique_ratio = len(np.unique(arr)) / arr.size + return DataType.CONTINUOUS if unique_ratio > 0.3 else DataType.ATTRIBUTE + + +def select_chart_type(data_type: DataType, subgroup_size: int = 1, + attribute_defectives: bool = True, + variable_size: bool = False) -> ChartType: + if data_type == DataType.CONTINUOUS: + if subgroup_size == 1: + return ChartType.I_MR + if subgroup_size <= 8: + return ChartType.XBAR_R + return ChartType.XBAR_S + if attribute_defectives: + return ChartType.P if variable_size else ChartType.NP + return ChartType.U if variable_size else ChartType.C + + +@dataclass +class ControlChartResult: + chart_type: ChartType + data_type: DataType + subgroup_size: int + limits: ControlLimits + plotted_values: list[float] + secondary_values: list[float] | None = None + secondary_name: str | None = None + signals: list[Signal] = field(default_factory=list) + secondary_signals: list[Signal] = field(default_factory=list) + summary: dict = field(default_factory=dict) + distribution_flag: DistributionFlag = DistributionFlag.NORMAL + transform_applied: str | None = None + phase: Phase = Phase.PHASE_I + ruleset_applied: str | None = None + + @property + def out_of_control_count(self) -> int: + idxs = {s.index for s in self.signals} | {s.index for s in self.secondary_signals} + return len(idxs) + + def to_records( + self, + *, + timestamps=None, + subgroup_ids=None, + quality_flags=None, + gage_id: str | None = None, + machine_id: str | None = None, + ) -> list[SPCRecord]: + """Emit one SPCRecord per plotted point with limits, flags, and signals.""" + primary = self.limits.primary + by_idx: dict[int, list[Signal]] = {} + for s in self.signals: + by_idx.setdefault(s.index, []).append(s) + + records: list[SPCRecord] = [] + for i, v in enumerate(self.plotted_values): + ucl = primary.ucl_at(i) if hasattr(primary, "ucl_at") else ( + primary.ucl[i] if isinstance(primary.ucl, list) else primary.ucl + ) + lcl = primary.lcl_at(i) if hasattr(primary, "lcl_at") else ( + primary.lcl[i] if isinstance(primary.lcl, list) else primary.lcl + ) + qf = QualityFlag.ORIGINAL + if quality_flags is not None and i < len(quality_flags): + raw = quality_flags[i] + qf = raw if isinstance(raw, QualityFlag) else QualityFlag(raw) + ts = timestamps[i] if timestamps is not None and i < len(timestamps) else None + sid = subgroup_ids[i] if subgroup_ids is not None and i < len(subgroup_ids) else i + records.append(SPCRecord( + timestamp=ts, + subgroup_id=int(sid) if sid is not None else i, + measurement_value=float(v), + data_quality_flag=qf, + distribution_flag=self.distribution_flag, + transform_applied=self.transform_applied, + phase=self.phase, + ucl=float(ucl) if ucl is not None else None, + lcl=float(lcl) if lcl is not None else None, + centerline=float(primary.center), + gage_id=gage_id, + machine_id=machine_id, + signals=by_idx.get(i, []), + )) + return records + + +def _secondary_signals(secondary_values, secondary_limits: LimitSet | None, ruleset: str) -> list[Signal]: + if secondary_values is None or secondary_limits is None: + return [] + center = secondary_limits.center + ucl = secondary_limits.ucl if not isinstance(secondary_limits.ucl, list) else None + if ucl is None: + return [] + sigma = (float(ucl) - center) / 3.0 + # Wheeler / points-outside for secondary panels (R/MR/S zone tests rarely used). + rs = "wheeler" if ruleset == "wheeler" else ruleset + return R.evaluate_series(secondary_values, center=center, sigma=max(sigma, 0.0), ruleset=rs) + + +def analyze_control_chart( + values, + subgroup_ids=None, + sample_sizes=None, + opportunities=None, + chart_type: ChartType | None = None, + ruleset: str = "nelson", + ewma_lambda: float = 0.2, + ewma_L: float = 3.0, + cusum_k: float = 0.5, + cusum_h: float = 5.0, + exclude_incomplete: bool = False, +) -> ControlChartResult: + """Compute Phase I limits, plotted statistics, and run-rule signals for a batch.""" + arr = np.asarray(values, dtype=float) + + # ---- determine chart type ---- + if chart_type is None: + dtype = detect_data_type( + arr, + sample_size_col_present=sample_sizes is not None, + opportunity_col_present=opportunities is not None, + subgroup_present=subgroup_ids is not None, + ) + if dtype == DataType.CONTINUOUS: + if subgroup_ids is not None: + subs = L.build_subgroups(arr, subgroup_ids) + n = int(np.median([len(s) for s in subs])) + else: + n = 1 + chart_type = select_chart_type(dtype, subgroup_size=n) + else: + if sample_sizes is not None: + variable = len(set(np.asarray(sample_sizes).tolist())) > 1 + chart_type = select_chart_type(dtype, attribute_defectives=True, variable_size=variable) + elif opportunities is not None: + variable = len(set(np.asarray(opportunities).tolist())) > 1 + chart_type = select_chart_type(dtype, attribute_defectives=False, variable_size=variable) + else: + chart_type = select_chart_type(dtype, attribute_defectives=False, variable_size=False) + + secondary = None + secondary_name = None + secondary_limits = None + subgroup_size = 1 + signals: list[Signal] = [] + + # ---- EWMA / CUSUM ---- + if chart_type == ChartType.EWMA: + clean = arr[~np.isnan(arr)] + ew = ewma_chart(clean, lam=ewma_lambda, L=ewma_L) + limits = ew.limits + plotted = ew.z + signals = ew.signals + dtype = DataType.CONTINUOUS + + elif chart_type == ChartType.CUSUM: + clean = arr[~np.isnan(arr)] + cu = cusum_chart(clean, k=cusum_k, h=cusum_h) + limits = cu.limits + plotted = cu.c_plus # primary panel; C- in secondary + secondary = cu.c_minus + secondary_name = "cusum_minus" + signals = cu.signals + dtype = DataType.CONTINUOUS + + elif chart_type == ChartType.I_MR: + clean = arr[~np.isnan(arr)] + limits = L.imr_limits(clean) + plotted = clean.tolist() + secondary = np.abs(np.diff(clean)).tolist() + secondary_name = "moving_range" + secondary_limits = limits.components.get("moving_range") + dtype = DataType.CONTINUOUS + + elif chart_type in (ChartType.XBAR_R, ChartType.XBAR_S): + subs = L.build_subgroups(arr, subgroup_ids) + if exclude_incomplete and subs: + from collections import Counter + + sizes = [len(s) for s in subs] + nominal = Counter(sizes).most_common(1)[0][0] + if len(set(sizes)) > 1: + subs = [s for s in subs if len(s) == nominal] + subgroup_size = int(np.median([len(s) for s in subs])) if subs else 0 + if chart_type == ChartType.XBAR_R: + limits = L.xbar_r_limits(subs, exclude_incomplete=exclude_incomplete) + secondary = [float(s.max() - s.min()) for s in subs] + secondary_name = "range" + secondary_limits = limits.components.get("range") + else: + limits = L.xbar_s_limits(subs, exclude_incomplete=exclude_incomplete) + secondary = [float(s.std(ddof=1)) for s in subs] + secondary_name = "s" + secondary_limits = limits.components.get("s") + plotted = [float(s.mean()) for s in subs] + dtype = DataType.CONTINUOUS + + elif chart_type == ChartType.P: + n_arr = np.asarray(sample_sizes, dtype=float) + if np.any(n_arr <= 0): + raise ValueError("P chart sample sizes must be > 0") + limits = L.p_limits(arr, n_arr) + plotted = (arr / n_arr).tolist() + dtype = DataType.ATTRIBUTE + + elif chart_type == ChartType.NP: + n_np = float(np.asarray(sample_sizes)[0]) if sample_sizes is not None else float(arr.size) + limits = L.np_limits(arr, n_np) + plotted = arr.tolist() + subgroup_size = int(n_np) + dtype = DataType.ATTRIBUTE + + elif chart_type == ChartType.C: + limits = L.c_limits(arr) + plotted = arr.tolist() + dtype = DataType.ATTRIBUTE + + elif chart_type == ChartType.U: + o = np.asarray(opportunities, dtype=float) + if np.any(o <= 0): + raise ValueError("U chart opportunities must be > 0") + limits = L.u_limits(arr, o) + plotted = (arr / o).tolist() + dtype = DataType.ATTRIBUTE + + else: # pragma: no cover + raise ValueError(f"Unsupported chart type: {chart_type}") + + if limits is None: + raise RuntimeError(f"Limits not established for chart type {chart_type}") + + # ---- run-rule signals (Shewhart paths) ---- + if chart_type not in (ChartType.EWMA, ChartType.CUSUM): + primary = limits.primary + # Variable-limit charts (P/U, variable-n Xbar): use Phase2Evaluator so + # per-point UCL/LCL are honoured. Fixed-limit charts use RuleEngine. + if ( + chart_type in (ChartType.P, ChartType.U) + or isinstance(primary.ucl, list) + or isinstance(primary.lcl, list) + ): + ev = Phase2Evaluator(limits, ruleset=ruleset) + signals = [] + for v in plotted: + signals.extend(ev.observe(float(v))) + else: + center = primary.center + sigma = limits.sigma if (limits.sigma and limits.sigma > 0) else ( + (float(primary.ucl) - center) / 3.0 + ) + signals = R.evaluate_series(plotted, center=center, sigma=sigma, ruleset=ruleset) + + sec_signals = _secondary_signals(secondary, secondary_limits, ruleset) + + summary = { + "n_points": len(plotted), + "mean": float(np.mean(plotted)) if plotted else 0.0, + "limits_version": limits.version, + } + + return ControlChartResult( + chart_type=chart_type, data_type=dtype, + subgroup_size=subgroup_size, limits=limits, plotted_values=plotted, + secondary_values=secondary, secondary_name=secondary_name, signals=signals, + secondary_signals=sec_signals, summary=summary, ruleset_applied=ruleset, + ) diff --git a/spc_core/cleaning.py b/spc_core/cleaning.py new file mode 100644 index 0000000..4363c2f --- /dev/null +++ b/spc_core/cleaning.py @@ -0,0 +1,119 @@ +"""SPC-specific data cleaning. + +SPC cleaning is the inverse of ML cleaning: preserve the process as it actually ran. +Never mean/median impute control-chart data; never silently drop. Every missing value +is classified and flagged. This module implements the missing-value decision tree from +the MVP design document (section 6). +""" +from __future__ import annotations + +import math +from dataclasses import dataclass, field + +from .models import QualityFlag + +# Explicit root-cause reason -> flag mapping (caller supplies reasons when known). +_REASON_TO_FLAG = { + "sensor": QualityFlag.MISSING_SENSOR, + "maintenance": QualityFlag.EXCLUDED_MAINTENANCE, + "incomplete": QualityFlag.EXCLUDED_INCOMPLETE, + "backup": QualityFlag.RESTORED_FROM_BACKUP, + "human": QualityFlag.MISSING_HUMAN, + "comms": None, # decided by gap length below +} + + +@dataclass +class CleaningResult: + values: list[float | None] # cleaned values (LOCF may fill a few points) + flags: list[QualityFlag] + usable: list[bool] # True if the point may enter control-chart math + notes: dict = field(default_factory=dict) + + +def _is_missing(v) -> bool: + return v is None or (isinstance(v, float) and math.isnan(v)) + + +def range_check(values, low: float, high: float) -> list[bool]: + """Flag measurement-system failures (out of physical range) vs process variation. + + Returns a boolean "is_valid_measurement" per point. A disconnected-sensor sentinel + (e.g. -999) fails the range check and is a measurement failure, NOT an OOC signal. + """ + out = [] + for v in values: + if _is_missing(v): + out.append(False) + else: + out.append(low <= v <= high) + return out + + +def classify_missing(values, reasons: list[str | None] | None = None, + locf_max: int = 3) -> CleaningResult: + """Classify and handle missing values without silent imputation. + + Parameters + ---------- + values : sequence with possible None/NaN entries. + reasons : optional per-index root-cause hint (see ``_REASON_TO_FLAG``). When absent, + a short consecutive gap (<= ``locf_max``) is forward-filled and flagged + IMPUTED_LOCF; a longer gap is held as MISSING_SENSOR for investigation. + locf_max : maximum consecutive gap that may be forward-filled. + """ + values = list(values) + reasons = reasons or [None] * len(values) + flags: list[QualityFlag] = [] + cleaned: list[float | None] = [] + usable: list[bool] = [] + counts: dict[str, int] = {} + + # Pre-compute consecutive missing run lengths. + run_len = [0] * len(values) + i = 0 + while i < len(values): + if _is_missing(values[i]): + j = i + while j < len(values) and _is_missing(values[j]): + j += 1 + for m in range(i, j): + run_len[m] = j - i + i = j + else: + i += 1 + + last_valid: float | None = None + for idx, v in enumerate(values): + reason = (reasons[idx] or "").lower() if reasons[idx] else None + + if not _is_missing(v): + last_valid = float(v) + cleaned.append(float(v)) + flag = _REASON_TO_FLAG.get(reason) if reason == "backup" else QualityFlag.ORIGINAL + flag = flag or QualityFlag.ORIGINAL + flags.append(flag) + usable.append(True) + counts[flag.value] = counts.get(flag.value, 0) + 1 + continue + + # Missing value: decide by explicit reason, else by gap length. + if reason and reason in _REASON_TO_FLAG and _REASON_TO_FLAG[reason] is not None: + flag = _REASON_TO_FLAG[reason] + elif reason == "comms": + flag = QualityFlag.IMPUTED_LOCF if run_len[idx] <= locf_max else QualityFlag.MISSING_SENSOR + else: + flag = QualityFlag.IMPUTED_LOCF if run_len[idx] <= locf_max else QualityFlag.MISSING_SENSOR + + assert flag is not None + if flag == QualityFlag.IMPUTED_LOCF and last_valid is not None: + cleaned.append(last_valid) + usable.append(True) + else: + cleaned.append(None) + usable.append(False) + flags.append(flag) + counts[flag.value] = counts.get(flag.value, 0) + 1 + + return CleaningResult(values=cleaned, flags=flags, usable=usable, + notes={"flag_counts": counts, "locf_max": locf_max}) diff --git a/spc_core/constants.py b/spc_core/constants.py new file mode 100644 index 0000000..ac3e5db --- /dev/null +++ b/spc_core/constants.py @@ -0,0 +1,109 @@ +"""Shewhart control-chart constants as functions of subgroup size n. + +The current codebase hardcoded A2/D3/D4 only up to n=9. Here we keep the standard +d2/d3 unbiasing tables (which have no simple closed form) and *derive* every other +constant from them plus c4, so charts remain correct for any subgroup size. + +References: ASTM / AIAG SPC control chart constant tables. +""" +from __future__ import annotations + +import math + +# Hartley's d2 (mean of the relative range) for n = 2..25. +_D2 = { + 2: 1.128, 3: 1.693, 4: 2.059, 5: 2.326, 6: 2.534, 7: 2.704, 8: 2.847, + 9: 2.970, 10: 3.078, 11: 3.173, 12: 3.258, 13: 3.336, 14: 3.407, + 15: 3.472, 16: 3.532, 17: 3.588, 18: 3.640, 19: 3.689, 20: 3.735, + 21: 3.778, 22: 3.819, 23: 3.858, 24: 3.895, 25: 3.931, +} + +# d3 (standard deviation of the relative range) for n = 2..25. +_D3 = { + 2: 0.853, 3: 0.888, 4: 0.880, 5: 0.864, 6: 0.848, 7: 0.833, 8: 0.820, + 9: 0.808, 10: 0.797, 11: 0.787, 12: 0.778, 13: 0.770, 14: 0.763, + 15: 0.756, 16: 0.750, 17: 0.744, 18: 0.739, 19: 0.734, 20: 0.729, + 21: 0.724, 22: 0.720, 23: 0.716, 24: 0.712, 25: 0.708, +} + + +def _require(n: int) -> None: + if n < 2: + raise ValueError(f"subgroup size must be >= 2, got {n}") + + +def c4(n: int) -> float: + """Unbiasing constant for the sample standard deviation. + + Closed form: c4(n) = sqrt(2/(n-1)) * Gamma(n/2) / Gamma((n-1)/2). + Uses lgamma for numerical stability at large n. + """ + _require(n) + return math.sqrt(2.0 / (n - 1)) * math.exp( + math.lgamma(n / 2.0) - math.lgamma((n - 1) / 2.0) + ) + + +def d2(n: int) -> float: + """Mean of the relative range. Tabulated for n<=25; asymptotic approx beyond.""" + _require(n) + if n in _D2: + return _D2[n] + # Tippett/Hartley asymptotic: d2 ≈ sqrt(2*ln(n)) - (γ + ln(ln(n))) / (2*sqrt(2*ln(n))) + # for large n, where γ ≈ 0.57721 (Euler-Mascheroni). Good enough for n>25. + import math + ln_n = math.log(n) + gamma = 0.5772156649 + return math.sqrt(2.0 * ln_n) - (gamma + math.log(ln_n)) / (2.0 * math.sqrt(2.0 * ln_n)) + + +def d3(n: int) -> float: + """Std of the relative range. Tabulated for n<=25; decays slowly beyond.""" + _require(n) + if n in _D3: + return _D3[n] + # Approximate decay: d3 ~ π / sqrt(6*ln(n)) for large n (extreme-value theory). + import math + return math.pi / math.sqrt(6.0 * math.log(n)) + + +def A2(n: int) -> float: + """Xbar-R: UCL/LCL = Xbar +/- A2 * Rbar.""" + return 3.0 / (d2(n) * math.sqrt(n)) + + +def A3(n: int) -> float: + """Xbar-S: UCL/LCL = Xbar +/- A3 * Sbar.""" + return 3.0 / (c4(n) * math.sqrt(n)) + + +def D3(n: int) -> float: + """R chart lower factor (clamped at 0).""" + val = 1.0 - 3.0 * d3(n) / d2(n) + return max(val, 0.0) + + +def D4(n: int) -> float: + """R chart upper factor.""" + return 1.0 + 3.0 * d3(n) / d2(n) + + +def B3(n: int) -> float: + """S chart lower factor (clamped at 0).""" + val = 1.0 - 3.0 / c4(n) * math.sqrt(1.0 - c4(n) ** 2) + return max(val, 0.0) + + +def B4(n: int) -> float: + """S chart upper factor.""" + return 1.0 + 3.0 / c4(n) * math.sqrt(1.0 - c4(n) ** 2) + + +def E2(n: int) -> float: + """I-MR: individuals limits = Xbar +/- E2 * MRbar (E2 = 3/d2, n=2 for moving range).""" + return 3.0 / d2(n) + + +# Convenience: the I-MR moving-range case uses n=2. +D4_MR = D4(2) # ~3.267 +E2_MR = E2(2) # ~2.660 diff --git a/spc_core/cusum.py b/spc_core/cusum.py new file mode 100644 index 0000000..9f25919 --- /dev/null +++ b/spc_core/cusum.py @@ -0,0 +1,109 @@ +"""Tabular CUSUM (Cumulative Sum) control chart. + +Two-sided tabular CUSUM (Montgomery): + + C+_i = max(0, x_i - (target + k·σ) + C+_{i-1}) + C-_i = max(0, (target - k·σ) - x_i + C-_{i-1}) + +Signal when C+ > h·σ or C- > h·σ. + +Defaults: k=0.5, h=5.0 (in units of sigma). +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +import numpy as np + +from . import constants as k +from .models import ChartType, ControlLimits, LimitSet, Signal + + +@dataclass +class CUSUMResult: + k: float + h: float + target: float + sigma: float + c_plus: list[float] + c_minus: list[float] + decision_interval: float + signals: list[Signal] = field(default_factory=list) + limits: ControlLimits | None = None + + +def _estimate_sigma_mr(values: np.ndarray) -> float: + if values.size < 2: + return float(values.std(ddof=1)) if values.size > 1 else 0.0 + mr = np.abs(np.diff(values)) + return float(mr.mean() / k.d2(2)) if mr.size else 0.0 + + +def cusum_chart( + values, + k: float = 0.5, + h: float = 5.0, + target: float | None = None, + sigma: float | None = None, +) -> CUSUMResult: + """Compute tabular two-sided CUSUM with decision interval h·σ.""" + if k <= 0: + raise ValueError(f"CUSUM k must be > 0, got {k}") + if h <= 0: + raise ValueError(f"CUSUM h must be > 0, got {h}") + + arr = np.asarray(values, dtype=float) + arr = arr[~np.isnan(arr)] + if arr.size < 2: + raise ValueError("CUSUM requires at least 2 observations") + + tgt = float(target) if target is not None else float(arr.mean()) + sig = float(sigma) if sigma is not None and sigma > 0 else _estimate_sigma_mr(arr) + if sig <= 0: + raise ValueError("CUSUM requires a positive process sigma") + + k_abs = k * sig + h_abs = h * sig + c_plus: list[float] = [] + c_minus: list[float] = [] + cp = cm = 0.0 + signals: list[Signal] = [] + + for i, x in enumerate(arr): + cp = max(0.0, float(x) - (tgt + k_abs) + cp) + cm = max(0.0, (tgt - k_abs) - float(x) + cm) + c_plus.append(cp) + c_minus.append(cm) + if cp >= h_abs: + signals.append(Signal( + rule_id="CUSUM+", rule_name="CUSUM upper shift", index=i, value=cp, + description=f"C+ exceeded h·σ={h_abs:.4g} (k={k}, h={h})", side="upper", + )) + cp = 0.0 # optional restart after signal + c_plus[-1] = 0.0 + if cm >= h_abs: + signals.append(Signal( + rule_id="CUSUM-", rule_name="CUSUM lower shift", index=i, value=cm, + description=f"C- exceeded h·σ={h_abs:.4g} (k={k}, h={h})", side="lower", + )) + cm = 0.0 + c_minus[-1] = 0.0 + + # Represent decision interval as a LimitSet on the C+ / C- scale. + limits = ControlLimits( + chart_type=ChartType.CUSUM, + subgroup_size=1, + components={ + "cusum_plus": LimitSet(center=0.0, ucl=h_abs, lcl=0.0), + "cusum_minus": LimitSet(center=0.0, ucl=h_abs, lcl=0.0), + }, + sigma=sig, + source_n_points=int(arr.size), + notes={"k": k, "h": h, "k_abs": k_abs, "h_abs": h_abs, "target": tgt}, + ) + + return CUSUMResult( + k=k, h=h, target=tgt, sigma=sig, + c_plus=c_plus, c_minus=c_minus, + decision_interval=h_abs, signals=signals, limits=limits, + ) diff --git a/spc_core/evaluator.py b/spc_core/evaluator.py new file mode 100644 index 0000000..e1108ab --- /dev/null +++ b/spc_core/evaluator.py @@ -0,0 +1,316 @@ +"""Phase II evaluation: apply frozen Phase I limits to new observations. + +The evaluator never recomputes limits. It consumes an immutable :class:`ControlLimits` +and streams observations through the stateful :class:`RuleEngine`. The same object is +used for a live stream or a replayed batch; batch is just "replay every row". + +Chart dispatch: +* I-MR / NP / C -> ``observe(value)`` (scalar plotted statistic, fixed limits). +* Xbar-R / Xbar-S -> ``observe_subgroup(values)`` (plots the subgroup mean). +* P / U / variable-n Xbar -> ``observe(value)`` with per-point limits via ``ucl_at``. +* EWMA -> ``observe(value)`` updates the EWMA statistic and checks time-varying limits. +* CUSUM -> ``observe(value)`` updates tabular C+/C- against the decision interval. +""" +from __future__ import annotations + +from .models import ChartType, ControlLimits, Signal +from .rules import RuleEngine + +_SUBGROUP_CHARTS = {ChartType.XBAR_R, ChartType.XBAR_S} + + +class Phase2Evaluator: + def __init__(self, limits: ControlLimits, ruleset: str = "nelson"): + self.limits = limits + self.ruleset = ruleset + self._i = -1 + self._prev_side = 0 + self._side_run = 0 + + primary = limits.primary + center = primary.center + # Prefer the stored sigma; else derive from the (unclamped upper) 3-sigma limit. + if limits.sigma and limits.sigma > 0: + sigma = limits.sigma + else: + ucl = primary.ucl if not isinstance(primary.ucl, list) else primary.ucl[0] + sigma = (ucl - center) / 3.0 if ucl is not None else 0.0 + self._center = center + self._sigma = sigma + + # Variable limits: P/U, or any chart whose UCL/LCL is a per-point list + # (including variable-n Xbar and EWMA time-varying limits). + self._variable = ( + limits.chart_type in {ChartType.P, ChartType.U} + or isinstance(primary.ucl, list) + or isinstance(primary.lcl, list) + ) + self._ewma = limits.chart_type == ChartType.EWMA + self._cusum = limits.chart_type == ChartType.CUSUM + + # EWMA state + notes = limits.notes or {} + self._lam = float(notes.get("lambda", 0.2)) if self._ewma else 0.2 + self._ewma_z = self._center # z_0 = target + self._sigma_process = float(notes.get("sigma_process", sigma)) if self._ewma else sigma + self._L = float(notes.get("L", 3.0)) if self._ewma else 3.0 + + # CUSUM state + self._cusum_k = float(notes.get("k_abs", notes.get("k", 0.5) * (sigma or 1.0))) if self._cusum else 0.0 + self._cusum_h = float(notes.get("h_abs", primary.ucl if not isinstance(primary.ucl, list) else 0.0)) if self._cusum else 0.0 + self._cusum_target = float(notes.get("target", center)) if self._cusum else center + self._c_plus = 0.0 + self._c_minus = 0.0 + + if self._ewma or self._cusum or self._variable: + self._engine: RuleEngine | None = None + else: + self._engine = RuleEngine(center, sigma, ruleset) + + @property + def index(self) -> int: + """Current 0-based observation index (-1 before first observe).""" + return self._i + + @property + def is_subgroup_chart(self) -> bool: + return self.limits.chart_type in _SUBGROUP_CHARTS and not self._variable + + def seed_state( + self, + *, + index: int = -1, + values: list[float] | None = None, + ) -> None: + """Restore evaluator continuity after restart. + + ``index`` is the last observation index already seen (so the next + ``observe`` continues at ``index + 1``). ``values`` are recent plotted + statistics used to warm the rule buffer / EWMA / CUSUM state without + emitting signals. + """ + self._i = int(index) + if not values: + return + # Warm internal state silently (no signal collection). + saved_i = self._i + # Replay from a temporary index so RuleEngine buffer fills correctly. + if self._engine is not None: + # Reset engine and replay + self._engine = RuleEngine(self._center, self._sigma, self.ruleset) + for v in values: + self._engine.add(float(v)) + self._i = saved_i + elif self._ewma: + z = self._center + for v in values: + z = self._lam * float(v) + (1.0 - self._lam) * z + self._ewma_z = z + elif self._cusum: + cp = cm = 0.0 + k_abs = self._cusum_k + tgt = self._cusum_target + for v in values: + x = float(v) + cp = max(0.0, x - (tgt + k_abs) + cp) + cm = max(0.0, (tgt - k_abs) - x + cm) + self._c_plus = cp + self._c_minus = cm + elif self._variable: + # Warm one-sided run counter only + for v in values: + side = 1 if float(v) > self._center else -1 if float(v) < self._center else 0 + if side != 0 and side == self._prev_side: + self._side_run += 1 + else: + self._side_run = 1 if side != 0 else 0 + self._prev_side = side + + def observe(self, value: float) -> list[Signal]: + if self.is_subgroup_chart: + raise ValueError( + f"{self.limits.chart_type.value} plots subgroup means; use observe_subgroup()." + ) + self._i += 1 + if self._ewma: + return self._observe_ewma(float(value)) + if self._cusum: + return self._observe_cusum(float(value)) + if self._variable: + return self._observe_variable(float(value)) + assert self._engine is not None + return self._engine.add(float(value)) + + def observe_subgroup(self, values) -> list[Signal]: + if self.limits.chart_type not in _SUBGROUP_CHARTS: + raise ValueError( + f"{self.limits.chart_type.value} is not a subgroup chart; use observe()." + ) + if not values: + raise ValueError("observe_subgroup requires a non-empty subgroup") + self._i += 1 + mean = float(sum(values) / len(values)) + if self._variable: + return self._observe_variable(mean) + assert self._engine is not None + return self._engine.add(mean) + + def _observe_variable(self, value: float) -> list[Signal]: + """Per-point limits (P/U, variable-n Xbar, EWMA list limits). + + Applies beyond-limits (Nelson 1), run-of-9 (Nelson 2), and zone-style + rules when a local sigma can be inferred from UCL−CL (≈ 3σ). + """ + comp = self.limits.primary + i = self._i + if isinstance(comp.ucl, list): + ucl = comp.ucl_at(i) if i < len(comp.ucl) else comp.ucl[-1] + else: + ucl = comp.ucl + if isinstance(comp.lcl, list): + lcl = comp.lcl_at(i) if i < len(comp.lcl) else comp.lcl[-1] + else: + lcl = comp.lcl + out: list[Signal] = [] + # Inclusive: a point exactly on UCL/LCL is out of control. + if ucl is not None and value >= ucl: + out.append(Signal( + rule_id="1", rule_name="Beyond control limits", index=i, + value=float(value), description="Point above the upper control limit", + side="upper", + )) + elif lcl is not None and value <= lcl: + out.append(Signal( + rule_id="1", rule_name="Beyond control limits", index=i, + value=float(value), description="Point below the lower control limit", + side="lower", + )) + + side = 1 if value > comp.center else -1 if value < comp.center else 0 + if side != 0 and side == self._prev_side: + self._side_run += 1 + else: + self._side_run = 1 if side != 0 else 0 + self._prev_side = side + if self.ruleset != "wheeler" and self._side_run >= 9: + out.append(Signal( + rule_id="2", rule_name="Run on one side", index=i, + value=float(value), + description="Nine points in a row on the same side of the center line", + )) + + # Infer local sigma from half the control band when available. + sigma = None + if ucl is not None and comp.center is not None: + sigma = abs(float(ucl) - float(comp.center)) / 3.0 + elif lcl is not None and comp.center is not None: + sigma = abs(float(comp.center) - float(lcl)) / 3.0 + if sigma and sigma > 0 and self.ruleset != "wheeler": + z = (float(value) - float(comp.center)) / sigma + # Track zone A (beyond 2σ) runs for Nelson 5/6 approximations. + in_zone_a = abs(z) >= 2.0 + prev_zone = getattr(self, "_zone_a_run", 0) + prev_zone_side = getattr(self, "_zone_a_side", 0) + zone_side = 1 if z > 0 else -1 if z < 0 else 0 + if in_zone_a and zone_side == prev_zone_side and zone_side != 0: + self._zone_a_run = prev_zone + 1 + else: + self._zone_a_run = 1 if in_zone_a else 0 + self._zone_a_side = zone_side if in_zone_a else 0 + # Nelson 5: 2 of 3 beyond 2σ on same side — approximate with consecutive zone A. + if self._zone_a_run >= 2: + out.append(Signal( + rule_id="5", + rule_name="Two of three beyond 2 sigma", + index=i, + value=float(value), + description="Two consecutive points beyond 2σ from center (variable limits)", + side="upper" if zone_side > 0 else "lower", + )) + # Nelson 6: 4 of 5 beyond 1σ — track with _zone_b_run + in_zone_b = abs(z) >= 1.0 + prev_b = getattr(self, "_zone_b_run", 0) + prev_b_side = getattr(self, "_zone_b_side", 0) + if in_zone_b and zone_side == prev_b_side and zone_side != 0: + self._zone_b_run = prev_b + 1 + else: + self._zone_b_run = 1 if in_zone_b else 0 + self._zone_b_side = zone_side if in_zone_b else 0 + if self._zone_b_run >= 4: + out.append(Signal( + rule_id="6", + rule_name="Four of five beyond 1 sigma", + index=i, + value=float(value), + description="Four consecutive points beyond 1σ from center (variable limits)", + side="upper" if zone_side > 0 else "lower", + )) + return out + + def _observe_ewma(self, value: float) -> list[Signal]: + """Update EWMA statistic and check against time-varying (or steady) limits.""" + i = self._i + z = self._lam * value + (1.0 - self._lam) * self._ewma_z + self._ewma_z = z + + primary = self.limits.primary + if isinstance(primary.ucl, list) and i < len(primary.ucl): + ucl = primary.ucl[i] + lcl = primary.lcl[i] if isinstance(primary.lcl, list) else primary.lcl + else: + # Steady-state fallback using stored EWMA sigma + import math + factor = self._lam / (2.0 - self._lam) + var_factor = factor * (1.0 - (1.0 - self._lam) ** (2 * (i + 1))) + sigma_z = self._sigma_process * math.sqrt(max(var_factor, 0.0)) + ucl = self._center + self._L * sigma_z + lcl = self._center - self._L * sigma_z + + out: list[Signal] = [] + if z >= ucl: + out.append(Signal( + rule_id="EWMA1", rule_name="Beyond EWMA UCL", index=i, value=z, + description=f"EWMA statistic beyond UCL (λ={self._lam})", side="upper", + )) + elif z <= lcl: + out.append(Signal( + rule_id="EWMA1", rule_name="Beyond EWMA LCL", index=i, value=z, + description=f"EWMA statistic beyond LCL (λ={self._lam})", side="lower", + )) + return out + + def _observe_cusum(self, value: float) -> list[Signal]: + """Update tabular C+/C- against the frozen decision interval.""" + i = self._i + k_abs = self._cusum_k + h_abs = self._cusum_h + tgt = self._cusum_target + self._c_plus = max(0.0, value - (tgt + k_abs) + self._c_plus) + self._c_minus = max(0.0, (tgt - k_abs) - value + self._c_minus) + out: list[Signal] = [] + if self._c_plus >= h_abs: + out.append(Signal( + rule_id="CUSUM+", rule_name="CUSUM upper shift", index=i, + value=self._c_plus, + description=f"C+ exceeded h·σ={h_abs:.4g}", side="upper", + )) + self._c_plus = 0.0 + if self._c_minus >= h_abs: + out.append(Signal( + rule_id="CUSUM-", rule_name="CUSUM lower shift", index=i, + value=self._c_minus, + description=f"C- exceeded h·σ={h_abs:.4g}", side="lower", + )) + self._c_minus = 0.0 + return out + + +def evaluate_batch(limits: ControlLimits, plotted_values, ruleset: str = "nelson") -> list[Signal]: + """Replay already-plotted statistics (individuals, means, proportions) through Phase II.""" + ev = Phase2Evaluator(limits, ruleset=ruleset) + signals: list[Signal] = [] + if ev.is_subgroup_chart: + raise ValueError("Use evaluate_batch only for scalar-plotted charts.") + for v in plotted_values: + signals.extend(ev.observe(float(v))) + return signals diff --git a/spc_core/ewma.py b/spc_core/ewma.py new file mode 100644 index 0000000..329eece --- /dev/null +++ b/spc_core/ewma.py @@ -0,0 +1,110 @@ +"""EWMA (Exponentially Weighted Moving Average) control chart. + +Statistic: z_i = λ x_i + (1-λ) z_{i-1}, with z_0 = target (or process mean). +Time-varying control limits (Montgomery): + + σ_{z_i} = σ √( λ/(2-λ) · [1 - (1-λ)^{2i}] ) + UCL_i = target + L · σ_{z_i} + LCL_i = target - L · σ_{z_i} + +Defaults: λ=0.2, L=3.0. Sigma estimated from MR/d2 when not supplied. +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +import numpy as np + +from . import constants as k +from .models import ChartType, ControlLimits, LimitSet, Signal + + +@dataclass +class EWMAResult: + lam: float + L: float + target: float + sigma: float + z: list[float] + ucl: list[float] + lcl: list[float] + center: float + signals: list[Signal] = field(default_factory=list) + limits: ControlLimits | None = None + + +def _estimate_sigma_mr(values: np.ndarray) -> float: + if values.size < 2: + return float(values.std(ddof=1)) if values.size > 1 else 0.0 + mr = np.abs(np.diff(values)) + return float(mr.mean() / k.d2(2)) if mr.size else 0.0 + + +def ewma_chart( + values, + lam: float = 0.2, + L: float = 3.0, + target: float | None = None, + sigma: float | None = None, +) -> EWMAResult: + """Compute EWMA statistic series with time-varying control limits.""" + if not (0.0 < lam <= 1.0): + raise ValueError(f"EWMA lambda must be in (0, 1], got {lam}") + if L <= 0: + raise ValueError(f"EWMA L must be > 0, got {L}") + + arr = np.asarray(values, dtype=float) + arr = arr[~np.isnan(arr)] + if arr.size < 2: + raise ValueError("EWMA requires at least 2 observations") + + tgt = float(target) if target is not None else float(arr.mean()) + sig = float(sigma) if sigma is not None and sigma > 0 else _estimate_sigma_mr(arr) + if sig <= 0: + raise ValueError("EWMA requires a positive process sigma") + + z: list[float] = [] + ucl: list[float] = [] + lcl: list[float] = [] + prev = tgt + factor = lam / (2.0 - lam) + + for i, x in enumerate(arr, start=1): + zi = lam * float(x) + (1.0 - lam) * prev + z.append(zi) + prev = zi + var_factor = factor * (1.0 - (1.0 - lam) ** (2 * i)) + sigma_z = sig * np.sqrt(max(var_factor, 0.0)) + ucl.append(tgt + L * sigma_z) + lcl.append(tgt - L * sigma_z) + + # Steady-state sigma for the frozen ControlLimits summary. + sigma_z_ss = sig * np.sqrt(factor) if sig > 0 else 0.0 + limits = ControlLimits( + chart_type=ChartType.EWMA, + subgroup_size=1, + components={ + "ewma": LimitSet(center=tgt, ucl=ucl, lcl=lcl), + }, + sigma=sigma_z_ss, + source_n_points=int(arr.size), + notes={"lambda": lam, "L": L, "sigma_process": sig}, + ) + + signals: list[Signal] = [] + for i, (zi, u, lo) in enumerate(zip(z, ucl, lcl)): + if zi >= u: + signals.append(Signal( + rule_id="EWMA1", rule_name="Beyond EWMA UCL", index=i, value=zi, + description=f"EWMA statistic beyond UCL (λ={lam})", side="upper", + )) + elif zi <= lo: + signals.append(Signal( + rule_id="EWMA1", rule_name="Beyond EWMA LCL", index=i, value=zi, + description=f"EWMA statistic beyond LCL (λ={lam})", side="lower", + )) + + return EWMAResult( + lam=lam, L=L, target=tgt, sigma=sig, z=z, ucl=ucl, lcl=lcl, + center=tgt, signals=signals, limits=limits, + ) diff --git a/spc_core/explain.py b/spc_core/explain.py new file mode 100644 index 0000000..04c2307 --- /dev/null +++ b/spc_core/explain.py @@ -0,0 +1,201 @@ +"""Deterministic Explainable SPC Copilot — structured "why" for OOC signals. + +Never invents rules. Optional LLM phrasing is out of scope for this module; +callers may rewrite the returned ``operator_summary`` only. +""" +from __future__ import annotations + +from typing import Any + +from .models import ControlLimits, Signal +from .rules import NELSON, WESTERN_ELECTRIC + +# Extra rule ids used by evaluator / EWMA / CUSUM (beyond Nelson catalog keys). +_EXTRA: dict[str, tuple[str, str]] = { + "EWMA1": ( + "Beyond EWMA limits", + "The EWMA statistic crossed its control limit — a sustained mean shift is likely.", + ), + "CUSUM+": ( + "CUSUM upper shift", + "The upper CUSUM crossed its decision interval — evidence of an upward mean shift.", + ), + "CUSUM-": ( + "CUSUM lower shift", + "The lower CUSUM crossed its decision interval — evidence of a downward mean shift.", + ), + "WE1": ("Beyond 3-sigma", WESTERN_ELECTRIC["WE1"][2]), + "WE2": ("2 of 3 beyond 2-sigma", WESTERN_ELECTRIC["WE2"][2]), + "WE3": ("4 of 5 beyond 1-sigma", WESTERN_ELECTRIC["WE3"][2]), + "WE4": ("8 on one side", WESTERN_ELECTRIC["WE4"][2]), +} + + +def _lookup(rule_id: str) -> tuple[str, str]: + if rule_id in NELSON: + name, _, desc = NELSON[rule_id] + return name, desc + if rule_id in WESTERN_ELECTRIC: + name, _, desc = WESTERN_ELECTRIC[rule_id] + return name, desc + if rule_id in _EXTRA: + return _EXTRA[rule_id] + return (f"Rule {rule_id}", f"Rule {rule_id} fired (see signal description).") + + +def explain_signal( + signal: Signal | dict[str, Any], + *, + limits_version: str | None = None, + limits: ControlLimits | dict[str, Any] | None = None, + gates: list[dict[str, Any]] | None = None, + checklist: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build a structured explanation for one OOC signal. + + Returns a JSON-serializable dict suitable for Live drawers and ``POST /analyze/explain``. + """ + if isinstance(signal, Signal): + rule_id = signal.rule_id + rule_name = signal.rule_name + description = signal.description + index = signal.index + value = signal.value + side = signal.side + else: + rule_id = str(signal.get("rule_id") or "") + rule_name = str(signal.get("rule_name") or "") + description = str(signal.get("description") or "") + index = int(signal.get("index") or 0) + value = float(signal.get("value") or 0.0) + side = signal.get("side") + + catalog_name, catalog_desc = _lookup(rule_id) + display_name = rule_name or catalog_name + why = description or catalog_desc + + limit_snapshot: dict[str, Any] | None = None + chart_type = None + if isinstance(limits, ControlLimits): + chart_type = limits.chart_type.value + primary = limits.primary + limit_snapshot = { + "center": primary.center, + "ucl": primary.ucl if not isinstance(primary.ucl, list) else primary.ucl[0], + "lcl": primary.lcl if not isinstance(primary.lcl, list) else primary.lcl[0], + "version": limits.version, + } + limits_version = limits_version or limits.version + elif isinstance(limits, dict): + chart_type = limits.get("chart_type") + comps = limits.get("components") or {} + primary = next(iter(comps.values()), None) if comps else None + if isinstance(primary, dict): + limit_snapshot = { + "center": primary.get("center"), + "ucl": primary.get("ucl"), + "lcl": primary.get("lcl"), + "version": limits.get("version") or limits_version, + } + limits_version = limits_version or limits.get("version") + + gate_summary = None + if gates: + gate_summary = [ + {"step": g.get("step"), "status": g.get("status"), "reason": g.get("reason")} + for g in gates + if isinstance(g, dict) + ] + + checklist_passed = None + if checklist and isinstance(checklist, dict): + checklist_passed = checklist.get("passed") + + operator_summary = ( + f"Point {index} (value={value}) triggered {display_name} " + f"(rule {rule_id}): {why}" + ) + if side: + operator_summary += f" Side: {side}." + if limits_version: + operator_summary += f" Frozen limits version: {limits_version}." + + return { + "rule_id": rule_id, + "rule_name": display_name, + "catalog_description": catalog_desc, + "why": why, + "index": index, + "value": value, + "side": side, + "limits_version": limits_version, + "chart_type": chart_type, + "limit_snapshot": limit_snapshot, + "gates": gate_summary, + "checklist_passed": checklist_passed, + "operator_summary": operator_summary, + "auditable": True, + "llm_required": False, + } + + +def explain_signals( + signals: list[Signal | dict[str, Any]], + **kwargs: Any, +) -> list[dict[str, Any]]: + return [explain_signal(s, **kwargs) for s in signals] + + +def diff_limits( + a: dict[str, Any], + b: dict[str, Any], +) -> dict[str, Any]: + """Compare two stored limits payloads (from persistence ``get_limits``).""" + va = a.get("version") or a.get("limits_version") + vb = b.get("version") or b.get("limits_version") + ca = a.get("chart_type") + cb = b.get("chart_type") + comps_a = a.get("components") or {} + comps_b = b.get("components") or {} + names = sorted(set(comps_a) | set(comps_b)) + component_diffs: list[dict[str, Any]] = [] + for name in names: + pa = comps_a.get(name) or {} + pb = comps_b.get(name) or {} + if not isinstance(pa, dict): + pa = {} + if not isinstance(pb, dict): + pb = {} + + def _num(x: Any) -> float | None: + if x is None: + return None + if isinstance(x, list): + return float(x[0]) if x else None + return float(x) + + ua, ub = _num(pa.get("ucl")), _num(pb.get("ucl")) + la, lb = _num(pa.get("lcl")), _num(pb.get("lcl")) + cta, ctb = _num(pa.get("center")), _num(pb.get("center")) + component_diffs.append( + { + "component": name, + "a": {"center": cta, "ucl": ua, "lcl": la}, + "b": {"center": ctb, "ucl": ub, "lcl": lb}, + "delta": { + "center": None if cta is None or ctb is None else ctb - cta, + "ucl": None if ua is None or ub is None else ub - ua, + "lcl": None if la is None or lb is None else lb - la, + }, + } + ) + return { + "version_a": va, + "version_b": vb, + "chart_type_a": ca, + "chart_type_b": cb, + "same_chart_type": ca == cb, + "components": component_diffs, + "notes_a": a.get("notes") or {}, + "notes_b": b.get("notes") or {}, + } diff --git a/spc_core/ingest.py b/spc_core/ingest.py new file mode 100644 index 0000000..936fbcd --- /dev/null +++ b/spc_core/ingest.py @@ -0,0 +1,167 @@ +"""Column auto-detection and frame validation (deduped from the three legacy pipelines). + +Works on plain dicts of column -> values so the core stays free of pandas/polars. +Adapters convert CSV/Parquet into this shape before calling. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +import numpy as np + +SKIP_PATTERNS = ("id", "subgroup", "batch", "sample", "group", "lot", "serial", "number", + "part", "operator", "trial", "appraiser") +MEASUREMENT_PRIORITY = ("measurement", "value", "measure", "reading", "result", "data", + "defect", "count", "defective") + + +@dataclass +class ColumnMap: + value_col: str | None = None + subgroup_col: str | None = None + sample_size_col: str | None = None + opportunity_col: str | None = None + part_col: str | None = None + operator_col: str | None = None + trial_col: str | None = None + reference_col: str | None = None + date_col: str | None = None + issues: list[str] = field(default_factory=list) + + +@dataclass +class IngestedFrame: + columns: dict[str, list[Any]] + column_map: ColumnMap + n_rows: int + + +def _numeric_columns(columns: dict[str, list[Any]]) -> list[str]: + out = [] + for name, vals in columns.items(): + try: + arr = np.asarray(vals, dtype=float) + if arr.size and not np.all(np.isnan(arr)): + out.append(name) + except (TypeError, ValueError): + continue + return out + + +def _pick_measurement(numeric_cols: list[str]) -> str | None: + filtered = [c for c in numeric_cols + if not any(p in c.lower() for p in SKIP_PATTERNS)] + candidates = filtered or numeric_cols + if not candidates: + return None + for priority in MEASUREMENT_PRIORITY: + matching = [c for c in candidates if priority in c.lower()] + if matching: + return matching[0] + return candidates[0] + + +def _find_by_names(columns: dict[str, list[Any]], names: tuple[str, ...]) -> str | None: + lower = {c.lower(): c for c in columns} + for name in names: + if name in lower: + return lower[name] + for col in columns: + for name in names: + if name in col.lower(): + return col + return None + + +def detect_columns(columns: dict[str, list[Any]], + value_col: str | None = None, + subgroup_col: str | None = None, + sample_size_col: str | None = None, + opportunity_col: str | None = None, + part_col: str | None = None, + operator_col: str | None = None, + trial_col: str | None = None, + reference_col: str | None = None, + date_col: str | None = None) -> ColumnMap: + """Auto-detect standard SPC/MSA column roles from a column dict.""" + cmap = ColumnMap( + value_col=value_col, + subgroup_col=subgroup_col, + sample_size_col=sample_size_col, + opportunity_col=opportunity_col, + part_col=part_col, + operator_col=operator_col, + trial_col=trial_col, + reference_col=reference_col, + date_col=date_col, + ) + numeric = _numeric_columns(columns) + + if cmap.value_col is None: + cmap.value_col = _pick_measurement(numeric) + if cmap.value_col is None: + cmap.issues.append("ERROR: No numeric columns found for analysis") + + if cmap.subgroup_col is None: + cmap.subgroup_col = _find_by_names(columns, ("subgroup", "batch", "group", "lot")) + if cmap.sample_size_col is None: + cmap.sample_size_col = _find_by_names( + columns, ("sample_size", "inspected", "n_inspected", "samplesize") + ) + if cmap.opportunity_col is None: + cmap.opportunity_col = _find_by_names( + columns, ("opportunity", "units", "area", "opportunity_area") + ) + if cmap.part_col is None: + cmap.part_col = _find_by_names(columns, ("part",)) + if cmap.operator_col is None: + cmap.operator_col = _find_by_names(columns, ("operator", "appraiser")) + if cmap.trial_col is None: + cmap.trial_col = _find_by_names(columns, ("trial", "repeat", "rep")) + if cmap.reference_col is None: + cmap.reference_col = _find_by_names(columns, ("reference", "standard", "master")) + if cmap.date_col is None: + cmap.date_col = _find_by_names(columns, ("date", "time", "timestamp", "datetime")) + + return cmap + + +def validate_frame(columns: dict[str, list[Any]], cmap: ColumnMap, + min_points: int = 25) -> list[str]: + """Basic quality checks shared by control charts, MSA, and capability.""" + issues = list(cmap.issues) + if cmap.value_col is None or cmap.value_col not in columns: + issues.append("ERROR: Measurement/value column not found") + return issues + + vals = columns[cmap.value_col] + try: + arr = np.asarray(vals, dtype=float) + except (TypeError, ValueError): + issues.append(f"ERROR: Column '{cmap.value_col}' is not numeric") + return issues + + missing = int(np.isnan(arr).sum()) + clean = arr[~np.isnan(arr)] + if missing > 0: + issues.append(f"WARNING: {missing} missing values in '{cmap.value_col}'") + if clean.size < min_points: + issues.append( + f"WARNING: Only {clean.size} points. Minimum {min_points} recommended" + ) + if clean.size > 0 and np.unique(clean).size == 1: + issues.append(f"WARNING: All values are identical ({clean[0]})") + error_codes = clean[np.isin(clean, [999, 9999, -999, -9999])] + if error_codes.size > 0: + issues.append("WARNING: Potential error codes (999/-999) detected") + return issues + + +def ingest(columns: dict[str, list[Any]], **overrides) -> IngestedFrame: + """Detect columns, validate, and return a typed ingest result.""" + cmap = detect_columns(columns, **overrides) + issues = validate_frame(columns, cmap) + cmap.issues = issues + n_rows = len(next(iter(columns.values()))) if columns else 0 + return IngestedFrame(columns=columns, column_map=cmap, n_rows=n_rows) diff --git a/spc_core/limits.py b/spc_core/limits.py new file mode 100644 index 0000000..eb10402 --- /dev/null +++ b/spc_core/limits.py @@ -0,0 +1,236 @@ +"""Phase I control-limit computation. + +Every function returns an immutable :class:`ControlLimits`. Key correctness points vs +the legacy code: + +* Xbar-S is implemented (was routed to but missing -> produced empty limits). +* X / Xbar center-line limits are NOT clamped at zero. Measurements are two-sided; + clamping the lower limit to 0 was a bug that hid low-side out-of-control points. +* Range/S/attribute lower limits ARE clamped at 0 (a range or count cannot be negative). +* Chart constants come from :mod:`spc_core.constants` as functions of n (not a table + that stopped at n=9). +""" +from __future__ import annotations + +import numpy as np + +from . import constants as k +from .models import ChartType, ControlLimits, LimitSet + + +def _as_array(values) -> np.ndarray: + arr = np.asarray(values, dtype=float) + return arr + + +def build_subgroups( + values, + subgroup_ids, + *, + expected_n: int | None = None, + exclude_incomplete: bool = False, +) -> list[np.ndarray]: + """Group flat values into subgroups, preserving first-seen subgroup order. + + If ``expected_n`` is set and ``exclude_incomplete`` is True, subgroups whose + size differs from ``expected_n`` are dropped (flagged incomplete). Otherwise + all subgroups are kept and callers should use per-subgroup constants. + """ + values = _as_array(values) + order: list = [] + buckets: dict = {} + for val, sid in zip(values, subgroup_ids): + if sid not in buckets: + buckets[sid] = [] + order.append(sid) + buckets[sid].append(val) + groups = [np.asarray(buckets[sid], dtype=float) for sid in order] + if expected_n is not None and exclude_incomplete: + groups = [g for g in groups if len(g) == expected_n] + return groups + + +def imr_limits(values) -> ControlLimits: + x = _as_array(values) + x = x[~np.isnan(x)] + if x.size < 2: + raise ValueError("I-MR requires at least 2 observations") + moving_ranges = np.abs(np.diff(x)) + mr_bar = float(moving_ranges.mean()) + x_bar = float(x.mean()) + sigma = mr_bar / k.d2(2) if mr_bar > 0 else 0.0 + + individuals = LimitSet( + center=x_bar, + ucl=x_bar + k.E2_MR * mr_bar, + lcl=x_bar - k.E2_MR * mr_bar, + ) + moving_range = LimitSet(center=mr_bar, ucl=k.D4_MR * mr_bar, lcl=0.0) + return ControlLimits( + chart_type=ChartType.I_MR, + subgroup_size=1, + components={"individuals": individuals, "moving_range": moving_range}, + sigma=sigma, + source_n_points=int(x.size), + ) + + +def _subgroup_sizes(subgroups: list[np.ndarray]) -> tuple[int, bool]: + """Return (nominal n, sizes_vary). Nominal n is the mode (most common size).""" + sizes = [len(s) for s in subgroups] + if not sizes: + raise ValueError("No subgroups provided") + # Mode; tie-break toward median. + vals, counts = np.unique(sizes, return_counts=True) + n = int(vals[int(np.argmax(counts))]) + vary = len(set(sizes)) > 1 + return n, vary + + +def xbar_r_limits(subgroups: list[np.ndarray], *, exclude_incomplete: bool = False) -> ControlLimits: + n, sizes_vary = _subgroup_sizes(subgroups) + if exclude_incomplete and sizes_vary: + subgroups = [s for s in subgroups if len(s) == n] + n, sizes_vary = _subgroup_sizes(subgroups) + if n < 2: + raise ValueError("Xbar-R requires subgroups of size >= 2") + if not subgroups: + raise ValueError("No complete subgroups remain after filtering") + + means = np.array([s.mean() for s in subgroups], dtype=float) + ranges = np.array([s.max() - s.min() for s in subgroups], dtype=float) + x_bar = float(means.mean()) + r_bar = float(ranges.mean()) + sigma_within = r_bar / k.d2(n) if r_bar > 0 else 0.0 + + # If sizes vary and we did not exclude, use per-point Xbar limits with each + # subgroup's own A2(n_i); R chart still uses the nominal n. + if sizes_vary and not exclude_incomplete: + ucl = [float(m + k.A2(len(s)) * r_bar) for m, s in zip(means, subgroups)] + lcl = [float(m - k.A2(len(s)) * r_bar) for m, s in zip(means, subgroups)] + # Recompute as limits around x_bar with per-n A2 (standard practice). + ucl = [float(x_bar + k.A2(len(s)) * r_bar) for s in subgroups] + lcl = [float(x_bar - k.A2(len(s)) * r_bar) for s in subgroups] + xbar = LimitSet(center=x_bar, ucl=ucl, lcl=lcl) + else: + xbar = LimitSet( + center=x_bar, + ucl=x_bar + k.A2(n) * r_bar, + lcl=x_bar - k.A2(n) * r_bar, + ) + rng = LimitSet(center=r_bar, ucl=k.D4(n) * r_bar, lcl=k.D3(n) * r_bar) + return ControlLimits( + chart_type=ChartType.XBAR_R, + subgroup_size=n, + components={"xbar": xbar, "range": rng}, + sigma=sigma_within / np.sqrt(n) if sigma_within else 0.0, + source_n_points=len(subgroups), + notes={ + "sigma_within": sigma_within, + "r_bar": r_bar, + "sizes_vary": float(sizes_vary), + }, + ) + + +def xbar_s_limits(subgroups: list[np.ndarray], *, exclude_incomplete: bool = False) -> ControlLimits: + """Xbar-S: preferred for subgroup sizes > ~8 (uses the subgroup std, not range).""" + n, sizes_vary = _subgroup_sizes(subgroups) + if exclude_incomplete and sizes_vary: + subgroups = [s for s in subgroups if len(s) == n] + n, sizes_vary = _subgroup_sizes(subgroups) + if n < 2: + raise ValueError("Xbar-S requires subgroups of size >= 2") + if not subgroups: + raise ValueError("No complete subgroups remain after filtering") + + means = np.array([s.mean() for s in subgroups], dtype=float) + stds = np.array([s.std(ddof=1) for s in subgroups], dtype=float) + x_bar = float(means.mean()) + s_bar = float(stds.mean()) + sigma_within = s_bar / k.c4(n) if s_bar > 0 else 0.0 + + if sizes_vary and not exclude_incomplete: + ucl = [float(x_bar + k.A3(len(s)) * s_bar) for s in subgroups] + lcl = [float(x_bar - k.A3(len(s)) * s_bar) for s in subgroups] + xbar = LimitSet(center=x_bar, ucl=ucl, lcl=lcl) + else: + xbar = LimitSet( + center=x_bar, + ucl=x_bar + k.A3(n) * s_bar, + lcl=x_bar - k.A3(n) * s_bar, + ) + s = LimitSet(center=s_bar, ucl=k.B4(n) * s_bar, lcl=k.B3(n) * s_bar) + return ControlLimits( + chart_type=ChartType.XBAR_S, + subgroup_size=n, + components={"xbar": xbar, "s": s}, + sigma=sigma_within / np.sqrt(n) if sigma_within else 0.0, + source_n_points=len(subgroups), + notes={ + "sigma_within": sigma_within, + "s_bar": s_bar, + "sizes_vary": float(sizes_vary), + }, + ) + + +def p_limits(defectives, sample_sizes) -> ControlLimits: + """P chart: plotted statistic is the proportion defective; limits vary with n_i.""" + d = _as_array(defectives) + n = _as_array(sample_sizes) + p_bar = float(d.sum() / n.sum()) + ucl, lcl = [], [] + for ni in n: + spread = 3.0 * np.sqrt(p_bar * (1 - p_bar) / ni) if ni > 0 else 0.0 + ucl.append(min(p_bar + spread, 1.0)) + lcl.append(max(p_bar - spread, 0.0)) + proportion = LimitSet(center=p_bar, ucl=ucl, lcl=lcl) + return ControlLimits( + chart_type=ChartType.P, subgroup_size=1, + components={"proportion": proportion}, source_n_points=int(d.size), + notes={"p_bar": p_bar}, + ) + + +def np_limits(defectives, n: float) -> ControlLimits: + """NP chart: constant sample size n; plotted statistic is the count defective.""" + d = _as_array(defectives) + np_bar = float(d.mean()) + p_bar = np_bar / n if n else 0.0 + spread = 3.0 * np.sqrt(np_bar * (1 - p_bar)) if np_bar > 0 else 0.0 + comp = LimitSet(center=np_bar, ucl=np_bar + spread, lcl=max(np_bar - spread, 0.0)) + return ControlLimits( + chart_type=ChartType.NP, subgroup_size=int(n) if n else 1, + components={"np": comp}, source_n_points=int(d.size), notes={"p_bar": p_bar}, + ) + + +def c_limits(counts) -> ControlLimits: + """C chart: defect counts over a constant area of opportunity.""" + c = _as_array(counts) + c_bar = float(c.mean()) + spread = 3.0 * np.sqrt(c_bar) if c_bar > 0 else 0.0 + comp = LimitSet(center=c_bar, ucl=c_bar + spread, lcl=max(c_bar - spread, 0.0)) + return ControlLimits( + chart_type=ChartType.C, subgroup_size=1, + components={"defects": comp}, source_n_points=int(c.size), notes={"c_bar": c_bar}, + ) + + +def u_limits(counts, opportunities) -> ControlLimits: + """U chart: defects per unit; plotted statistic is counts_i / opportunity_i.""" + c = _as_array(counts) + o = _as_array(opportunities) + u_bar = float(c.sum() / o.sum()) + ucl, lcl = [], [] + for oi in o: + spread = 3.0 * np.sqrt(u_bar / oi) if oi > 0 else 0.0 + ucl.append(u_bar + spread) + lcl.append(max(u_bar - spread, 0.0)) + comp = LimitSet(center=u_bar, ucl=ucl, lcl=lcl) + return ControlLimits( + chart_type=ChartType.U, subgroup_size=1, + components={"defects_per_unit": comp}, source_n_points=int(c.size), + notes={"u_bar": u_bar}, + ) diff --git a/spc_core/models.py b/spc_core/models.py new file mode 100644 index 0000000..b2ebf11 --- /dev/null +++ b/spc_core/models.py @@ -0,0 +1,160 @@ +"""Typed contracts for the SPC core. + +These models are the stable, serializable boundary of the library. Everything that +crosses into an adapter (persistence, API, rendering) or is returned to a caller uses +these types. The core computation modules never touch I/O; they only produce/consume +these models and plain numpy arrays. +""" +from __future__ import annotations + +import hashlib +import json +from datetime import UTC, datetime +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field, computed_field + + +class ChartType(str, Enum): + """Supported control chart types.""" + + I_MR = "I-MR" + XBAR_R = "Xbar-R" + XBAR_S = "Xbar-S" + P = "P" + NP = "NP" + C = "C" + U = "U" + EWMA = "EWMA" + CUSUM = "CUSUM" + + +class DataType(str, Enum): + CONTINUOUS = "continuous" + ATTRIBUTE = "attribute" + + +class Phase(str, Enum): + """Phase I establishes and freezes limits; Phase II applies them to new data.""" + + PHASE_I = "PHASE_I" + PHASE_II = "PHASE_II" + + +class QualityFlag(str, Enum): + """Provenance of a single measurement (SPC never silently imputes/drops).""" + + ORIGINAL = "ORIGINAL" + IMPUTED_LOCF = "IMPUTED_LOCF" + MISSING_SENSOR = "MISSING_SENSOR" + EXCLUDED_MAINTENANCE = "EXCLUDED_MAINTENANCE" + EXCLUDED_INCOMPLETE = "EXCLUDED_INCOMPLETE" + RESTORED_FROM_BACKUP = "RESTORED_FROM_BACKUP" + MISSING_HUMAN = "MISSING_HUMAN" + + +class DistributionFlag(str, Enum): + NORMAL = "NORMAL" + TRANSFORMED = "TRANSFORMED" + NON_NORMAL_RAW = "NON_NORMAL_RAW" + + +class LimitSet(BaseModel): + """Center line and control limits for one chart component (e.g. the X or the R panel). + + UCL/LCL may be a scalar (fixed limits) or a per-point list (variable limits for + P and U charts where the subgroup size changes point to point). + """ + + model_config = ConfigDict(frozen=True) + + center: float + ucl: float | list[float] + lcl: float | list[float] + + def ucl_at(self, i: int) -> float: + return self.ucl[i] if isinstance(self.ucl, list) else self.ucl + + def lcl_at(self, i: int) -> float: + return self.lcl[i] if isinstance(self.lcl, list) else self.lcl + + +class ControlLimits(BaseModel): + """Immutable, versioned Phase I limits. + + Frozen after computation. Phase II must consume these without recomputing them. + `version` is a content hash so any downstream record can prove which limits it used. + """ + + model_config = ConfigDict(frozen=True) + + chart_type: ChartType + subgroup_size: int + # Component name -> LimitSet. e.g. {"individuals": ..., "moving_range": ...} + components: dict[str, LimitSet] + # Estimated within/short-term sigma of the plotted statistic (used by the rule engine). + sigma: float | None = None + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + source_n_points: int | None = None + notes: dict[str, float] = Field(default_factory=dict) + + @property + def primary(self) -> LimitSet: + """The component the run-rules are evaluated against (first declared component).""" + return next(iter(self.components.values())) + + @computed_field # type: ignore[prop-decorator] + @property + def version(self) -> str: + """Content hash so any downstream record can prove which limits it used. + + Serialized by ``model_dump()`` via pydantic ``computed_field``. + """ + payload = { + "chart_type": self.chart_type.value, + "subgroup_size": self.subgroup_size, + "components": { + name: {"center": ls.center, "ucl": ls.ucl, "lcl": ls.lcl} + for name, ls in self.components.items() + }, + "sigma": self.sigma, + } + blob = json.dumps(payload, sort_keys=True, default=str).encode() + return hashlib.sha256(blob).hexdigest()[:16] + + +class Signal(BaseModel): + """A detected out-of-control condition.""" + + rule_id: str + rule_name: str + index: int + value: float + description: str + side: str | None = None # "upper" | "lower" | None + + def __str__(self) -> str: # pragma: no cover - convenience only + return f"[{self.rule_id}] point {self.index}: {self.description}" + + +class SPCRecord(BaseModel): + """The SPC-ready output schema (one plotted point). + + Mirrors the minimum-fields schema from the MVP design document so every point + carries its provenance, distribution handling, phase, and the limits it was judged + against. + """ + + timestamp: datetime | None = None + subgroup_id: int | None = None + measurement_value: float + data_quality_flag: QualityFlag = QualityFlag.ORIGINAL + distribution_flag: DistributionFlag = DistributionFlag.NORMAL + transform_applied: str | None = None + phase: Phase = Phase.PHASE_I + ucl: float | None = None + lcl: float | None = None + centerline: float | None = None + gage_id: str | None = None + machine_id: str | None = None + signals: list[Signal] = Field(default_factory=list) diff --git a/spc_core/msa.py b/spc_core/msa.py new file mode 100644 index 0000000..57f6da9 --- /dev/null +++ b/spc_core/msa.py @@ -0,0 +1,310 @@ +"""Measurement System Analysis: Gage R&R (ANOVA + range), bias, linearity, stability. + +Pure numpy/scipy. Results are dataclasses with an unambiguous ``grr_percent`` field so +the acceptance decision cannot be silently mis-keyed (a legacy bug read ``grr_percent`` +while the pipeline emitted a nested ``percent_study_variation['gage_rr']``). +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +import numpy as np +from scipy import stats + +from . import constants as k + + +def _acceptability(grr_percent: float, ndc: int = 0, *, require_ndc: bool = True) -> str: + """AIAG MSA-4: %GRR < 10 excellent, 10-30 conditional, >30 unacceptable. + NDC >= 5 is also required for acceptability when ``require_ndc`` is True. + """ + if require_ndc and ndc < 5: + return "Unacceptable" + if grr_percent < 10: + return "Excellent" + if grr_percent < 30: + return "Acceptable" + return "Unacceptable" + + +def ndc_gate(ndc: int, minimum: int = 5) -> tuple[bool, str]: + """NDC must be >= 5 for the gage to discriminate parts for SPC.""" + ok = ndc >= minimum + reason = ( + f"NDC={ndc} meets minimum {minimum}." + if ok + else f"NDC={ndc} < {minimum}: gage cannot discriminate parts sufficiently for SPC." + ) + return ok, reason + + +def gage_resolution_gate(resolution: float, tolerance: float, ratio: float = 10.0) -> tuple[bool, str]: + """10:1 rule — gage resolution must be <= tolerance / ratio.""" + if tolerance <= 0: + return False, "Tolerance must be positive for the 10:1 resolution rule." + max_res = tolerance / ratio + ok = resolution <= max_res + reason = ( + f"Resolution {resolution} <= tolerance/{ratio:g} = {max_res}." + if ok + else f"Resolution {resolution} > tolerance/{ratio:g} = {max_res} (fails 10:1 rule)." + ) + return ok, reason + + +def _cell_counts(parts, operators) -> dict[tuple, int]: + counts: dict[tuple, int] = {} + for p, o in zip(parts, operators): + key = (p, o) + counts[key] = counts.get(key, 0) + 1 + return counts + + +def _is_balanced(parts, operators, n_parts: int, n_ops: int) -> tuple[bool, int]: + """Return (balanced, trials_per_cell). Unbalanced if any cell missing or unequal.""" + counts = _cell_counts(parts, operators) + expected_cells = n_parts * n_ops + if len(counts) != expected_cells: + return False, 0 + vals = list(counts.values()) + if len(set(vals)) != 1: + return False, 0 + return True, vals[0] + + +def _groups(keys, values) -> dict: + out: dict = {} + for kk, v in zip(keys, values): + out.setdefault(kk, []).append(v) + return {kk: np.asarray(v, dtype=float) for kk, v in out.items()} + + +@dataclass +class GageRRResult: + method: str + n_parts: int + n_operators: int + n_trials: int + var_repeatability: float + var_reproducibility: float + var_gage_rr: float + var_part: float + var_total: float + grr_percent: float # % study variation attributable to Gage R&R + part_percent: float + ndc: int + acceptability: str + grr_percent_tolerance: float | None = None + detail: dict = field(default_factory=dict) + + +def gage_rr_anova(parts, operators, measurements, tolerance: float | None = None) -> GageRRResult: + parts = np.asarray(list(parts)) + operators = np.asarray(list(operators)) + y = np.asarray(list(measurements), dtype=float) + + uparts = list(dict.fromkeys(parts.tolist())) + uops = list(dict.fromkeys(operators.tolist())) + n_parts, n_ops = len(uparts), len(uops) + balanced, n_trials = _is_balanced(parts, operators, n_parts, n_ops) + if not balanced: + # Balanced ANOVA formulas are invalid on unbalanced cells — fall back to + # the range method rather than returning silently wrong variance components. + result = gage_rr_range(parts, operators, measurements, tolerance=tolerance) + result.detail = { + **(result.detail or {}), + "balanced": False, + "anova_skipped": True, + "reason": "Unbalanced design; ANOVA formulas not applied — used range method.", + } + result.method = "Range (unbalanced fallback)" + return result + if n_trials < 1: + raise ValueError("Gage R&R requires at least one trial per part-operator cell") + + grand = y.mean() + part_means = _group_means(parts, y) + op_means = _group_means(operators, y) + + ss_total = float(np.sum((y - grand) ** 2)) + ss_part = n_ops * n_trials * float(np.sum((np.array([part_means[p] for p in uparts]) - grand) ** 2)) + ss_op = n_parts * n_trials * float(np.sum((np.array([op_means[o] for o in uops]) - grand) ** 2)) + + cell_means: dict[tuple, list[float]] = {} + for p, o, v in zip(parts, operators, y): + cell_means.setdefault((p, o), []).append(v) + cell_mean_arr = np.array([np.mean(cell_means[(p, o)]) for p in uparts for o in uops + if (p, o) in cell_means]) + ss_cells = n_trials * float(np.sum((cell_mean_arr - grand) ** 2)) + ss_interaction = ss_cells - ss_part - ss_op + ss_equip = ss_total - ss_part - ss_op - ss_interaction + + df_part = n_parts - 1 + df_op = n_ops - 1 + df_int = df_part * df_op + df_equip = n_parts * n_ops * (n_trials - 1) + + ms_part = ss_part / df_part if df_part > 0 else 0.0 + ms_op = ss_op / df_op if df_op > 0 else 0.0 + ms_int = ss_interaction / df_int if df_int > 0 else 0.0 + ms_equip = ss_equip / df_equip if df_equip > 0 else 0.0 + + var_rep = max(ms_equip, 0.0) # repeatability + var_reprod = max((ms_op - ms_int) / (n_parts * n_trials), 0.0) + var_int = max((ms_int - ms_equip) / n_trials, 0.0) + var_reprod_total = var_reprod + var_int + var_grr = var_rep + var_reprod_total + var_part = max((ms_part - ms_int) / (n_ops * n_trials), 0.0) + var_total = var_grr + var_part + + std_grr = np.sqrt(var_grr) + std_part = np.sqrt(var_part) + std_total = np.sqrt(var_total) + + grr_percent = float(std_grr / std_total * 100) if std_total > 0 else 0.0 + part_percent = float(std_part / std_total * 100) if std_total > 0 else 0.0 + ndc = int(np.floor(np.sqrt(2) * std_part / std_grr)) if std_grr > 0 else 0 + + grr_pct_tol = float(6 * std_grr / tolerance * 100) if tolerance else None + + return GageRRResult( + method="ANOVA", n_parts=n_parts, n_operators=n_ops, n_trials=n_trials, + var_repeatability=float(var_rep), var_reproducibility=float(var_reprod_total), + var_gage_rr=float(var_grr), var_part=float(var_part), var_total=float(var_total), + grr_percent=grr_percent, part_percent=part_percent, ndc=ndc, + acceptability=_acceptability(grr_percent, ndc), grr_percent_tolerance=grr_pct_tol, + detail={ + "pct_contribution_grr": float(var_grr / var_total * 100) if var_total > 0 else 0.0, + "std_gage_rr": float(std_grr), "std_part": float(std_part), "std_total": float(std_total), + "balanced": balanced, + "ndc_ok": ndc >= 5, + }, + ) + + +def gage_rr_range(parts, operators, measurements, tolerance: float | None = None) -> GageRRResult: + parts = np.asarray(list(parts)) + operators = np.asarray(list(operators)) + y = np.asarray(list(measurements), dtype=float) + uparts = list(dict.fromkeys(parts.tolist())) + uops = list(dict.fromkeys(operators.tolist())) + n_parts, n_ops = len(uparts), len(uops) + n_trials = len(y) // (n_parts * n_ops) if (n_parts * n_ops) else 0 + + ranges = [] + for p in uparts: + for o in uops: + cell = y[(parts == p) & (operators == o)] + if cell.size > 1: + ranges.append(cell.max() - cell.min()) + r_bar = float(np.mean(ranges)) if ranges else 0.0 + d2 = k.d2(max(n_trials, 2)) + ev = r_bar / d2 if d2 else 0.0 + + op_means = _group_means(operators, y) + r_ops = max(op_means.values()) - min(op_means.values()) if op_means else 0.0 + av = np.sqrt(max((r_ops / d2) ** 2 - (ev ** 2 / (n_parts * max(n_trials, 1))), 0.0)) + + part_means = _group_means(parts, y) + r_parts = max(part_means.values()) - min(part_means.values()) if part_means else 0.0 + pv = r_parts / d2 if d2 else 0.0 + + grr = np.sqrt(ev ** 2 + av ** 2) + tv = np.sqrt(grr ** 2 + pv ** 2) + grr_percent = float(grr / tv * 100) if tv > 0 else 0.0 + part_percent = float(pv / tv * 100) if tv > 0 else 0.0 + ndc = int(np.floor(np.sqrt(2) * pv / grr)) if grr > 0 else 0 + + return GageRRResult( + method="Range", n_parts=n_parts, n_operators=n_ops, n_trials=n_trials, + var_repeatability=float(ev ** 2), var_reproducibility=float(av ** 2), + var_gage_rr=float(grr ** 2), var_part=float(pv ** 2), var_total=float(tv ** 2), + grr_percent=grr_percent, part_percent=part_percent, ndc=ndc, + acceptability=_acceptability(grr_percent, ndc), + grr_percent_tolerance=float(6 * grr / tolerance * 100) if tolerance else None, + detail={"EV": float(ev), "AV": float(av), "PV": float(pv), "ndc_ok": ndc >= 5}, + ) + + +@dataclass +class BiasResult: + mean_bias: float + std_bias: float + percent_bias: float + t_statistic: float + p_value: float + is_significant: bool + n: int + + +def bias_study(measurements, references) -> BiasResult: + m = np.asarray(list(measurements), dtype=float) + r = np.asarray(list(references), dtype=float) + bias = m - r + mean_bias = float(bias.mean()) + std_bias = float(bias.std(ddof=1)) + t_stat, p = stats.ttest_1samp(bias, 0.0) + ref_mean = float(r.mean()) + return BiasResult( + mean_bias=mean_bias, std_bias=std_bias, + percent_bias=float(mean_bias / ref_mean * 100) if ref_mean else 0.0, + t_statistic=float(t_stat), p_value=float(p), is_significant=bool(p < 0.05), n=int(m.size), + ) + + +@dataclass +class LinearityResult: + slope: float + intercept: float + r_squared: float + p_value: float + std_error: float + is_linear: bool + + +def linearity_study(measurements, references) -> LinearityResult: + m = np.asarray(list(measurements), dtype=float) + r = np.asarray(list(references), dtype=float) + slope, intercept, rval, p, se = stats.linregress(r, m - r) + return LinearityResult( + slope=float(slope), intercept=float(intercept), r_squared=float(rval ** 2), + p_value=float(p), std_error=float(se), is_linear=bool(abs(slope) < 0.1), + ) + + +@dataclass +class StabilityResult: + mean: float + std_dev: float + ucl: float + lcl: float + out_of_control_points: int + has_trend: bool + is_stable: bool + n: int + + +def stability_study(measurements) -> StabilityResult: + m = np.asarray(list(measurements), dtype=float) + mean = float(m.mean()) + std = float(m.std(ddof=1)) + mr_bar = float(np.mean(np.abs(np.diff(m)))) if m.size > 1 else 0.0 + ucl = mean + k.E2_MR * mr_bar + lcl = mean - k.E2_MR * mr_bar + ooc = int(np.sum((m > ucl) | (m < lcl))) + + n = m.size + s = 0 + for i in range(n - 1): + s += int(np.sum(np.sign(m[i + 1:] - m[i]))) + has_trend = abs(s) > (n * (n - 1) / 4) + + return StabilityResult( + mean=mean, std_dev=std, ucl=ucl, lcl=lcl, out_of_control_points=ooc, + has_trend=bool(has_trend), is_stable=bool(ooc == 0 and not has_trend), n=int(n), + ) + + +def _group_means(keys, values) -> dict: + groups = _groups(keys.tolist() if hasattr(keys, "tolist") else list(keys), values) + return {kk: float(v.mean()) for kk, v in groups.items()} diff --git a/spc_core/msa_stream.py b/spc_core/msa_stream.py new file mode 100644 index 0000000..7fc5549 --- /dev/null +++ b/spc_core/msa_stream.py @@ -0,0 +1,122 @@ +"""Continuous / real-time MSA: reference-standard injection and bias drift. + +Insert certified reference standards into the measurement stream at fixed intervals. +Track EWMA of bias (α=0.2) and a rolling R chart; raise a calibration alert when +EWMA bias exceeds ±10% of tolerance. +""" +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass, field + +import numpy as np + +from . import constants as k + + +@dataclass +class CalibrationAlert: + index: int + ewma_bias: float + threshold: float + message: str + + +@dataclass +class ContinuousMSAState: + alpha: float = 0.2 + tolerance: float = 1.0 + alert_fraction: float = 0.10 # ±10% of tolerance + ewma_bias: float = 0.0 + initialized: bool = False + reference_biases: list[float] = field(default_factory=list) + rolling_ranges: deque = field(default_factory=lambda: deque(maxlen=50)) + alerts: list[CalibrationAlert] = field(default_factory=list) + _last_ref: float | None = None + _n: int = 0 + + @property + def threshold(self) -> float: + return abs(self.tolerance) * self.alert_fraction + + @property + def gage_healthy(self) -> bool: + return abs(self.ewma_bias) <= self.threshold + + +class ContinuousMSA: + """Incremental continuous MSA monitor.""" + + def __init__( + self, + tolerance: float, + alpha: float = 0.2, + alert_fraction: float = 0.10, + ): + if tolerance <= 0: + raise ValueError("tolerance must be > 0") + self.state = ContinuousMSAState( + alpha=alpha, tolerance=tolerance, alert_fraction=alert_fraction, + ) + + def observe_reference(self, measured: float, reference: float) -> CalibrationAlert | None: + """Record a reference-standard measurement; return alert if drift detected.""" + bias = float(measured) - float(reference) + st = self.state + st._n += 1 + st.reference_biases.append(bias) + + if not st.initialized: + st.ewma_bias = bias + st.initialized = True + else: + st.ewma_bias = st.alpha * bias + (1.0 - st.alpha) * st.ewma_bias + + if st._last_ref is not None: + st.rolling_ranges.append(abs(bias - st._last_ref)) + st._last_ref = bias + + if abs(st.ewma_bias) > st.threshold: + alert = CalibrationAlert( + index=st._n - 1, + ewma_bias=st.ewma_bias, + threshold=st.threshold, + message=( + f"Calibration alert: EWMA bias={st.ewma_bias:.4g} exceeds " + f"±{st.threshold:.4g} ({st.alert_fraction:.0%} of tolerance). " + "Suspend measurements and recalibrate." + ), + ) + st.alerts.append(alert) + return alert + return None + + def rolling_r_chart(self) -> dict: + """Summary of the rolling range chart on consecutive reference biases.""" + st = self.state + if len(st.rolling_ranges) < 2: + return {"n": 0, "r_bar": 0.0, "ucl": 0.0, "lcl": 0.0} + ranges = np.asarray(list(st.rolling_ranges), dtype=float) + r_bar = float(ranges.mean()) + return { + "n": int(ranges.size), + "r_bar": r_bar, + "ucl": k.D4_MR * r_bar, + "lcl": 0.0, + "values": ranges.tolist(), + } + + def summary(self) -> dict: + st = self.state + return { + "ewma_bias": st.ewma_bias, + "threshold": st.threshold, + "gage_healthy": st.gage_healthy, + "n_references": st._n, + "n_alerts": len(st.alerts), + "rolling_r": self.rolling_r_chart(), + "alerts": [ + {"index": a.index, "ewma_bias": a.ewma_bias, "message": a.message} + for a in st.alerts + ], + } diff --git a/spc_core/multimodal.py b/spc_core/multimodal.py new file mode 100644 index 0000000..424a8c4 --- /dev/null +++ b/spc_core/multimodal.py @@ -0,0 +1,128 @@ +"""Multimodality detection (Hartigan dip test) for stratification STOP gate.""" +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + + +@dataclass +class MultimodalResult: + is_multimodal: bool + dip_statistic: float + p_value: float + recommendation: str + + +def _dip_statistic(x: np.ndarray) -> float: + """Hartigan dip statistic approximation via modal-interval grid search.""" + x = np.sort(x) + n = x.size + if n < 8: + return 0.0 + + ecdf = np.arange(1, n + 1) / n + step = max(1, n // 40) + idxs = list(range(0, n, step)) + if idxs[-1] != n - 1: + idxs.append(n - 1) + + best = 0.0 + for a in idxs: + for b in idxs: + if b <= a: + continue + um = np.empty(n) + if a > 0: + um[: a + 1] = np.linspace(0.0, ecdf[a], a + 1) + else: + um[0] = ecdf[0] + mid_mass = ecdf[b] - ecdf[a] + um[a: b + 1] = ecdf[a] + np.linspace(0.0, mid_mass, b - a + 1) + if b < n - 1: + um[b:] = np.linspace(ecdf[b], 1.0, n - b) + else: + um[-1] = 1.0 + dip = float(np.max(np.abs(ecdf - um))) + if dip > best: + best = dip + return best + + +def check_multimodal(values, alpha: float = 0.05) -> MultimodalResult: + """Detect multimodality for the stratification STOP gate. + + Uses the ``diptest`` package when installed; otherwise a dip approximation + plus a histogram peak heuristic. + """ + arr = np.asarray(values, dtype=float) + arr = arr[~np.isnan(arr)] + if arr.size < 8: + return MultimodalResult( + is_multimodal=False, dip_statistic=0.0, p_value=1.0, + recommendation="Insufficient data for multimodality test (n < 8).", + ) + + try: + from diptest import diptest as _diptest + + dip, p = _diptest(arr) + dip, p = float(dip), float(p) + except ImportError: + dip = _dip_statistic(arr) + thresh = 1.0 / (2.0 * np.sqrt(arr.size)) + p = float(np.exp(-((dip / max(thresh, 1e-9)) ** 2))) + p = min(max(p, 0.0), 1.0) + + hist, _ = np.histogram(arr, bins=min(20, max(5, arr.size // 5)), density=True) + # Local maxima including edge bins, prominent relative to the tallest bin. + mx = float(hist.max()) if hist.size else 0.0 + peak_idxs: list[int] = [] + for i in range(len(hist)): + left = hist[i - 1] if i > 0 else -np.inf + right = hist[i + 1] if i < len(hist) - 1 else -np.inf + if hist[i] > left and hist[i] > right and mx > 0 and hist[i] >= 0.4 * mx: + peak_idxs.append(i) + + # A genuine mixture is two populated modes separated by a near-EMPTY region. A merely + # *shallow* dip is not evidence: normal data at n < 100 routinely shows two or three + # noise peaks whose valley sits at 0.2-0.3 of the mode height, which would false-STOP + # an in-control process. Require an essentially empty gap at least two bins wide with + # substantial mass on both sides of it. + clear_bimodal = False + if len(peak_idxs) >= 2 and mx > 0: + empty = 0.05 * mx + widest: tuple[int, int] | None = None + start: int | None = None + for i in range(peak_idxs[0] + 1, peak_idxs[-1]): + if hist[i] <= empty: + if start is None: + start = i + if widest is None or (i - start) > (widest[1] - widest[0]): + widest = (start, i) + else: + start = None + if widest is not None and (widest[1] - widest[0] + 1) >= 2: + total = float(hist.sum()) + left_mass = float(hist[: widest[0]].sum()) / total + right_mass = float(hist[widest[1] + 1 :].sum()) / total + clear_bimodal = min(left_mass, right_mass) >= 0.15 + + # ``diptest`` is a declared dependency, so ``p`` is normally the real Hartigan + # p-value. The histogram check stays as a fallback for installs where the optional + # C extension is unavailable and ``_dip_statistic``'s approximate p is unreliable. + is_mm = (p < alpha and dip > 0) or clear_bimodal + if is_mm: + rec = ( + "Multimodal distribution detected. STOP — stratify by machine/shift/lot " + "and run separate charts per stratum. Mixing streams invalidates limits." + ) + else: + rec = "No significant multimodality detected." + + return MultimodalResult( + is_multimodal=bool(is_mm), + dip_statistic=float(dip), + p_value=float(p), + recommendation=rec, + ) diff --git a/spc_core/normality.py b/spc_core/normality.py new file mode 100644 index 0000000..3e98bdc --- /dev/null +++ b/spc_core/normality.py @@ -0,0 +1,246 @@ +"""Normality assessment, autocorrelation gate, and transformations. + +Pure numpy/scipy. No pandas, no I/O. The autocorrelation check is implemented with +numpy directly so statsmodels is not a required dependency. +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +import numpy as np +from scipy import stats + + +@dataclass +class NormalityResult: + n: int + is_normal: bool + tests_passed: int + total_tests: int + confidence: str + shapiro_stat: float | None = None + shapiro_p: float | None = None + anderson_stat: float | None = None + anderson_critical: float | None = None + ks_stat: float | None = None + ks_p: float | None = None + skewness: float | None = None + kurtosis: float | None = None + recommendation: str = "" + detail: dict = field(default_factory=dict) + + +def _clean(values) -> np.ndarray: + arr = np.asarray(values, dtype=float) + return arr[~np.isnan(arr)] + + +def check_normality(values, alpha: float = 0.05) -> NormalityResult: + """Run AD + SW + KS + skew/kurt and combine into a majority verdict. + + Thresholds follow the MVP design doc: |skew| < 1.0, |excess kurt| < 3.5. + """ + arr = _clean(values) + n = int(arr.size) + if n < 3: + return NormalityResult( + n=n, is_normal=False, tests_passed=0, total_tests=0, + confidence="None", recommendation="Insufficient data for normality test (n < 3).", + ) + + passed = 0 + total = 0 + detail: dict = {} + + # Anderson-Darling (index 2 == 5% significance level). + # Prefer method='interpolate' on SciPy >=1.17 to avoid FutureWarning; fall back. + ad_stat = ad_crit = None + try: + try: + ad = stats.anderson(arr, dist="norm", method="interpolate") + ad_stat = float(ad.statistic) + # With method=, result exposes pvalue instead of critical_values. + ad_p = float(getattr(ad, "pvalue", 0.0) or 0.0) + ad_pass = ad_p > alpha if hasattr(ad, "pvalue") else False + ad_crit = None + detail["anderson_darling"] = { + "stat": ad_stat, "p": ad_p, "critical": ad_crit, "passes": ad_pass + } + except TypeError: + ad = stats.anderson(arr, dist="norm") + idx = 2 if len(ad.critical_values) > 2 else -1 + ad_stat = float(ad.statistic) + ad_crit = float(ad.critical_values[idx]) + ad_pass = ad_stat < ad_crit + detail["anderson_darling"] = { + "stat": ad_stat, "critical": ad_crit, "passes": ad_pass + } + passed += int(ad_pass) + total += 1 + except Exception as exc: # pragma: no cover - defensive + detail["anderson_darling"] = {"error": str(exc)} + + # Shapiro-Wilk (valid for 3 <= n <= 5000). + sw_stat = sw_p = None + if 3 <= n <= 5000: + try: + sw_stat, sw_p = (float(x) for x in stats.shapiro(arr)) + sw_pass = sw_p > alpha + detail["shapiro_wilk"] = {"stat": sw_stat, "p": sw_p, "passes": sw_pass} + passed += int(sw_pass) + total += 1 + except Exception as exc: # pragma: no cover - defensive + detail["shapiro_wilk"] = {"error": str(exc)} + + # Kolmogorov-Smirnov with Lilliefors correction for estimated parameters. + # Plain KS against a fitted normal is anticonservative; use scipy's + # lilliefors when available, otherwise skip KS from the majority vote. + ks_stat = ks_p = None + try: + mean, std = float(arr.mean()), float(arr.std(ddof=1)) + if std > 0: + try: + from statsmodels.stats.diagnostic import lilliefors as _lilliefors + ks_stat, ks_p = (float(x) for x in _lilliefors(arr, dist="norm")) + ks_pass = ks_p > alpha + detail["kolmogorov_smirnov"] = { + "stat": ks_stat, "p": ks_p, "passes": ks_pass, "method": "lilliefors", + } + passed += int(ks_pass) + total += 1 + except ImportError: + # Without statsmodels: report raw KS for diagnostics but do NOT + # count it toward the majority vote (parameters estimated from data). + ks_stat, ks_p = (float(x) for x in stats.kstest(arr, "norm", args=(mean, std))) + detail["kolmogorov_smirnov"] = { + "stat": ks_stat, "p": ks_p, "passes": None, + "method": "raw_kstest_not_voted", + "note": "KS skipped from vote; install statsmodels for Lilliefors.", + } + except Exception as exc: # pragma: no cover - defensive + detail["kolmogorov_smirnov"] = {"error": str(exc)} + + # Shape: skewness and excess kurtosis. + skew = float(stats.skew(arr)) + kurt = float(stats.kurtosis(arr)) # excess kurtosis (0 == normal) + skew_ok = abs(skew) < 1.0 + kurt_ok = abs(kurt) < 3.5 + detail["shape"] = {"skewness": skew, "kurtosis": kurt, + "skew_ok": skew_ok, "kurt_ok": kurt_ok} + passed += int(skew_ok) + int(kurt_ok) + total += 2 + + is_normal = total > 0 and passed >= (total / 2) + confidence = "High" if passed >= total - 1 else "Medium" if passed >= total / 2 else "Low" + + if is_normal: + rec = "Data appears normally distributed. Standard Cp/Cpk and all zone tests are valid." + elif abs(skew) > 1.0: + rec = ("Data is skewed. Try Box-Cox/log transformation; if that fails, use the " + "Wheeler individuals path (points-outside-limits only) with percentile Cpk.") + else: + rec = "Data is mildly non-normal. Interpret capability with caution or transform." + + return NormalityResult( + n=n, is_normal=is_normal, tests_passed=passed, total_tests=total, + confidence=confidence, shapiro_stat=sw_stat, shapiro_p=sw_p, + anderson_stat=ad_stat, anderson_critical=ad_crit, ks_stat=ks_stat, ks_p=ks_p, + skewness=skew, kurtosis=kurt, recommendation=rec, detail=detail, + ) + + +def autocorrelation(values, max_lag: int = 1) -> list[float]: + """Sample autocorrelation function (ACF) up to ``max_lag``, computed in numpy.""" + arr = _clean(values) + n = arr.size + if n < 2: + return [1.0] + [0.0] * max_lag + arr = arr - arr.mean() + denom = float(np.sum(arr ** 2)) + if denom == 0: + return [1.0] + [0.0] * max_lag + acf = [] + for lag in range(0, max_lag + 1): + num = float(np.sum(arr[: n - lag] * arr[lag:])) + acf.append(num / denom) + return acf + + +@dataclass +class AutocorrelationResult: + lag1: float + threshold: float + is_autocorrelated: bool + recommendation: str + + +def check_autocorrelation(values, threshold: float = 0.2) -> AutocorrelationResult: + """Gate before Shewhart charting. |lag-1 ACF| > threshold => use EWMA/CUSUM.""" + acf = autocorrelation(values, max_lag=1) + lag1 = acf[1] if len(acf) > 1 else 0.0 + is_ac = abs(lag1) > threshold + rec = ( + "Autocorrelation detected. Standard Shewhart limits are invalid; " + "route to EWMA (lambda=0.2) or CUSUM (k=0.5, h=5)." + if is_ac + else "No significant autocorrelation. Shewhart charts are appropriate." + ) + return AutocorrelationResult(lag1=lag1, threshold=threshold, is_autocorrelated=is_ac, recommendation=rec) + + +@dataclass +class TransformResult: + applied: str # "NONE" | "LOG" | "BOXCOX" | "YEO-JOHNSON" + label: str # human-readable, includes lambda where relevant + values: np.ndarray + lam: float | None = None + became_normal: bool | None = None + + +def apply_transform(values, method: str = "auto", alpha: float = 0.05) -> TransformResult: + """Attempt a normalizing transform. + + method: "auto" (choose based on positivity), "log", "boxcox", "yeo-johnson", "none". + """ + arr = _clean(values) + method = method.lower() + if arr.size < 2 and method != "none": + raise ValueError( + f"apply_transform() requires at least 2 finite values; got {arr.size}." + ) + + if method == "none": + return TransformResult(applied="NONE", label="No transform", values=arr, became_normal=None) + + all_positive = bool(np.all(arr > 0)) + + if method == "auto": + method = "boxcox" if all_positive else "yeo-johnson" + + if method == "log": + if not all_positive: + # log1p keeps zeros usable; negatives are undefined. + if np.any(arr < 0): + return TransformResult(applied="NONE", label="Log requires non-negative data", + values=arr, became_normal=None) + out = np.log1p(arr) + label = "log(x+1)" + else: + out = np.log(arr) + label = "log(x)" + lam = None + elif method == "boxcox": + if not all_positive: + method = "yeo-johnson" + else: + out, lam = stats.boxcox(arr) + out = np.asarray(out, dtype=float) + label = f"Box-Cox(lambda={lam:.3f})" + if method == "yeo-johnson": + out, lam = stats.yeojohnson(arr) + out = np.asarray(out, dtype=float) + label = f"Yeo-Johnson(lambda={lam:.3f})" + + applied = {"log": "LOG", "boxcox": "BOXCOX", "yeo-johnson": "YEO-JOHNSON"}[method] + became_normal = check_normality(out, alpha=alpha).is_normal + return TransformResult(applied=applied, label=label, values=out, lam=lam, became_normal=became_normal) diff --git a/spc_core/pipeline.py b/spc_core/pipeline.py new file mode 100644 index 0000000..6700d9e --- /dev/null +++ b/spc_core/pipeline.py @@ -0,0 +1,509 @@ +"""Gated Phase I master pipeline (MVP S1 decision flow). + +Order: MSA gate → ACF → normality → multimodal STOP → transform/Wheeler → +outlier classification → missing-value handling → chart → freeze/version limits. + +Returns gates with status ok | warn | stop so callers can enforce go-live. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +import numpy as np + +from .charts import ControlChartResult, analyze_control_chart +from .cleaning import classify_missing, range_check +from .models import ChartType, DistributionFlag, Phase, QualityFlag +from .msa import GageRRResult, gage_resolution_gate, gage_rr_anova, ndc_gate +from .multimodal import MultimodalResult, check_multimodal +from .normality import ( + AutocorrelationResult, + NormalityResult, + TransformResult, + apply_transform, + check_autocorrelation, + check_normality, +) + +# Hard floor for computing any control limits at all (a moving range needs two points). +# This is *not* the Phase I adequacy threshold — that is phase1_checklist's job. +MIN_ESTABLISH_POINTS = 2 + + +@dataclass +class Gate: + step: str + status: str # "ok" | "warn" | "stop" + reason: str + detail: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class PipelineResult: + chart: ControlChartResult + gates: list[Gate] + normality: NormalityResult | None = None + autocorrelation: AutocorrelationResult | None = None + multimodal: MultimodalResult | None = None + transform: TransformResult | None = None + msa: GageRRResult | None = None + stopped: bool = False + frozen: bool = True + chart_route: str = "shewhart" + + @property + def limits_version(self) -> str: + return self.chart.limits.version + + def gate_status(self, step: str) -> str | None: + for g in self.gates: + if g.step == step: + return g.status + return None + + +def establish( + values, + *, + subgroup_ids=None, + sample_sizes=None, + opportunities=None, + chart_type: ChartType | None = None, + ruleset: str = "nelson", + acf_threshold: float = 0.2, + # Optional MSA inputs — when provided, MSA gate runs. + msa_parts=None, + msa_operators=None, + msa_measurements=None, + msa_tolerance: float | None = None, + gage_resolution: float | None = None, + # Missing-value reasons aligned with values (optional). + missing_reasons=None, + # Physical (low, high) measurement range; out-of-range points are measurement failures. + valid_range: tuple[float, float] | None = None, + # Prefer EWMA over CUSUM when autocorrelated. + autocorrelated_chart: str = "EWMA", + force_wheeler: bool = False, +) -> PipelineResult: + """Run the gated Phase I pipeline and return chart + gates.""" + gates: list[Gate] = [] + arr = np.asarray(values, dtype=float) + if arr.size < MIN_ESTABLISH_POINTS: + raise ValueError( + f"establish() requires at least {MIN_ESTABLISH_POINTS} observations to " + f"compute control limits; received {arr.size}." + ) + working = arr.copy() + dist_flag = DistributionFlag.NORMAL + transform_applied = None + transform: TransformResult | None = None + normality: NormalityResult | None = None + acf: AutocorrelationResult | None = None + multimodal: MultimodalResult | None = None + msa_result: GageRRResult | None = None + route = "shewhart" + active_ruleset = ruleset + active_chart = chart_type + # Determined up front from the caller's intent, because active_chart is mutated below. + # Attribute (count) data is binomial/Poisson, so the continuous-data machinery — + # Gaussian normality, Hartigan's dip test, and the EWMA/CUSUM reroute — does not + # apply to it. + is_attribute = ( + chart_type in (ChartType.P, ChartType.NP, ChartType.C, ChartType.U) + or sample_sizes is not None + or opportunities is not None + ) + + # ---- 1. MSA gate ---- + if msa_measurements is not None and msa_parts is not None and msa_operators is not None: + msa_result = gage_rr_anova( + msa_parts, msa_operators, msa_measurements, tolerance=msa_tolerance, + ) + ndc_ok, ndc_reason = ndc_gate(msa_result.ndc) + if msa_result.grr_percent > 30 or not ndc_ok: + gates.append(Gate( + step="msa", status="stop", + reason=( + f"MSA unacceptable: %GRR={msa_result.grr_percent:.1f}, " + f"NDC={msa_result.ndc}. {ndc_reason}" + ), + detail={"grr_percent": msa_result.grr_percent, "ndc": msa_result.ndc}, + )) + # Still produce a chart for diagnostics, but mark stopped. + elif msa_result.grr_percent >= 10: + gates.append(Gate( + step="msa", status="warn", + reason=f"MSA conditional: %GRR={msa_result.grr_percent:.1f} (10–30%). Document risk.", + detail={"grr_percent": msa_result.grr_percent, "ndc": msa_result.ndc}, + )) + else: + gates.append(Gate( + step="msa", status="ok", + reason=f"MSA acceptable: %GRR={msa_result.grr_percent:.1f}, NDC={msa_result.ndc}.", + detail={"grr_percent": msa_result.grr_percent, "ndc": msa_result.ndc}, + )) + if gage_resolution is not None and msa_tolerance is not None: + ok, reason = gage_resolution_gate(gage_resolution, msa_tolerance) + gates.append(Gate( + step="gage_resolution", status="ok" if ok else "stop", reason=reason, + )) + else: + gates.append(Gate( + step="msa", status="warn", + reason="MSA inputs not provided; proceeding without measurement-system gate.", + )) + + # ---- 1b. Physical range gate ---- + # An out-of-range reading (e.g. a -999 disconnected-sensor sentinel) is a measurement + # failure, not process variation. Blank it here so it cannot be charted as an OOC + # signal; the missing-value step below then classifies it as MISSING_SENSOR. + if valid_range is not None: + low, high = float(valid_range[0]), float(valid_range[1]) + out_of_range = [ + i for i, ok in enumerate(range_check(working, low, high)) + if not ok and not np.isnan(working[i]) + ] + if out_of_range: + reasons = ( + list(missing_reasons) if missing_reasons is not None + else [None] * int(working.size) + ) + for i in out_of_range: + working[i] = np.nan + reasons[i] = "sensor" + missing_reasons = reasons + gates.append(Gate( + step="range", + status="warn" if out_of_range else "ok", + reason=( + f"{len(out_of_range)} reading(s) outside physical range " + f"[{low}, {high}] blanked as measurement failures." + if out_of_range + else f"All readings within physical range [{low}, {high}]." + ), + detail={"n_out_of_range": len(out_of_range), "low": low, "high": high}, + )) + + # ---- 2. Missing-value classification ---- + if missing_reasons is not None or np.any(np.isnan(working)): + result = classify_missing(working, reasons=missing_reasons) + flags = result.flags + n_imputed = sum(1 for f in flags if f == QualityFlag.IMPUTED_LOCF) + n_excluded = sum( + 1 for f in flags + if f in (QualityFlag.EXCLUDED_MAINTENANCE, QualityFlag.EXCLUDED_INCOMPLETE, + QualityFlag.MISSING_SENSOR, QualityFlag.MISSING_HUMAN) + ) + working = np.asarray( + [float(v) if v is not None else np.nan for v in result.values], + dtype=float, + ) + gates.append(Gate( + step="missing", status="ok" if n_excluded == 0 else "warn", + reason=f"Missing classified: imputed={n_imputed}, excluded={n_excluded}.", + detail={"imputed": n_imputed, "excluded": n_excluded}, + )) + else: + gates.append(Gate(step="missing", status="ok", reason="No missing values.")) + + clean = working[~np.isnan(working)] + if clean.size < MIN_ESTABLISH_POINTS: + raise ValueError( + f"establish() requires at least {MIN_ESTABLISH_POINTS} usable observations " + f"to compute control limits; only {clean.size} of {arr.size} values remain " + "after missing-value classification. Whether there are *enough* points for " + "Phase I is judged separately by phase1_checklist()." + ) + + # ---- 3. Autocorrelation ---- + acf = check_autocorrelation(clean, threshold=acf_threshold) + if acf.is_autocorrelated and is_attribute: + # Flag it, but keep the attribute chart: an EWMA on raw counts would throw away + # the per-point binomial/Poisson limits that P and U charts depend on. + gates.append(Gate( + step="autocorrelation", status="warn", + reason=( + f"Autocorrelation detected (lag-1={acf.lag1:.3f}) in attribute data. " + "Investigate serial dependence in the count process; the chart stays " + "on binomial/Poisson limits rather than rerouting to EWMA/CUSUM." + ), + detail={"lag1": acf.lag1, "route": "attribute", "reroute_suppressed": True}, + )) + elif acf.is_autocorrelated: + route = autocorrelated_chart.upper() + active_chart = ChartType.EWMA if route == "EWMA" else ChartType.CUSUM + gates.append(Gate( + step="autocorrelation", status="warn", + reason=acf.recommendation, + detail={"lag1": acf.lag1, "route": route}, + )) + else: + gates.append(Gate( + step="autocorrelation", status="ok", reason=acf.recommendation, + detail={"lag1": acf.lag1}, + )) + + # ---- 4. Normality + multimodal (only if not already routed to EWMA/CUSUM) ---- + if is_attribute: + gates.append(Gate( + step="normality", status="ok", + reason=( + "Attribute (count) data: binomial/Poisson limits apply, so the normality " + "and dip tests are not applicable and were skipped." + ), + detail={"skipped": True, "reason_code": "attribute_data"}, + )) + elif active_chart not in (ChartType.EWMA, ChartType.CUSUM): + multimodal = check_multimodal(clean) + if multimodal.is_multimodal: + gates.append(Gate( + step="multimodal", status="stop", reason=multimodal.recommendation, + detail={"dip": multimodal.dip_statistic, "p": multimodal.p_value}, + )) + else: + gates.append(Gate( + step="multimodal", status="ok", reason=multimodal.recommendation, + )) + + normality = check_normality(clean) + if normality.is_normal: + gates.append(Gate( + step="normality", status="ok", reason=normality.recommendation, + )) + dist_flag = DistributionFlag.NORMAL + else: + transform = apply_transform(clean, method="auto") + if transform.became_normal: + clean = transform.values + working = transform.values + # Transform may drop NaNs / change length — clear parallel + # subgroup metadata so analyze_control_chart lengths match. + if subgroup_ids is not None and len(subgroup_ids) != len(clean): + subgroup_ids = None + if sample_sizes is not None and len(sample_sizes) != len(clean): + sample_sizes = None + if opportunities is not None and len(opportunities) != len(clean): + opportunities = None + dist_flag = DistributionFlag.TRANSFORMED + transform_applied = transform.label + gates.append(Gate( + step="normality", status="ok", + reason=f"Non-normal; transform {transform.label} restored normality.", + detail={"transform": transform.applied, "lambda": transform.lam}, + )) + else: + # Non-normal after transform: Wheeler robust path (points-outside only). + # Never flatten subgrouped Phase I data to I-MR — that destroys + # within-subgroup variance structure. Keep Xbar-R/S and only switch + # the ruleset. force_wheeler forces I-MR only when there are no subgroups. + active_ruleset = "wheeler" + dist_flag = DistributionFlag.NON_NORMAL_RAW + route = "wheeler" + if subgroup_ids is not None: + chart_note = "subgroup chart preserved; Wheeler ruleset only" + elif force_wheeler or active_chart is None: + active_chart = ChartType.I_MR + chart_note = "I-MR, points-outside-limits only" + else: + chart_note = f"{active_chart.value}, Wheeler ruleset" + gates.append(Gate( + step="normality", status="warn", + reason=( + "Non-normal after transform; using Wheeler robust path " + f"({chart_note})." + ), + detail={ + "transform_tried": transform.applied if transform else None, + "force_wheeler": force_wheeler, + "subgroup_preserved": subgroup_ids is not None, + }, + )) + + # ---- 5. Chart (always produced for diagnostics, even when STOP gates fired) ---- + chart = analyze_control_chart( + clean if dist_flag == DistributionFlag.TRANSFORMED else working[~np.isnan(working)], + subgroup_ids=subgroup_ids, + sample_sizes=sample_sizes, + opportunities=opportunities, + chart_type=active_chart, + ruleset=active_ruleset, + ) + chart.distribution_flag = dist_flag + chart.transform_applied = transform_applied + chart.phase = Phase.PHASE_I + + gates.append(Gate( + step="chart", status="ok", + reason=f"Chart {chart.chart_type.value} established; limits version {chart.limits.version}.", + detail={ + "chart_type": chart.chart_type.value, + "limits_version": chart.limits.version, + "n_points": len(chart.plotted_values), + "ooc": chart.out_of_control_count, + "route": route, + }, + )) + + # ---- 6. Freeze gate — STOP blocks freezing ---- + stopped = any(g.status == "stop" for g in gates) + frozen = not stopped + if frozen: + gates.append(Gate( + step="freeze", status="ok", + reason=f"Phase I limits frozen with version hash {chart.limits.version}.", + detail={"limits_version": chart.limits.version, "frozen": True}, + )) + else: + stop_steps = [g.step for g in gates if g.status == "stop"] + gates.append(Gate( + step="freeze", status="blocked", + reason=( + "Phase I limits NOT frozen: STOP gate(s) fired " + f"({', '.join(stop_steps)}). Chart is diagnostic only." + ), + detail={ + "limits_version": chart.limits.version, + "frozen": False, + "stop_steps": stop_steps, + }, + )) + + return PipelineResult( + chart=chart, gates=gates, normality=normality, autocorrelation=acf, + multimodal=multimodal, transform=transform, msa=msa_result, + stopped=stopped, frozen=frozen, chart_route=route, + ) + + +def phase1_checklist( + pipeline: PipelineResult, + *, + min_subgroups: int = 25, + gage_calibration_ok: bool = True, + phase2_enabled: bool = False, + outliers_investigated: bool = True, +) -> dict[str, Any]: + """MVP S8 Phase I go-live checklist — 10 items, pass/fail with reasons.""" + items: list[dict[str, Any]] = [] + + def _add(name: str, passed: bool, reason: str) -> None: + items.append({"item": name, "passed": passed, "reason": reason}) + + # 1. MSA + msa_gate = next((g for g in pipeline.gates if g.step == "msa"), None) + msa_ok = msa_gate is not None and msa_gate.status == "ok" + if pipeline.msa is not None: + _add( + "msa_grr_ndc", + pipeline.msa.grr_percent < 10 and pipeline.msa.ndc >= 5, + f"%GRR={pipeline.msa.grr_percent:.1f}, NDC={pipeline.msa.ndc}", + ) + else: + _add("msa_grr_ndc", False, "MSA not run — required before go-live.") + + # 2. Normality / distribution path + norm_gate = next((g for g in pipeline.gates if g.step == "normality"), None) + _add( + "normality_path", + norm_gate is None or norm_gate.status in ("ok", "warn"), + norm_gate.reason if norm_gate else "Skipped (EWMA/CUSUM route).", + ) + + # 3. ACF + acf_gate = next((g for g in pipeline.gates if g.step == "autocorrelation"), None) + _add( + "autocorrelation", + acf_gate is not None and acf_gate.status in ("ok", "warn"), + acf_gate.reason if acf_gate else "ACF not checked.", + ) + + # 4. Outliers investigated + _add( + "outliers_investigated", + outliers_investigated, + "Outliers investigated with root-cause notes." + if outliers_investigated else "Outliers not yet investigated.", + ) + + # 5. Missing data classified + miss_gate = next((g for g in pipeline.gates if g.step == "missing"), None) + _add( + "missing_classified", + miss_gate is not None and miss_gate.status in ("ok", "warn"), + miss_gate.reason if miss_gate else "Missing-value step not run.", + ) + + # 6. Min 25 subgroups / points + n_pts = len(pipeline.chart.plotted_values) + _add( + "min_subgroups", + n_pts >= min_subgroups, + f"{n_pts} points/subgroups (need >= {min_subgroups}).", + ) + + # 7. Limits frozen with version hash (blocked when STOP gates fired) + _add( + "limits_frozen", + pipeline.frozen and bool(pipeline.chart.limits.version), + ( + f"Limits version {pipeline.chart.limits.version}." + if pipeline.frozen + else "Limits not frozen — STOP gate(s) blocked Phase I freeze." + ), + ) + + # 8. Transform documented + if pipeline.chart.distribution_flag.value == "TRANSFORMED": + _add( + "transform_documented", + bool(pipeline.chart.transform_applied), + f"Transform: {pipeline.chart.transform_applied}", + ) + else: + _add("transform_documented", True, "No transform applied (or Wheeler/EWMA path).") + + # 9. Gage calibration + _add( + "gage_calibration", + gage_calibration_ok, + "Gage calibration within active interval." + if gage_calibration_ok else "Gage calibration expired or unknown.", + ) + + # 10. Phase II monitoring enabled + _add( + "phase2_enabled", + phase2_enabled, + "Phase II monitoring enabled." + if phase2_enabled else "Phase II not yet enabled — freeze & go-live required.", + ) + + # Multimodal stop + mm = next((g for g in pipeline.gates if g.step == "multimodal"), None) + if mm and mm.status == "stop": + _add("stratification", False, mm.reason) + + all_pass = all(i["passed"] for i in items) + return { + "passed": all_pass, + "items": items, + "limits_version": pipeline.chart.limits.version, + "stopped": pipeline.stopped, + "msa_ok": msa_ok, + } + + +def checklist_ready_for_golive(checklist: dict[str, Any]) -> bool: + """True when checklist items other than ``phase2_enabled`` pass. + + Analyze always runs the checklist with ``phase2_enabled=False``, so the + aggregate ``passed`` flag stays False until go-live. Use this for the + persisted go-live gate instead. + """ + items = checklist.get("items") or [] + return all( + bool(i.get("passed")) + for i in items + if i.get("item") != "phase2_enabled" + ) diff --git a/spc_core/report.py b/spc_core/report.py new file mode 100644 index 0000000..09e0b92 --- /dev/null +++ b/spc_core/report.py @@ -0,0 +1,242 @@ +"""Pure data report models — no HTML, no I/O. + +Adapters (render_plotly, persistence) consume these. Keeping reports as data means +the same analysis result can be rendered as HTML, JSON, or stored without re-running +the statistics. +""" +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from pydantic import BaseModel, Field + +from .capability import CapabilityResult +from .charts import ControlChartResult +from .models import ChartType, ControlLimits, Phase, Signal +from .msa import BiasResult, GageRRResult, LinearityResult, StabilityResult +from .normality import AutocorrelationResult, NormalityResult + + +class SPCReport(BaseModel): + """Serializable SPC analysis report.""" + + analysis_type: str = "control_chart" + chart_type: ChartType + phase: Phase = Phase.PHASE_I + limits: ControlLimits + plotted_values: list[float] + secondary_values: list[float] | None = None + secondary_name: str | None = None + signals: list[Signal] = Field(default_factory=list) + subgroup_size: int = 1 + summary: dict[str, Any] = Field(default_factory=dict) + normality: dict[str, Any] | None = None + autocorrelation: dict[str, Any] | None = None + gates: list[dict[str, Any]] | None = None + checklist: dict[str, Any] | None = None + records: list[dict[str, Any]] | None = None + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + source_file: str | None = None + + @classmethod + def from_chart_result( + cls, + result: ControlChartResult, + normality: NormalityResult | None = None, + autocorrelation: AutocorrelationResult | None = None, + source_file: str | None = None, + phase: Phase = Phase.PHASE_I, + gates: list | None = None, + checklist: dict | None = None, + include_records: bool = False, + ) -> SPCReport: + records = None + if include_records: + records = [r.model_dump(mode="json") for r in result.to_records()] + return cls( + chart_type=result.chart_type, + phase=phase, + limits=result.limits, + plotted_values=result.plotted_values, + secondary_values=result.secondary_values, + secondary_name=result.secondary_name, + signals=result.signals, + subgroup_size=result.subgroup_size, + summary={ + **result.summary, + "out_of_control_count": result.out_of_control_count, + "data_type": result.data_type.value, + "distribution_flag": result.distribution_flag.value, + "transform_applied": result.transform_applied, + }, + normality=_normality_dict(normality), + autocorrelation=_acf_dict(autocorrelation), + gates=[ + {"step": g.step, "status": g.status, "reason": g.reason, "detail": g.detail} + if hasattr(g, "step") else g + for g in (gates or []) + ] or None, + checklist=checklist, + records=records, + source_file=source_file, + ) + + +class CapabilityReport(BaseModel): + analysis_type: str = "capability" + result: dict[str, Any] + normality: dict[str, Any] | None = None + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + source_file: str | None = None + + @classmethod + def from_capability( + cls, + result: CapabilityResult, + normality: NormalityResult | None = None, + source_file: str | None = None, + ) -> CapabilityReport: + return cls( + result=_capability_dict(result), + normality=_normality_dict(normality), + source_file=source_file, + ) + + +class MSAReport(BaseModel): + analysis_type: str = "msa" + study_type: str + result: dict[str, Any] + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + source_file: str | None = None + + @classmethod + def from_gage_rr(cls, result: GageRRResult, source_file: str | None = None) -> MSAReport: + return cls( + study_type="Gage R&R", + result={ + "method": result.method, + "n_parts": result.n_parts, + "n_operators": result.n_operators, + "n_trials": result.n_trials, + "grr_percent": result.grr_percent, + "part_percent": result.part_percent, + "ndc": result.ndc, + "acceptability": result.acceptability, + "grr_percent_tolerance": result.grr_percent_tolerance, + "var_repeatability": result.var_repeatability, + "var_reproducibility": result.var_reproducibility, + "var_gage_rr": result.var_gage_rr, + "var_part": result.var_part, + "var_total": result.var_total, + "detail": result.detail, + }, + source_file=source_file, + ) + + @classmethod + def from_bias(cls, result: BiasResult, source_file: str | None = None) -> MSAReport: + return cls( + study_type="Bias", + result={ + "mean_bias": result.mean_bias, + "std_bias": result.std_bias, + "percent_bias": result.percent_bias, + "t_statistic": result.t_statistic, + "p_value": result.p_value, + "is_significant": result.is_significant, + "n": result.n, + }, + source_file=source_file, + ) + + @classmethod + def from_linearity(cls, result: LinearityResult, source_file: str | None = None) -> MSAReport: + return cls( + study_type="Linearity", + result={ + "slope": result.slope, + "intercept": result.intercept, + "r_squared": result.r_squared, + "p_value": result.p_value, + "std_error": result.std_error, + "is_linear": result.is_linear, + }, + source_file=source_file, + ) + + @classmethod + def from_stability(cls, result: StabilityResult, source_file: str | None = None) -> MSAReport: + return cls( + study_type="Stability", + result={ + "mean": result.mean, + "std_dev": result.std_dev, + "ucl": result.ucl, + "lcl": result.lcl, + "out_of_control_points": result.out_of_control_points, + "has_trend": result.has_trend, + "is_stable": result.is_stable, + "n": result.n, + }, + source_file=source_file, + ) + + +def _normality_dict(n: NormalityResult | None) -> dict[str, Any] | None: + if n is None: + return None + return { + "is_normal": n.is_normal, + "tests_passed": n.tests_passed, + "total_tests": n.total_tests, + "confidence": n.confidence, + "shapiro_p": n.shapiro_p, + "anderson_stat": n.anderson_stat, + "skewness": n.skewness, + "kurtosis": n.kurtosis, + "recommendation": n.recommendation, + } + + +def _acf_dict(a: AutocorrelationResult | None) -> dict[str, Any] | None: + if a is None: + return None + return { + "lag1": a.lag1, + "threshold": a.threshold, + "is_autocorrelated": a.is_autocorrelated, + "recommendation": a.recommendation, + } + + +def _capability_dict(r: CapabilityResult) -> dict[str, Any]: + return { + "n": r.n, + "mean": r.mean, + "usl": r.usl, + "lsl": r.lsl, + "target": r.target, + "method": r.method, + "sigma_within": r.sigma_within, + "sigma_overall": r.sigma_overall, + "Cp": r.cp, + "Cpk": r.cpk, + "Cpu": r.cpu, + "Cpl": r.cpl, + "Cpm": r.cpm, + "Pp": r.pp, + "Ppk": r.ppk, + "Ppu": r.ppu, + "Ppl": r.ppl, + "observed_dpmo": r.observed_dpmo, + "expected_dpmo": r.expected_dpmo, + "z_bench": r.z_bench, + "sigma_level": r.sigma_level, + "yield_pct": r.yield_pct, + "is_centered": r.is_centered, + "offset_from_target": r.offset_from_target, + "rating": r.rating, + "notes": r.notes, + } diff --git a/spc_core/rules.py b/spc_core/rules.py new file mode 100644 index 0000000..6daa0e1 --- /dev/null +++ b/spc_core/rules.py @@ -0,0 +1,215 @@ +"""Stateful Nelson / Western Electric run-rule engine. + +The legacy engine only flagged points outside the control limits (Nelson rule 1). Real +SPC needs the pattern rules, which require *history*. This engine keeps a bounded ring +buffer of recent points and evaluates every rule against the window ending at the newest +point, so it works identically for a streamed point or a replayed batch. + +Rule severities/windows follow the standard Nelson set (1-8). Western Electric is the +classic subset (rules 1, 5, 6, and a "n-in-a-row on one side" run test). +""" +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass + +from .models import Signal + +# Nelson rule catalog: id -> (name, window length, description). +NELSON = { + "1": ("Beyond 3-sigma", 1, "One point beyond zone A (>3 sigma from center)"), + "2": ("Run on one side", 9, "Nine points in a row on the same side of the center line"), + "3": ("Trend", 6, "Six points in a row steadily increasing or decreasing"), + "4": ("Alternating", 14, "Fourteen points in a row alternating up and down"), + "5": ("2 of 3 beyond 2-sigma", 3, "Two out of three consecutive points beyond 2 sigma (same side)"), + "6": ("4 of 5 beyond 1-sigma", 5, "Four out of five consecutive points beyond 1 sigma (same side)"), + "7": ("Stratification", 15, "Fifteen points in a row within 1 sigma (both sides)"), + "8": ("Mixture", 8, "Eight points in a row beyond 1 sigma, none within zone C"), +} + +WESTERN_ELECTRIC = { + "WE1": ("Beyond 3-sigma", 1, "One point beyond 3 sigma"), + "WE2": ("2 of 3 beyond 2-sigma", 3, "Two of three consecutive points beyond 2 sigma (same side)"), + "WE3": ("4 of 5 beyond 1-sigma", 5, "Four of five consecutive points beyond 1 sigma (same side)"), + "WE4": ("8 on one side", 8, "Eight points in a row on the same side of the center line"), +} + + +@dataclass +class _Pt: + index: int + value: float + side: int # +1 above center, -1 below, 0 on center + zone: int # number of sigmas away, floored (0,1,2,3+) using abs distance + + +class RuleEngine: + """Incremental run-rule evaluator. + + Parameters + ---------- + center, sigma : the frozen center line and 1-sigma width of the plotted statistic. + ruleset : "nelson" (default) or "western_electric". + """ + + def __init__(self, center: float, sigma: float, ruleset: str = "nelson"): + self.center = center + self.sigma = sigma if sigma and sigma > 0 else 0.0 + # "nelson" | "western_electric" | "wheeler" (points-outside-limits only) + self.ruleset = ruleset + self._buf: deque[_Pt] = deque(maxlen=15) + self._i = -1 + + def _classify(self, value: float) -> _Pt: + self._i += 1 + if value > self.center: + side = 1 + elif value < self.center: + side = -1 + else: + side = 0 + if self.sigma > 0: + z = abs(value - self.center) / self.sigma + else: + z = 0.0 + # Inclusive zone boundaries: a point exactly on 3σ is zone 3 (fires rule 1). + zone = 3 if z >= 3 else 2 if z >= 2 else 1 if z >= 1 else 0 + return _Pt(index=self._i, value=value, side=side, zone=zone) + + def add(self, value: float) -> list[Signal]: + pt = self._classify(value) + self._buf.append(pt) + if self.ruleset == "western_electric": + return self._eval_we(pt) + if self.ruleset == "wheeler": + return self._eval_wheeler(pt) + return self._eval_nelson(pt) + + def _eval_wheeler(self, pt: _Pt) -> list[Signal]: + """Wheeler robust path: only points beyond 3σ (skip all zone/run tests).""" + if pt.zone >= 3 and self.sigma > 0: + return [self._sig("1", pt, side="upper" if pt.side > 0 else "lower")] + return [] + + # ---- Nelson ----------------------------------------------------------------- + def _eval_nelson(self, pt: _Pt) -> list[Signal]: + b = list(self._buf) + out: list[Signal] = [] + + # Rule 1: beyond 3 sigma. + if pt.zone >= 3 and self.sigma > 0: + out.append(self._sig("1", pt, side="upper" if pt.side > 0 else "lower")) + + # Rule 2: 9 in a row same side. + if self._run_same_side(b, 9): + out.append(self._sig("2", pt)) + + # Rule 3: 6 monotonic. + if self._monotonic(b, 6): + out.append(self._sig("3", pt)) + + # Rule 4: 14 alternating. + if self._alternating(b, 14): + out.append(self._sig("4", pt)) + + # Rule 5: 2 of 3 beyond 2 sigma, same side (window ends at current point). + if self.sigma > 0 and self._k_of_m_beyond(b, k=2, m=3, zone=2): + out.append(self._sig("5", pt)) + + # Rule 6: 4 of 5 beyond 1 sigma, same side. + if self.sigma > 0 and self._k_of_m_beyond(b, k=4, m=5, zone=1): + out.append(self._sig("6", pt)) + + # Rule 7: 15 within 1 sigma. + if self.sigma > 0 and self._run_within(b, 15, zone_lt=1): + out.append(self._sig("7", pt)) + + # Rule 8: 8 in a row beyond 1 sigma (either side, none within zone C). + if self.sigma > 0 and self._run_beyond(b, 8, zone_ge=1): + out.append(self._sig("8", pt)) + + return out + + # ---- Western Electric ------------------------------------------------------- + def _eval_we(self, pt: _Pt) -> list[Signal]: + b = list(self._buf) + out: list[Signal] = [] + if pt.zone >= 3 and self.sigma > 0: + out.append(self._sig("WE1", pt, side="upper" if pt.side > 0 else "lower", + catalog=WESTERN_ELECTRIC)) + if self.sigma > 0 and self._k_of_m_beyond(b, k=2, m=3, zone=2): + out.append(self._sig("WE2", pt, catalog=WESTERN_ELECTRIC)) + if self.sigma > 0 and self._k_of_m_beyond(b, k=4, m=5, zone=1): + out.append(self._sig("WE3", pt, catalog=WESTERN_ELECTRIC)) + if self._run_same_side(b, 8): + out.append(self._sig("WE4", pt, catalog=WESTERN_ELECTRIC)) + return out + + # ---- window predicates ------------------------------------------------------ + @staticmethod + def _run_same_side(b: list[_Pt], length: int) -> bool: + if len(b) < length: + return False + tail = b[-length:] + first = tail[0].side + return first != 0 and all(p.side == first for p in tail) + + @staticmethod + def _monotonic(b: list[_Pt], length: int) -> bool: + if len(b) < length: + return False + tail = [p.value for p in b[-length:]] + inc = all(tail[i] < tail[i + 1] for i in range(len(tail) - 1)) + dec = all(tail[i] > tail[i + 1] for i in range(len(tail) - 1)) + return inc or dec + + @staticmethod + def _alternating(b: list[_Pt], length: int) -> bool: + if len(b) < length: + return False + tail = [p.value for p in b[-length:]] + diffs = [tail[i + 1] - tail[i] for i in range(len(tail) - 1)] + if any(d == 0 for d in diffs): + return False + return all((diffs[i] > 0) != (diffs[i + 1] > 0) for i in range(len(diffs) - 1)) + + @staticmethod + def _k_of_m_beyond(b: list[_Pt], k: int, m: int, zone: int) -> bool: + """k of the last m points beyond `zone` sigma on the SAME side, current point included.""" + if len(b) < m: + return False + tail = b[-m:] + if tail[-1].zone < zone: + return False # attribute to the current point only when it participates + for side in (1, -1): + cnt = sum(1 for p in tail if p.side == side and p.zone >= zone) + if cnt >= k and tail[-1].side == side: + return True + return False + + @staticmethod + def _run_within(b: list[_Pt], length: int, zone_lt: int) -> bool: + if len(b) < length: + return False + return all(p.zone < zone_lt for p in b[-length:]) + + @staticmethod + def _run_beyond(b: list[_Pt], length: int, zone_ge: int) -> bool: + if len(b) < length: + return False + return all(p.zone >= zone_ge for p in b[-length:]) + + def _sig(self, rule_id: str, pt: _Pt, side: str | None = None, catalog=None) -> Signal: + catalog = catalog or NELSON + name, _, desc = catalog[rule_id] + return Signal(rule_id=rule_id, rule_name=name, index=pt.index, + value=pt.value, description=desc, side=side) + + +def evaluate_series(values, center: float, sigma: float, ruleset: str = "nelson") -> list[Signal]: + """Replay a whole series through the stateful engine (batch convenience).""" + engine = RuleEngine(center=center, sigma=sigma, ruleset=ruleset) + signals: list[Signal] = [] + for v in values: + signals.extend(engine.add(float(v))) + return signals diff --git a/temp_uploads/capability_excellent.csv b/temp_uploads/capability_excellent.csv deleted file mode 100644 index 35990d2..0000000 --- a/temp_uploads/capability_excellent.csv +++ /dev/null @@ -1,52 +0,0 @@ -measurement -10.05 -10.08 -10.02 -10.06 -10.04 -10.07 -10.03 -10.05 -10.06 -10.04 -10.08 -10.02 -10.05 -10.07 -10.03 -10.06 -10.04 -10.05 -10.07 -10.03 -10.05 -10.06 -10.04 -10.08 -10.02 -10.05 -10.07 -10.03 -10.06 -10.04 -10.05 -10.07 -10.03 -10.05 -10.06 -10.04 -10.08 -10.02 -10.05 -10.07 -10.03 -10.06 -10.04 -10.05 -10.07 -10.03 -10.05 -10.06 -10.04 -10.05 - diff --git a/temp_uploads/capability_excellent_capability_report.html b/temp_uploads/capability_excellent_capability_report.html deleted file mode 100644 index 69004d7..0000000 --- a/temp_uploads/capability_excellent_capability_report.html +++ /dev/null @@ -1,133 +0,0 @@ - - - - Process Capability Analysis Report - - - -

    Process Capability Analysis Report

    - -
    -

    Specifications

    -

    USL: 10.5

    -

    LSL: 9.5

    -

    Target: 10.0

    -

    Tolerance: 1.0

    -
    - -
    -

    ⚠️ Normality Test Results

    -

    Is Normal: - ✓ YES -

    -

    Confidence: Medium

    -

    Tests Passed: 3/5

    -

    Recommendation: Data appears normally distributed. Cp/Cpk calculations are valid.

    -
    - Click for detailed test results -
      -
    • Anderson-Darling: ✗ Fail - (Stat: 0.8368131002127726)
    • -
    • Shapiro-Wilk: ✗ Fail - (p-value: 0.028747811832861007)
    • -
    • Skewness: -0.000 - (✓ Acceptable)
    • -
    • Kurtosis: -0.859 - (✓ Acceptable)
    • -
    -
    -
    - -
    -

    Capability Indices

    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    IndexValueInterpretation
    Cp (Potential Capability)9.722 - Excellent -
    Cpk (Actual Capability)8.750 - Excellent -
    Pp (Potential Performance)9.722 - Excellent -
    Ppk (Actual Performance)8.750 - Excellent -
    -
    - -
    -

    Process Performance

    -

    Mean: 10.0500

    -

    Std Dev: 0.0171

    -

    Yield: 100.00%

    -

    DPMO: 0

    -

    Defects: 0 out of 50

    -
    - -
    -

    Process Centering

    -

    Process Mean: 10.0500

    -

    Spec Midpoint: 10.0000

    -

    Offset: 0.0500

    -

    Status: Process is well centered

    -
    - -
    -

    Visualizations

    - - - -
    -
    - - -
    - -
    -

    Overall Assessment

    -

    ✓ Process is capable (Cpk=8.75) | ✓ Process performance is good (Ppk=8.75) | ✓ Excellent yield (100.00%) | ✓ Process is well centered

    -
    - -
    -

    Capability Criteria Guide

    -
      -
    • Cp/Cpk ≥ 1.33: Excellent - Six Sigma capable
    • -
    • Cp/Cpk ≥ 1.00: Adequate - Meets minimum requirements
    • -
    • Cp/Cpk < 1.00: Poor - Process needs improvement
    • -
    -
    - - - \ No newline at end of file diff --git a/temp_uploads/msa_gage_rr_excellent.csv b/temp_uploads/msa_gage_rr_excellent.csv deleted file mode 100644 index beaef67..0000000 --- a/temp_uploads/msa_gage_rr_excellent.csv +++ /dev/null @@ -1,62 +0,0 @@ -Part,Operator,Trial,Measurement -1,A,1,10.12 -1,A,2,10.15 -1,B,1,10.08 -1,B,2,10.14 -1,C,1,10.18 -1,C,2,10.16 -2,A,1,9.95 -2,A,2,9.98 -2,B,1,9.92 -2,B,2,9.96 -2,C,1,10.02 -2,C,2,10.00 -3,A,1,11.25 -3,A,2,11.28 -3,B,1,11.22 -3,B,2,11.27 -3,C,1,11.30 -3,C,2,11.28 -4,A,1,10.55 -4,A,2,10.58 -4,B,1,10.52 -4,B,2,10.56 -4,C,1,10.60 -4,C,2,10.58 -5,A,1,9.35 -5,A,2,9.38 -5,B,1,9.32 -5,B,2,9.36 -5,C,1,9.40 -5,C,2,9.38 -6,A,1,10.75 -6,A,2,10.78 -6,B,1,10.72 -6,B,2,10.76 -6,C,1,10.80 -6,C,2,10.78 -7,A,1,11.45 -7,A,2,11.48 -7,B,1,11.42 -7,B,2,11.46 -7,C,1,11.50 -7,C,2,11.48 -8,A,1,9.65 -8,A,2,9.68 -8,B,1,9.62 -8,B,2,9.66 -8,C,1,9.70 -8,C,2,9.68 -9,A,1,10.85 -9,A,2,10.88 -9,B,1,10.82 -9,B,2,10.86 -9,C,1,10.90 -9,C,2,10.88 -10,A,1,11.15 -10,A,2,11.18 -10,B,1,11.12 -10,B,2,11.16 -10,C,1,11.20 -10,C,2,11.18 - diff --git a/temp_uploads/msa_gage_rr_excellent_msa_report.html b/temp_uploads/msa_gage_rr_excellent_msa_report.html deleted file mode 100644 index 9191735..0000000 --- a/temp_uploads/msa_gage_rr_excellent_msa_report.html +++ /dev/null @@ -1,36 +0,0 @@ - - - - MSA Report - Gage R&R (ANOVA) - - - -

    MSA Report: Gage R&R (ANOVA)

    - -
    -

    Summary

    -
    {'study_type': 'Gage R&R (ANOVA)', 'n_parts': 10, 'n_operators': 3, 'n_trials': 2, 'variance_components': {'repeatability': 0.0, 'reproducibility': 0.0, 'gage_rr': 0.0, 'part_to_part': 9.166666666666666, 'total_variation': 9.166666666666666}, 'standard_deviations': {'repeatability': 0.0, 'reproducibility': 0.0, 'gage_rr': 0.0, 'part_to_part': 3.0276503540974917, 'total': 3.0276503540974917}, 'study_variation': {'repeatability': 0.0, 'reproducibility': 0.0, 'gage_rr': 0.0, 'part_to_part': 18.16590212458495, 'total': 18.16590212458495}, 'percent_contribution': {'repeatability': 0.0, 'reproducibility': 0.0, 'gage_rr': 0.0, 'part_to_part': 100.0}, 'percent_study_variation': {'repeatability': 0.0, 'reproducibility': 0.0, 'gage_rr': 0.0}, 'percent_tolerance': None, 'ndc': 0, 'acceptability': 'Excellent', 'interpretation': {'gage_rr': 'Excellent - 0.0% of total variation', 'ndc': 'Inadequate - 0 distinct categories'}}
    -
    - -
    -

    Visualization

    - - - -
    -
    - - -
    - - - \ No newline at end of file diff --git a/temp_uploads/spc_c_chart_data.csv b/temp_uploads/spc_c_chart_data.csv deleted file mode 100644 index bb4eadc..0000000 --- a/temp_uploads/spc_c_chart_data.csv +++ /dev/null @@ -1,22 +0,0 @@ -sample,defects -1,5 -2,8 -3,6 -4,7 -5,9 -6,5 -7,4 -8,6 -9,8 -10,7 -11,10 -12,6 -13,5 -14,7 -15,8 -16,6 -17,5 -18,9 -19,7 -20,6 - diff --git a/temp_uploads/spc_c_chart_data_control_chart_report.html b/temp_uploads/spc_c_chart_data_control_chart_report.html deleted file mode 100644 index e9418e9..0000000 --- a/temp_uploads/spc_c_chart_data_control_chart_report.html +++ /dev/null @@ -1,53 +0,0 @@ - - - - Control Chart Analysis Report - - - -

    Control Chart Analysis Report

    - -
    -

    Summary

    - - - - - - -
    Chart TypeC
    Data Typeattribute
    Sample Size20
    Mean6.7000
    Standard Deviation1.5927
    -
    - -
    -

    Control Chart

    - - - -
    -
    - - -
    - -
    -

    Control Limits

    -
    {'defects': {'UCL': np.float64(14.46530746332687), 'LCL': 0, 'center': np.float64(6.7)}}
    -
    - -
    -

    Out of Control Points

    -

    Number of out-of-control points: 0

    -

    No out-of-control points detected

    -
    - - - \ No newline at end of file diff --git a/temp_uploads/spc_individual_in_control.csv b/temp_uploads/spc_individual_in_control.csv deleted file mode 100644 index a1d7fae..0000000 --- a/temp_uploads/spc_individual_in_control.csv +++ /dev/null @@ -1,42 +0,0 @@ -measurement -100.12 -99.85 -100.23 -99.92 -100.15 -99.88 -100.05 -100.18 -99.95 -100.08 -100.22 -99.78 -100.12 -100.05 -99.92 -100.18 -99.85 -100.15 -100.02 -99.95 -100.08 -100.12 -99.88 -100.05 -99.98 -100.15 -100.22 -99.85 -100.08 -99.95 -100.12 -100.05 -99.92 -100.18 -99.88 -100.15 -100.02 -99.95 -100.08 -100.12 - diff --git a/temp_uploads/spc_individual_in_control_control_chart_report.html b/temp_uploads/spc_individual_in_control_control_chart_report.html deleted file mode 100644 index f32a404..0000000 --- a/temp_uploads/spc_individual_in_control_control_chart_report.html +++ /dev/null @@ -1,53 +0,0 @@ - - - - Control Chart Analysis Report - - - -

    Control Chart Analysis Report

    - -
    -

    Summary

    - - - - - - -
    Chart TypeI-MR
    Data Typecontinuous
    Sample Size40
    Mean100.0370
    Standard Deviation0.1219
    -
    - -
    -

    Control Chart

    - - - -
    -
    - - -
    - -
    -

    Control Limits

    -
    {'individuals': {'UCL': np.float64(100.54990256410258), 'LCL': np.float64(99.52409743589743), 'center': np.float64(100.037)}, 'moving_range': {'UCL': np.float64(0.6305230769230873), 'LCL': 0, 'center': np.float64(0.192820512820516)}}
    -
    - -
    -

    Out of Control Points

    -

    Number of out-of-control points: 0

    -

    No out-of-control points detected

    -
    - - - \ No newline at end of file diff --git a/temp_uploads/spc_individual_out_of_control.csv b/temp_uploads/spc_individual_out_of_control.csv deleted file mode 100644 index 8271f24..0000000 --- a/temp_uploads/spc_individual_out_of_control.csv +++ /dev/null @@ -1,32 +0,0 @@ -measurement -100.12 -99.85 -100.23 -99.92 -100.15 -99.88 -100.05 -100.18 -99.95 -100.08 -100.22 -99.78 -100.12 -100.05 -99.92 -105.50 -106.20 -105.85 -106.10 -105.95 -100.08 -100.12 -99.88 -100.05 -99.98 -100.15 -100.22 -99.85 -100.08 -99.95 - diff --git a/temp_uploads/spc_individual_out_of_control_control_chart_report.html b/temp_uploads/spc_individual_out_of_control_control_chart_report.html deleted file mode 100644 index df01bad..0000000 --- a/temp_uploads/spc_individual_out_of_control_control_chart_report.html +++ /dev/null @@ -1,53 +0,0 @@ - - - - Control Chart Analysis Report - - - -

    Control Chart Analysis Report

    - -
    -

    Summary

    - - - - - - -
    Chart TypeI-MR
    Data Typecontinuous
    Sample Size30
    Mean101.0153
    Standard Deviation2.2363
    -
    - -
    -

    Control Chart

    - - - -
    -
    - - -
    - -
    -

    Control Limits

    -
    {'individuals': {'UCL': np.float64(102.63242988505746), 'LCL': np.float64(99.39823678160917), 'center': np.float64(101.01533333333332)}, 'moving_range': {'UCL': np.float64(1.987934482758628), 'LCL': 0, 'center': np.float64(0.6079310344827609)}}
    -
    - -
    -

    Out of Control Points

    -

    Number of out-of-control points: 5

    -

    Point 16: Value 105.5000 - Outside control limits

    Point 17: Value 106.2000 - Outside control limits

    Point 18: Value 105.8500 - Outside control limits

    Point 19: Value 106.1000 - Outside control limits

    Point 20: Value 105.9500 - Outside control limits

    -
    - - - \ No newline at end of file diff --git a/temp_uploads/spc_np_chart_data.csv b/temp_uploads/spc_np_chart_data.csv deleted file mode 100644 index 975c62d..0000000 --- a/temp_uploads/spc_np_chart_data.csv +++ /dev/null @@ -1,22 +0,0 @@ -sample,sample_size,defectives -1,100,3 -2,100,5 -3,100,2 -4,100,4 -5,100,3 -6,100,6 -7,100,4 -8,100,2 -9,100,5 -10,100,3 -11,100,7 -12,100,4 -13,100,3 -14,100,5 -15,100,2 -16,100,4 -17,100,3 -18,100,6 -19,100,4 -20,100,3 - diff --git a/temp_uploads/spc_np_chart_data_control_chart_report.html b/temp_uploads/spc_np_chart_data_control_chart_report.html deleted file mode 100644 index a257227..0000000 --- a/temp_uploads/spc_np_chart_data_control_chart_report.html +++ /dev/null @@ -1,53 +0,0 @@ - - - - Control Chart Analysis Report - - - -

    Control Chart Analysis Report

    - -
    -

    Summary

    - - - - - - -
    Chart TypeNP
    Data Typeattribute
    Sample Size20
    Mean3.9000
    Standard Deviation1.4105
    -
    - -
    -

    Control Chart

    - - - -
    -
    - - -
    - -
    -

    Control Limits

    -
    {'np': {'UCL': np.float64(9.70784813851051), 'LCL': 0, 'center': np.float64(3.9)}}
    -
    - -
    -

    Out of Control Points

    -

    Number of out-of-control points: 0

    -

    No out-of-control points detected

    -
    - - - \ No newline at end of file diff --git a/temp_uploads/spc_p_chart_data.csv b/temp_uploads/spc_p_chart_data.csv deleted file mode 100644 index f48d781..0000000 --- a/temp_uploads/spc_p_chart_data.csv +++ /dev/null @@ -1,22 +0,0 @@ -sample,inspected,defective -1,100,5 -2,100,8 -3,100,6 -4,100,7 -5,100,9 -6,100,5 -7,100,4 -8,100,6 -9,100,8 -10,100,7 -11,100,10 -12,100,6 -13,100,5 -14,100,7 -15,100,8 -16,100,6 -17,100,5 -18,100,9 -19,100,7 -20,100,6 - diff --git a/temp_uploads/spc_p_chart_data_control_chart_report.html b/temp_uploads/spc_p_chart_data_control_chart_report.html deleted file mode 100644 index 20247cd..0000000 --- a/temp_uploads/spc_p_chart_data_control_chart_report.html +++ /dev/null @@ -1,53 +0,0 @@ - - - - Control Chart Analysis Report - - - -

    Control Chart Analysis Report

    - -
    -

    Summary

    - - - - - - -
    Chart TypeP
    Data Typeattribute
    Sample Size20
    Mean100.0000
    Standard Deviation0.0000
    -
    - -
    -

    Control Chart

    - - - -
    -
    - - -
    - -
    -

    Control Limits

    -
    {'proportion': {'UCL': [np.float64(nan), np.float64(nan), np.float64(nan), np.float64(nan), np.float64(nan), np.float64(nan), np.float64(nan), np.float64(nan), np.float64(nan), np.float64(nan), np.float64(nan), np.float64(nan), np.float64(nan), np.float64(nan), np.float64(nan), np.float64(nan), np.float64(nan), np.float64(nan), np.float64(nan), np.float64(nan)], 'LCL': [np.float64(nan), np.float64(nan), np.float64(nan), np.float64(nan), np.float64(nan), np.float64(nan), np.float64(nan), np.float64(nan), np.float64(nan), np.float64(nan), np.float64(nan), np.float64(nan), np.float64(nan), np.float64(nan), np.float64(nan), np.float64(nan), np.float64(nan), np.float64(nan), np.float64(nan), np.float64(nan)], 'center': np.float64(5.0)}}
    -
    - -
    -

    Out of Control Points

    -

    Number of out-of-control points: 0

    -

    No out-of-control points detected

    -
    - - - \ No newline at end of file diff --git a/temp_uploads/spc_subgroup_stable.csv b/temp_uploads/spc_subgroup_stable.csv deleted file mode 100644 index 96b2398..0000000 --- a/temp_uploads/spc_subgroup_stable.csv +++ /dev/null @@ -1,52 +0,0 @@ -subgroup,measurement -1,10.1 -1,10.2 -1,9.9 -1,10.0 -1,10.1 -2,10.0 -2,9.8 -2,10.2 -2,10.1 -2,10.0 -3,10.1 -3,10.0 -3,9.9 -3,10.1 -3,10.2 -4,9.9 -4,10.0 -4,10.1 -4,10.0 -4,9.8 -5,10.2 -5,10.1 -5,10.0 -5,9.9 -5,10.1 -6,10.0 -6,10.1 -6,9.9 -6,10.0 -6,10.2 -7,10.1 -7,10.0 -7,10.1 -7,9.9 -7,10.0 -8,9.8 -8,10.0 -8,10.1 -8,10.2 -8,10.0 -9,10.0 -9,10.1 -9,9.9 -9,10.0 -9,10.1 -10,10.2 -10,10.0 -10,10.1 -10,9.9 -10,10.0 - diff --git a/temp_uploads/spc_subgroup_stable_control_chart_report.html b/temp_uploads/spc_subgroup_stable_control_chart_report.html deleted file mode 100644 index 28e9f2b..0000000 --- a/temp_uploads/spc_subgroup_stable_control_chart_report.html +++ /dev/null @@ -1,53 +0,0 @@ - - - - Control Chart Analysis Report - - - -

    Control Chart Analysis Report

    - -
    -

    Summary

    - - - - - - -
    Chart TypeI-MR
    Data Typecontinuous
    Sample Size50
    Mean10.0300
    Standard Deviation0.1093
    -
    - -
    -

    Control Chart

    - - - -
    -
    - - -
    - -
    -

    Control Limits

    -
    {'individuals': {'UCL': np.float64(10.415428571428569), 'LCL': np.float64(9.64457142857143), 'center': np.float64(10.03)}, 'moving_range': {'UCL': np.float64(0.47381632653061057), 'LCL': 0, 'center': np.float64(0.14489795918367296)}}
    -
    - -
    -

    Out of Control Points

    -

    Number of out-of-control points: 0

    -

    No out-of-control points detected

    -
    - - - \ No newline at end of file diff --git a/temp_uploads/spc_u_chart_data.csv b/temp_uploads/spc_u_chart_data.csv deleted file mode 100644 index 346c8ae..0000000 --- a/temp_uploads/spc_u_chart_data.csv +++ /dev/null @@ -1,22 +0,0 @@ -sample,units,defects -1,10,5 -2,12,8 -3,15,12 -4,10,6 -5,20,15 -6,10,7 -7,15,10 -8,12,9 -9,10,5 -10,18,14 -11,10,8 -12,15,11 -13,12,7 -14,10,4 -15,20,16 -16,15,12 -17,10,6 -18,12,8 -19,15,10 -20,10,5 - diff --git a/temp_uploads/spc_u_chart_data_control_chart_report.html b/temp_uploads/spc_u_chart_data_control_chart_report.html deleted file mode 100644 index 385f851..0000000 --- a/temp_uploads/spc_u_chart_data_control_chart_report.html +++ /dev/null @@ -1,53 +0,0 @@ - - - - Control Chart Analysis Report - - - -

    Control Chart Analysis Report

    - -
    -

    Summary

    - - - - - - -
    Chart TypeU
    Data Typeattribute
    Sample Size20
    Mean8.9000
    Standard Deviation3.5229
    -
    - -
    -

    Control Chart

    - - - -
    -
    - - -
    - -
    -

    Control Limits

    -
    {'defects_per_unit': {'UCL': [np.float64(17.849860334105777), np.float64(17.849860334105777), np.float64(17.849860334105777), np.float64(17.849860334105777), np.float64(17.849860334105777), np.float64(17.849860334105777), np.float64(17.849860334105777), np.float64(17.849860334105777), np.float64(17.849860334105777), np.float64(17.849860334105777), np.float64(17.849860334105777), np.float64(17.849860334105777), np.float64(17.849860334105777), np.float64(17.849860334105777), np.float64(17.849860334105777), np.float64(17.849860334105777), np.float64(17.849860334105777), np.float64(17.849860334105777), np.float64(17.849860334105777), np.float64(17.849860334105777)], 'LCL': [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 'center': np.float64(8.9)}}
    -
    - -
    -

    Out of Control Points

    -

    Number of out-of-control points: 0

    -

    No out-of-control points detected

    -
    - - - \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..d4839a6 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Tests package diff --git a/tests/combinatorial/test_matrix_smoke.py b/tests/combinatorial/test_matrix_smoke.py new file mode 100644 index 0000000..084c98d --- /dev/null +++ b/tests/combinatorial/test_matrix_smoke.py @@ -0,0 +1,55 @@ +"""Smoke tests for combinatorial matrix.""" +from __future__ import annotations + +from combinatorial.matrix import build_matrix, load_config +from combinatorial.schema_catalog import RULESETS, load_catalog +from combinatorial.dual_runner import run_case +from spc_core import ChartType + + +def test_catalog_covers_chart_types_and_rulesets(): + catalog = load_catalog() + chart_values = {ct.value for ct in ChartType} + assert set(catalog.chart_types) == chart_values + assert set(RULESETS) == set(catalog.rulesets) + for ct in ChartType: + assert ct.value in catalog.chart_types + for rs in ("nelson", "western_electric", "wheeler"): + assert rs in catalog.rulesets + + +def test_sparse_matrix_includes_critical_and_passes(): + cfg = load_config() + cases = build_matrix( + mode="sparse", + seed=int(cfg.get("seed", 42)), + max_sparse_cases=min(40, int(cfg.get("max_sparse_cases", 80))), + adversarial=True, + ) + assert any(c.id.startswith("crit_") for c in cases) + # One known parity case must pass + parity = next(c for c in cases if c.id == "crit_imr_in_control_nelson") + result = run_case(parity, seed=0) + assert result["status"] == "PASS", result + + +def test_sparse_run_smoke(): + """Full sparse subsample must be mostly green; allow soft multimodal flake.""" + from combinatorial.dual_runner import run_matrix + + summary = run_matrix( + mode="sparse", + seed=42, + max_sparse_cases=35, + adversarial=True, + write_fixtures=False, + ) + assert summary["n_cases"] >= 20 + # Critical path: zero unexpected ERROR except soft-allowed ids + soft = {"crit_multimodal_stop", "crit_nan_inf"} + hard_fails = [ + r + for r in summary["results"] + if r["status"] in ("FAIL", "ERROR") and r["id"] not in soft + ] + assert not hard_fails, hard_fails[:5] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4ca774a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,31 @@ +"""Shared fixtures — deterministic synthetic datasets (no committed CSVs).""" +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest + +from sample_data import get_dataset, write_csv + + +@pytest.fixture +def dataset(): + """Factory: ``dataset("spc_individual_in_control")`` → column dict.""" + + def _get(name: str) -> dict[str, list[Any]]: + return get_dataset(name) + + return _get + + +@pytest.fixture +def write_dataset(tmp_path: Path): + """Factory: write a named dataset to tmp_path and return the CSV Path.""" + + def _write(name: str, filename: str | None = None) -> Path: + cols = get_dataset(name) + path = tmp_path / (filename or f"{name}.csv") + return write_csv(cols, path) + + return _write diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..0ca287e --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1 @@ +# Integration tests diff --git a/tests/integration/test_e2e_batch.py b/tests/integration/test_e2e_batch.py new file mode 100644 index 0000000..39d76e2 --- /dev/null +++ b/tests/integration/test_e2e_batch.py @@ -0,0 +1,47 @@ +"""End-to-end batch analysis against synthetic datasets.""" +from __future__ import annotations + +from adapters.stream import FileReplaySource, stream_evaluate +from spc_core import analyze_control_chart, capability_analysis, gage_rr_anova, ingest +from spc_core.report import CapabilityReport, MSAReport, SPCReport + + +def test_e2e_control_chart_individual(dataset): + cols = dataset("spc_individual_in_control") + frame = ingest(cols) + result = analyze_control_chart(cols[frame.column_map.value_col]) + report = SPCReport.from_chart_result(result, source_file="synthetic:spc_individual_in_control") + assert report.chart_type.value == "I-MR" + assert report.limits.version + d = report.model_dump(mode="json") + assert "plotted_values" in d + + +def test_e2e_capability(dataset): + cols = dataset("capability_excellent") + values = [float(v) for v in cols["measurement"]] + result = capability_analysis(values, usl=10.5, lsl=9.5) + report = CapabilityReport.from_capability(result) + assert "Cpk" in report.result + assert report.result["rating"] + + +def test_e2e_msa(dataset): + cols = dataset("msa_gage_rr_excellent") + result = gage_rr_anova(cols["Part"], cols["Operator"], cols["Measurement"]) + report = MSAReport.from_gage_rr(result) + assert report.result["grr_percent"] == result.grr_percent + + +def test_e2e_file_replay_phase2(write_dataset): + phase1 = write_dataset("spc_individual_in_control") + phase2 = write_dataset("spc_individual_out_of_control") + from adapters.io_files import load_columns + + cols = load_columns(phase1) + result = analyze_control_chart(cols["measurement"]) + source = FileReplaySource(phase2, value_col="measurement") + signals = stream_evaluate(source, result.limits) + # Out-of-control fixture should produce signals against in-control limits + # (or zero if the spike isn't extreme enough — just assert it runs cleanly) + assert isinstance(signals, list) diff --git a/tests/integration/test_realtime_gated.py b/tests/integration/test_realtime_gated.py new file mode 100644 index 0000000..36cf309 --- /dev/null +++ b/tests/integration/test_realtime_gated.py @@ -0,0 +1,36 @@ +"""Service-gated integration tests — skip when brokers/DB are unavailable.""" +from __future__ import annotations + +import os + +import pytest + +pytestmark = [ + pytest.mark.integration, + pytest.mark.skipif( + os.environ.get("ASPC_INTEGRATION") != "1", + reason="Set ASPC_INTEGRATION=1 with compose stack up to run", + ), +] + + +def test_timescale_roundtrip(): + dsn = os.environ.get("ASPC_TIMESCALE_DSN") + if not dsn: + pytest.skip("ASPC_TIMESCALE_DSN not set") + from adapters.persistence_tsdb import TimescaleDBRepository + + repo = TimescaleDBRepository(dsn) + ver = repo.save_limits({"chart_type": "I-MR"}, "testver", "I-MR") + assert repo.get_limits(ver) is not None + + +def test_redis_publish_smoke(): + url = os.environ.get("ASPC_REDIS_URL", "redis://localhost:6379/0") + redis = pytest.importorskip("redis") + try: + r = redis.Redis.from_url(url, socket_connect_timeout=1) + r.ping() + except Exception: + pytest.skip("Redis not reachable") + r.publish("spc:live:test", '{"value": 1.0}') diff --git a/tests/load/locustfile.py b/tests/load/locustfile.py new file mode 100644 index 0000000..cfeb668 --- /dev/null +++ b/tests/load/locustfile.py @@ -0,0 +1,25 @@ +"""Minimal Locust load smoke for ASPC API health + auth. + +Usage (with API running): + locust -f tests/load/locustfile.py --headless -u 10 -r 2 -t 30s --host http://localhost:8000 +""" +from __future__ import annotations + +try: + from locust import HttpUser, between, task +except ImportError: # pragma: no cover + HttpUser = object # type: ignore + between = lambda *a, **k: None # type: ignore + task = lambda f: f # type: ignore + + +class AspcUser(HttpUser): + wait_time = between(0.5, 1.5) if callable(between) else None + + @task(3) + def health(self): + self.client.get("/health") + + @task(1) + def token(self): + self.client.post("/auth/token", data={"username": "load", "password": "admin"}) diff --git a/tests/resilience/__init__.py b/tests/resilience/__init__.py new file mode 100644 index 0000000..c6f6bc8 --- /dev/null +++ b/tests/resilience/__init__.py @@ -0,0 +1 @@ +# Resilience judgment tests package. diff --git a/tests/resilience/test_resilience_catalog.py b/tests/resilience/test_resilience_catalog.py new file mode 100644 index 0000000..055569e --- /dev/null +++ b/tests/resilience/test_resilience_catalog.py @@ -0,0 +1,82 @@ +"""Judgment suite: every MANIFEST case must match its expect block.""" +from __future__ import annotations + +import pytest + +from resilience_data import load_manifest +from resilience_data.runner import run_case + +CASES = load_manifest() +CASE_IDS = [c["id"] for c in CASES] + + +@pytest.mark.parametrize("case_id", CASE_IDS, ids=CASE_IDS) +def test_resilience_case(case_id: str): + spec = next(c for c in CASES if c["id"] == case_id) + result = run_case(spec) + assert result["status"] == "PASS", ( + f"{case_id} → {result['status']}: {result.get('mismatches')}\n" + f"observed={result.get('observed')}" + ) + + +def test_manifest_covers_all_chart_types_and_quality_flags(): + """Sanity: catalog exercises the surface area claimed in the README.""" + from spc_core.models import ChartType, QualityFlag + + chart_types: set[str] = set() + quality_flags: set[str] = set() + gate_steps: set[str] = set() + for spec in CASES: + exp = spec.get("expect") or {} + if isinstance(exp.get("chart_type"), str): + chart_types.add(exp["chart_type"]) + ct = (spec.get("params") or {}).get("chart_type") + if ct: + chart_types.add(ct) + quality_flags.update((exp.get("flag_counts") or {}).keys()) + gate_steps.update((exp.get("gates") or {}).keys()) + + for member in ChartType: + assert member.value in chart_types, f"ChartType {member.value} missing from catalog" + + for flag in QualityFlag: + if flag == QualityFlag.ORIGINAL: + continue # present on every clean series; not asserted via flag_counts + assert flag.value in quality_flags, f"QualityFlag {flag.value} not exercised" + + for step in ("msa", "autocorrelation", "multimodal", "normality", "range", "freeze"): + assert step in gate_steps, f"gate {step} missing from expects" + assert any(c["entry"] == "gage_resolution_gate" for c in CASES) + assert any(c["entry"] == "classify_missing" for c in CASES) + + +def test_manifest_asserts_every_run_rule(): + """Counting signals is not enough — a case must pin down *which* rule fired. + + Without this, a trend case would pass on an unrelated beyond-3-sigma point, and a + whole ruleset could regress unnoticed. + """ + from spc_core.rules import NELSON, WESTERN_ELECTRIC + + asserted: set[str] = set() + rulesets: set[str] = set() + for spec in CASES: + exp = spec.get("expect") or {} + wanted = exp.get("rule_ids") + if isinstance(wanted, dict): + asserted.update(wanted.get("includes") or []) + secondary = exp.get("secondary_rule_ids") + if isinstance(secondary, dict): + asserted.update(secondary.get("includes") or []) + if isinstance(exp.get("ruleset_applied"), str): + rulesets.add(exp["ruleset_applied"]) + + missing_nelson = sorted(set(NELSON) - asserted) + assert not missing_nelson, f"Nelson rules never asserted by any case: {missing_nelson}" + + missing_we = sorted(set(WESTERN_ELECTRIC) - asserted) + assert not missing_we, f"Western Electric rules never asserted: {missing_we}" + + for ruleset in ("nelson", "western_electric", "wheeler"): + assert ruleset in rulesets, f"ruleset {ruleset!r} never asserted as applied" diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..a0291f0 --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +# Unit tests diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py new file mode 100644 index 0000000..2ddd0d3 --- /dev/null +++ b/tests/unit/test_api.py @@ -0,0 +1,60 @@ +"""API smoke tests (httpx / TestClient). Auth disabled via env for unit speed.""" +from __future__ import annotations + +import os + +import pytest + +# Disable auth before app import side-effects in dependent fixtures. +os.environ.setdefault("ASPC_AUTH_ENABLED", "false") +os.environ.setdefault("ASPC_API_KEYS", "") +os.environ.setdefault("ASPC_DEV_INSECURE", "1") +os.environ.setdefault("ASPC_JWT_SECRET", "unit-test-secret") + +pytest.importorskip("fastapi") +from fastapi.testclient import TestClient + + +@pytest.fixture(scope="module") +def client(tmp_path_factory): + db = tmp_path_factory.mktemp("api") / "test.db" + os.environ["ASPC_PERSISTENCE_BACKEND"] = "sqlite" + os.environ["ASPC_SQLITE_PATH"] = str(db) + # Re-import config/repo cleanly + import apps.api.main as api_main + import apps.config as config_mod + + config_mod._config = None + api_main.cfg = config_mod.get_config() + from adapters.factory import get_repository + + api_main.repo = get_repository(api_main.cfg) + return TestClient(api_main.app) + + +def test_health(client): + r = client.get("/health") + assert r.status_code == 200 + assert r.json()["status"] == "healthy" + + +def test_analyze_control_chart(client, write_dataset): + path = write_dataset("spc_individual_in_control", "spc.csv") + with path.open("rb") as f: + r = client.post( + "/analyze/control-chart", + files={"file": ("spc.csv", f, "text/csv")}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert "run_id" in body + report = body["report"] + assert "limits" in report + assert "gates" in report or "summary" in report + + +def test_auth_token_when_enabled(client): + # Token endpoint should still respond + r = client.post("/auth/token", data={"username": "op", "password": "admin"}) + # May be 200 with token or 401 depending on config; must not 500 + assert r.status_code in (200, 401, 403) diff --git a/tests/unit/test_capability.py b/tests/unit/test_capability.py new file mode 100644 index 0000000..05163db --- /dev/null +++ b/tests/unit/test_capability.py @@ -0,0 +1,106 @@ +"""Capability analysis — analytic DPMO/sigma, non-parametric path, flat contract keys.""" +from __future__ import annotations + +import numpy as np +import pytest + +from spc_core.capability import ( + capability_analysis, + dpmo_to_sigma, + nonparametric_capability, + parametric_capability, + sigma_to_dpmo, +) +from spc_core.normality import check_normality +from spc_core.report import CapabilityReport + + +def test_dpmo_sigma_analytic_not_bucketed(): + """Legacy used buckets (691462→1σ etc). We use norm.ppf.""" + # ~3.4 DPMO at 6σ short-term (with 1.5 shift => Z_bench≈4.5) + sigma = dpmo_to_sigma(3.4) + assert sigma == pytest.approx(6.0, abs=0.1) + # Round-trip + z_bench = 3.0 + dpmo = sigma_to_dpmo(z_bench) + assert dpmo == pytest.approx(1350, abs=50) + + +def test_parametric_capability_centered(dataset): + cols = dataset("capability_excellent") + values = cols["measurement"] + # Specs wide enough for excellent data around 10 + result = parametric_capability(values, usl=10.5, lsl=9.5, target=10.0) + assert result.method == "parametric" + assert result.cp is not None and result.cpk is not None + assert result.cpk > 1.0 + assert result.sigma_level is not None + + +def test_capability_report_exposes_flat_normality_keys(dataset): + """Fixes the legacy tool/pipeline key mismatch (is_normal / shapiro_p / anderson_stat).""" + cols = dataset("capability_excellent") + values = [float(v) for v in cols["measurement"]] + normality = check_normality(values) + result = capability_analysis(values, usl=10.5, lsl=9.5) + report = CapabilityReport.from_capability(result, normality=normality) + d = report.model_dump() + assert "is_normal" in d["normality"] + assert "shapiro_p" in d["normality"] + assert "anderson_stat" in d["normality"] + # These are the keys the broken tool expected at the top level of normality + assert isinstance(d["normality"]["is_normal"], bool) + + +def test_nonparametric_for_skewed(dataset): + cols = dataset("capability_skewed_data") + values = [float(v) for v in cols["measurement"]] + # Force non-parametric + result = nonparametric_capability(values, usl=max(values) * 1.2, lsl=min(values) * 0.8) + assert result.method == "nonparametric" + assert result.cp is None # parametric indices withheld + assert result.ppk is not None + + +def test_auto_routes_nonnormal_to_nonparametric_or_transformed(): + rng = np.random.default_rng(0) + # Strongly right-skewed + values = rng.exponential(2.0, 200) + result = capability_analysis(values, usl=float(np.percentile(values, 99)), + lsl=float(np.percentile(values, 1))) + assert result.method in ("nonparametric", "parametric", "transformed") + # If Shapiro fails (almost always for exponential), expect nonparametric OR + # transformed (when Box-Cox/YJ restores normality and specs transform). + if not check_normality(values).is_normal: + assert result.method in ("nonparametric", "transformed") + + +def test_transform_spec_failure_recorded_in_notes(monkeypatch): + """Spec transform failures must not silent-fallthrough without a reason.""" + from types import SimpleNamespace + + import spc_core.capability as cap_mod + import spc_core.normality as norm_mod + + values = list(np.random.default_rng(0).normal(10, 1, 80)) + fake_tr = SimpleNamespace( + became_normal=True, + applied="LOG", + label="log(x)", + lam=None, + values=np.asarray(values), + ) + monkeypatch.setattr(norm_mod, "apply_transform", lambda *a, **k: fake_tr) + monkeypatch.setattr( + norm_mod, "check_normality", lambda *a, **k: SimpleNamespace(is_normal=False) + ) + monkeypatch.setattr( + cap_mod, + "_transform_specs", + lambda *a, **k: (_ for _ in ()).throw(ValueError("bad specs")), + ) + result = capability_analysis(values, usl=12.0, lsl=8.0) + assert result.method == "nonparametric" + notes = result.notes["normality"] + assert notes["path"] == "nonparametric_after_transform_error" + assert "bad specs" in notes["transform_error"] diff --git a/tests/unit/test_cleaning.py b/tests/unit/test_cleaning.py new file mode 100644 index 0000000..ec233ef --- /dev/null +++ b/tests/unit/test_cleaning.py @@ -0,0 +1,46 @@ +"""SPC cleaning — missing-value classifier (never silent impute).""" +from __future__ import annotations + +from spc_core.cleaning import classify_missing, range_check +from spc_core.models import QualityFlag + + +def test_original_values_pass_through(): + result = classify_missing([1.0, 2.0, 3.0]) + assert all(f == QualityFlag.ORIGINAL for f in result.flags) + assert all(result.usable) + + +def test_short_gap_locf(): + result = classify_missing([1.0, None, None, 4.0]) + assert result.flags[1] == QualityFlag.IMPUTED_LOCF + assert result.flags[2] == QualityFlag.IMPUTED_LOCF + assert result.values[1] == 1.0 + assert result.values[2] == 1.0 + assert result.usable[1] and result.usable[2] + + +def test_long_gap_held_for_investigation(): + result = classify_missing([1.0, None, None, None, None, 6.0], locf_max=3) + assert result.flags[1] == QualityFlag.MISSING_SENSOR + assert result.values[1] is None + assert not result.usable[1] + + +def test_explicit_maintenance_reason(): + result = classify_missing( + [1.0, None, 3.0], + reasons=[None, "maintenance", None], + ) + assert result.flags[1] == QualityFlag.EXCLUDED_MAINTENANCE + assert not result.usable[1] + + +def test_nan_treated_as_missing(): + result = classify_missing([1.0, float("nan"), 3.0]) + assert result.flags[1] == QualityFlag.IMPUTED_LOCF + + +def test_range_check_sensor_failure(): + valid = range_check([10.0, -999.0, 11.0], low=0.0, high=100.0) + assert valid == [True, False, True] diff --git a/tests/unit/test_constants.py b/tests/unit/test_constants.py new file mode 100644 index 0000000..60aab59 --- /dev/null +++ b/tests/unit/test_constants.py @@ -0,0 +1,54 @@ +"""Golden-value tests for Shewhart chart constants.""" +from __future__ import annotations + +import math + +import pytest + +from spc_core import constants as k + + +def test_d2_known_values(): + assert k.d2(2) == pytest.approx(1.128, abs=0.001) + assert k.d2(5) == pytest.approx(2.326, abs=0.001) + assert k.d2(10) == pytest.approx(3.078, abs=0.001) + + +def test_c4_formula(): + # c4(2) = sqrt(2/pi) ≈ 0.7979 + assert k.c4(2) == pytest.approx(math.sqrt(2 / math.pi), abs=1e-6) + # c4(5) ≈ 0.9400 + assert k.c4(5) == pytest.approx(0.9400, abs=0.001) + + +def test_A2_A3_derived(): + # A2(5) = 3/(d2*sqrt(5)) ≈ 0.577 + assert k.A2(5) == pytest.approx(0.577, abs=0.01) + # A3(5) = 3/(c4*sqrt(5)) ≈ 1.427 + assert k.A3(5) == pytest.approx(1.427, abs=0.01) + + +def test_D3_D4_B3_B4(): + assert k.D3(5) == pytest.approx(0.0, abs=0.01) # D3(n<=6) == 0 + assert k.D4(5) == pytest.approx(2.114, abs=0.02) + assert k.B3(5) == pytest.approx(0.0, abs=0.05) + assert k.B4(5) == pytest.approx(2.089, abs=0.05) + + +def test_imr_constants(): + assert k.E2_MR == pytest.approx(2.66, abs=0.01) + assert k.D4_MR == pytest.approx(3.267, abs=0.01) + + +def test_constants_beyond_n9(): + """Legacy code stopped at n=9; these must still work for Xbar-S.""" + assert k.A3(10) > 0 + assert k.B4(10) > 1 + assert k.d2(15) > k.d2(10) + + +def test_n_too_small_raises(): + with pytest.raises(ValueError): + k.d2(1) + with pytest.raises(ValueError): + k.c4(1) diff --git a/tests/unit/test_establish_guards.py b/tests/unit/test_establish_guards.py new file mode 100644 index 0000000..a42db30 --- /dev/null +++ b/tests/unit/test_establish_guards.py @@ -0,0 +1,69 @@ +"""Input guards and data-type routing in the Phase I pipeline. + +These cover failure modes that the resilience catalog previously accepted as "handled" +because it only asserted the exception *type*: a domain guard and an internal crash both +surface as ValueError. +""" +from __future__ import annotations + +import numpy as np +import pytest + +from spc_core import ChartType, establish + + +def test_empty_input_raises_domain_error(): + with pytest.raises(ValueError, match="at least 2 observations"): + establish([]) + + +def test_single_point_raises_domain_error(): + with pytest.raises(ValueError, match="at least 2 observations"): + establish([42.0]) + + +def test_all_missing_raises_domain_error(): + """Previously crashed inside scipy.stats.boxcox with 'not enough values to unpack'.""" + with pytest.raises(ValueError, match="usable observations"): + establish([None, None, None, None]) + + +def test_attribute_chart_skips_distribution_gates(): + """Count data is discrete and binomial/Poisson-distributed. + + Applying a Gaussian normality test or Hartigan's dip test to it is invalid — the + tie-heavy ECDF of a handful of distinct integers reads as multimodal and used to + STOP in-control attribute studies. + """ + rng = np.random.default_rng(7) + defects = [int(v) for v in rng.poisson(3.0, 25)] + + pipe = establish(defects, chart_type=ChartType.C) + + assert pipe.stopped is False + assert pipe.frozen is True + assert pipe.gate_status("multimodal") is None + normality = next(g for g in pipe.gates if g.step == "normality") + assert normality.status == "ok" + assert normality.detail.get("skipped") is True + + +def test_out_of_range_sentinel_is_not_an_ooc_signal(): + """A -999 disconnected-sensor reading is a measurement failure, not process variation. + + Left in the data it fires Nelson rule 1 and drags the distribution non-normal, + diverting the whole study to the Wheeler route. + """ + rng = np.random.default_rng(11) + values = [float(v) for v in 100.0 + rng.normal(0.0, 1.0, 80)] + values[17] = -999.0 + values[52] = -999.0 + + unguarded = establish(values) + assert "1" in {s.rule_id for s in unguarded.chart.signals} + + guarded = establish(values, valid_range=(0.0, 200.0)) + assert "1" not in {s.rule_id for s in guarded.chart.signals} + assert guarded.gate_status("range") == "warn" + assert guarded.gate_status("missing") == "warn" + assert guarded.chart_route == "shewhart" diff --git a/tests/unit/test_evaluator.py b/tests/unit/test_evaluator.py new file mode 100644 index 0000000..420c96a --- /dev/null +++ b/tests/unit/test_evaluator.py @@ -0,0 +1,50 @@ +"""Phase I / Phase II evaluator — frozen limits, incremental observation.""" +from __future__ import annotations + +import numpy as np +import pytest + +from spc_core.evaluator import Phase2Evaluator, evaluate_batch +from spc_core.limits import imr_limits +from spc_core.models import ChartType + + +def test_phase2_does_not_recompute_limits(): + rng = np.random.default_rng(0) + phase1 = 100 + rng.normal(0, 1, 40) + limits = imr_limits(phase1) + version = limits.version + + ev = Phase2Evaluator(limits) + # Stream Phase II points — limits object must stay the same version + for v in (100 + rng.normal(0, 1, 20)).tolist(): + ev.observe(float(v)) + assert limits.version == version + assert ev.limits is limits + + +def test_phase2_detects_ooc(): + limits = imr_limits([10.0, 10.1, 9.9, 10.05, 10.0, 10.02, 9.98, 10.01] * 4) + ev = Phase2Evaluator(limits) + # A huge spike must trigger rule 1 + signals = ev.observe(limits.primary.ucl + 10) + assert any(s.rule_id == "1" for s in signals) + + +def test_evaluate_batch(): + limits = imr_limits([1.0, 1.1, 0.9, 1.05, 1.0, 0.95, 1.02, 0.98] * 3) + plotted = [1.0] * 5 + [limits.primary.ucl + 5] + signals = evaluate_batch(limits, plotted) + assert any(s.rule_id == "1" for s in signals) + + +def test_subgroup_chart_requires_observe_subgroup(): + from spc_core.limits import xbar_r_limits + subs = [np.array([1.0, 1.1, 0.9, 1.05, 1.0]) for _ in range(20)] + limits = xbar_r_limits(subs) + assert limits.chart_type == ChartType.XBAR_R + ev = Phase2Evaluator(limits) + with pytest.raises(ValueError): + ev.observe(1.0) + signals = ev.observe_subgroup([1.0, 1.0, 1.0, 1.0, 1.0]) + assert isinstance(signals, list) diff --git a/tests/unit/test_ewma.py b/tests/unit/test_ewma.py new file mode 100644 index 0000000..323dd99 --- /dev/null +++ b/tests/unit/test_ewma.py @@ -0,0 +1,41 @@ +"""Unit tests for EWMA control chart.""" +from __future__ import annotations + +import numpy as np +import pytest + +from spc_core.ewma import ewma_chart + + +def test_ewma_basic_in_control(): + rng = np.random.default_rng(0) + values = 10.0 + rng.normal(0, 0.1, size=40) + result = ewma_chart(values, lam=0.2, L=3.0) + assert result.lam == 0.2 + assert result.L == 3.0 + assert len(result.z) == 40 + assert len(result.ucl) == 40 + assert len(result.lcl) == 40 + assert result.limits is not None + assert result.limits.chart_type.value == "EWMA" + assert result.limits.version + + +def test_ewma_detects_shift(): + # Stable then large mean shift — EWMA should fire + values = [10.0] * 20 + [12.0] * 20 + result = ewma_chart(values, lam=0.2, L=3.0, target=10.0, sigma=0.2) + assert any(s.side == "upper" for s in result.signals) + assert result.z[-1] > result.z[0] + + +def test_ewma_rejects_bad_lambda(): + with pytest.raises(ValueError): + ewma_chart([1.0, 2.0, 3.0], lam=0.0) + with pytest.raises(ValueError): + ewma_chart([1.0, 2.0, 3.0], lam=1.5) + + +def test_ewma_requires_two_points(): + with pytest.raises(ValueError): + ewma_chart([1.0]) diff --git a/tests/unit/test_exclude_incomplete.py b/tests/unit/test_exclude_incomplete.py new file mode 100644 index 0000000..706053b --- /dev/null +++ b/tests/unit/test_exclude_incomplete.py @@ -0,0 +1,31 @@ +"""exclude_incomplete must drop incomplete subgroups from plotted values.""" +from __future__ import annotations + +from spc_core import ChartType, analyze_control_chart + + +def test_exclude_incomplete_drops_ragged_subgroup(): + measurements = [] + subgroups = [] + for sid in range(1, 25): + for _ in range(5): + measurements.append(50.0) + subgroups.append(sid) + # Ragged last subgroup of size 2. + measurements.extend([50.0, 51.0]) + subgroups.extend([25, 25]) + + keep = analyze_control_chart( + measurements, + subgroup_ids=subgroups, + chart_type=ChartType.XBAR_R, + exclude_incomplete=False, + ) + drop = analyze_control_chart( + measurements, + subgroup_ids=subgroups, + chart_type=ChartType.XBAR_R, + exclude_incomplete=True, + ) + assert len(keep.plotted_values) == 25 + assert len(drop.plotted_values) == 24 diff --git a/tests/unit/test_explain.py b/tests/unit/test_explain.py new file mode 100644 index 0000000..70298e3 --- /dev/null +++ b/tests/unit/test_explain.py @@ -0,0 +1,38 @@ +"""Unit tests for deterministic Explainable SPC Copilot.""" +from __future__ import annotations + +from spc_core.explain import diff_limits, explain_signal +from spc_core.models import Signal + + +def test_explain_nelson_rule_1(): + sig = Signal( + rule_id="1", + rule_name="Beyond 3-sigma", + index=12, + value=110.0, + description="One point beyond zone A", + side="above", + ) + out = explain_signal(sig, limits_version="abc123") + assert out["rule_id"] == "1" + assert out["auditable"] is True + assert out["llm_required"] is False + assert "abc123" in out["operator_summary"] + assert "Beyond" in out["catalog_description"] or "zone" in out["catalog_description"].lower() + + +def test_diff_limits_delta(): + a = { + "version": "v1", + "chart_type": "I_MR", + "components": {"I": {"center": 100.0, "ucl": 103.0, "lcl": 97.0}}, + } + b = { + "version": "v2", + "chart_type": "I_MR", + "components": {"I": {"center": 101.0, "ucl": 104.0, "lcl": 98.0}}, + } + d = diff_limits(a, b) + assert d["same_chart_type"] is True + assert d["components"][0]["delta"]["center"] == 1.0 diff --git a/tests/unit/test_hardening_phase1.py b/tests/unit/test_hardening_phase1.py new file mode 100644 index 0000000..646f8cf --- /dev/null +++ b/tests/unit/test_hardening_phase1.py @@ -0,0 +1,166 @@ +"""Hardening Phase 1: correctness and gate-enforcement regression tests.""" +from __future__ import annotations + +import numpy as np +import pytest + +from spc_core.charts import analyze_control_chart +from spc_core.evaluator import Phase2Evaluator +from spc_core.ewma import ewma_chart +from spc_core.models import ChartType, ControlLimits +from spc_core.msa import gage_rr_anova +from spc_core.pipeline import establish, phase1_checklist + + +def test_wheeler_preserves_subgroup_chart(): + """Non-normal subgrouped data must stay Xbar-R, not flatten to I-MR.""" + rng = np.random.default_rng(99) + # Right-skewed: lognormal — fails normality, transform may or may not help. + values = list(rng.lognormal(mean=0.0, sigma=1.0, size=100)) + subgroup_ids = [i // 5 for i in range(100)] + result = establish( + values, + subgroup_ids=subgroup_ids, + chart_type=ChartType.XBAR_R, + force_wheeler=True, + ) + assert result.chart.chart_type == ChartType.XBAR_R + assert result.chart_route == "wheeler" or result.chart.limits.chart_type == ChartType.XBAR_R + # Plotted values are subgroup means, not all 100 individuals + assert len(result.chart.plotted_values) == 20 + + +def test_force_wheeler_false_still_preserves_subgroups_when_nonnormal(): + rng = np.random.default_rng(7) + values = list(rng.exponential(scale=2.0, size=80)) + subgroup_ids = [i // 4 for i in range(80)] + result = establish( + values, + subgroup_ids=subgroup_ids, + chart_type=ChartType.XBAR_R, + force_wheeler=False, + ) + # Must not collapse to I-MR + assert result.chart.chart_type != ChartType.I_MR or result.chart_route != "wheeler" + if result.chart_route == "wheeler": + assert result.chart.chart_type == ChartType.XBAR_R + + +def test_stop_gate_blocks_freeze(): + """MSA STOP must set frozen=False and freeze gate status=blocked.""" + # Construct deliberately bad MSA: huge measurement noise vs part variation + parts, ops, meas = [], [], [] + rng = np.random.default_rng(1) + for p in range(5): + for o in range(2): + for _ in range(2): + parts.append(p) + ops.append(o) + # Enormous gage noise → high %GRR + meas.append(float(rng.normal(0, 50))) + values = list(rng.normal(100, 1, size=40)) + result = establish( + values, + msa_parts=parts, + msa_operators=ops, + msa_measurements=meas, + msa_tolerance=1.0, + ) + assert result.stopped is True + assert result.frozen is False + freeze = next(g for g in result.gates if g.step == "freeze") + assert freeze.status == "blocked" + checklist = phase1_checklist(result, min_subgroups=25, phase2_enabled=False) + limits_item = next(i for i in checklist["items"] if i["item"] == "limits_frozen") + assert limits_item["passed"] is False + + +def test_variable_n_xbar_emits_beyond_limit_signals(): + """Variable-size subgroups must not silence run rules via sigma=0.""" + # Build unequal subgroups with one clear outlier mean + values = [] + subgroup_ids = [] + # 10 subgroups of size 3 around 10, then one of size 5 with huge mean + for s in range(10): + for _ in range(3): + values.append(10.0) + subgroup_ids.append(s) + for _ in range(5): + values.append(50.0) # clear shift + subgroup_ids.append(10) + + result = analyze_control_chart( + values, + subgroup_ids=subgroup_ids, + chart_type=ChartType.XBAR_R, + ruleset="nelson", + ) + assert isinstance(result.limits.primary.ucl, list) + # The last subgroup mean (50) must fire beyond-limits + assert result.out_of_control_count >= 1 + assert any(s.rule_id == "1" for s in result.signals) + + +def test_ewma_phase2_roundtrip(): + """Phase2Evaluator must support EWMA limits from Phase I.""" + rng = np.random.default_rng(3) + phase1 = list(rng.normal(0, 1, size=40)) + ew = ewma_chart(phase1, lam=0.2, L=3.0) + assert ew.limits is not None + assert ew.limits.chart_type == ChartType.EWMA + + ev = Phase2Evaluator(ew.limits, ruleset="nelson") + # In-control observations should generally not signal + for v in rng.normal(0, 1, size=5): + ev.observe(float(v)) + # Large shift should eventually trip EWMA + signals = [] + for _ in range(30): + signals.extend(ev.observe(5.0)) + assert any(s.rule_id.startswith("EWMA") for s in signals) + + +def test_control_limits_version_in_model_dump(): + from spc_core.models import LimitSet + + limits = ControlLimits( + chart_type=ChartType.I_MR, + subgroup_size=1, + components={ + "individuals": LimitSet(center=0.0, ucl=3.0, lcl=-3.0), + }, + sigma=1.0, + ) + dumped = limits.model_dump() + assert "version" in dumped + assert dumped["version"] == limits.version + assert len(dumped["version"]) == 16 + + +def test_p_chart_rejects_zero_sample_size(): + with pytest.raises(ValueError, match="sample sizes"): + analyze_control_chart( + [1, 2, 0], + sample_sizes=[10, 0, 10], + chart_type=ChartType.P, + ) + + +def test_u_chart_rejects_zero_opportunity(): + with pytest.raises(ValueError, match="opportunities"): + analyze_control_chart( + [1, 2, 0], + opportunities=[10, 0, 10], + chart_type=ChartType.U, + ) + + +def test_unbalanced_gage_rr_falls_back_to_range(): + # Missing some part-operator cells + parts = [1, 1, 1, 2, 2, 3] + ops = ["A", "A", "B", "A", "A", "B"] + meas = [10.0, 10.1, 10.2, 11.0, 11.1, 12.0] + result = gage_rr_anova(parts, ops, meas) + assert "unbalanced" in result.method.lower() or result.detail.get("anova_skipped") + assert result.detail.get("balanced") is False + assert result.grr_percent >= 0.0 diff --git a/tests/unit/test_hypothesis_limits.py b/tests/unit/test_hypothesis_limits.py new file mode 100644 index 0000000..6a24ce9 --- /dev/null +++ b/tests/unit/test_hypothesis_limits.py @@ -0,0 +1,44 @@ +"""Hypothesis property tests for limit/rule invariants.""" +from __future__ import annotations + +import numpy as np +import pytest + +hypothesis = pytest.importorskip("hypothesis") +from hypothesis import given, settings +from hypothesis import strategies as st + +from spc_core.charts import analyze_control_chart +from spc_core.constants import A2, D3, D4, c4, d2 +from spc_core.rules import RuleEngine + + +@given(st.integers(min_value=2, max_value=40)) +@settings(max_examples=30) +def test_shewhart_constants_positive(n): + assert d2(n) > 0 + assert c4(n) > 0 + assert A2(n) > 0 + assert D4(n) > D3(n) >= 0 + + +@given(st.lists(st.floats(min_value=-100, max_value=100, allow_nan=False, allow_infinity=False), min_size=10, max_size=80)) +@settings(max_examples=20) +def test_imr_limits_bracket_center(values): + # Need some spread + if np.std(values) < 1e-9: + return + result = analyze_control_chart(values) + primary = result.limits.primary + assert primary.lcl <= primary.center <= primary.ucl + + +@given(st.floats(min_value=-50, max_value=50, allow_nan=False, allow_infinity=False)) +@settings(max_examples=20) +def test_point_on_ucl_fires_rule1(center_offset): + center = 0.0 + sigma = 1.0 + eng = RuleEngine(center=center, sigma=sigma, ruleset="nelson") + # Exactly 3 sigma above center + signals = eng.add(center + 3.0 * sigma) + assert any(s.rule_id == "1" for s in signals) diff --git a/tests/unit/test_ingest.py b/tests/unit/test_ingest.py new file mode 100644 index 0000000..996101b --- /dev/null +++ b/tests/unit/test_ingest.py @@ -0,0 +1,50 @@ +"""Ingest / column auto-detection (deduped from 3 legacy pipelines).""" +from __future__ import annotations + +import pytest + +from adapters.io_files import FileReadError, read_csv, safe_filename +from spc_core.ingest import detect_columns, ingest + + +def test_detect_measurement_prefers_named_column(): + cols = {"subgroup_id": [1, 1, 2], "measurement": [10.0, 10.1, 10.2], "batch": [1, 1, 2]} + cmap = detect_columns(cols) + assert cmap.value_col == "measurement" + assert cmap.subgroup_col in ("subgroup_id", "batch") + + +def test_ingest_sample(dataset): + cols = dataset("spc_subgroup_data") + frame = ingest(cols) + assert frame.column_map.value_col == "measurement" + assert frame.column_map.subgroup_col == "subgroup" + assert frame.n_rows > 0 + + +def test_msa_column_detect(dataset): + cols = dataset("msa_gage_rr_excellent") + frame = ingest(cols) + assert frame.column_map.part_col == "Part" + assert frame.column_map.operator_col == "Operator" + assert frame.column_map.value_col == "Measurement" + + +def test_safe_filename_rejects_traversal(): + with pytest.raises(FileReadError): + safe_filename("../etc/passwd") + with pytest.raises(FileReadError): + safe_filename("foo/bar.csv") + assert safe_filename("data.csv") == "data.csv" + + +def test_empty_csv_raises(tmp_path): + p = tmp_path / "empty.csv" + p.write_text("") + with pytest.raises(FileReadError, match="empty"): + read_csv(p) + + +def test_missing_file_raises(): + with pytest.raises(FileReadError, match="not found"): + read_csv("/tmp/definitely_does_not_exist_aspc_xyz.csv") diff --git a/tests/unit/test_limits.py b/tests/unit/test_limits.py new file mode 100644 index 0000000..715db87 --- /dev/null +++ b/tests/unit/test_limits.py @@ -0,0 +1,132 @@ +"""Control limit computation — including the Xbar-S branch that was missing.""" +from __future__ import annotations + +import numpy as np +import pytest + +from spc_core.charts import analyze_control_chart, select_chart_type +from spc_core.limits import ( + imr_limits, + xbar_r_limits, + xbar_s_limits, +) +from spc_core.models import ChartType, DataType + + +def test_imr_limits_basic(): + rng = np.random.default_rng(0) + values = 100 + rng.normal(0, 1, 30) + limits = imr_limits(values) + assert limits.chart_type == ChartType.I_MR + assert "individuals" in limits.components + assert "moving_range" in limits.components + ind = limits.components["individuals"] + assert ind.ucl > ind.center > ind.lcl + # LCL must NOT be clamped to 0 for two-sided measurements + assert ind.lcl < ind.center + assert limits.version # content hash present + assert len(limits.version) == 16 + + +def test_xbar_r_lcl_not_clamped_to_zero(): + """Legacy bug: Xbar LCL was max(..., 0) which hides low-side OOC.""" + subgroups = [np.array([10.0, 10.1, 9.9, 10.05, 10.02]) for _ in range(25)] + limits = xbar_r_limits(subgroups) + xbar = limits.components["xbar"] + # Mean ~10, LCL should be positive but computed without artificial floor forcing + # (for this data LCL is naturally >0; the point is the formula is unclamped) + assert xbar.lcl == pytest.approx(xbar.center - (xbar.ucl - xbar.center), abs=1e-9) + + +def test_xbar_s_implemented(): + """The critical missing branch — n>=9 must produce Xbar-S limits, not {}.""" + # 12 subgroups of size 10 + rng = np.random.default_rng(1) + subgroups = [100 + rng.normal(0, 2, 10) for _ in range(12)] + limits = xbar_s_limits(subgroups) + assert limits.chart_type == ChartType.XBAR_S + assert "xbar" in limits.components + assert "s" in limits.components + assert limits.subgroup_size == 10 + assert limits.components["xbar"].ucl > limits.components["xbar"].center + + +def test_select_chart_routing(): + assert select_chart_type(DataType.CONTINUOUS, 1) == ChartType.I_MR + assert select_chart_type(DataType.CONTINUOUS, 5) == ChartType.XBAR_R + assert select_chart_type(DataType.CONTINUOUS, 8) == ChartType.XBAR_R + assert select_chart_type(DataType.CONTINUOUS, 9) == ChartType.XBAR_S + assert select_chart_type(DataType.CONTINUOUS, 15) == ChartType.XBAR_S + assert select_chart_type(DataType.ATTRIBUTE, attribute_defectives=True, variable_size=False) == ChartType.NP + assert select_chart_type(DataType.ATTRIBUTE, attribute_defectives=True, variable_size=True) == ChartType.P + assert select_chart_type(DataType.ATTRIBUTE, attribute_defectives=False, variable_size=False) == ChartType.C + assert select_chart_type(DataType.ATTRIBUTE, attribute_defectives=False, variable_size=True) == ChartType.U + + +def test_analyze_imr_sample(dataset): + cols = dataset("spc_individual_out_of_control") + result = analyze_control_chart(cols["measurement"]) + assert result.chart_type == ChartType.I_MR + assert len(result.plotted_values) == len(cols["measurement"]) + assert result.limits.components["individuals"].ucl > result.limits.components["individuals"].center + + +def test_analyze_subgroup_xbar_r(dataset): + cols = dataset("spc_subgroup_data") + result = analyze_control_chart(cols["measurement"], subgroup_ids=cols["subgroup"]) + assert result.chart_type == ChartType.XBAR_R + assert result.subgroup_size == 5 + assert result.secondary_name == "range" + + +def test_analyze_xbar_s_large_subgroups(): + """End-to-end: n=10 subgroups must select and compute Xbar-S.""" + rng = np.random.default_rng(2) + values, sids = [], [] + for i in range(15): + for v in 50 + rng.normal(0, 1, 10): + values.append(float(v)) + sids.append(i) + result = analyze_control_chart(values, subgroup_ids=sids) + assert result.chart_type == ChartType.XBAR_S + assert "s" in result.limits.components + assert result.limits.components["xbar"].ucl != result.limits.components["xbar"].lcl + + +def test_c_chart(dataset): + cols = dataset("spc_c_chart_data") + result = analyze_control_chart(cols["defects"], chart_type=ChartType.C) + assert result.chart_type == ChartType.C + assert result.limits.components["defects"].lcl >= 0 + + +def test_p_chart(dataset): + cols = dataset("spc_p_chart_data") + result = analyze_control_chart( + cols["defective"], sample_sizes=cols["inspected"], chart_type=ChartType.P + ) + assert result.chart_type == ChartType.P + assert isinstance(result.limits.primary.ucl, list) + + +def test_np_chart(dataset): + cols = dataset("spc_np_chart_data") + result = analyze_control_chart( + cols["defectives"], sample_sizes=cols["sample_size"], chart_type=ChartType.NP + ) + assert result.chart_type == ChartType.NP + + +def test_u_chart(dataset): + cols = dataset("spc_u_chart_data") + result = analyze_control_chart( + cols["defects"], opportunities=cols["units"], chart_type=ChartType.U + ) + assert result.chart_type == ChartType.U + assert isinstance(result.limits.primary.ucl, list) + + +def test_limits_immutable(): + limits = imr_limits([1.0, 2.0, 1.5, 2.1, 1.8, 2.0, 1.9]) + with pytest.raises(Exception): + limits.subgroup_size = 99 # frozen pydantic model diff --git a/tests/unit/test_msa.py b/tests/unit/test_msa.py new file mode 100644 index 0000000..bf2f815 --- /dev/null +++ b/tests/unit/test_msa.py @@ -0,0 +1,61 @@ +"""MSA — Gage R&R exposes grr_percent at the top level (legacy key-mismatch fix).""" +from __future__ import annotations + +from spc_core.msa import bias_study, gage_rr_anova, gage_rr_range, linearity_study, stability_study +from spc_core.report import MSAReport + + +def test_gage_rr_anova_excellent(dataset): + cols = dataset("msa_gage_rr_excellent") + result = gage_rr_anova(cols["Part"], cols["Operator"], cols["Measurement"]) + # Flat key — this is what the broken msa_tools expected + assert hasattr(result, "grr_percent") + assert isinstance(result.grr_percent, float) + assert result.ndc >= 0 + assert result.acceptability in ("Excellent", "Acceptable", "Unacceptable") + # Excellent fixture should have low GRR + assert result.grr_percent < 30 + + +def test_gage_rr_report_has_grr_percent(dataset): + cols = dataset("msa_gage_rr_excellent") + result = gage_rr_anova(cols["Part"], cols["Operator"], cols["Measurement"]) + report = MSAReport.from_gage_rr(result) + d = report.model_dump() + assert "grr_percent" in d["result"] + assert d["result"]["grr_percent"] == result.grr_percent + + +def test_gage_rr_poor(dataset): + cols = dataset("msa_gage_rr_poor") + result = gage_rr_anova(cols["Part"], cols["Operator"], cols["Measurement"]) + assert isinstance(result.grr_percent, float) + assert result.grr_percent >= 10.0 # poor fixture is intentionally high GRR + assert result.acceptability in ("Acceptable", "Unacceptable") + + +def test_gage_rr_range_method(dataset): + cols = dataset("msa_gage_rr_excellent") + result = gage_rr_range(cols["Part"], cols["Operator"], cols["Measurement"]) + assert result.method == "Range" + assert "grr_percent" in result.__dataclass_fields__ + + +def test_bias_study(dataset): + cols = dataset("msa_bias_study") + result = bias_study(cols["Measurement"], cols["Reference"]) + assert result.n > 0 + assert isinstance(result.is_significant, bool) + + +def test_linearity_study(dataset): + cols = dataset("msa_linearity_study") + result = linearity_study(cols["Measurement"], cols["Reference"]) + assert isinstance(result.r_squared, float) + + +def test_stability_study(dataset): + cols = dataset("msa_stability_study") + result = stability_study(cols["Measurement"]) + assert result.ucl > result.lcl + assert isinstance(result.is_stable, bool) diff --git a/tests/unit/test_msa_stream.py b/tests/unit/test_msa_stream.py new file mode 100644 index 0000000..205be6a --- /dev/null +++ b/tests/unit/test_msa_stream.py @@ -0,0 +1,38 @@ +"""Unit tests for continuous / streaming MSA.""" +from __future__ import annotations + +from spc_core.msa_stream import ContinuousMSA + + +def test_continuous_msa_healthy_when_on_target(): + msa = ContinuousMSA(tolerance=1.0, alpha=0.2, alert_fraction=0.10) + for _ in range(10): + alert = msa.observe_reference(measured=10.01, reference=10.0) + assert alert is None + assert msa.state.gage_healthy is True + summary = msa.summary() + assert summary["n_references"] == 10 + assert summary["n_alerts"] == 0 + assert abs(summary["ewma_bias"]) < summary["threshold"] + + +def test_continuous_msa_alerts_on_drift(): + msa = ContinuousMSA(tolerance=1.0, alpha=0.5, alert_fraction=0.10) + # Bias of 0.5 exceeds 10% of tolerance (0.1) + alert = None + for _ in range(5): + alert = msa.observe_reference(measured=10.5, reference=10.0) + assert alert is not None + assert "Calibration alert" in alert.message + assert msa.state.gage_healthy is False + assert msa.summary()["n_alerts"] >= 1 + + +def test_rolling_r_chart_after_refs(): + msa = ContinuousMSA(tolerance=2.0) + for i in range(5): + msa.observe_reference(measured=5.0 + 0.01 * i, reference=5.0) + chart = msa.rolling_r_chart() + assert chart["n"] >= 2 + assert chart["r_bar"] >= 0.0 + assert "ucl" in chart diff --git a/tests/unit/test_multimodal.py b/tests/unit/test_multimodal.py new file mode 100644 index 0000000..d814c72 --- /dev/null +++ b/tests/unit/test_multimodal.py @@ -0,0 +1,53 @@ +"""Multimodality STOP gate — clear bimodality vs normal false positives.""" +from __future__ import annotations + +import numpy as np +import pytest + +from spc_core.multimodal import check_multimodal + + +def test_clear_bimodal_stops(): + rng = np.random.default_rng(0) + values = np.concatenate( + [rng.normal(0.0, 0.4, 50), rng.normal(20.0, 0.4, 50)] + ) + result = check_multimodal(values) + assert result.is_multimodal is True + + +def test_normal_does_not_false_stop(): + rng = np.random.default_rng(0) + values = rng.normal(0.0, 1.0, 200) + result = check_multimodal(values) + assert result.is_multimodal is False + + +def test_small_normal_sample_does_not_false_stop(): + """At n<100 a normal histogram shows several noise peaks with a shallow dip. + + Treating a shallow dip as bimodality STOPped in-control studies (the gate blocks + go-live), so the separation must be near-empty, not merely lower than the modes. + """ + for seed in range(25): + rng = np.random.default_rng(seed) + for n in (30, 40, 60): + result = check_multimodal(rng.normal(100.0, 1.0, n)) + assert result.is_multimodal is False, f"false STOP at seed={seed} n={n}" + + +def test_diptest_backend_is_wired_correctly(): + """``diptest`` is a declared dependency, so the real Hartigan p-value must be used. + + The integration was previously written against a wrong signature, and because the + call sat under ``except ImportError`` the resulting TypeError was not caught — the + gate crashed as soon as the package was present. + """ + pytest.importorskip("diptest") + rng = np.random.default_rng(1) + separated = np.concatenate([rng.normal(0.0, 1.0, 60), rng.normal(6.0, 1.0, 60)]) + result = check_multimodal(separated) + assert result.is_multimodal is True + # The fallback approximation returns a near-1.0 p even for obvious mixtures; the + # real test must produce a significant one. + assert result.p_value < 0.05 diff --git a/tests/unit/test_normality.py b/tests/unit/test_normality.py new file mode 100644 index 0000000..3fbf34e --- /dev/null +++ b/tests/unit/test_normality.py @@ -0,0 +1,54 @@ +"""Normality, autocorrelation gate, transforms.""" +from __future__ import annotations + +import numpy as np + +from spc_core.normality import apply_transform, check_autocorrelation, check_normality + + +def test_normal_data_passes(): + rng = np.random.default_rng(0) + values = rng.normal(0, 1, 200) + result = check_normality(values) + assert result.is_normal is True + assert result.shapiro_p is not None + assert result.anderson_stat is not None + + +def test_skewed_data_fails(): + rng = np.random.default_rng(0) + values = rng.exponential(2, 200) + result = check_normality(values) + assert result.is_normal is False + assert abs(result.skewness) > 1.0 + + +def test_autocorrelation_gate(): + # Independent + rng = np.random.default_rng(0) + ind = rng.normal(0, 1, 100) + r = check_autocorrelation(ind, threshold=0.2) + assert r.is_autocorrelated is False + + # Strong AR(1) + ar = [0.0] + for _ in range(99): + ar.append(0.9 * ar[-1] + rng.normal(0, 0.3)) + r2 = check_autocorrelation(ar, threshold=0.2) + assert r2.is_autocorrelated is True + assert abs(r2.lag1) > 0.2 + + +def test_boxcox_transform_positive(): + rng = np.random.default_rng(0) + values = rng.exponential(2, 100) + 0.1 + tr = apply_transform(values, method="boxcox") + assert tr.applied == "BOXCOX" + assert tr.lam is not None + assert len(tr.values) == len(values) + + +def test_yeojohnson_for_negatives(): + values = np.array([-1.0, 0.0, 1.0, 2.0, -0.5, 3.0] * 20) + tr = apply_transform(values, method="auto") + assert tr.applied == "YEO-JOHNSON" diff --git a/tests/unit/test_persistence.py b/tests/unit/test_persistence.py new file mode 100644 index 0000000..d5b143e --- /dev/null +++ b/tests/unit/test_persistence.py @@ -0,0 +1,33 @@ +"""SQLite persistence + audit trail.""" +from __future__ import annotations + +from adapters.persistence import SQLiteRepository +from spc_core.limits import imr_limits + + +def test_save_and_get_limits(tmp_path): + repo = SQLiteRepository(tmp_path / "test.db") + limits = imr_limits([1.0, 1.1, 0.9, 1.05, 1.0, 0.95, 1.02, 0.98] * 3) + payload = limits.model_dump(mode="json") + version = repo.save_limits(payload, limits.version, limits.chart_type.value) + stored = repo.get_limits(version) + assert stored is not None + assert stored["version"] == limits.version + assert stored["chart_type"] == "I-MR" + + +def test_save_run_and_audit(tmp_path): + repo = SQLiteRepository(tmp_path / "test.db") + run_id = repo.save_run( + "control_chart", + {"chart_type": "I-MR", "signals": []}, + limits_version="abc123", + source_file="foo.csv", + user_id="tester", + ) + run = repo.get_run(run_id) + assert run is not None + assert run["analysis_type"] == "control_chart" + assert run["user_id"] == "tester" + runs = repo.list_runs(analysis_type="control_chart") + assert any(r["run_id"] == run_id for r in runs) diff --git a/tests/unit/test_pipeline.py b/tests/unit/test_pipeline.py new file mode 100644 index 0000000..a3219f2 --- /dev/null +++ b/tests/unit/test_pipeline.py @@ -0,0 +1,68 @@ +"""Unit tests for gated Phase I pipeline.""" +from __future__ import annotations + +import numpy as np + +from spc_core.pipeline import establish, phase1_checklist + + +def test_establish_returns_chart_and_gates(): + rng = np.random.default_rng(42) + values = 100.0 + rng.normal(0, 1.0, size=40) + result = establish(values, ruleset="nelson") + assert result.chart is not None + assert result.limits_version + assert result.gates + steps = {g.step for g in result.gates} + assert "msa" in steps + assert "autocorrelation" in steps + assert "chart" in steps + assert "freeze" in steps + assert result.chart.limits.version == result.limits_version + + +def test_phase1_checklist_structure(): + rng = np.random.default_rng(1) + values = 50.0 + rng.normal(0, 0.5, size=30) + pipeline = establish(values) + checklist = phase1_checklist(pipeline, min_subgroups=25, phase2_enabled=False) + assert "passed" in checklist + assert "items" in checklist + assert checklist["limits_version"] == pipeline.limits_version + names = {i["item"] for i in checklist["items"]} + assert "min_subgroups" in names + assert "limits_frozen" in names + # Without MSA inputs, msa item should fail go-live + msa_item = next(i for i in checklist["items"] if i["item"] == "msa_grr_ndc") + assert msa_item["passed"] is False + + +def test_checklist_ready_for_golive_excludes_phase2(): + """Go-live readiness ignores phase2_enabled chicken-and-egg.""" + from sample_data import get_dataset + from spc_core.pipeline import checklist_ready_for_golive + + spc = get_dataset("spc_individual_in_control") + msa = get_dataset("msa_gage_rr_excellent") + pipeline = establish( + spc["measurement"], + msa_parts=msa["Part"], + msa_operators=msa["Operator"], + msa_measurements=msa["Measurement"], + msa_tolerance=10.0, + ) + checklist = phase1_checklist(pipeline, min_subgroups=25, phase2_enabled=False) + assert checklist["passed"] is False # phase2_enabled still false + assert checklist_ready_for_golive(checklist) is True + bare = phase1_checklist(establish(spc["measurement"]), phase2_enabled=False) + assert checklist_ready_for_golive(bare) is False # MSA still required + + +def test_establish_respects_chart_type(): + values = list(range(30)) + from spc_core.models import ChartType + + result = establish(values, chart_type=ChartType.I_MR) + # May route to Wheeler/EWMA if non-normal, but should still produce a chart + assert result.chart.plotted_values + assert result.chart.limits.version diff --git a/tests/unit/test_rules.py b/tests/unit/test_rules.py new file mode 100644 index 0000000..80de132 --- /dev/null +++ b/tests/unit/test_rules.py @@ -0,0 +1,60 @@ +"""Stateful Nelson / Western Electric run-rule engine.""" +from __future__ import annotations + +from spc_core.rules import RuleEngine, evaluate_series + + +def test_rule1_beyond_3sigma(): + engine = RuleEngine(center=0.0, sigma=1.0, ruleset="nelson") + # Points inside: no signal + for v in [0.0, 0.5, -0.5, 1.0]: + assert engine.add(v) == [] + # Beyond 3 sigma + signals = engine.add(3.5) + assert any(s.rule_id == "1" for s in signals) + assert signals[0].side == "upper" + + +def test_rule2_nine_on_one_side(): + engine = RuleEngine(center=0.0, sigma=1.0) + signals = [] + for _ in range(8): + signals.extend(engine.add(0.5)) # all above center, within 1 sigma + assert not any(s.rule_id == "2" for s in signals) + signals = engine.add(0.5) # 9th + assert any(s.rule_id == "2" for s in signals) + + +def test_rule3_trend(): + engine = RuleEngine(center=10.0, sigma=2.0) + signals = [] + for v in [1, 2, 3, 4, 5, 6]: + signals.extend(engine.add(float(v))) + assert any(s.rule_id == "3" for s in signals) + + +def test_western_electric_rule4_eight_on_side(): + engine = RuleEngine(center=0.0, sigma=1.0, ruleset="western_electric") + for _ in range(7): + assert not any(s.rule_id == "WE4" for s in engine.add(-0.3)) + signals = engine.add(-0.3) + assert any(s.rule_id == "WE4" for s in signals) + + +def test_evaluate_series_batch_matches_incremental(): + values = [0.1, 0.2, -0.1, 0.0, 0.3, 3.5, 0.1] + batch = evaluate_series(values, center=0.0, sigma=1.0) + engine = RuleEngine(center=0.0, sigma=1.0) + incr = [] + for v in values: + incr.extend(engine.add(v)) + assert [s.rule_id for s in batch] == [s.rule_id for s in incr] + assert [s.index for s in batch] == [s.index for s in incr] + + +def test_rule5_two_of_three_beyond_2sigma(): + engine = RuleEngine(center=0.0, sigma=1.0) + engine.add(0.0) + engine.add(2.5) # beyond 2σ + signals = engine.add(2.6) # 2 of last 3 beyond 2σ + assert any(s.rule_id == "5" for s in signals) diff --git a/tests/unit/test_security.py b/tests/unit/test_security.py new file mode 100644 index 0000000..bdb37b1 --- /dev/null +++ b/tests/unit/test_security.py @@ -0,0 +1,212 @@ +"""Security hardening tests — auth, traversal, API keys, go-live gates.""" +from __future__ import annotations + +import pytest + +pytest.importorskip("fastapi") +from fastapi.testclient import TestClient + + +@pytest.fixture() +def secure_client(tmp_path, monkeypatch): + """Auth enabled, keys required, non-default JWT secret.""" + db = tmp_path / "sec.db" + monkeypatch.setenv("ASPC_AUTH_ENABLED", "true") + monkeypatch.setenv("ASPC_DEV_INSECURE", "0") + monkeypatch.setenv("ASPC_JWT_SECRET", "unit-test-secret-not-default") + monkeypatch.setenv("ASPC_API_KEYS", "test-key") + monkeypatch.setenv("ASPC_ADMIN_USERNAME", "admin") + monkeypatch.setenv("ASPC_ADMIN_PASSWORD", "s3cret") + monkeypatch.setenv("ASPC_PERSISTENCE_BACKEND", "sqlite") + monkeypatch.setenv("ASPC_SQLITE_PATH", str(db)) + monkeypatch.setenv("ASPC_CORS_ORIGINS", "http://localhost:3000") + + import apps.api.main as api_main + import apps.config as config_mod + from adapters.factory import get_repository + + config_mod._config = None + api_main.cfg = config_mod.get_config() + api_main.repo = get_repository(api_main.cfg) + api_main._admin_password_hash = None # force re-hash + return TestClient(api_main.app) + + +def _token(client: TestClient) -> str: + r = client.post("/auth/token", data={"username": "admin", "password": "s3cret"}) + assert r.status_code == 200, r.text + return r.json()["access_token"] + + +def test_login_rejects_wrong_username(secure_client): + r = secure_client.post("/auth/token", data={"username": "anyone", "password": "s3cret"}) + assert r.status_code == 401 + + +def test_login_rejects_wrong_password(secure_client): + r = secure_client.post("/auth/token", data={"username": "admin", "password": "nope"}) + assert r.status_code == 401 + + +def test_login_accepts_admin(secure_client): + r = secure_client.post("/auth/token", data={"username": "admin", "password": "s3cret"}) + assert r.status_code == 200 + assert r.json()["access_token"] + + +def test_api_key_required_for_register(secure_client): + token = _token(secure_client) + r = secure_client.post( + "/streams/register", + json={"stream_key": "line-1"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert r.status_code == 401 + + +def test_api_key_accepted(secure_client): + token = _token(secure_client) + r = secure_client.post( + "/streams/register", + json={"stream_key": "line-1"}, + headers={"Authorization": f"Bearer {token}", "X-API-Key": "test-key"}, + ) + # SQLite has no stream registry → 501, but auth passed + assert r.status_code == 501 + + +def test_reports_require_auth(secure_client): + r = secure_client.get("/reports/abc") + assert r.status_code == 401 + + +def test_reports_reject_traversal(secure_client): + token = _token(secure_client) + # Dots / separators are rejected by run_id allowlist + r = secure_client.get( + "/reports/..passwd", + headers={"Authorization": f"Bearer {token}"}, + ) + assert r.status_code == 400 + r2 = secure_client.get( + "/reports/foo/bar", + headers={"Authorization": f"Bearer {token}"}, + ) + # Nested path does not match the route → 404 + assert r2.status_code == 404 + + +def test_stream_replay_rejects_escape(secure_client, tmp_path): + token = _token(secure_client) + r = secure_client.get( + "/stream/replay", + params={ + "file_path": "../../../etc/passwd", + "limits_version": "deadbeefdeadbeef", + }, + headers={"Authorization": f"Bearer {token}"}, + ) + # 400 (path escape) or 404 (limits not found — order may vary) + assert r.status_code in (400, 404) + + +def test_go_live_rejects_unfrozen_limits(secure_client, tmp_path): + """Limits saved with frozen=False must not go live.""" + import apps.api.main as api_main + + token = _token(secure_client) + version = "unfrozenversion01" + api_main.repo.save_limits( + {"chart_type": "I-MR", "subgroup_size": 1, "components": {}}, + version, + "I-MR", + meta={"frozen": False, "stopped": True, "checklist_passed": False}, + ) + r = secure_client.post( + "/streams/line-x/go-live", + json={"limits_version": version}, + headers={"Authorization": f"Bearer {token}", "X-API-Key": "test-key"}, + ) + # 409 preferred; 501 if sqlite has no registry (still proves auth path) + assert r.status_code in (409, 501) + if r.status_code == 409: + assert "not frozen" in r.json()["detail"].lower() or "STOP" in r.json()["detail"] + + +def test_startup_refuses_default_admin_password(monkeypatch, tmp_path): + monkeypatch.setenv("ASPC_AUTH_ENABLED", "true") + monkeypatch.setenv("ASPC_DEV_INSECURE", "0") + monkeypatch.setenv("ASPC_JWT_SECRET", "unit-test-secret-not-default") + monkeypatch.setenv("ASPC_API_KEYS", "test-key") + monkeypatch.setenv("ASPC_ADMIN_PASSWORD", "admin") + monkeypatch.setenv("ASPC_SQLITE_PATH", str(tmp_path / "sec.db")) + + import apps.api.main as api_main + import apps.config as config_mod + + config_mod._config = None + api_main.cfg = config_mod.get_config() + with pytest.raises(RuntimeError, match="ASPC_ADMIN_PASSWORD"): + api_main._startup_security_checks() + + +def test_startup_refuses_empty_api_keys(monkeypatch, tmp_path): + monkeypatch.setenv("ASPC_AUTH_ENABLED", "true") + monkeypatch.setenv("ASPC_DEV_INSECURE", "0") + monkeypatch.setenv("ASPC_JWT_SECRET", "unit-test-secret-not-default") + monkeypatch.setenv("ASPC_API_KEYS", "") + monkeypatch.setenv("ASPC_ADMIN_PASSWORD", "s3cret") + monkeypatch.setenv("ASPC_SQLITE_PATH", str(tmp_path / "sec.db")) + + import apps.api.main as api_main + import apps.config as config_mod + + config_mod._config = None + api_main.cfg = config_mod.get_config() + api_main.cfg.config["auth"]["api_keys"] = [] + with pytest.raises(RuntimeError, match="ASPC_API_KEYS"): + api_main._startup_security_checks() + + +def test_startup_refuses_weak_secrets_even_when_auth_disabled(monkeypatch, tmp_path): + monkeypatch.setenv("ASPC_AUTH_ENABLED", "false") + monkeypatch.setenv("ASPC_DEV_INSECURE", "0") + monkeypatch.setenv("ASPC_JWT_SECRET", "change-me-in-production") + monkeypatch.setenv("ASPC_API_KEYS", "test-key") + monkeypatch.setenv("ASPC_ADMIN_PASSWORD", "s3cret") + monkeypatch.setenv("ASPC_SQLITE_PATH", str(tmp_path / "sec.db")) + + import apps.api.main as api_main + import apps.config as config_mod + + config_mod._config = None + api_main.cfg = config_mod.get_config() + with pytest.raises(RuntimeError, match="ASPC_JWT_SECRET"): + api_main._startup_security_checks() + + +def test_metrics_require_auth(secure_client): + r = secure_client.get("/metrics") + assert r.status_code == 401 + token = _token(secure_client) + r2 = secure_client.get("/metrics", headers={"Authorization": f"Bearer {token}"}) + assert r2.status_code == 200 + + +def test_bcrypt_is_required(): + import apps.api.main as api_main + + assert api_main._bcrypt is not None + + +def test_save_upload_uses_uuid_prefix(tmp_path): + from io import BytesIO + + from adapters.io_files import save_upload, save_upload_stream + + p1 = save_upload(b"a,b\n1,2\n", tmp_path, "data.csv") + p2 = save_upload_stream(BytesIO(b"a,b\n3,4\n"), tmp_path, "data.csv") + assert p1.name != "data.csv" + assert p1.name.endswith("_data.csv") + assert p2.name.endswith("_data.csv") + assert p1.name != p2.name diff --git a/tests/unit/test_stream_engine.py b/tests/unit/test_stream_engine.py new file mode 100644 index 0000000..8d5966f --- /dev/null +++ b/tests/unit/test_stream_engine.py @@ -0,0 +1,225 @@ +"""Unit tests for StreamEngine OOC detection with a fake in-memory repository.""" +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from adapters.stream_engine import StreamEngine +from spc_core.limits import imr_limits + + +class FakeStreamRepo: + """In-memory stand-in implementing the StreamRepository protocol.""" + + def __init__(self): + self.raw: list[dict[str, Any]] = [] + self.ooc: list[dict[str, Any]] = [] + + def save_raw_measurement( + self, stream_key: str, ts: datetime, value: float, **meta: Any + ) -> None: + self.raw.append( + {"stream_key": stream_key, "ts": ts, "value": float(value), **meta} + ) + + def save_ooc_event( + self, + stream_key: str, + ts: datetime, + *, + limits_version: str | None, + index: int, + value: float, + rule_id: str, + rule_name: str, + description: str, + side: str | None = None, + ) -> bool: + key = (stream_key, ts.isoformat(), rule_id) + if any( + (e["stream_key"], e["ts"].isoformat(), e["rule_id"]) == key for e in self.ooc + ): + return False + self.ooc.append( + { + "stream_key": stream_key, + "ts": ts, + "limits_version": limits_version, + "index": index, + "value": float(value), + "rule_id": rule_id, + "rule_name": rule_name, + "description": description, + "side": side, + } + ) + return True + + +def test_handle_observation_detects_ooc_and_persists(): + limits = imr_limits([10.0, 10.1, 9.9, 10.05, 10.0, 10.02, 9.98, 10.01] * 4) + repo = FakeStreamRepo() + engine = StreamEngine(repo) + engine.register("line-a", limits, ruleset="nelson") + + ts = datetime(2026, 1, 15, 12, 0, 0, tzinfo=UTC) + # In-control point + signals = engine.handle_observation("line-a", 10.0, ts) + assert signals == [] + assert len(repo.raw) == 1 + assert repo.raw[0]["limits_version"] == limits.version + assert repo.ooc == [] + + # Beyond UCL — must fire rule 1 + ooc_ts = datetime(2026, 1, 15, 12, 0, 1, tzinfo=UTC) + spike = float(limits.primary.ucl) + 10.0 + signals = engine.handle_observation("line-a", spike, ooc_ts) + assert any(s.rule_id == "1" for s in signals) + assert len(repo.raw) == 2 + assert len(repo.ooc) >= 1 + assert all(e["limits_version"] == limits.version for e in repo.ooc) + assert any(e["rule_id"] == "1" and e["value"] == spike for e in repo.ooc) + + +def test_ooc_writes_are_idempotent(): + limits = imr_limits([10.0, 10.1, 9.9, 10.05, 10.0, 10.02, 9.98, 10.01] * 4) + repo = FakeStreamRepo() + engine = StreamEngine(repo) + engine.register("line-b", limits) + + ts = datetime(2026, 1, 15, 13, 0, 0, tzinfo=UTC) + spike = float(limits.primary.ucl) + 5.0 + s1 = engine.handle_observation("line-b", spike, ts) + n_ooc = len(repo.ooc) + assert n_ooc >= 1 + assert any(s.rule_id == "1" for s in s1) + + # Same stream/ts/rule again via direct repo call must not duplicate + first = repo.ooc[0] + inserted = repo.save_ooc_event( + first["stream_key"], + first["ts"], + limits_version=first["limits_version"], + index=first["index"], + value=first["value"], + rule_id=first["rule_id"], + rule_name=first["rule_name"], + description=first["description"], + side=first["side"], + ) + assert inserted is False + assert len(repo.ooc) == n_ooc + + +def test_unregistered_stream_raises(): + repo = FakeStreamRepo() + engine = StreamEngine(repo) + try: + engine.handle_observation("missing", 1.0) + assert False, "expected KeyError" + except KeyError: + pass + + +def test_unregister_evicts_state(): + limits = imr_limits([10.0, 10.1, 9.9, 10.05, 10.0, 10.02, 9.98, 10.01] * 4) + repo = FakeStreamRepo() + engine = StreamEngine(repo) + engine.register("line-c", limits) + assert "line-c" in engine.registered_keys() + engine.unregister("line-c") + assert "line-c" not in engine.registered_keys() + + +def test_restore_evaluator_index_after_reregister(): + """Re-registering should seed index from prior raw measurements.""" + limits = imr_limits([10.0, 10.1, 9.9, 10.05, 10.0, 10.02, 9.98, 10.01] * 4) + + class RestoringRepo(FakeStreamRepo): + def count_raw_measurements(self, stream_key: str) -> int: + return sum(1 for r in self.raw if r["stream_key"] == stream_key) + + def recent_raw_measurements(self, stream_key: str, *, limit: int = 15): + rows = [r for r in self.raw if r["stream_key"] == stream_key] + return rows[-limit:] + + repo = RestoringRepo() + engine = StreamEngine(repo) + engine.register("line-d", limits) + ts0 = datetime(2026, 1, 15, 14, 0, 0, tzinfo=UTC) + for i in range(5): + engine.handle_observation( + "line-d", 10.0, ts0.replace(second=i) + ) + assert engine._evaluators["line-d"].index == 4 + + # Simulate restart + engine2 = StreamEngine(repo) + engine2.register("line-d", limits) + assert engine2._evaluators["line-d"].index == 4 + + # Next observation continues at 5 + engine2.handle_observation("line-d", 10.0, ts0.replace(second=10)) + assert engine2._evaluators["line-d"].index == 5 + + +def test_sqlite_repo_rejected_by_stream_engine(): + import pytest + + from adapters.persistence import SQLiteRepository + + repo = SQLiteRepository(":memory:") + with pytest.raises(TypeError, match="streaming"): + StreamEngine(repo) + + +def test_xbar_subgroup_observation_accepted(): + import numpy as np + import pytest + + from spc_core.limits import xbar_r_limits + + rng = np.random.default_rng(0) + subs = [rng.normal(10.0, 0.5, size=5) for _ in range(25)] + limits = xbar_r_limits(subs) + repo = FakeStreamRepo() + engine = StreamEngine(repo) + engine.register("xbar-line", limits) + + ts = datetime(2026, 1, 15, 15, 0, 0, tzinfo=UTC) + signals = engine.handle_observation("xbar-line", [10.0, 10.1, 9.9, 10.0, 10.05], ts) + assert signals == [] + assert len(repo.raw) == 1 + assert repo.raw[0]["value"] == pytest.approx(10.01, abs=1e-9) + + # Scalar payload must fail clearly for Xbar streams + with pytest.raises(ValueError, match="subgroup means"): + engine.handle_observation("xbar-line", 10.0, ts) + + # List payload must fail clearly for I-MR streams + imr = imr_limits([10.0, 10.1, 9.9, 10.05, 10.0, 10.02, 9.98, 10.01] * 4) + engine.register("imr-line", imr) + with pytest.raises(ValueError, match="scalar"): + engine.handle_observation("imr-line", [10.0, 10.1], ts) + + +def test_handle_message_accepts_subgroup_list(): + import numpy as np + + from spc_core.limits import xbar_r_limits + + rng = np.random.default_rng(1) + subs = [rng.normal(10.0, 0.5, size=5) for _ in range(25)] + limits = xbar_r_limits(subs) + repo = FakeStreamRepo() + engine = StreamEngine(repo) + engine.register("xbar-msg", limits) + signals = engine.handle_message( + { + "key": "xbar-msg", + "value": [10.0, 10.0, 10.0, 10.0, 10.0], + "timestamp": "2026-01-15T15:30:00+00:00", + } + ) + assert signals == [] + assert len(repo.raw) == 1 diff --git a/tests/unit/test_transform_subgroup_align.py b/tests/unit/test_transform_subgroup_align.py new file mode 100644 index 0000000..1bf1534 --- /dev/null +++ b/tests/unit/test_transform_subgroup_align.py @@ -0,0 +1,36 @@ +"""Transform path must not pass misaligned subgroup metadata into charting.""" +from __future__ import annotations + +import numpy as np + +from spc_core.pipeline import establish + + +def test_transform_clears_mismatched_subgroup_ids(monkeypatch): + values = np.concatenate([ + np.random.lognormal(mean=0.0, sigma=1.0, size=40), + [np.nan, np.nan], + ]) + subgroup_ids = list(range(len(values))) + + class _FakeTransform: + became_normal = True + values = np.linspace(0, 1, 30) # shorter than subgroup_ids + label = "boxcox" + applied = "boxcox" + lam = 0.0 + + monkeypatch.setattr("spc_core.pipeline.check_multimodal", lambda *_a, **_k: type( + "M", (), {"is_multimodal": False, "recommendation": "ok", "dip_statistic": 0, "p_value": 1} + )()) + monkeypatch.setattr( + "spc_core.pipeline.check_normality", + lambda *_a, **_k: type( + "N", (), {"is_normal": False, "recommendation": "non-normal"} + )(), + ) + monkeypatch.setattr("spc_core.pipeline.apply_transform", lambda *_a, **_k: _FakeTransform()) + + # Should not raise due to length mismatch. + result = establish(values, subgroup_ids=subgroup_ids, chart_type=None) + assert result.chart is not None diff --git a/tests/unit/test_variable_phase2_rules.py b/tests/unit/test_variable_phase2_rules.py new file mode 100644 index 0000000..ce6aa08 --- /dev/null +++ b/tests/unit/test_variable_phase2_rules.py @@ -0,0 +1,26 @@ +"""Unit tests for Phase II variable-limit rule extensions.""" +from __future__ import annotations + +from spc_core.evaluator import Phase2Evaluator +from spc_core.models import ChartType, ControlLimits, LimitSet + + +def test_variable_limits_fires_zone_rules(): + limits = ControlLimits( + chart_type=ChartType.P, + subgroup_size=1, + components={ + "p": LimitSet( + center=0.1, + ucl=[0.4, 0.4, 0.4, 0.4], + lcl=[0.0, 0.0, 0.0, 0.0], + ) + }, + sigma=0.1, + ) + ev = Phase2Evaluator(limits, ruleset="nelson") + # Two points beyond 2σ (~0.1 + 2*0.1 = 0.3) on the upper side. + s1 = ev.observe(0.35) + s2 = ev.observe(0.36) + rule_ids = {s.rule_id for s in s1 + s2} + assert "5" in rule_ids or "1" in rule_ids diff --git a/tests/unit/test_webhooks.py b/tests/unit/test_webhooks.py new file mode 100644 index 0000000..faa4f14 --- /dev/null +++ b/tests/unit/test_webhooks.py @@ -0,0 +1,15 @@ +"""Webhook helper tests.""" +from __future__ import annotations + +from adapters.webhooks import resolve_webhook_url + + +def test_resolve_prefers_stream_meta(): + assert ( + resolve_webhook_url({"webhook_url": "https://a.example/hook"}, global_url="https://b.example") + == "https://a.example/hook" + ) + + +def test_resolve_falls_back_global(): + assert resolve_webhook_url({}, global_url="https://b.example/hook") == "https://b.example/hook" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..4cf8d5e --- /dev/null +++ b/uv.lock @@ -0,0 +1,2538 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.12' and python_full_version < '3.15'", + "python_full_version < '3.12'", +] + +[[package]] +name = "aiokafka" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/5f/dfc1180fd22d1acdc91949ec36e97199c43742dacb057cb8efed3679ed04/aiokafka-0.14.0.tar.gz", hash = "sha256:8ffdc945798ba4d3d132b705d4244d0a1f493925efb57c637a2ca88ee82794e1", size = 601374, upload-time = "2026-04-29T10:43:03.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/f6/82b3d4eee9be6e81468f4b8b8a2f3780a0147095de6965d8c79db4c86f48/aiokafka-0.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:549ac4bf3bbc823151fd4bdf761d644db8b0271bd9ae3f110b7f5ab804fcc1aa", size = 348017, upload-time = "2026-04-29T10:42:26.323Z" }, + { url = "https://files.pythonhosted.org/packages/f4/45/78cb9ab3e3d16c7fde1523cfefe329f3d1331e55abaff4da67c2f355e942/aiokafka-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9fa8416efd9f260c76125eceb554c4d731115df11d15fe6c4356a4855df7eccb", size = 351463, upload-time = "2026-04-29T10:42:28.441Z" }, + { url = "https://files.pythonhosted.org/packages/b3/59/ee9a414470a978ac8edd8711482a973c513e3ef0a980eb71fc2d9dba6173/aiokafka-0.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ccacd1c5e0e3e1ab4d2b3dac5228623e5a682915a61d2adc2e018015aa259475", size = 1090654, upload-time = "2026-04-29T10:42:30.142Z" }, + { url = "https://files.pythonhosted.org/packages/22/1f/780842cd28363c00a518859a58b6e802e5f79e88a442ffb482b88cfa891c/aiokafka-0.14.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ceb49c78b3e08ed3f9ff85350932ae59788596e8b45c6a4ca5d599337ba261e9", size = 1071040, upload-time = "2026-04-29T10:42:31.794Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a5/d13ee2387feb95fccaae56c6a4ee9df15dbc33bf319693ed7a81293643db/aiokafka-0.14.0-cp311-cp311-win32.whl", hash = "sha256:5383991dcad641868a0af78c42ac86a1406ccf9803a20e2d690fc34a6119134e", size = 313386, upload-time = "2026-04-29T10:42:34.105Z" }, + { url = "https://files.pythonhosted.org/packages/12/5d/efe156d9e6cfc1affa8b513c8ec1f2ca0f2770e80a04058ce8a8851e7900/aiokafka-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:91f34a6f8626b20f0adacdd364036f40d1da85d213c2cf7be0607cde2c8d0f2b", size = 331737, upload-time = "2026-04-29T10:42:35.71Z" }, + { url = "https://files.pythonhosted.org/packages/f9/9d/3441db94829f9feb802a2f4052df61c0d1a01272accd174c351d7e9e1f6a/aiokafka-0.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:284a90d617584d7e42688a181aaa8c2a909d9c658ab9b69c6cf92f4df5c4b320", size = 348458, upload-time = "2026-04-29T10:42:37.243Z" }, + { url = "https://files.pythonhosted.org/packages/a4/10/7297589aac95654596af13301b31da2c9502c80e7e308530ee7a9bd5b9f1/aiokafka-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b4f211d9e03a1fc83871a37eefcf307bc0943ee99adae25aa39bd1722e70747b", size = 351057, upload-time = "2026-04-29T10:42:38.69Z" }, + { url = "https://files.pythonhosted.org/packages/26/4e/5c0aa8db717fff0ffb8f3e16deece8f98ded6ca17c6a543b6b20cc9a7f84/aiokafka-0.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be517b9b9513eba43ba19961dd770a6e26d08325743093feb47182770d235dd9", size = 1142238, upload-time = "2026-04-29T10:42:39.96Z" }, + { url = "https://files.pythonhosted.org/packages/88/78/322f797b9593a4cc8afd647342fa66b9ad732ee55098e5e084188c6202aa/aiokafka-0.14.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:219d2dc66b97b1aaea100697c928024b6a0348b7baa370b824900054bf86916e", size = 1131567, upload-time = "2026-04-29T10:42:41.542Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0a/a45320778385142299a7fc3ae402152ec1f383537130b8aa8e8587742fad/aiokafka-0.14.0-cp312-cp312-win32.whl", hash = "sha256:1086b470f6c452471603a2d9c8d6933739230c75758d777d8d113ff8112bad68", size = 312160, upload-time = "2026-04-29T10:42:42.811Z" }, + { url = "https://files.pythonhosted.org/packages/a3/fb/7802a0ed69200e3e8e8791df06bd6daf9b00523839d045662de4ff061b18/aiokafka-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:bcf3a8f6592d73f45965ca0750bfdfccf2555c8625358175c92f75f2cce1261a", size = 331897, upload-time = "2026-04-29T10:42:43.984Z" }, + { url = "https://files.pythonhosted.org/packages/30/b0/c9384541b2e4cc52a16402fc53fb9d44af0d78d37954cf8c7271c376ad47/aiokafka-0.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db16e43fac4c1c5006131046c1bf370c580d6ac4495a10ac7778245710943179", size = 345859, upload-time = "2026-04-29T10:42:45.449Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d1/fc266d9f4ffba4f197356c6ffdfbb0fe32e7cb874e240f299935d058ac06/aiokafka-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:32a8e91d88cf3ccf0778927715610d6579888c5f4748db4c2022cda25d628a48", size = 348284, upload-time = "2026-04-29T10:42:47.104Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8e/0c4c270786dac79f3fca74c6166c3a25b61b0d26132be0d69f0d7f206f0a/aiokafka-0.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aad4a575a506e7784e25e430f27026fe2f4378560b21b7f4e8c9a54f0d06eaee", size = 1117867, upload-time = "2026-04-29T10:42:48.394Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7f/3b89fbd0a3be9edfd5b51e20bb5cd695c851219b63c501c051cf84367fa9/aiokafka-0.14.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75e4a003502c9c3b5c705fa7c00d634ba146bf38fa5d525b80bb6ff6e3e779fe", size = 1108860, upload-time = "2026-04-29T10:42:50.249Z" }, + { url = "https://files.pythonhosted.org/packages/b3/59/849aba75cff93277bf6bf8b630de79e902949ff7ec48e4b12a64e6e32cae/aiokafka-0.14.0-cp313-cp313-win32.whl", hash = "sha256:a128e213cbc2bce0ea3db65a68920e52cebeeb8209bf001ac7aa022a8bd54d7d", size = 310889, upload-time = "2026-04-29T10:42:52.038Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e5/52eab8f8515d23da7b5d90e2c5ba10eab9494a0314f749e3f73e003f4a50/aiokafka-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:d6fa16bef3544be87bd1a7a8317b9d85e3da59f3202326d9ff22735ed052746e", size = 329470, upload-time = "2026-04-29T10:42:53.536Z" }, + { url = "https://files.pythonhosted.org/packages/50/9d/984803315fe2b883ea6e08b1d9c8a752bd5c16e966d8714bacc67c72c417/aiokafka-0.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5d70615d1530ad19d0c4da8d87abaec0a12b9fdaabffdcd4e400efa0c50ef80c", size = 346672, upload-time = "2026-04-29T10:42:55.267Z" }, + { url = "https://files.pythonhosted.org/packages/49/df/da314966b7f3c3117bd78b082563cb03dbe3007848cb8f4b0932faf390a0/aiokafka-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7e2392360c370b1ba6564c57d2889e154ecdb43157a8f7b7d7afe5e3c02fcc1a", size = 349594, upload-time = "2026-04-29T10:42:56.565Z" }, + { url = "https://files.pythonhosted.org/packages/57/7a/160516944ea0e0f68ea78e38f944c52f5248c7c7df26cba22a40b9f25709/aiokafka-0.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:201e38ecc595f9f65a945f1ef9085157ddf28f25cd2e482fd9efa1fcf4638213", size = 1114112, upload-time = "2026-04-29T10:42:57.869Z" }, + { url = "https://files.pythonhosted.org/packages/68/c4/9841118a2157e913e8ebfbc0a2b58f7b60f1f7202040c3e1df8925ed1184/aiokafka-0.14.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1cd651e1f56571baae306fdd0b5509047ab9625797a24cd75902e139c5a20318", size = 1098571, upload-time = "2026-04-29T10:42:59.356Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a1/0af8a37849a4108ae227f46c4c62f6beab31863cf66ba318fb73b0be5b26/aiokafka-0.14.0-cp314-cp314-win32.whl", hash = "sha256:128127eb96dab98150b636bb5f480c80e15f02f82a118eec206a521c8cf7cf7c", size = 314107, upload-time = "2026-04-29T10:43:01.111Z" }, + { url = "https://files.pythonhosted.org/packages/fa/18/fb46c65f758900c71d0f1c73b7802720f99cabcb1f4a11676573f9bc1b8f/aiokafka-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:aa385039aa9b235359319bbdcf48c9c86a75d81c9c547d645056d00361238903", size = 333320, upload-time = "2026-04-29T10:43:02.424Z" }, +] + +[[package]] +name = "aiomqtt" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "paho-mqtt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/44/cfc58272783a11729462dc6df5adbfeabd084f840f609054ac772ae98c19/aiomqtt-2.5.1.tar.gz", hash = "sha256:25a0a47d157e8f158d2da1110ea4786c0615518751e94f7b04976c977a8ff20d", size = 86641, upload-time = "2026-03-05T18:28:56.421Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/9e/5089fa596220bf0dc73deeb23db27904e4b3504986caf08571f6f5cb84a8/aiomqtt-2.5.1-py3-none-any.whl", hash = "sha256:fd58c3593160e4d475d90ce911cdfc4239cd64de96b0ba22edf6c86bd7afa278", size = 16051, upload-time = "2026-03-05T18:28:55.14Z" }, +] + +[[package]] +name = "alembic" +version = "1.18.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/cc/ac0bed8e562e7407fe55c3ba85a4dce86e6dbd8730887bd1e406a6c5c18a/alembic-1.18.5.tar.gz", hash = "sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e", size = 2060480, upload-time = "2026-06-25T15:20:54.888Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/78/5fe6dc3a3a5b2f5a2a4faef8bfe336d5fa049a38884ab3172e0098160c01/alembic-1.18.5-py3-none-any.whl", hash = "sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc", size = 264664, upload-time = "2026-06-25T15:20:56.673Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[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 = "aspc" +version = "2.0.0" +source = { editable = "." } +dependencies = [ + { name = "diptest" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pydantic" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] + +[package.optional-dependencies] +all = [ + { name = "aiokafka" }, + { name = "aiomqtt" }, + { name = "alembic" }, + { name = "asyncpg" }, + { name = "bcrypt" }, + { name = "fastapi" }, + { name = "greenlet" }, + { name = "openpyxl" }, + { name = "passlib", extra = ["bcrypt"] }, + { name = "plotly" }, + { name = "polars" }, + { name = "prometheus-client" }, + { name = "psycopg", extra = ["binary"] }, + { name = "python-dotenv" }, + { name = "python-jose", extra = ["cryptography"] }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "redis" }, + { name = "requests" }, + { name = "slowapi" }, + { name = "sqlalchemy", extra = ["asyncio"] }, + { name = "structlog" }, + { name = "uvicorn", extra = ["standard"] }, +] +apps = [ + { name = "bcrypt" }, + { name = "fastapi" }, + { name = "openpyxl" }, + { name = "passlib", extra = ["bcrypt"] }, + { name = "prometheus-client" }, + { name = "python-dotenv" }, + { name = "python-jose", extra = ["cryptography"] }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "redis" }, + { name = "requests" }, + { name = "slowapi" }, + { name = "structlog" }, + { name = "uvicorn", extra = ["standard"] }, +] +data = [ + { name = "polars" }, +] +dev = [ + { name = "aiokafka" }, + { name = "aiomqtt" }, + { name = "alembic" }, + { name = "asyncpg" }, + { name = "bcrypt" }, + { name = "fastapi" }, + { name = "greenlet" }, + { name = "httpx" }, + { name = "hypothesis" }, + { name = "mypy" }, + { name = "passlib", extra = ["bcrypt"] }, + { name = "plotly" }, + { name = "polars" }, + { name = "prometheus-client" }, + { name = "psycopg", extra = ["binary"] }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "python-dotenv" }, + { name = "python-jose", extra = ["cryptography"] }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "redis" }, + { name = "requests" }, + { name = "ruff" }, + { name = "slowapi" }, + { name = "sqlalchemy", extra = ["asyncio"] }, + { name = "structlog" }, + { name = "uvicorn", extra = ["standard"] }, +] +render = [ + { name = "plotly" }, +] +stream = [ + { name = "aiokafka" }, + { name = "aiomqtt" }, +] +tsdb = [ + { name = "alembic" }, + { name = "asyncpg" }, + { name = "greenlet" }, + { name = "psycopg", extra = ["binary"] }, + { name = "sqlalchemy", extra = ["asyncio"] }, +] + +[package.metadata] +requires-dist = [ + { name = "aiokafka", marker = "extra == 'dev'", specifier = ">=0.10" }, + { name = "aiokafka", marker = "extra == 'stream'", specifier = ">=0.10" }, + { name = "aiomqtt", marker = "extra == 'dev'", specifier = ">=2.0" }, + { name = "aiomqtt", marker = "extra == 'stream'", specifier = ">=2.0" }, + { name = "alembic", marker = "extra == 'dev'", specifier = ">=1.13" }, + { name = "alembic", marker = "extra == 'tsdb'", specifier = ">=1.13" }, + { name = "aspc", extras = ["data", "render", "apps", "stream", "tsdb"], marker = "extra == 'all'" }, + { name = "asyncpg", marker = "extra == 'dev'", specifier = ">=0.29" }, + { name = "asyncpg", marker = "extra == 'tsdb'", specifier = ">=0.29" }, + { name = "bcrypt", marker = "extra == 'apps'", specifier = ">=4.0" }, + { name = "bcrypt", marker = "extra == 'dev'", specifier = ">=4.0" }, + { name = "diptest", specifier = ">=0.9" }, + { name = "fastapi", marker = "extra == 'apps'", specifier = ">=0.110" }, + { name = "fastapi", marker = "extra == 'dev'", specifier = ">=0.110" }, + { name = "greenlet", marker = "extra == 'dev'", specifier = ">=3.0" }, + { name = "greenlet", marker = "extra == 'tsdb'", specifier = ">=3.0" }, + { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" }, + { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.98" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.9" }, + { name = "numpy", specifier = ">=1.26" }, + { name = "openpyxl", marker = "extra == 'apps'", specifier = ">=3.1" }, + { name = "passlib", extras = ["bcrypt"], marker = "extra == 'apps'", specifier = ">=1.7" }, + { name = "passlib", extras = ["bcrypt"], marker = "extra == 'dev'", specifier = ">=1.7" }, + { name = "plotly", marker = "extra == 'dev'", specifier = ">=5.18" }, + { name = "plotly", marker = "extra == 'render'", specifier = ">=5.18" }, + { name = "polars", marker = "extra == 'data'", specifier = ">=0.20" }, + { name = "polars", marker = "extra == 'dev'", specifier = ">=0.20" }, + { name = "prometheus-client", marker = "extra == 'apps'", specifier = ">=0.20" }, + { name = "prometheus-client", marker = "extra == 'dev'", specifier = ">=0.20" }, + { name = "psycopg", extras = ["binary"], marker = "extra == 'dev'", specifier = ">=3.1" }, + { name = "psycopg", extras = ["binary"], marker = "extra == 'tsdb'", specifier = ">=3.1" }, + { name = "pydantic", specifier = ">=2.5" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, + { name = "python-dotenv", marker = "extra == 'apps'", specifier = ">=1.0" }, + { name = "python-dotenv", marker = "extra == 'dev'", specifier = ">=1.0" }, + { name = "python-jose", extras = ["cryptography"], marker = "extra == 'apps'", specifier = ">=3.3" }, + { name = "python-jose", extras = ["cryptography"], marker = "extra == 'dev'", specifier = ">=3.3" }, + { name = "python-multipart", marker = "extra == 'apps'", specifier = ">=0.0.9" }, + { name = "python-multipart", marker = "extra == 'dev'", specifier = ">=0.0.9" }, + { name = "pyyaml", marker = "extra == 'apps'", specifier = ">=6.0" }, + { name = "pyyaml", marker = "extra == 'dev'", specifier = ">=6.0" }, + { name = "redis", marker = "extra == 'apps'", specifier = ">=5.0" }, + { name = "redis", marker = "extra == 'dev'", specifier = ">=5.0" }, + { name = "requests", marker = "extra == 'apps'", specifier = ">=2.31" }, + { name = "requests", marker = "extra == 'dev'", specifier = ">=2.31" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4" }, + { name = "scipy", specifier = ">=1.11" }, + { name = "slowapi", marker = "extra == 'apps'", specifier = ">=0.1.9" }, + { name = "slowapi", marker = "extra == 'dev'", specifier = ">=0.1.9" }, + { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'dev'", specifier = ">=2.0" }, + { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'tsdb'", specifier = ">=2.0" }, + { name = "structlog", marker = "extra == 'apps'", specifier = ">=24.1" }, + { name = "structlog", marker = "extra == 'dev'", specifier = ">=24.1" }, + { name = "uvicorn", extras = ["standard"], marker = "extra == 'apps'", specifier = ">=0.27" }, + { name = "uvicorn", extras = ["standard"], marker = "extra == 'dev'", specifier = ">=0.27" }, +] +provides-extras = ["data", "render", "apps", "stream", "tsdb", "dev", "all"] + +[[package]] +name = "ast-serialize" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" }, + { url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" }, + { url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" }, + { url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" }, + { url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, + { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, + { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/17/cc02bc49bc350623d050fa139e34ea512cd6e020562f2a7312a7bcae4bc9/asyncpg-0.31.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eee690960e8ab85063ba93af2ce128c0f52fd655fdff9fdb1a28df01329f031d", size = 643159, upload-time = "2025-11-24T23:25:36.443Z" }, + { url = "https://files.pythonhosted.org/packages/a4/62/4ded7d400a7b651adf06f49ea8f73100cca07c6df012119594d1e3447aa6/asyncpg-0.31.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2657204552b75f8288de08ca60faf4a99a65deef3a71d1467454123205a88fab", size = 638157, upload-time = "2025-11-24T23:25:37.89Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/4179538a9a72166a0bf60ad783b1ef16efb7960e4d7b9afe9f77a5551680/asyncpg-0.31.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a429e842a3a4b4ea240ea52d7fe3f82d5149853249306f7ff166cb9948faa46c", size = 2918051, upload-time = "2025-11-24T23:25:39.461Z" }, + { url = "https://files.pythonhosted.org/packages/e6/35/c27719ae0536c5b6e61e4701391ffe435ef59539e9360959240d6e47c8c8/asyncpg-0.31.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0807be46c32c963ae40d329b3a686356e417f674c976c07fa49f1b30303f109", size = 2972640, upload-time = "2025-11-24T23:25:41.512Z" }, + { url = "https://files.pythonhosted.org/packages/43/f4/01ebb9207f29e645a64699b9ce0eefeff8e7a33494e1d29bb53736f7766b/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e5d5098f63beeae93512ee513d4c0c53dc12e9aa2b7a1af5a81cddf93fe4e4da", size = 2851050, upload-time = "2025-11-24T23:25:43.153Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f4/03ff1426acc87be0f4e8d40fa2bff5c3952bef0080062af9efc2212e3be8/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37fc6c00a814e18eef51833545d1891cac9aa69140598bb076b4cd29b3e010b9", size = 2962574, upload-time = "2025-11-24T23:25:44.942Z" }, + { url = "https://files.pythonhosted.org/packages/c7/39/cc788dfca3d4060f9d93e67be396ceec458dfc429e26139059e58c2c244d/asyncpg-0.31.0-cp311-cp311-win32.whl", hash = "sha256:5a4af56edf82a701aece93190cc4e094d2df7d33f6e915c222fb09efbb5afc24", size = 521076, upload-time = "2025-11-24T23:25:46.486Z" }, + { url = "https://files.pythonhosted.org/packages/28/fc/735af5384c029eb7f1ca60ccb8fa95521dbdaeef788edf4cecfc604c3cab/asyncpg-0.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:480c4befbdf079c14c9ca43c8c5e1fe8b6296c96f1f927158d4f1e750aacc047", size = 584980, upload-time = "2025-11-24T23:25:47.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, +] + +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" }, + { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" }, + { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" }, + { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" }, + { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" }, + { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" }, + { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" }, + { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" }, + { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" }, + { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" }, + { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" }, + { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, + { url = "https://files.pythonhosted.org/packages/8a/75/4aa9f5a4d40d762892066ba1046000b329c7cd58e888a6db878019b282dc/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534", size = 271180, upload-time = "2025-09-25T19:50:38.575Z" }, + { url = "https://files.pythonhosted.org/packages/54/79/875f9558179573d40a9cc743038ac2bf67dfb79cecb1e8b5d70e88c94c3d/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4", size = 273791, upload-time = "2025-09-25T19:50:39.913Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fe/975adb8c216174bf70fc17535f75e85ac06ed5252ea077be10d9cff5ce24/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911", size = 270746, upload-time = "2025-09-25T19:50:43.306Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375, upload-time = "2025-09-25T19:50:45.43Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +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/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.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", size = 183845, upload-time = "2026-07-06T21:32:26.32Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", size = 184186, upload-time = "2026-07-06T21:32:28.025Z" }, + { url = "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", size = 211892, upload-time = "2026-07-06T21:32:29.565Z" }, + { url = "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", size = 218793, upload-time = "2026-07-06T21:32:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", size = 205737, upload-time = "2026-07-06T21:32:32.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", size = 204909, upload-time = "2026-07-06T21:32:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565", size = 217883, upload-time = "2026-07-06T21:32:35.173Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", size = 221251, upload-time = "2026-07-06T21:32:36.527Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", size = 214250, upload-time = "2026-07-06T21:32:37.852Z" }, + { url = "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", size = 219441, upload-time = "2026-07-06T21:32:39.146Z" }, + { url = "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", size = 174496, upload-time = "2026-07-06T21:32:40.467Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", size = 185113, upload-time = "2026-07-06T21:32:41.761Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", size = 179927, upload-time = "2026-07-06T21:32:42.961Z" }, + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, + { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, + { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, + { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, + { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, + { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, + { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, + { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, + { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, + { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, + { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, + { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, + { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, +] + +[[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/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, + { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, + { 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" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, +] + +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + +[[package]] +name = "diptest" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "psutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/0b/92a00796fb3a7e0fd1aea26ebcacfd6282857789ea72e847662e5103b24e/diptest-0.11.0.tar.gz", hash = "sha256:cd74d61d4e2620aa4c40900b7ff0ff48b7517fd00871c9066f737ba4081b2f81", size = 88751, upload-time = "2026-04-24T15:02:30.192Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/6b/53f48c6f143ce74cf186e53f062301161e40cab1766c2a97e54eaf80ad0e/diptest-0.11.0-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:82a0e12a6bd4047a361748c8deaea2ccface432dbb2c59bc44d4e06ca2fc51f9", size = 381319, upload-time = "2026-04-24T15:01:53.394Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c6/d7278a5d53c5eb928dc00f3ffdb4812834732d2a01d7e6aca6a169de8683/diptest-0.11.0-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:021254d83aa695369c784438ec8767035acf2c51148e92fb555b11847db8a4cd", size = 420070, upload-time = "2026-04-24T15:01:54.95Z" }, + { url = "https://files.pythonhosted.org/packages/3e/3a/b07665ad28d343fd15f61ae6b317a716655513fd3fb120239d1bf049769a/diptest-0.11.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e0720dcbb1a90d487bc7c47117039b7f12576a89d7bec99bbda1365cf072cc8", size = 236924, upload-time = "2026-04-24T15:01:56.287Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/21b6a1130ff39d8d1736e968b1476eabb7323139659dcb0e097fb30dfb34/diptest-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:399894348c60d3694c6fb37d249cbc618b2e12f2463620fd23b0f8aee442b13c", size = 401454, upload-time = "2026-04-24T15:01:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/7b/99/1e42c734b0695aec2aef68ab1691bc1b0f602b38e72c06f086c5968ed8fd/diptest-0.11.0-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:7d2b8822daabbd11b0a4e4d8356e3116db8adc0a4b67b49d487fbc8809264d54", size = 382111, upload-time = "2026-04-24T15:01:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/19/81/7ee938c02e8592e63bb4affb978ec1a7038ecc84c43a16b2942e65648cef/diptest-0.11.0-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:e2fc655e90250ffb031e616ec78e28b6c74d90ad5cfe5e84f2b9b44c417a595f", size = 421931, upload-time = "2026-04-24T15:02:00.881Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ac/34d243df356728f3d1d97ea380341a65a5cbd09a52aeb40d449cddd6a450/diptest-0.11.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6748d22df28b030b34b2d47f257d45520564b63f20330ab507c888995d6b272", size = 238025, upload-time = "2026-04-24T15:02:02.362Z" }, + { url = "https://files.pythonhosted.org/packages/e5/6b/ba30e2c6c748c54d75c6d08fde2c52a10edff987c7767be19468fea9bbf9/diptest-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:e886ff65bc938188ec65530ce377063c87ea356bf65c5e1f80e5aacb862aebb0", size = 403543, upload-time = "2026-04-24T15:02:03.634Z" }, + { url = "https://files.pythonhosted.org/packages/d3/d1/75575cb04d4852354492eb869e876ed31054be6f1dfaf7b3f0c53e92d369/diptest-0.11.0-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:f192191bcd50d9ec715b5fb6b259a1fb136970e979b69ec0f2f47d419653a12b", size = 382114, upload-time = "2026-04-24T15:02:05.268Z" }, + { url = "https://files.pythonhosted.org/packages/10/06/dbdbfaa2e80bb742fb12666091ed1a3bed61237842607efbb2866c64e603/diptest-0.11.0-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:c35497a46c727618004ab0a34ceb5e17b52d83487d87de3e3a103863f0b4d49e", size = 422010, upload-time = "2026-04-24T15:02:06.739Z" }, + { url = "https://files.pythonhosted.org/packages/a2/47/14ee6416a288abeb46f40836985d6e064b9c7c90005ed3146cdf3b7f7a81/diptest-0.11.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c582ae71d4577faed5fc8487b03f9d8c00f80d6290f5455785fd1222d879b72", size = 238138, upload-time = "2026-04-24T15:02:08.666Z" }, + { url = "https://files.pythonhosted.org/packages/04/a0/b95dbd5ed59ec2efaa16dac075b217706fa0296d97473508c78cea66aa38/diptest-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:737a17bc806b0761684d2e2bbba7a83ec7f9d77b5bda3b2b613f59e0abba8256", size = 403545, upload-time = "2026-04-24T15:02:09.955Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3f/9ac5ebe1fe0a653066e71e448ee02746ffd4377df8d5bfe7dc26b00365c1/diptest-0.11.0-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:4c7aca12a559137395dd195e091eb4056d01f787a1861b97d20c609340d94bbe", size = 382253, upload-time = "2026-04-24T15:02:11.25Z" }, + { url = "https://files.pythonhosted.org/packages/af/bd/acd2da0086aeab997da07b5ab5230e49a6113bb0bcf6b3097acf5c9285ef/diptest-0.11.0-cp314-cp314-macosx_13_0_x86_64.whl", hash = "sha256:ab5666183fd0051598ad1c7cca0e70d084efdc503245c9294bcf7cd4ad141b62", size = 422044, upload-time = "2026-04-24T15:02:12.414Z" }, + { url = "https://files.pythonhosted.org/packages/08/87/4131ec4535edf36f6ef84ef379dd7cab7dcee227c10ecdc4f68826df0169/diptest-0.11.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1965173824566d873f6c7ecfcc7949b83f18880515488ecefd367bbbb7a01369", size = 238092, upload-time = "2026-04-24T15:02:14.123Z" }, + { url = "https://files.pythonhosted.org/packages/25/46/2b4589cd82371e2ed69f351ef0384545ae46584bcf9fed4498c392844022/diptest-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:15886ee258cf7ce94fd443e29984a732230972a2ffdfb4bebea3bd2cf42bbb85", size = 414689, upload-time = "2026-04-24T15:02:15.368Z" }, +] + +[[package]] +name = "ecdsa" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/ca/8de7744cb3bc966c85430ca2d0fcaeea872507c6a4cf6e007f7fe269ed9d/ecdsa-0.19.2.tar.gz", hash = "sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930", size = 202432, upload-time = "2026-03-26T09:58:17.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/79/119091c98e2bf49e24ed9f3ae69f816d715d2904aefa6a2baa039a2ba0b0/ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399", size = 150818, upload-time = "2026-03-26T09:58:15.808Z" }, +] + +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + +[[package]] +name = "fastapi" +version = "0.139.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/95/d3f0ae10836324a2eab98a52b61210ac609f08200bf4bb0dc8132d32f78a/fastapi-0.139.2.tar.gz", hash = "sha256:333145a6891e9b5b3cfceb69baf817e8240cde4d4588ae5a10bf56ffacb6255e", size = 423428, upload-time = "2026-07-16T15:06:17.912Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/c7/cb03251d9dfb177246a9809a76f189d21df32dbd4a845951881d11323b7f/fastapi-0.139.2-py3-none-any.whl", hash = "sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c", size = 130234, upload-time = "2026-07-16T15:06:19.557Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/74/b13368064b09053253555d3f2839cc2684d22d5aed0d2ccffbf7a6736558/greenlet-3.5.4.tar.gz", hash = "sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20", size = 206538, upload-time = "2026-07-22T12:47:14.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/16/71eefcf68267bbf06a9b6bff57d0b222e49432326e85d74348b67694b8d4/greenlet-3.5.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e883de250e299654b1f1680f72a1a9f9ba62c9bd1bce84099c90657349a8dfbb", size = 294266, upload-time = "2026-07-22T11:37:56.142Z" }, + { url = "https://files.pythonhosted.org/packages/36/ea/a0b19adfc35d07e10acb626e9d22a3893b95f1309c42c4a20161dec16800/greenlet-3.5.4-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32802705c2c1ff25e8237b3bdacf2594fa02be80af8a66703eb7853ea7e68686", size = 613712, upload-time = "2026-07-22T12:26:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a121978b3337407d05a1ce5f79b4aa5998a43a9d8422f9726029b90b4471/greenlet-3.5.4-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57aa201b351f7c7c75627c60d29e4d5b97a07d37efeb62b903466fca42c097d7", size = 625582, upload-time = "2026-07-22T12:29:00.814Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4a/f301f1d85c69a86b90b5d581a73e8927bba4e79450037e6e2cbca05eb4fd/greenlet-3.5.4-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9667862a2e38ad379f11b845daeda22c8989186def44f06962c9c4c05e556da7", size = 633429, upload-time = "2026-07-22T12:43:42.073Z" }, + { url = "https://files.pythonhosted.org/packages/34/c2/080f16cf870e929e592f55767f01d6c98d2ee83bfdc36c3b892f2d0459ab/greenlet-3.5.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3fe76c2cac86b4f7a1e92865ac0a54384deb05c92986287c1a7110d9bd53071", size = 624663, upload-time = "2026-07-22T11:51:08.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2e/26884072b0eb343a4d5fee903341bfe5171b32b7f14553886e2b6349135a/greenlet-3.5.4-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:ae53534b5dec0f4c2ec26f898f538dc8ea1ca3ef2927d597a9439e40a09da937", size = 428238, upload-time = "2026-07-22T12:39:49.973Z" }, + { url = "https://files.pythonhosted.org/packages/9e/bb/8f3ca88370b817369008faeceeee85970adc16c92a70a3e5fe5fea495a57/greenlet-3.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1e1a4a684b16c45ba324e60b32a4386a87722bcb815d2a149d2182f9b401ca72", size = 1585010, upload-time = "2026-07-22T12:25:02.539Z" }, + { url = "https://files.pythonhosted.org/packages/51/c2/45877154689709ebce9a0b83c2235e6ca0f31577889b02af308c8cc5f8fb/greenlet-3.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e849e6e139b9671adeac505f72fc05f4af7fd1921faef40295e214fc3b361b59", size = 1651283, upload-time = "2026-07-22T11:51:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7d/8711a75cb61d85246277c07ff6e1a6504621ba473d808c11ad225ffca43f/greenlet-3.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:dc418cf4c873357964d6624445ed09472e50def990c65dd4e76fc3ba8cd9cef6", size = 246434, upload-time = "2026-07-22T11:43:15.557Z" }, + { url = "https://files.pythonhosted.org/packages/00/62/e290b3bce433da8f0324ac02da0b128d683482229f1a8b789fa47818a4cd/greenlet-3.5.4-cp311-cp311-win_arm64.whl", hash = "sha256:c38c902a0986eba1f6e7ba1ab39ad5195926abde90f3fe080e08212db62176da", size = 244990, upload-time = "2026-07-22T11:39:22.626Z" }, + { url = "https://files.pythonhosted.org/packages/f3/04/81bd731d6d1e3a469d9a4c36f5eb069bcf0cbb2d5d342c9fec22245b91fc/greenlet-3.5.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3d66250e8b09f182ede05490998c818b5961f7a3640332d44c4927caec7bbfe4", size = 295909, upload-time = "2026-07-22T11:38:09.261Z" }, + { url = "https://files.pythonhosted.org/packages/cc/dd/f5f22903a6ae70f5ea328ed0beaec92ad903f0e3b7d2845133b354abc4b8/greenlet-3.5.4-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c90e930c9c192e5b3ee9fb8bcd920ea3926155e2e3ded39fc697323addecee17", size = 612011, upload-time = "2026-07-22T12:26:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/8e/10/92a4a88d12b915d74ea5b6d288e4afefda4771647caa34442c156f7a454f/greenlet-3.5.4-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:791fdfeeb9c6e0c7b10fa151bf110d2a6974866f13dcb5b1c7efae698245893a", size = 624299, upload-time = "2026-07-22T12:29:02.089Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f9/03e26be3487c5238e81f2b84714959a86ea8515a869828cf41f4fc54b34e/greenlet-3.5.4-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b7c895310363f310361e0fe2072af85269d2a2a285cd04c0c59e79a5e3670dcf", size = 629603, upload-time = "2026-07-22T12:43:43.456Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/0b14bb9db2989f32cd9fe7f76afedea01ee8bee3f87c07e69f24adfe7e63/greenlet-3.5.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f88193799d43dbf8c8a806d6405c9c52fe2af40bf75072a606357b33cc336c7f", size = 621541, upload-time = "2026-07-22T11:51:09.464Z" }, + { url = "https://files.pythonhosted.org/packages/57/6b/7c55ca72ef80d57c16c4a55210f82582622462dc4485799a30f4ec6f3372/greenlet-3.5.4-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:13b980043cb1b3134e81ea469da1250ddcc6bfe6d245bbaa59168d9cdc8f228f", size = 432554, upload-time = "2026-07-22T12:39:51.379Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/25e9a2d9eb6b2e8b7ca4e80a3a26cb887cce6c8e0a87c921164f11bc5574/greenlet-3.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7a5f095767c4493afcd06067f2bb3b8716e3f3f9e92b99c88e7e99f885b3d4d", size = 1581444, upload-time = "2026-07-22T12:25:03.818Z" }, + { url = "https://files.pythonhosted.org/packages/b9/96/4c9bf2e2c408dcc0556edce69efa9f802e82223573c53240136a086821f1/greenlet-3.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:42afdc1ab5f66da8c586c32af9224a74a706b4f0ea0dc3a4188a0860a09c65c9", size = 1645842, upload-time = "2026-07-22T11:51:12.295Z" }, + { url = "https://files.pythonhosted.org/packages/b5/41/303ecb26a3a56122c0f4d4073ee078881847bd6b6f463ae0ec57ec20223b/greenlet-3.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:60149df8f462d1b230038e6590c23c3b4768bb5d6c022b3b6e82532b34b0b8a3", size = 247169, upload-time = "2026-07-22T11:38:19.893Z" }, + { url = "https://files.pythonhosted.org/packages/a4/e3/ef56864b4c35fcb3eb3b41b869f6cc46f4cd3f5e2c68e74acde8ac433951/greenlet-3.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:77d6ce04fed0d9aeed42e0f37923cc43eba9b027bdd9c34546bb4ccd143d0fe0", size = 245565, upload-time = "2026-07-22T11:38:27.061Z" }, + { url = "https://files.pythonhosted.org/packages/c0/9a/e51225dcd58713f16ccbdcc501a8da21098ea14515b7870f1f94459e5ff5/greenlet-3.5.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:24e61b88cb7e1b1d794b32a10cc346ac779681d6d74ff137a3e0a444d2bf1f02", size = 294831, upload-time = "2026-07-22T11:38:53.389Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ea/de50a50fadf979713ab18b46f22ad5ff5f2dcfc637a3ebdecf669801e1a5/greenlet-3.5.4-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:870d730fec833f5a06906a32596cc099b9161594642a92a520b7a88911c95356", size = 614619, upload-time = "2026-07-22T12:26:42.282Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/2aae27fea41205b8650294c301f042a2a4bb6155eea48c995b890a92f2c1/greenlet-3.5.4-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec5ff0d1878df6af3bf9b638a5a92a7d5693291de77c91bff10fa48519c604ef", size = 627021, upload-time = "2026-07-22T12:29:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/1b/80/fb4d4788bbc8e54761f1fc88533af9523a6e86299fa113d6e8a8503ed9fc/greenlet-3.5.4-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07bd44616608d873d06735b63ef1a88191d6ca57c8d291d6559c71bc14c0893c", size = 632845, upload-time = "2026-07-22T12:43:45.19Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/79fd826f9ccaae0b84e1b4ef68dabba5e105bb044ffcd448a0b782fcba9a/greenlet-3.5.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d84d993f6e575c950d91a23c1345d18fe1a4310d447bf630849d7809196b52f0", size = 624002, upload-time = "2026-07-22T11:51:11.391Z" }, + { url = "https://files.pythonhosted.org/packages/42/e3/6086fa578ebb72772722cdc4bcd628459814b42e0c2db1e3cbd6552b3271/greenlet-3.5.4-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:3529a8a933582ad19e224792cac7372489526576b75b4c124e8e4f29948f4861", size = 435053, upload-time = "2026-07-22T12:39:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/27319f97e731298513dcba1a2e91b63e9d8811d9de22130f960b129b1bf1/greenlet-3.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:58023945f421093de5e6fa108c0985a8659d43f49e0216da25099369a121bcbd", size = 1581533, upload-time = "2026-07-22T12:25:05.322Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6d/24240bf562e9786dd2799ee0a4a4dadb4ded22510f41b20245099159ac8c/greenlet-3.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bae2728e1897aa8df8cb1af38cd48b3a743aefe29372de7b8b7a9f532501e69f", size = 1645781, upload-time = "2026-07-22T11:51:14.805Z" }, + { url = "https://files.pythonhosted.org/packages/c1/5a/442ab1a9ef7ca6bf7210e5397a95972206a91a31033a03c8900866a10039/greenlet-3.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:ca5726c0b08ca35ae873557266a78b2c3f3b2b7d7401aa5ff886c2045dd0111c", size = 247133, upload-time = "2026-07-22T11:39:20.661Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/9160210222386b1a378ff94db846b9508ca24a121cf684991561fdb69280/greenlet-3.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:7c1303791d603080cac6fc3b34df51c3b75b723739c282c8029e48a0d241672f", size = 245500, upload-time = "2026-07-22T11:40:22.185Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/6ab1d4f9cd548d15ab90da29947f2076100130bb179b0bde59f795a459e3/greenlet-3.5.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7e8afa5eac028f8140ceafe5ceec66e6aa127ddcb21452d2a564dcd2900b5f22", size = 295410, upload-time = "2026-07-22T11:40:35.747Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7a/422f63b4715cbc0b24385305407adf38b48f6bb68b3e6b04090e994d0f5a/greenlet-3.5.4-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73b37afe369021423ea53dd3123e04bffa7e93ac64429b9f50835b2e4fcae7cf", size = 661286, upload-time = "2026-07-22T12:26:43.8Z" }, + { url = "https://files.pythonhosted.org/packages/d0/31/5a1cac663bf5582190c5a714ef81364f03cde232227f39748f8ae4c11da5/greenlet-3.5.4-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ef964f56dfcb6f9bbef2a190d9126795eac408716aeae47b5e7c73c32aafca9", size = 673517, upload-time = "2026-07-22T12:29:04.815Z" }, + { url = "https://files.pythonhosted.org/packages/9c/bf/250c2921c7b585dde12f5239e313ca2dcbc464d161ecca36e4e6ef21762d/greenlet-3.5.4-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cef589bc65fae02d10bca2ac341191c5b33acc2967892ebf4fcbd10eabb7a74c", size = 677968, upload-time = "2026-07-22T12:43:46.788Z" }, + { url = "https://files.pythonhosted.org/packages/15/4a/2a82a1e3f8aaca020853ac8d12211280ca2b231aa08ea39f636f1060c319/greenlet-3.5.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c53ff01a5c53a40f2c16820ebc56d7c61a77f5fbe009dadd96292d5682f80f8", size = 670917, upload-time = "2026-07-22T11:51:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/18/40/10bfcf6513558d82f7b95dd728001c63bd388259fe27d3e30ae01f103430/greenlet-3.5.4-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:dfc41ae893d9ceaf22c824f2153a88b30651b20e8758c2cd9ac143f23640563c", size = 480643, upload-time = "2026-07-22T12:39:54.149Z" }, + { url = "https://files.pythonhosted.org/packages/68/b0/e379a152b17bfdfa95795af4049e37c0fd1b4d81f020d426db104ed07c77/greenlet-3.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecca4d80d55a01ad6b23b33262662956149fbb7b2c6be2910f1705921958cbf3", size = 1628478, upload-time = "2026-07-22T12:25:06.678Z" }, + { url = "https://files.pythonhosted.org/packages/5e/43/bffdfa64f7317f954c5c1230b5dd5922676ce198689a68c1ac1ed4b1b1a5/greenlet-3.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffbc533e0eaf8e80d8471411646ab88fe58f641d508c0b02b24494479f4d9ec", size = 1692021, upload-time = "2026-07-22T11:51:17.008Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/f799f9637e2c6e9b0b716015e339040598b058cf7654dfc0d67468b177ed/greenlet-3.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:305f69e6c4523d7f6979ed001cff4e5853c063e5da04880296603aa0227e544c", size = 248031, upload-time = "2026-07-22T11:40:11.007Z" }, + { url = "https://files.pythonhosted.org/packages/05/75/625bcdd74d5e6b2dca1ecba3c3ac77bcf8a026c21a649a46cef23e421f97/greenlet-3.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:f260930bbbbcf9caee661211235a5111c86dfe5832fdf6ae4570da1e0995320f", size = 246892, upload-time = "2026-07-22T11:40:27.357Z" }, + { url = "https://files.pythonhosted.org/packages/ec/69/35c62ed49c320cb4d98e14698ccca5467d3bfe683984172be9cb564d9ce3/greenlet-3.5.4-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:41ddab54e4b238f4a6c323f39b4e59e176affd5a94d461a9fb7583dac74240a3", size = 305571, upload-time = "2026-07-22T11:40:31.659Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6c/64d60216b3640dcb0b62d913dd9e0d80030c09115bb2e4ba70c95d10ca45/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3dabe3e2809013052c68bdf0b7fa5f5f2859c43a80803131ad61af9cabd7867", size = 672568, upload-time = "2026-07-22T12:26:45.298Z" }, + { url = "https://files.pythonhosted.org/packages/5c/de/ba3ab0a96292e53039530333b0d2ae18d9e508f3a325cd7bf15f8172944c/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:27d3f00718634d4520a3a150154ac5da36f257869d41321953375b90bfbbc72c", size = 680076, upload-time = "2026-07-22T12:29:06.125Z" }, + { url = "https://files.pythonhosted.org/packages/ae/db/24a10af12bf8e639cec46c38b9ce1a282543ba42ff4fb0b31a970f1ab603/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39169a11d87a6a263afda3e9a27d1df16d0f919d40a4837cc73986c9884c0dd8", size = 681690, upload-time = "2026-07-22T12:43:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/aeada79083c6f1c15f45d77a332f9c441af263ee298e3eb17522cd337d22/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbd60b5763c6543c1827e48faaf14ea9bfbad245f52b1a4d76a2a2d8884c6c66", size = 676733, upload-time = "2026-07-22T11:51:16.027Z" }, + { url = "https://files.pythonhosted.org/packages/f4/60/44a2eca7b9fd71ae0fae7ff184da1cd3169d176652b97aa1cffcbb0ef961/greenlet-3.5.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:bd3d1145f603b2db19feb9078c2e6855eb7c67e15580c010ed815cee519b86fd", size = 510263, upload-time = "2026-07-22T12:39:55.678Z" }, + { url = "https://files.pythonhosted.org/packages/e9/10/2392fc3a98948652ef5fd1e7275c04f861dd13f74b78a2b4309f4ee4d090/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f00f910f0e7b35416c63b23ad78b769aeccfc1775f712b43c4ee525624a2eef7", size = 1637327, upload-time = "2026-07-22T12:25:07.879Z" }, + { url = "https://files.pythonhosted.org/packages/55/c6/e7237a3dfa1f205ed0d9ea1e46d70bd2811b32d516266399fb59d28ab90a/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:91c26423753b92caf41ab3f98fd547d7374d4d9fc2d85be041886c1579d9255e", size = 1697493, upload-time = "2026-07-22T11:51:19.214Z" }, + { url = "https://files.pythonhosted.org/packages/55/e3/4ba8154ba2a3d43729e499f72471b4b5c993f3826d3e24da81d5f06d6572/greenlet-3.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:ee032b91fd8ec29ec6c4cea2b8c561b178435134bd0752c7334b94e9c736c132", size = 251637, upload-time = "2026-07-22T11:40:37.44Z" }, + { url = "https://files.pythonhosted.org/packages/90/03/e3f96dfc100261a29545ddc8270cafe58f9195b6651466910e820910de77/greenlet-3.5.4-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:178111881dd7a6c946471fda85485ec796e1043c2b939f694b096e2ecf986809", size = 296076, upload-time = "2026-07-22T11:39:38.364Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3d/da52d208e5c977bce8667e784729e584e38b5785f4c1ec0f4c836e9a1c42/greenlet-3.5.4-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d92df08dd65fede97fc37aad36c2e9dcda3b31c467f8e0c2c096456cb818e927", size = 666870, upload-time = "2026-07-22T12:26:46.691Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8a/7e6dee25cb8a8cf9b362c8e597cc269593378bd916f16c736c059a52e85a/greenlet-3.5.4-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99e8f8c4ebc4fd80aa26c1280ae9ad43a0976e786349703a181cf0bae60413e5", size = 677678, upload-time = "2026-07-22T12:29:07.508Z" }, + { url = "https://files.pythonhosted.org/packages/51/a7/dafc7415d430b0a43a16396eb49ecb3b62fd720877fb259cc4dcfaf5f31e/greenlet-3.5.4-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1f17e362d78e37559e0506c5a7d066bdd45073c36a0127a543e8a0df27242ff3", size = 681428, upload-time = "2026-07-22T12:43:49.623Z" }, + { url = "https://files.pythonhosted.org/packages/6c/21/5a38699fa45de749e3857d93b8f07e4c20489e77c2d35d915a2e1c456606/greenlet-3.5.4-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:394de08dad5ffcb1f50c2159d93e398d9d2da3ed437645eaa54771fa720db9f0", size = 676067, upload-time = "2026-07-22T11:51:18.163Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d9/6298f3432de301d4718766cf934bd73c418c73f81fbb77247319364b0d96/greenlet-3.5.4-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:cd320d998cbaa032932830448e39abf3c6a12901295e386e8114db926e10cffb", size = 487446, upload-time = "2026-07-22T12:39:57.044Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ba/863116ab8ff1ca7a729e327800268939d182db47aa433db70e216e7d9194/greenlet-3.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:c883d61f2282d72c767a14936641b3efcbde9d82f1080712aaea0b1d3126cb88", size = 1633489, upload-time = "2026-07-22T12:25:09.605Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7466ced82818d6132462d7f26b3f83c66ea15d2b193a6c0088d558ed7d95/greenlet-3.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2a924f15d17957e252a810acefcb5942f5ca712298e8b6fcaed9a307d357522c", size = 1696584, upload-time = "2026-07-22T11:51:21.304Z" }, + { url = "https://files.pythonhosted.org/packages/f9/4d/55b638489260065de9ffce606c8b5d04507bef705de4b212a0c3d6a1a0df/greenlet-3.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ed17e5f3420360d5b459de8462efb52060399a5326a613d4cde31cef63ef95da", size = 248297, upload-time = "2026-07-22T11:42:04.055Z" }, + { url = "https://files.pythonhosted.org/packages/bb/08/9dd4ae635da93d41dc268bc34bd62a9d711ed8b8825c5d22ac910c7d6e6d/greenlet-3.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:f908898d6fa484ce4b6f447ce70ea99b52c503fee419e53cf74d60a16bc9e667", size = 247423, upload-time = "2026-07-22T11:44:00.764Z" }, + { url = "https://files.pythonhosted.org/packages/19/66/7c87ed9cdbf1d49c2c6cd1c7b9dd4d16c33b24235ca03972293a1876b30c/greenlet-3.5.4-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:1833637f17d5e7472548a48575c394fe39f1b1890d676d162d86593610f44d8c", size = 306487, upload-time = "2026-07-22T11:41:25.118Z" }, + { url = "https://files.pythonhosted.org/packages/5b/05/0a4201e7c0054866eefc05da234f236dd4c950d0fbf9ca0517141f01b269/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12cda9122e03341f1cb6b8207a19d7a9d375e52f1b4e9243918375f40fd7b4b9", size = 676479, upload-time = "2026-07-22T12:26:48.129Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/4b6b8c5a3aec70f0649cd89662d120fdd6421e2bdc8e3b15c3ab5ec568d8/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d83ae0e32d14957ab7170785a20f582635c8474deab1bfbb552b17e769a6ce25", size = 684321, upload-time = "2026-07-22T12:29:08.925Z" }, + { url = "https://files.pythonhosted.org/packages/88/15/0b167aeea95285b0e654ddce651922f666c089363c2ec528ca8b9a9ba74f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:123aa379c962ed5fe90a880327e0c3066124ac64ec99e12a238be9fd8eb3db3d", size = 685995, upload-time = "2026-07-22T12:43:50.993Z" }, + { url = "https://files.pythonhosted.org/packages/24/c9/b49c31c9a972eee91e260445770e922244a7efc542697f51a012ec046d0f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f1467de1bb767f75db0aa34c195e3a496d8d1278c796e70c24ce205d3e99cde", size = 681293, upload-time = "2026-07-22T11:51:20.43Z" }, + { url = "https://files.pythonhosted.org/packages/de/90/c023ec337f32ff505be7db759c80d98f0532bb94d0c6fa13645efe9bee2e/greenlet-3.5.4-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:adf2244d7f69409925a8f22ed22cc5f93cdfe5c9dc87ff3476be2c2aaae61a05", size = 516928, upload-time = "2026-07-22T12:39:58.359Z" }, + { url = "https://files.pythonhosted.org/packages/95/6f/7f2d4653770500eee667866016d42d7a68e3d3462f80df6b8e3fcd48a0eb/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0fa53040b78b578120eecdc0265e3f1051487cc425d11a2b7c761daadf4feaa8", size = 1642474, upload-time = "2026-07-22T12:25:10.819Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d8/8cba31036a4caae448087ba5d150660ab03b4a0f54d9150f6495a3be7262/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:60e0bc961d367df506660e9ac0177a76bc6d81305300704b0977d1634f76efe2", size = 1701012, upload-time = "2026-07-22T11:51:23.17Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cd/3f77a4cce3bae631b08eb52f53a82a976669600337e21dfdba811cb50267/greenlet-3.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:f680e549edb3eaf21eea4e7fe101e15ec180c74b7879ab46adc080f22d4015d2", size = 251977, upload-time = "2026-07-22T11:41:38.125Z" }, + { url = "https://files.pythonhosted.org/packages/93/e8/65e8707d00fe2a49bf12f609a9b2b39ba6dd23c2810eacad877c4fc94bfe/greenlet-3.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:08fc36de8442d5c3e95b044550dbea9bf144d31ec0cc58e36fb241cb6ef6a994", size = 250538, upload-time = "2026-07-22T11:40:17.985Z" }, +] + +[[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/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/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 = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +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/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" }, + { url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" }, + { url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload-time = "2026-05-25T22:17:06.75Z" }, + { 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 = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +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/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 = "hypothesis" +version = "6.161.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/1d/f5453faa2dd41890212d858f9dfa2a9e6db643c3037d6758101f38ae3f8a/hypothesis-6.161.1.tar.gz", hash = "sha256:44ee51052b560245676cacf8c88be23f312132cdf29cb08dd092b52fdcf07a5d", size = 486148, upload-time = "2026-07-23T20:37:08.065Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/23/445da79f0847e63f2acfc7ca446d18dc4ddc05a2bd88c70c2a28b3a65510/hypothesis-6.161.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c74cc6f010ac2635591891de8c16a3b5c0576f3661750509cef2487a6c1bee9a", size = 766527, upload-time = "2026-07-23T20:36:38.332Z" }, + { url = "https://files.pythonhosted.org/packages/b2/da/d7329ec56c2762553ad0f370406af4f47c1a74aed8fc7549f13c007f549c/hypothesis-6.161.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e1584bd873c68847ece7d8310f44c739a465122d94a506c44e9378278838779b", size = 762095, upload-time = "2026-07-23T20:36:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9e/7ef3091342d7b6445aec7a77b243b5fdf54ca61a6c1a3020f68c16ad52c2/hypothesis-6.161.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed29064e1c5b3062f74c8d073a3b623b33310fa4b13ce168db2c7ecd43caa922", size = 1091343, upload-time = "2026-07-23T20:35:49.161Z" }, + { url = "https://files.pythonhosted.org/packages/68/41/4b48032eaa17c59e648dfd5224fefcfe9626d7972cd99f89b3632d9dac4d/hypothesis-6.161.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7567f56527cdfe53989fcb99dca46cd805e28ca392c183886757b7f870b90046", size = 1140882, upload-time = "2026-07-23T20:36:09.431Z" }, + { url = "https://files.pythonhosted.org/packages/47/7e/9be7ae0c525a169eb6e824ea0f9b517a47887d22e379f2ad013cd0ff736f/hypothesis-6.161.1-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:730244719991ab11c1dfb6fe7a40488bdb6a632d4c856d08499d979e71e86c6b", size = 1132968, upload-time = "2026-07-23T20:36:58.457Z" }, + { url = "https://files.pythonhosted.org/packages/dd/10/f71f3fbb34cf996957d832dad6bec8a34415cd2b9145693bb082c00944b8/hypothesis-6.161.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b9956a4de0d04078791ef0b78f60e160aae205d9c8871b9a23f1db727d099dd7", size = 1265175, upload-time = "2026-07-23T20:35:52.172Z" }, + { url = "https://files.pythonhosted.org/packages/f3/28/82c4edad608931113d5993fede9fc9b6045f5d1203e538af0726d5b14a79/hypothesis-6.161.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:def1fa012d9d8fce70e8c107e2137b9ab89293884470cc6f565c42c5f07c029e", size = 1307849, upload-time = "2026-07-23T20:36:06.065Z" }, + { url = "https://files.pythonhosted.org/packages/4e/10/7ad339cdec8df2bd907e3b2a712dd5f36f59f2c1ea2d7d01417436338692/hypothesis-6.161.1-cp310-abi3-win32.whl", hash = "sha256:410e09dff0cc332b4434aa6f1a20f9d6c037ab938f3db4f6adc272b2a7d03fc4", size = 652384, upload-time = "2026-07-23T20:36:12.771Z" }, + { url = "https://files.pythonhosted.org/packages/68/65/821390df2877ea60e48d028a2659fbfa1a852b393655ce3a99dd04522002/hypothesis-6.161.1-cp310-abi3-win_amd64.whl", hash = "sha256:bba4ea9d1ba5ad6ad93c500ce4f241329de28a91489a9a318f75be8dc79f477d", size = 658547, upload-time = "2026-07-23T20:36:19.407Z" }, + { url = "https://files.pythonhosted.org/packages/91/eb/e8905c416553928666faa4570e76ebdbf2e9cfd2bde2e6b3fed5142858f2/hypothesis-6.161.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:62db533a8f41045372a91e6acb8518dfa31ba1ba9f0c7ce2a8ac5f2e27279e99", size = 767021, upload-time = "2026-07-23T20:36:51.371Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b8/97ae26ed290f35002b6de54e3f098fcbcb655719d6cb4e12949475662555/hypothesis-6.161.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c2a187fe599a3110db939f69e17a2b4afd273f495c2853ceafc9df1158bb430e", size = 762815, upload-time = "2026-07-23T20:35:40.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/58/74e0fdc83ca20e489ea1335423412af0603484ec13de22d67cce1bb421d5/hypothesis-6.161.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b403242df4e141073766b5838cd1ad28230210f77b6d8a15ab97337619806aaa", size = 1091701, upload-time = "2026-07-23T20:36:41.876Z" }, + { url = "https://files.pythonhosted.org/packages/66/df/c16b86e35079a55146228a1ec929fc2ed2ed5633fe3c798a5bf6d5a0015a/hypothesis-6.161.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2cf74d66cf0a62d6699d2b8b663a3c037e534c1a696ddbf1763718d9e9e5fc1b", size = 1141173, upload-time = "2026-07-23T20:36:27.665Z" }, + { url = "https://files.pythonhosted.org/packages/2c/b4/1145b989a0a882904659daff846bfb3c805ed51e706e4512b8e3f3246073/hypothesis-6.161.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:44494a3e00b698330a171819a32f6b94cb369f7308d026ebbd3472722ff9b387", size = 1265531, upload-time = "2026-07-23T20:36:23.005Z" }, + { url = "https://files.pythonhosted.org/packages/26/52/f61917ab9637ea20a27ba62ce55b0803789b946c346fc2540621627086ae/hypothesis-6.161.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:eb6161c6bad24d9e2b00276fb84597c3f4335382ee8aa1c310c95a9b8aa331b5", size = 1308129, upload-time = "2026-07-23T20:36:07.952Z" }, + { url = "https://files.pythonhosted.org/packages/9d/a7/047a86953b73e2936c7af8bee5f4185ebb7b9b2cd75c34c158a7f980db8c/hypothesis-6.161.1-cp311-cp311-win_amd64.whl", hash = "sha256:c0dc530643f6a7e5be535219b31bd63a7bbe484a38f5224f2fa30da08b286729", size = 658237, upload-time = "2026-07-23T20:36:53.204Z" }, + { url = "https://files.pythonhosted.org/packages/92/06/c724ff585b9e61a2c40500e214f19794a02fe6a48d7587d9fcb27b75cf56/hypothesis-6.161.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2b12a6bc4f3f6071b9db444c49b387a2f005b53ecb491aa10c4bdac32abc8df4", size = 768144, upload-time = "2026-07-23T20:37:04.462Z" }, + { url = "https://files.pythonhosted.org/packages/78/3e/35ac6507910eaced5d06dc60b6e3484a718a9cc89791e067f7f17b45ec60/hypothesis-6.161.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:db2d1d564c9a232007e5f1b7e9a4333c3718f2b73dc47d82f2300fb2cfce0840", size = 759714, upload-time = "2026-07-23T20:35:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/94/16/3505c465f0e9f611197730561a9c787ff02ce9a7f54cdf2bd32f083af207/hypothesis-6.161.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12cf495fc49e4e3cee80f50fb0126a0ad99f9d6baa0984f1f5923c62bdd365f8", size = 1090154, upload-time = "2026-07-23T20:35:42.987Z" }, + { url = "https://files.pythonhosted.org/packages/35/09/720f80ff2daca23e164a133c4102a25d5006a5d00577bdfdf1f7df5045d1/hypothesis-6.161.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf5a709efe1eb193c340f57110df210d146967a93e7fe611213df43594c59530", size = 1140198, upload-time = "2026-07-23T20:36:33.243Z" }, + { url = "https://files.pythonhosted.org/packages/72/06/10583f6b185031e0366cdffaeb54a9fa7577799b4017e67381eb309610b2/hypothesis-6.161.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:432f832c52872369c0f9a85bd4d18198944c64a7b4d4f63a133c9837c360f951", size = 1262979, upload-time = "2026-07-23T20:36:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/98/b2/abd0a1e9e05ba7ce639833eb93ab27ffdb940ffd146ab9b17ba3dcd2a4a7/hypothesis-6.161.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eb8a90449d2138ef6b82cecf39311e10f30cef89ad792aeb574086193752a8c9", size = 1307159, upload-time = "2026-07-23T20:35:58.162Z" }, + { url = "https://files.pythonhosted.org/packages/50/7d/03607c3f83c2312cdab52d9ce25054216fcea2c33da483d8d1afb6356d10/hypothesis-6.161.1-cp312-cp312-win_amd64.whl", hash = "sha256:36652bce788e77ccb1bd92a21fd6491980bae5ecdf7686e8943aa70458c873a6", size = 655680, upload-time = "2026-07-23T20:36:40.063Z" }, + { url = "https://files.pythonhosted.org/packages/22/c7/8295a14fabbfa8ed90bd2c382e8f00b8726e4feb5b94236ff463d5ab2004/hypothesis-6.161.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b6f3a2eeb3b141c662b572585c903955d41ce6ac1d07ff6b927e658170fab2f8", size = 768018, upload-time = "2026-07-23T20:37:06.242Z" }, + { url = "https://files.pythonhosted.org/packages/d8/44/88f576f295aec95ff07febabb2029b64db02d392b9d0969cb70d27a5f2ef/hypothesis-6.161.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f6bc47b4100af38c740cbcd8a1cc5a69b57d67b6c910feeb964a0badad35cdf", size = 759679, upload-time = "2026-07-23T20:36:49.537Z" }, + { url = "https://files.pythonhosted.org/packages/d7/10/9d757e68d53e5ecd973aa5e09bc16ab3d045f7cc4b409ac69bf2a62c31a0/hypothesis-6.161.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ac69b3f9682b54a8dca79738805301efb6cbdc0645eacbac650992f6571b56a", size = 1090070, upload-time = "2026-07-23T20:35:50.72Z" }, + { url = "https://files.pythonhosted.org/packages/e2/eb/1ee79c1f34024738090a1033cd2e3825e590011a310922f4ff88a0730ad5/hypothesis-6.161.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b3abe32e180cef09772768ec59b2ce9320b03cbc8756a686163582c82ce5056", size = 1140012, upload-time = "2026-07-23T20:36:56.686Z" }, + { url = "https://files.pythonhosted.org/packages/43/95/cef03067e9d3249893d6c8e2527af7cd0d843729b1d5580734230c00c794/hypothesis-6.161.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3fbcf071b12d7f133f910dde2ddcb820285b4d3549f5c75aa826b8eda6e42768", size = 1263022, upload-time = "2026-07-23T20:36:11.118Z" }, + { url = "https://files.pythonhosted.org/packages/98/05/6d9732f5052a77eb119784eaf18a19e83a23f0d0aeba8358ccfd6ae53e9a/hypothesis-6.161.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f82837674382a8e03e9389cf3c3444a052420c6e697b7cab4ada2d28448e226f", size = 1306912, upload-time = "2026-07-23T20:36:47.5Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9f/e1fd97cd3801782a98d642fc10d9310e4f5c852aee8933d49e50d0265c49/hypothesis-6.161.1-cp313-cp313-win_amd64.whl", hash = "sha256:e688d96fa01816c3205336fc9954c6efa561c9bfbef783b2960ab4ec714ff73e", size = 655638, upload-time = "2026-07-23T20:35:46.164Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1b/35683d3c1089354ae75d15b4cf4f00900508f8b10100f74f350d9005a7ac/hypothesis-6.161.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:36062ae4cd60fdbcc765edb47b73677c70350d6611cbcec2fdc3069be16479c1", size = 768213, upload-time = "2026-07-23T20:36:54.872Z" }, + { url = "https://files.pythonhosted.org/packages/f8/92/d85baa4241b2625f012989943bae190d21aead7b7705a0573ec61f8ca152/hypothesis-6.161.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6e59608ad96e2bfb7e4d1eb5adac633a5a2f735b9e368d8b5e0b12d976aacb2c", size = 759815, upload-time = "2026-07-23T20:35:44.516Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/08214b62fa1b85d059b71f5401130506c11d35a82d221133b415de19793d/hypothesis-6.161.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d4249baa5fa38221432318ad692c2bc34b74eee1e8fe3d828c498a27f0545a8", size = 1090571, upload-time = "2026-07-23T20:37:00.529Z" }, + { url = "https://files.pythonhosted.org/packages/13/a8/3d66569b0fbf8b7be5b2cead898295eb65d5efb0eb298f6552441292cdfc/hypothesis-6.161.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3eb24e5f115de84e089829fd6f904f7c3296d8462560f22b7d9bc04bb7828123", size = 1140198, upload-time = "2026-07-23T20:35:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/8391c5dcb1dcbb41e193dace61092d1311718405b696207d1ba35ae5f3e4/hypothesis-6.161.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2d079ae6ae39fd602393bbf853627aa530fcb467200246ca8700a401e1c1862d", size = 1263355, upload-time = "2026-07-23T20:35:38.905Z" }, + { url = "https://files.pythonhosted.org/packages/2a/97/40c3a8dccc3a8e6cdb262b846a5de8b8f9792d47645b41b073233f435976/hypothesis-6.161.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:34a95d9684e760fa122194e721b35fe61fc4f792b6f90402fc43ce86ef1e021d", size = 1307192, upload-time = "2026-07-23T20:36:35.121Z" }, + { url = "https://files.pythonhosted.org/packages/69/7c/8919fcefd596c4fab3d89fbcced9431269edb6e7d3b0fd178949f56d9798/hypothesis-6.161.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:13b3523058a8240748f3756d443b2af6dfdca9f741b268e0e5f71fb6fe7f5934", size = 599732, upload-time = "2026-07-23T20:36:29.22Z" }, + { url = "https://files.pythonhosted.org/packages/86/04/3bda8a4d9f29c0e6225129ce394ebd75a9735f65d91fbd780d559c607acf/hypothesis-6.161.1-cp314-cp314-win_amd64.whl", hash = "sha256:3c5d057b7601801d1aa93070a38b77511e0009e1ccffe6a42cb259e846f50e31", size = 655588, upload-time = "2026-07-23T20:36:36.639Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b3/cf868c489c28cfd1c293401568a927650ff09dbab92079c322fe49f7ebe3/hypothesis-6.161.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:b79650b1b90806fc06c88ff1963c974678155770bcf488d19fd5e44b8d276893", size = 766790, upload-time = "2026-07-23T20:35:56.734Z" }, + { url = "https://files.pythonhosted.org/packages/31/a0/d743e0fbfb0f0cb58739890d3d132f605e666419c0b25d96efeffee1b5ea/hypothesis-6.161.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b7dd1963fefcfa6ac5a33abba1e401f03e25fbca57ca9c2c57b121a1bea9d14f", size = 758280, upload-time = "2026-07-23T20:36:02.819Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e9/8a13ff4ecb3582e0bd5c02d68c28ae550cf22c8bd1554ad46a4ba6919ef1/hypothesis-6.161.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84e0d123ef2996ac2025bba9c2b2d51ba44320fb744c4044acb74b8deb95c3d6", size = 1089167, upload-time = "2026-07-23T20:36:44.003Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3a/e8d950dbea29184aa5863136a6e835f5fccb9ac73be13f555a62c1284b10/hypothesis-6.161.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f736f6ec2081f974cb96a14d2e05b796197e508fca0e6c84f50c163496851eac", size = 1139083, upload-time = "2026-07-23T20:37:02.593Z" }, + { url = "https://files.pythonhosted.org/packages/49/43/a55b85c185d81214849bb802714d13ca62ee5ecbbbdc6d1417efe313aaf3/hypothesis-6.161.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c2ecc445a73a77f30a7b1c95280d91ec04f674e8c61df86b3f7adbfde4a0ac1b", size = 1261591, upload-time = "2026-07-23T20:36:31.442Z" }, + { url = "https://files.pythonhosted.org/packages/8c/de/aceb9c110e2f6f995c929827ffae1f21ad85fe9c43dec7ac94619ecd8de1/hypothesis-6.161.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c9e34f122639949dfabe1c2b70c0b06287e2eaf1ce7bc49b14cd4c532a14d25", size = 1305967, upload-time = "2026-07-23T20:35:53.65Z" }, + { url = "https://files.pythonhosted.org/packages/cc/bd/06a88dfa122e549336c0b48b83f27190822a3702172b987a8448faf1d9bb/hypothesis-6.161.1-cp314-cp314t-win_amd64.whl", hash = "sha256:027367e736255b9cf3ac894bbde4815ebc000396af9d9427f16776f395697159", size = 655718, upload-time = "2026-07-23T20:35:47.721Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1c/1284fcbbe23770b4497f5a9a6e4b3cb1cd23372cca51f57a116e9c044249/hypothesis-6.161.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e100cb06e0e17072bcb4fa400cf607e9c07bc661ee0d6322cec7bbb5fbe26bbe", size = 767959, upload-time = "2026-07-23T20:36:21.178Z" }, + { url = "https://files.pythonhosted.org/packages/12/ce/8457db19466d081f153e0926f26adcb94068b07e4f509d4fd0f1c6440cbc/hypothesis-6.161.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ccb7204ca01c212af5bc9c8d5e21a2b4ca85ea067b4aca4c2fee733201f26798", size = 763809, upload-time = "2026-07-23T20:36:16.005Z" }, + { url = "https://files.pythonhosted.org/packages/19/fa/363a07f35036d84d8253c68d0dd663add1cd266c14b2eb395395ef1766ee/hypothesis-6.161.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80e64bd0b8f81a7e2177ee51a099e9f49ca03203a16177ada0055735ac9cf198", size = 1092673, upload-time = "2026-07-23T20:36:17.704Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ef/826cc76c5beb4703eab2ee8dbc4baf68d390ed741ae8fa442391ba266b2b/hypothesis-6.161.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6f6e3ee7a6b78c8d54b15800f7bc0a35bf29f03d941ecc37699aba05f7fbde6", size = 1142454, upload-time = "2026-07-23T20:35:55.211Z" }, + { url = "https://files.pythonhosted.org/packages/16/33/23290d7ac49dcc5aa4d9f3351551acebec7c3305d56711fc7147bbc3694c/hypothesis-6.161.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:08dcfd88fd9a958c12a22f61032d27e8b79ad95bef01fbd6c1983336478d39b1", size = 659351, upload-time = "2026-07-23T20:36:26.111Z" }, +] + +[[package]] +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 = "librt" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/25/a6498964cfeec270c468cffdc118f69c29b412593610d55fa1327ca51ff4/librt-0.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1b5a7bbff495baedbd9b916c367d66854008f8f3b575908ded477c499dc60082", size = 148029, upload-time = "2026-07-08T12:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/dc86d1bffd8e0c2818bace29d9f7783cfbb8e0673bf3673b5bbd5bbe0420/librt-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34bc7938b9fdf14fe32a406c19c71faf894c5cee7e7474bd0be2f17200b82d14", size = 153036, upload-time = "2026-07-08T12:24:47.257Z" }, + { url = "https://files.pythonhosted.org/packages/29/3f/b923826660f02f286186cd9303d52bb05ced0a13708edc104dc8480920e3/librt-0.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f40e56b61b41be5f7dec938cfeffd660668cf4b5e72c78e7bd671d66b7bc2c79", size = 493062, upload-time = "2026-07-08T12:24:48.483Z" }, + { url = "https://files.pythonhosted.org/packages/88/87/6c0980a9c9b1302cb68d108906697b89eceb55889bb1dcf77c109aa56ca5/librt-0.13.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:9c5d02b89de5acd0379a51ec44a89476fb03df6145442e1c8ecd6bee2f91b176", size = 485510, upload-time = "2026-07-08T12:24:49.727Z" }, + { url = "https://files.pythonhosted.org/packages/32/81/795ae3b9df5dd94079fb807e38191855e023e8c6249014ae6bc3f0d9a490/librt-0.13.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7db9a3ff32ef5f7d1703d93831a3316cdf0b537de6a1cc03cc8fdd09b9194e89", size = 515909, upload-time = "2026-07-08T12:24:51.135Z" }, + { url = "https://files.pythonhosted.org/packages/20/e5/182de15abce8907108a6fdb41487de65beb5099b74dc5841b19b099168db/librt-0.13.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3dbb2a31882456cadc7053378e81ad7ed7693db4ac9f98ab5f81ef034aa8ec9f", size = 508620, upload-time = "2026-07-08T12:24:52.358Z" }, + { url = "https://files.pythonhosted.org/packages/32/03/33978d32db76e1f66377e8f78e42a2ca3c162143331677d1f50bbad36cfb/librt-0.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c6014e3c80f9c1fe268ef8b0e0ef113bac672cc032f2f93866e7ddad4f3e663d", size = 530363, upload-time = "2026-07-08T12:24:53.503Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f5/b291fbd2d00f7d8287bcbf67b5aa0c6afed4bc26cef23e079629c47a2c04/librt-0.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:091b60a4d2174fc1ec5c34cdc0b72efb6224753d76b7da61ebeab7a191aec8bd", size = 534209, upload-time = "2026-07-08T12:24:55.138Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/6f41f17939d191bc21609f220da8509316bc62797f078545fe83be522e78/librt-0.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:66cb1138f384a191a6d75f986064841fcfdc0cea98f7bd9c9ab9b38049917588", size = 514254, upload-time = "2026-07-08T12:24:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/af/c2/2e4befa5410a7443019c14abccc94ff619797171f6b72013635fb87f31d7/librt-0.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:17221a7569f8f292aa0014226e48aa25b8c2b08da18088cd230953d0ea0f9cd1", size = 557611, upload-time = "2026-07-08T12:24:57.561Z" }, + { url = "https://files.pythonhosted.org/packages/ab/54/8b69f81448417adbc040a2185f4e2eece1e1994b7dcfaeed4662b30f98a5/librt-0.13.0-cp311-cp311-win32.whl", hash = "sha256:fc67741da44c6eaa90e01eafb586bbba9b51eb5b6ed381ee6f5ae72eb3316d21", size = 104906, upload-time = "2026-07-08T12:24:58.806Z" }, + { url = "https://files.pythonhosted.org/packages/76/5a/f4aaf37b50f2fde12c8c663b83fdd499cdc24f957f19543d7414bfcc9e25/librt-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:cc99dfb62b23c9207c33d0be8a2e2af7a42e21e6ea388b380a0c948c7b88953b", size = 125852, upload-time = "2026-07-08T12:25:00.065Z" }, + { url = "https://files.pythonhosted.org/packages/f2/99/bf1820e6feeabc2f218c24450ec0c995d6a91e8ba0fd3caf042c9e8adb2a/librt-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:40ccd13c252d3fe473ffc8a57be7565abc8b64cf1b108344c859d5164f7f3e0c", size = 111832, upload-time = "2026-07-08T12:25:01.148Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" }, + { url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" }, + { url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" }, + { url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" }, + { url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/9f/54/b811151805c795f55e0dedee6ec687b75f9982a8105d240ea3910737a77b/librt-0.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b", size = 496108, upload-time = "2026-07-08T12:25:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f8/094d6b2bd93f3fdaa54db54cc788c4a365333bddad65ab02e04da0b1d004/librt-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9", size = 531576, upload-time = "2026-07-08T12:25:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/2e/40/541733d5755824f968f7ec39d78ffbd75d145964157ae5e69a09ec6d7326/librt-0.13.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db", size = 524390, upload-time = "2026-07-08T12:25:25.898Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b5/255673cfdbf5ba663339d36cd863c897289ab4337577e19f9405ce059f36/librt-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6", size = 543053, upload-time = "2026-07-08T12:25:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/9e/11/ab5005e9c9850710f21e354201bf090646349d3fabf5f951eaf70235729e/librt-0.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7", size = 546387, upload-time = "2026-07-08T12:25:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/a2/04/a5d7ce1d1df1afd15ca283dcdf7530ac073e12d69ae8c40879dda96f7868/librt-0.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1", size = 535970, upload-time = "2026-07-08T12:25:30.171Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/927e267a6daa290174ac281b23c9804c8829b042ade9c6f24a065f540958/librt-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a", size = 573582, upload-time = "2026-07-08T12:25:31.507Z" }, + { url = "https://files.pythonhosted.org/packages/10/24/b6c5213efe39c19f9e13605644d0cf063b4ddaa33ac2e45b088e23a70e2e/librt-0.13.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628", size = 82189, upload-time = "2026-07-08T12:25:32.675Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/d29736be177a906ac0b84a5b04b4fbfa22c776dc2f366de4172b0f968c08/librt-0.13.0-cp313-cp313-win32.whl", hash = "sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927", size = 106193, upload-time = "2026-07-08T12:25:33.692Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ac/aff6fb45393cb8912f39dfb156ef6b2d1cadb207ff465fc8f66141054be8/librt-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650", size = 126962, upload-time = "2026-07-08T12:25:34.769Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3a/d68cb2b334d53fd30fac81d3a489ce4ba0d9506f4df43fcf676b68352b19/librt-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566", size = 112127, upload-time = "2026-07-08T12:25:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/7b/66/f49ae0d592bd45b6941e9a8bafcb6a87cddcd501ee7874707e767f01b585/librt-0.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:25218d94b1d2cbc0ba1d8a3f9dc9af578d9646e5ed16443a70cde1dfdcce6d71", size = 149818, upload-time = "2026-07-08T12:25:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/3d/50/51c76d74014d04fb95b6506d286808984b78a2f7a41039094e6b2194ac48/librt-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f26629539d4893c2957a16c41bb058e1e135c1f150f6a2e25ed047f64cf3f5c6", size = 154071, upload-time = "2026-07-08T12:25:39.399Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fe/f19b0f5f82d5a1f2da736586bc840abd00ce07d6388136ae80b7333883fc/librt-0.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4517d47b2b8af26975a406fba7d314de9696d864252e0257c6ea90238cfe27f", size = 494168, upload-time = "2026-07-08T12:25:40.641Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/b8550c75775127fd31a5f20e8775997f7b527ad661fc8ddccd7497c064f7/librt-0.13.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f19e181de5b3a1148bb3420b8c4b0b0ea0fce6950099724ad151d6cea5acc180", size = 491054, upload-time = "2026-07-08T12:25:41.905Z" }, + { url = "https://files.pythonhosted.org/packages/30/14/4d0204867623df3f33f86efd3d3692ba5e01321443f4d6eab35a22697618/librt-0.13.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22034924f5b42d5a56371cf271771bfeaabf235a7a8b6264bef2d20013f786c6", size = 523006, upload-time = "2026-07-08T12:25:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/c45fc9a260934696bace1ac5df1e148ac92bd71767aee3bf7cd7a4534f4c/librt-0.13.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7897db4e95e22468bdda33d8e012ceacd0182abf001e6389d763f0def6286b9", size = 515058, upload-time = "2026-07-08T12:25:44.541Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/50c5ce45b326854ef8fa6ae4c36cf5142e5c55315eaf9e51d0ae73ac4da3/librt-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ce61b3746545029d4f5c17d6bd74b676254ad98433086c846ffb5e8fa73f007", size = 534025, upload-time = "2026-07-08T12:25:45.825Z" }, + { url = "https://files.pythonhosted.org/packages/89/2d/08c413c8f93fc13b8103624fce38e5caa86cd08cbbc8465870ab287af54b/librt-0.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:46c330e82565962c761dbce7941be2cff7db674ee807455a8d0cadc5f9b759b0", size = 540557, upload-time = "2026-07-08T12:25:47.059Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/93af71fb4a364952210051811dd4e40174e79656b050c89cacac18af3330/librt-0.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:375f5af8f99cbaa99dd293af986e3d57caabc9ba81a5d3f021603764854197a1", size = 523201, upload-time = "2026-07-08T12:25:48.392Z" }, + { url = "https://files.pythonhosted.org/packages/c1/6e/9766f07b676a4889d9f8bc2864e9ba5fff165653143ef4dda7df6aa34d16/librt-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9320d34c3376ae204b2cd176e8d4883a013934e0aef822f1aed9c536490c275d", size = 565740, upload-time = "2026-07-08T12:25:49.678Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1e/664e3472ce2b6e10e9b83f29d4a36eb982ff6b5a169ae7567bba3a4c4ff5/librt-0.13.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9af313c66157a69dc69ea0059a66961692250e0dc95af9c385a48ffb770a0d16", size = 81611, upload-time = "2026-07-08T12:25:50.857Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/8582a4d65e2234673685e07309d02c230b28a85724eb0acbf13f019b7f6e/librt-0.13.0-cp314-cp314-win32.whl", hash = "sha256:f2a7253458e34f33543551394ae4fe104b497ec2a65ac266074de64c1df82e37", size = 100106, upload-time = "2026-07-08T12:25:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/63/ce/0cb99efe6086b46cd985dc26672166fae312a239690e75871f7fafbd3fc5/librt-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:a3dfe4edf10e8ed7e55b026a8bfc2c2a8704218b659cd4bffdf604fab966dc39", size = 121209, upload-time = "2026-07-08T12:25:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/26/85/4f3ccb083a3c9b0d42e223acdb3c3f507953324a59cdcab4826e8e2e3b89/librt-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:68a5faee4bba381cb93b5961f684a514cf0053cb92308ff9c792c2fea0b174c6", size = 106404, upload-time = "2026-07-08T12:25:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/b2/77/333191499538c8e8189de7a4cba8e6f49ee949fd6d6e6324b21fd1522466/librt-0.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a38fb81d8376dfa2f8963b265fec07637802b0d01e2a127c19c66cb070fb24f5", size = 159231, upload-time = "2026-07-08T12:25:55.432Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/2aa83758f22c278b837a1d8025898434ce2b8bff36678d5330ecaef56dff/librt-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4c8d9bd5abce34b2e75edb3bf37ab0f34e49b1f915a40ae8468eb7c85bc5b46", size = 161300, upload-time = "2026-07-08T12:25:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c0/86791e936553ca763d6b3c2fb4d31d596cd00e14fa631c283a40ba01559a/librt-0.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:387e2f1d27e89bffe0d3f520f0da0662c973fd607ca16c1808f8a5085419485e", size = 582056, upload-time = "2026-07-08T12:25:58.144Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d3/a9ec15984a185e000c4d2a16ba28bd623124ad4c38a10974c7ff78e3a893/librt-0.13.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:4f6db193d2e5e0ed60359b9a5a682cd67205d0d3b1e459a867dd4b5c4e7eaa7a", size = 562758, upload-time = "2026-07-08T12:25:59.544Z" }, + { url = "https://files.pythonhosted.org/packages/3c/af/dbe36b78b19c06a55097f99305e4ea9458e2273e6ae16a3cbecaad7ee978/librt-0.13.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d38604854e8d22faadf683ec6c02bb0f886e2ba56ef981a1c36ee275f21ea22", size = 602095, upload-time = "2026-07-08T12:26:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a8/2966891b4dd2830f5203fbee92ac2c4947653a2390ba73dfa44244fad025/librt-0.13.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:371f7ce73026815dafd51c50ce38416e91428b28c4b2ec97cd39271164b0045c", size = 593452, upload-time = "2026-07-08T12:26:02.352Z" }, + { url = "https://files.pythonhosted.org/packages/61/f5/4df8bfc8405ecf8c0d525b4d69636f694bdd8620b313ec8b76e54a5926cc/librt-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3aaedf52171bee90860704c560bc798fe83b76247df47568e0197e9b13c735a0", size = 623729, upload-time = "2026-07-08T12:26:04.294Z" }, + { url = "https://files.pythonhosted.org/packages/d6/13/9ac202dffc8db06f75d06c08c2f9f6ff054be67d21272dcc078fa1cc0c57/librt-0.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96bad8725a4f196a798366c25ce075d1f7543a4ec045ffc13e6a7ec095cdab04", size = 617077, upload-time = "2026-07-08T12:26:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f0/ebe38610716aee5cb28efd95089bb90192096179802779381e1c5dcf239c/librt-0.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6bf6a559ffe4a93bbea6cf31ddf01a7fd9ba342ef51f27beb178e318b74acd61", size = 599561, upload-time = "2026-07-08T12:26:07.21Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5c/c2e72e236fff7abc716d5b1753b8b8cd3ea85ac46fe17d2e7c51d4e1c723/librt-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:301067672387902c55f94b51d5022304b36c966ea9fe1f21caab99a9bef487c9", size = 645511, upload-time = "2026-07-08T12:26:08.562Z" }, + { url = "https://files.pythonhosted.org/packages/0c/99/6203ce619dee940d6bfbe099ec3fe4be00a68e9d60f70abf906cf124fe66/librt-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:5fdcf34f86de8fb66d7dc7589f96ba91c4aa46671200d400e6fd6f109a483f18", size = 104357, upload-time = "2026-07-08T12:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/52/dd/843b6314087c41657c7036d7914d8f294bdf9b580aa8513ea0588c8e9a3d/librt-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:260c33e92263fa629b4f6d3c51967a1c2158fe6c33237aaa3ebeac586b085259", size = 126998, upload-time = "2026-07-08T12:26:10.975Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" }, +] + +[[package]] +name = "limits" +version = "5.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/69/826a5d1f45426c68d8f6539f8d275c0e4fcaa57f0c017ec3100986558a41/limits-5.8.0.tar.gz", hash = "sha256:c9e0d74aed837e8f6f50d1fcebcf5fd8130957287206bc3799adaee5092655da", size = 226104, upload-time = "2026-02-05T07:17:35.859Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/98/cb5ca20618d205a09d5bec7591fbc4130369c7e6308d9a676a28ff3ab22c/limits-5.8.0-py3-none-any.whl", hash = "sha256:ae1b008a43eb43073c3c579398bd4eb4c795de60952532dc24720ab45e1ac6b8", size = 60954, upload-time = "2026-02-05T07:17:34.425Z" }, +] + +[[package]] +name = "mako" +version = "1.3.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/b9/d75b3082b05f1b3028828aeb18e74ae5ab0a0936051bbf1f32f59f654747/mypy-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3419d00717afbc5265b50dd14b1278f29ea4884dd398ab67873489ac093fd329", size = 14838725, upload-time = "2026-07-13T11:32:44.655Z" }, + { url = "https://files.pythonhosted.org/packages/a9/50/79a65c6ea6e115bc73296038a4543b2d5c91f07912b918a2c616a2514bba/mypy-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cfca8ee88544090f86b6dcce05ec55d66eb48a762412ac2507810ba4bd793b6f", size = 13911128, upload-time = "2026-07-13T11:32:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/90/48/e11ed7716c26953ca321f726e452e374dbf81a6f2b8b212ec02af29b6b8f/mypy-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75cbb4b9ef04a0c84a957f07abc4504fbf64b8dcc145675101f2d3a78a4b1d6a", size = 14146742, upload-time = "2026-07-13T11:33:03.313Z" }, + { url = "https://files.pythonhosted.org/packages/06/72/6807565b1c4861ef66f7fdd98b51c61556356eab80235717b46c53bb8627/mypy-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:982e3d53dd23d0a4cef67dd66791fdbede0cf38f9eb617bf47663554c51e1e36", size = 15081418, upload-time = "2026-07-13T11:31:13.899Z" }, + { url = "https://files.pythonhosted.org/packages/00/80/1ea14c5d80e589e415973db3e47c78c2219a305b808b2b506395342c1d79/mypy-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85c5385b93012ffa3b31479ab579aef5415f4f3a32c6cf1ae07a984d2a0ff461", size = 15328164, upload-time = "2026-07-13T11:31:35.723Z" }, + { url = "https://files.pythonhosted.org/packages/37/28/8223157404a3d51920078459c37f80fbdc590e1d8ea049dc5ce48643022a/mypy-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:13b1b16e2fa39f3b2e33fb1c468abc7a69369fa2e886b4b87b5afc81472325cd", size = 11136472, upload-time = "2026-07-13T11:27:37.018Z" }, + { url = "https://files.pythonhosted.org/packages/6f/cc/ea27e5959c5f258585a756b252031f3b313583d81b5064b2bebc41d3706b/mypy-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5cd2f027a972a4a5f2278a11fac9747f5f81a53a30b714d74950b6807e55568", size = 10135800, upload-time = "2026-07-13T11:30:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" }, + { url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" }, + { url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" }, + { url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411, upload-time = "2026-07-13T11:30:29.904Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790, upload-time = "2026-07-13T11:33:14.686Z" }, + { url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919, upload-time = "2026-07-13T11:33:39.28Z" }, + { url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510, upload-time = "2026-07-13T11:31:52.619Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" }, + { url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" }, + { url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" }, + { url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" }, + { url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" }, + { url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" }, + { url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" }, + { url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" }, +] + +[[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 = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.12' and python_full_version < '3.15'", +] +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" }, + { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, + { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, + { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, +] + +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +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 = "paho-mqtt" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/15/0a6214e76d4d32e7f663b109cf71fb22561c2be0f701d67f93950cd40542/paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834", size = 148848, upload-time = "2024-04-29T19:52:55.591Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" }, +] + +[[package]] +name = "passlib" +version = "1.7.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" }, +] + +[package.optional-dependencies] +bcrypt = [ + { name = "bcrypt" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[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/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]] +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 = "polars" +version = "1.43.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "polars-runtime-32" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/5b/5d0f0aa53c6e9a8ecbc99ff502edcf9584e5d08ab34ea407c086999103d5/polars-1.43.0.tar.gz", hash = "sha256:bb2c67553e4968c18dfe268a88ff9a5790d5c2e0b7ea7efe97640b9a90438c88", size = 749537, upload-time = "2026-07-21T04:30:25.966Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/28/a8eac2c1d1b2d2a4ba2eb745921616d863185d94b1afd91cbb07af9ef21a/polars-1.43.0-py3-none-any.whl", hash = "sha256:c49078b14e2d6b8ff5cc5b78b6d9638603ea5dffafb889d9204818822f55b813", size = 846493, upload-time = "2026-07-21T04:29:06.68Z" }, +] + +[[package]] +name = "polars-runtime-32" +version = "1.43.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/96/7e714cad082e9e6aaebb8886fcb1b0220d5c35149d4c8d3466bd7e7d581e/polars_runtime_32-1.43.0.tar.gz", hash = "sha256:5fb47a3a883402e62eab2fde5922f78c531d037aeece3640c15225f39228621e", size = 3090044, upload-time = "2026-07-21T04:30:27.185Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/14/9b1f5eb1c5104ba1ceb380a7308ffcd979f3ce1df86e76293062698f2ff1/polars_runtime_32-1.43.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:6707193d30a7135bce0424304f76d8145270527444097548e794ad8d26823b70", size = 53059463, upload-time = "2026-07-21T04:29:09.194Z" }, + { url = "https://files.pythonhosted.org/packages/ca/14/73d77d1c0c928eb599d9516d874af0cd2b6225201e1327a6c4857e6776d0/polars_runtime_32-1.43.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:78ca2f97740b2a6beb36eb112749280b5e08750c60f53c08d2feffebdba9d35a", size = 47499586, upload-time = "2026-07-21T04:29:13.229Z" }, + { url = "https://files.pythonhosted.org/packages/80/b1/98278fa796f93d0975fd3fe1d4ab4031707d4a9f1da44c21c29996b62c73/polars_runtime_32-1.43.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffa99bb2c7ee0a9392ae50b350af0ed17acf0519d15c75fe223021798566174", size = 51326702, upload-time = "2026-07-21T04:29:16.084Z" }, + { url = "https://files.pythonhosted.org/packages/ab/7d/24ae73389aac03296925973c4e2cbe2e4982e859b9fad69eb1a72b9026fa/polars_runtime_32-1.43.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ecc8feaf04de5989a29245885921db612ef1ce9065e5cb6ec37495acfa55bba", size = 57266705, upload-time = "2026-07-21T04:29:19.288Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/2026be1f7b51242ad62e08b20728462558e161fb84b564e5b986f2b38664/polars_runtime_32-1.43.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:01e1471a5ee161a969c7a96991d8e5d20b97a0b7fe075df9c7a01f52a49c5ac2", size = 51484129, upload-time = "2026-07-21T04:29:22.639Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/047c7695f08a9b18614c0e1ec5e70a754455542a21a6d52955f7f05c6268/polars_runtime_32-1.43.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cd8b813afe67d59e87027dedcaf5c6a06fe602472fd74e1a49f4bafea47259c", size = 55169401, upload-time = "2026-07-21T04:29:25.902Z" }, + { url = "https://files.pythonhosted.org/packages/92/dc/bfd2533c487563c7a21ab7d7af3d78f820b0236e14dc5ee63d46188cd275/polars_runtime_32-1.43.0-cp310-abi3-win_amd64.whl", hash = "sha256:41a75fb3cb4cc574eb21801383578f75cfc374597c22322ef457ab7bac8a3301", size = 52541527, upload-time = "2026-07-21T04:29:29.162Z" }, + { url = "https://files.pythonhosted.org/packages/46/d7/5c47f1bf57479d1671669af9c421f9abf4986b2ddaa64c177244ff811de0/polars_runtime_32-1.43.0-cp310-abi3-win_arm64.whl", hash = "sha256:c285e598dd91e08560e519275b8b8108adbafb438d218a175ebe073dbc2027fb", size = 46552281, upload-time = "2026-07-21T04:29:32.224Z" }, +] + +[[package]] +name = "prometheus-client" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/fb/d9aa83ffe43ce1f19e557c0971d04b90561b0cfd50762aafb01968285553/prometheus_client-0.25.0.tar.gz", hash = "sha256:5e373b75c31afb3c86f1a52fa1ad470c9aace18082d39ec0d2f918d11cc9ba28", size = 86035, upload-time = "2026-04-09T19:53:42.359Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/9b/d4b1e644385499c8346fa9b622a3f030dce14cd6ef8a1871c221a17a67e7/prometheus_client-0.25.0-py3-none-any.whl", hash = "sha256:d5aec89e349a6ec230805d0df882f3807f74fd6c1a2fa86864e3c2279059fed1", size = 64154, upload-time = "2026-04-09T19:53:41.324Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +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/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]] +name = "psycopg" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/82/df3312c0ca083d5b43b352f27d4dd8b1e614bd334473074715d9e0000da4/psycopg_binary-3.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:612a627d733f695b1de1f9b4bd511c15f999a5d8b915d444bbd7dd71cf3370da", size = 4609813, upload-time = "2026-05-01T23:26:30.612Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b5/d74d542458d3e8ac0571d8a88f57ca369999b9a82f4fa528052d0d7d3e4c/psycopg_binary-3.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:13a7f380824c35896dcac7fe0f61440f7ca49d6dc73f3c13a9a4471e6a3b302e", size = 4676799, upload-time = "2026-05-01T23:26:38.475Z" }, + { url = "https://files.pythonhosted.org/packages/09/67/06bab9c60671999f4c6ceff1b334f3ac1f9fc5789eb467c714623ea21de9/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:276904e3452d6a23d474ef9a21eee19f20eed3d53ddd2576af033827e0ba0992", size = 5497050, upload-time = "2026-05-01T23:26:47.061Z" }, + { url = "https://files.pythonhosted.org/packages/72/9b/023433e2b20f970de1e22d29132a95281277646da0b2e2879dd4ee94b8c1/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab8cca8ef8fb1ccf5b048ae5bd78ba55b9e4b5d472e3ce5ca39ff4d2a9c249e4", size = 5172428, upload-time = "2026-05-01T23:26:56.708Z" }, + { url = "https://files.pythonhosted.org/packages/08/cd/ae16da8fde228a38b2fe9269bbc13cf89e0186173f2265600f02d6a71e64/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7465bfe6087d2d5b42d4c53b9b11ca9f218e477317a4a162a10e3c19e984ba8e", size = 6762746, upload-time = "2026-05-01T23:27:07.023Z" }, + { url = "https://files.pythonhosted.org/packages/4f/81/0ba09fa5f5f88779093a2541a8e02489825721f258ab88058b11d68b3eb5/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22cdbf5f91ef7bb91fe0c5757e1962d3127a8010256eefd9c61fcaf441802097", size = 5006033, upload-time = "2026-05-01T23:27:12.221Z" }, + { url = "https://files.pythonhosted.org/packages/73/6a/629136040cc3497adb442a305710b5913f2a754d4630fc3d3717c4c0df65/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2631da29253a98bd496e6c4813b24e09a4fe3fb2a9e88513305d6f8747cce95", size = 4534175, upload-time = "2026-05-01T23:27:18.248Z" }, + { url = "https://files.pythonhosted.org/packages/7c/32/1027f843c6dc2d5d51960ee62cc0c2cf755a4c39455aff1371173edbef7d/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7f7668f30b9dd5163197e5cbf4e0efd54e00f0a859cc566ce56cfc31f4054839", size = 4224203, upload-time = "2026-05-01T23:27:24.3Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e1/380a724d9093c74adb14d4fce920ea8327838abb61f760b1448586b14a8e/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:cffc3408d77a27973f33e5d909b624cce683db5fc25964b02fe0aae7886c1007", size = 3954509, upload-time = "2026-05-01T23:27:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/db/cd/895893ae575a09c97ccfd5def070d88993d955ef34df45a881fd5ff506d6/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0579252a1202cd73e4da137a1426e2dae993ae44e757605344282af3a082848c", size = 4259551, upload-time = "2026-05-01T23:27:38.828Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c6/2330a20794e37a3ec609ef2fd8522919ec7a4395a1abf979a8e2d1775cd5/psycopg_binary-3.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:41f2ec0fea529832982bcb6c9415de3c86264ebe562b77a467c0fbcd7efbba8d", size = 3572054, upload-time = "2026-05-01T23:27:45.455Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" }, + { url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" }, + { url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" }, + { url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" }, + { url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" }, + { url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" }, + { url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" }, + { url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" }, + { url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" }, + { url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" }, + { url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" }, + { url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" }, + { url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, +] + +[[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]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +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 = "pytest" +version = "9.1.1" +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/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-jose" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ecdsa" }, + { name = "pyasn1" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" }, +] + +[package.optional-dependencies] +cryptography = [ + { name = "cryptography" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +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/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 = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { 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 = "redis" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/c3/928b290c2c0ca99ab96eea5b4ff8f30be8112b075301a7d3ba214a3c8c12/redis-8.0.1.tar.gz", hash = "sha256:afc5a7a2f5a084f5b1880dec548dd45be17db7e43c82a30d84f952aefb05cfb0", size = 5114170, upload-time = "2026-06-23T14:52:37.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/0a/c2345ebf1ebe70840ce3f6c6ee612f8fa749cfbd1b03069c53bf0c62aaad/redis-8.0.1-py3-none-any.whl", hash = "sha256:47daa35a058c23468d6437f17a8c76882cb316b838ef763036af99b96cedd743", size = 502406, upload-time = "2026-06-23T14:52:36.137Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +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 = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" }, + { url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" }, + { url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" }, + { url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" }, + { url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" }, + { url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" }, + { url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" }, + { url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.12' and python_full_version < '3.15'", +] +dependencies = [ + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +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 = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "slowapi" +version = "0.1.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "limits" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/52/24527cf25a8b508926aff53350b0136561dfe86c7125f61526653666e1b2/slowapi-0.1.10.tar.gz", hash = "sha256:d320d5bc04d9f171a77fb16700faf3036d85b00f420f22924c8a225f95bd14f9", size = 13841, upload-time = "2026-06-13T11:59:31.571Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/8b/1d359f38706b4097d9a943bf8bd22599f537de4cbaff1e622d3e3936e164/slowapi-0.1.10-py3-none-any.whl", hash = "sha256:3acb61561dc9d687e3d3669362ff6a439de9ba44e2fed3a9c165da26b4b83e28", size = 14921, upload-time = "2026-06-13T11:59:30.485Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.51" +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/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/69/a67c69e5f28fc9c99d6f7bd60bd50e91f2fed2423e3b30fb228fa00e51f3/sqlalchemy-2.0.51-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1aa10c0daee6705294d181daadaa793221e1a59ed55000a3fab1d42b088ce4ba", size = 2161838, upload-time = "2026-06-15T16:05:17.144Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/c8c22b8438bddc0a030157c6ec0f6ef97b3c38effa444bdab2a27af04090/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5b2ed6d828f1f09bd812861f4f59ca3bc3803f9df871f4555187f0faf018604", size = 3319402, upload-time = "2026-06-15T16:10:40.002Z" }, + { url = "https://files.pythonhosted.org/packages/90/54/44012d32fd77d991256d2ff793ba3807c51d40cb27a85b4796224f6744df/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:436728ce18a80f6951a1e11cc6112c2ede9faf20766f1a26195a7c441ca12dbd", size = 3319675, upload-time = "2026-06-15T16:12:25.658Z" }, + { url = "https://files.pythonhosted.org/packages/29/a5/de0592acaf5906cd7430874392d6f7e8b4a7c8437610953ee2d1501c0b44/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dc261707bf5739aea8a541593f3cc1d463c2701fb05fbcbba0ce031b69a21260", size = 3270777, upload-time = "2026-06-15T16:10:42.125Z" }, + { url = "https://files.pythonhosted.org/packages/cb/14/a44c90739c780b362238e4ac3cb19dd0ca40d13e6ddc5daa112166ddab4f/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a6d26094615306d116dd5e4a51b0304c99dd2356fc569eed6922a80a6bd3b265", size = 3293940, upload-time = "2026-06-15T16:12:27.156Z" }, + { url = "https://files.pythonhosted.org/packages/65/eb/fbd0f206a330e66f8c602a99c37c4e731f107faed62954b41b01f16dd9d9/sqlalchemy-2.0.51-cp311-cp311-win32.whl", hash = "sha256:ca8435d13829b92f4a97362d91975154a4015db3a2634154e1754e9a915e6b86", size = 2121183, upload-time = "2026-06-15T16:13:29.905Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fd/005bf80f3cf6e5c62b5dd68616280f51cd012c60840fa74781b3ed7b1623/sqlalchemy-2.0.51-cp311-cp311-win_amd64.whl", hash = "sha256:4a011ea4510683319ce4ed274b56ee05194b39b6da9d09ca7a39388f0fa84dcc", size = 2145796, upload-time = "2026-06-15T16:13:31.283Z" }, + { url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" }, + { url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" }, + { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" }, + { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" }, + { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" }, + { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" }, + { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" }, + { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, +] + +[package.optional-dependencies] +asyncio = [ + { name = "greenlet" }, +] + +[[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 = "structlog" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/89/b4a0bcfdf4f71a3dea31379f095929613d7e4528a0996bca6aa964cd0dca/structlog-26.1.0.tar.gz", hash = "sha256:f63a716cbd1b1291cf7661de7794b455acfa4c43c5bcf1630e6ad5ddc1adb3b7", size = 1459881, upload-time = "2026-06-06T07:33:39.348Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/18/489c97b834dfff9cf2fc2507cede4bcd4b11e67f84bc462acd1992496f86/structlog-26.1.0-py3-none-any.whl", hash = "sha256:e081a26d6c373e6d201eca24eede26d8ffab07f88f477822e679183428d3d91e", size = 73764, upload-time = "2026-06-06T07:33:38.046Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.51.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" }, + { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" }, + { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" }, + { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, +] + +[[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/2b/03/47debfe28e9d6d354be5d777b67fd44c359b9eb299a5d103500bd7cc3e37/websockets-16.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d0fcf657e9f13ff4b177960ab2200237b12994232dfb6df16f1cfe1d4339f93c", size = 179566, upload-time = "2026-07-17T22:48:49.596Z" }, + { url = "https://files.pythonhosted.org/packages/72/93/31efa1ed78c17e5cfc229fd449e3966e1b9cc15753204cd585cc8dd01f4a/websockets-16.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b852788aa51764e2d8e4cf5493d559326bcae5e38d16ba25ffa322b034df272a", size = 177250, upload-time = "2026-07-17T22:48:50.942Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/542378ab3972b0c1cf1df3df3eff9591cea0d30c58c3aa3c4ddbc244e787/websockets-16.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1427fb4cf0d72f66333e2cacc3ff5f575bf2d7008166ce991a4a470b21d51a22", size = 177528, upload-time = "2026-07-17T22:48:52.59Z" }, + { url = "https://files.pythonhosted.org/packages/33/d9/162321f63c7eed558e9e1798ed7a1e34a4f6dab51f35419e4ed7a4907979/websockets-16.1.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:da4ca1a9d72f9030b3146b8d7022719a9f3d478f61efe6f7dd51d243f61c51b2", size = 186859, upload-time = "2026-07-17T22:48:53.915Z" }, + { url = "https://files.pythonhosted.org/packages/de/09/87df740f7430ce564bd52402e9c9458d4d0459cc7d2ee29e530c8204851b/websockets-16.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d7f0f8bdb25d2c632b72527325e4776430fd5bc61b9118de4e2b8ddb5f5b01", size = 188095, upload-time = "2026-07-17T22:48:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/d2/12/3d2703af7cc095f3c81904c92208cc1ae79affbc67376944b50ee9301f73/websockets-16.1.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7dfcad78ea1492ee3a9ec765cb7f51bbc17d477107aaf6b22abf7b2558d1c5a0", size = 191385, upload-time = "2026-07-17T22:48:56.742Z" }, + { url = "https://files.pythonhosted.org/packages/1d/69/986aa0234a964a00f5149cfc46e136e96c8faad1c783474550f40d31aef4/websockets-16.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb9a0a6dc3d1b3986cb88091b6899f0396651e0f74e2c9766ab8d6ffc3842e29", size = 188653, upload-time = "2026-07-17T22:48:58.134Z" }, + { url = "https://files.pythonhosted.org/packages/35/6b/10f9d03e3970a69ba67bd3b46b87a929b586d0300fadbfe14f57c1f85490/websockets-16.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29dfa8114c4a620c69591c5973860f768eac29d3fd6904f37f34266cb219c512", size = 187426, upload-time = "2026-07-17T22:48:59.515Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/bb3aad62bf63d8bb3f0634b2eabffcfb3677a34bd19492110ff6869cf703/websockets-16.1.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ff9417c0ada4d0f7d212f928303e5579bdf3ace4c802fa4afabb30995da58c3", size = 184882, upload-time = "2026-07-17T22:49:00.916Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4c/c09a2ea9bfbeccce52fdc383e5f28af4bc8843338aabac28c81489af6120/websockets-16.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fe0b50da2d84535fb4f7b4bfa951280f97ce3d558a0443b541166d609e67b57", size = 187584, upload-time = "2026-07-17T22:49:02.283Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8b/31bb4eb4d9eaacf1fdd39d115772a8aeaedfc19b5dc262e57ffbc8a9d42c/websockets-16.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:34420aaa64440ebd51ac72ca8a45ef4626429438c9b02e633ae412ed43f925d3", size = 186174, upload-time = "2026-07-17T22:49:03.973Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e4/dc02d725610a1ad49e193ef91a548194d71bdc6cdf27da83067dd1f73995/websockets-16.1.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a6a61aff018180c9c50b7b0da33bfd29d378af3497429c95006c589a23a11648", size = 187986, upload-time = "2026-07-17T22:49:05.553Z" }, + { url = "https://files.pythonhosted.org/packages/e0/73/30ed84c8bfd14c73d4af29d5ed9323c3073b48e0b7b23b67070f4e7fd59b/websockets-16.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:04fd29a0e2fe9414a95b00e92c67ae51bf900c50c0f8a4b2dafdad621f49ea1d", size = 185565, upload-time = "2026-07-17T22:49:06.959Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d3/4be8d4959f51e31b4f8fc0ece12b45bd3b6c0d15ea23b9990d9c11fc805f/websockets-16.1.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5c31aa7e39ee3e8a358573257f1c0bb5c52430d1b637030dd9c8cc2c282926be", size = 186598, upload-time = "2026-07-17T22:49:08.293Z" }, + { url = "https://files.pythonhosted.org/packages/26/fa/abb38597a52d84ed9cfacadc7a0c6f2db282c0ab23cdf72b58a666a21227/websockets-16.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d14bfb217eb4701e850f1525c9d29d79c44794cdf1c299ead25f39f8c78dea81", size = 186834, upload-time = "2026-07-17T22:49:09.766Z" }, + { url = "https://files.pythonhosted.org/packages/59/80/1119ad08a228b90c4eb77fbe48df7836731a605f5f881ba701ca826a4a65/websockets-16.1.1-cp311-cp311-win32.whl", hash = "sha256:2e28e602bb13da44fbe518c1781a88e3b9d4c3d48d02c9bad83e546164336f57", size = 179940, upload-time = "2026-07-17T22:49:11.196Z" }, + { url = "https://files.pythonhosted.org/packages/71/b2/e511c1c6f64a95c2f3fc54bffda0e14eaa7e9442be605c29270f7589b918/websockets-16.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:7421fad442de870a8cbf2287d1cad7e706ece0dbfeba5e911df132cbdc1cb56a", size = 180239, upload-time = "2026-07-17T22:49:12.519Z" }, + { 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/e1/ed/71fea6e141590cafc40b14dc5943b0845606bee87bdb52a21b6a73eb4311/websockets-16.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:820fb8450edddae3812fd58cbc08e2bf22812cb248ecb5f06dbb82119a56e869", size = 177185, upload-time = "2026-07-17T22:50:56.665Z" }, + { url = "https://files.pythonhosted.org/packages/01/ec/00e7eeca200facf9266a83e4cbbf1bed0e67fba1d4d45031d3e5b3d81b5c/websockets-16.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:125f22dbefaf1554fea66fc83851490edb284ce4f501d37ffed2752f418332d9", size = 177459, upload-time = "2026-07-17T22:50:58.197Z" }, + { url = "https://files.pythonhosted.org/packages/75/fd/5774c4b33f7c0d8f0c51809c8b3a93456c48e3543579262cfa64eb5f522e/websockets-16.1.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30bbe120437b5648a77d3519b7024ea09530e0b5b18d3698c5a0ae536fe0cc2e", size = 178294, upload-time = "2026-07-17T22:50:59.641Z" }, + { url = "https://files.pythonhosted.org/packages/37/c3/48e2c03d2bd79bb45948841c592d24156312dd5f58cdf8f549febe652fb6/websockets-16.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b6b9dadbef0cccd9f4c4ee96b08898afa73e26803bbe0f6aeb5bb12b0074206d", size = 179190, upload-time = "2026-07-17T22:51:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3f/73e511ecf2496ceac57dd4ed8388efe2bcf0769338a2dbf242c8366ae87e/websockets-16.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56cd5fc4f10a9ea8aa0804bddb7b42506cf9e136046f3b4c27de8fec9e2ecba5", size = 180330, upload-time = "2026-07-17T22:51:02.603Z" }, + { 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 = "wrapt" +version = "2.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/a4/282c8e64300a59fc834518a54bf0afabb4ff9218b5fa76958b450459a844/wrapt-2.2.2.tar.gz", hash = "sha256:0788e321027c999bf221b667bd4a54aaefd1a36283749a860ac3eb77daed0302", size = 129068, upload-time = "2026-06-20T23:49:44.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/15/0c2d55168707465abfc41f33c0b23d792a5fa9b65c26983606940900a120/wrapt-2.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f1a2ff355ece6a111ca7a20dc86df6659c9205d3fcee674ca34f2a2854fd4e73", size = 80782, upload-time = "2026-06-20T23:47:44.367Z" }, + { url = "https://files.pythonhosted.org/packages/7d/b5/5c0b093eb48f8a062ef6267d3cb36e9bb1b88440181f6545a383c60efdf8/wrapt-2.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:55b9a899e6fff5444f229d30aa6e9ac92d2216d9d60f33c771b5d76a760d5f8e", size = 81678, upload-time = "2026-06-20T23:47:45.857Z" }, + { url = "https://files.pythonhosted.org/packages/34/f3/de70937472dd3e8a4e6811192f9c6075efdffd4a2cd9b4596bf160f89668/wrapt-2.2.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a2d78c363f97d8bd718ee40432c66395685e9e98528ccaa423c3355d1715a26d", size = 159671, upload-time = "2026-06-20T23:47:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/40aed2330e7f02ecf74386ffcfef9ccb7108c6a430f15b6a252b663b1bed/wrapt-2.2.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d619e1eed9bd4f6ed9f24cd61971aa086fa86505289628d464bcf8a2c2e3f328", size = 160785, upload-time = "2026-06-20T23:47:48.759Z" }, + { url = "https://files.pythonhosted.org/packages/45/04/aa5309beed5344b00220ae6b3b24055852192656194c27947bee1736306a/wrapt-2.2.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:518b0c5e323511ec56a38894802ddd5e1222626484e68efe63f201854ad788e5", size = 153699, upload-time = "2026-06-20T23:47:50.177Z" }, + { url = "https://files.pythonhosted.org/packages/01/df/2def7e99d1fe87eea413f95f671924cdddcb08823b1ffd212748dfa6d062/wrapt-2.2.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4bccea5cdecffa9dd70e343741f0e41e0a16619313d04b72f78bb525162ebcd0", size = 159695, upload-time = "2026-06-20T23:47:51.602Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f6/a906d01a2ce12157bad2404957b3e2140da354b8a70b2fa48bbf282871c0/wrapt-2.2.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:209112cafd963710a05d199aae431d79a28bc76eb8e6d1bbbb8ad24340722cae", size = 152813, upload-time = "2026-06-20T23:47:53.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/49/bc0086292d239575b4c08f4cf8a4079fa58abbad58ec23abf84833a283ed/wrapt-2.2.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5a5290e4bf2f332fc29ce72ffb9a2fff678aaac047e2e9f5f7165cd7792e099", size = 158809, upload-time = "2026-06-20T23:47:54.391Z" }, + { url = "https://files.pythonhosted.org/packages/55/83/8fbd034de1f3e907edaa18786d5dd8f6932874edee0826c7cecb5cab03a1/wrapt-2.2.2-cp311-cp311-win32.whl", hash = "sha256:5499236ad1dc116012e2a5dd943f3f31af12fce452128e2bbcbd55a7d3d4d14c", size = 77414, upload-time = "2026-06-20T23:47:55.882Z" }, + { url = "https://files.pythonhosted.org/packages/7e/9c/23695baa331c6de4e874c3d78b8e0bed92e1d2a274e665b29858f6841672/wrapt-2.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:8636809939152be6ae20a6cef0fed9fe60f411b47847d0426a826884b469e971", size = 80368, upload-time = "2026-06-20T23:47:57.237Z" }, + { url = "https://files.pythonhosted.org/packages/08/49/40cefc342bf89b234a4490d741290fce781774b831aefb39c25471da96c9/wrapt-2.2.2-cp311-cp311-win_arm64.whl", hash = "sha256:5d0a142f7af07caeb5e5da87493162a7b8efa19ba919e550a746f7446e13fb30", size = 79489, upload-time = "2026-06-20T23:47:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/2a/85/180b40628b23772692a0c76e8030114e1c0ae068470ed531919f0a5f2a4a/wrapt-2.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8417fd3c674d3c8023d080292d29301531a12daf8bd938dd419710dd2f464f2b", size = 81484, upload-time = "2026-06-20T23:47:59.924Z" }, + { url = "https://files.pythonhosted.org/packages/94/f2/21c90f2a16689702e2aaff45795b11018dff2c9b1242bac10d225483f676/wrapt-2.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e7070c7472582e31af3dfc2622b2381a0df7435110a9388ed8db5ffbce67efb", size = 82151, upload-time = "2026-06-20T23:48:01.303Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b3/7e6e9fcf4fe7e1b69a49fe6cc5a44e8224bab6283c5233c97e132f14908e/wrapt-2.2.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2e096c9d39a59b35b63c9aacfbbbec2088ff51ff1fc31051acc60a07f42f273a", size = 169828, upload-time = "2026-06-20T23:48:02.719Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/894f132d857ed5a9904d937baf368badcbe5ea9e436e2f1930fe21c9f1f0/wrapt-2.2.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d1a6050405bf334be33bf66296f113563622972a34900ae6fa60fd283a1a900", size = 171544, upload-time = "2026-06-20T23:48:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/29/de/3c833e03725b477e9ea34028224dd21a48781830101e4e036f77e8b6b102/wrapt-2.2.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10adb01371408c6de504a6658b9886480f1a4919a83752748a387a504a21df79", size = 160663, upload-time = "2026-06-20T23:48:05.708Z" }, + { url = "https://files.pythonhosted.org/packages/33/be/27edce350b24e3054d9d047f65f16d4c4d4c1f3f31c4278a1f8a95c723c8/wrapt-2.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3442eee2a5798f9b451f1b2cd7518ce8b7e28a2a364696c414460a0e295c012a", size = 169387, upload-time = "2026-06-20T23:48:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c4/9fd9679af8bf38e146652c7f47b6b352c3e5795b4ad1c0b7f94e15ac2aa7/wrapt-2.2.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6c99012a22f735a85eed7c4b86a3e99c30fdd57d9e115b2b45f796264b58d0bf", size = 158849, upload-time = "2026-06-20T23:48:08.91Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c2/aa6c0c2206803068c6859dabe01f8c84c43744da93d4c67b8946d21655ee/wrapt-2.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3b686cfc008776a3952d6213cb296ed7f45d782a8453936406faa89eac0835ab", size = 168147, upload-time = "2026-06-20T23:48:10.374Z" }, + { url = "https://files.pythonhosted.org/packages/42/63/3eb25da41049d20ae18fcab2dd8b056e02387c4bfa626cbdfb7c3b872e4f/wrapt-2.2.2-cp312-cp312-win32.whl", hash = "sha256:ef2cce266b5b0b07e19fa82e59673b81142b7a3607c8ed1254113d048ed668da", size = 77734, upload-time = "2026-06-20T23:48:11.769Z" }, + { url = "https://files.pythonhosted.org/packages/da/09/0390e008a305360948fa9ce69507d041ac12cb2ee5d28e34467e2ee79391/wrapt-2.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:abf8c20a2d72ee69e16328b3c91342c446e723bfe48bfcc4dded3b9722ac027f", size = 80585, upload-time = "2026-06-20T23:48:13.117Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b3/84c445c66969f2d3457276b183a48c91097d59bbef9af6c075366b0f8c36/wrapt-2.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:c6c64c5d02578bc4c4bca4f0aef1504de933c1d5b4ac2710b9131111459506c8", size = 79553, upload-time = "2026-06-20T23:48:14.5Z" }, + { url = "https://files.pythonhosted.org/packages/43/fc/f32f4b22c6511173c11d9e541ab4e7d8467a0f1b3455acaf784115d31ff8/wrapt-2.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9e8b648270c613720a202d9a45ebabc33261b22c3a839b115ac5bce8c0bb0d69", size = 81296, upload-time = "2026-06-20T23:48:15.881Z" }, + { url = "https://files.pythonhosted.org/packages/72/06/4d117d5d77a9344776c0248b24dae3d3dd2f58e5f765fa08cf887072e719/wrapt-2.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6fb7e94e8fe3e4c3067bb1653a91cce7c5e83acc119fdd41501b1bf74654617", size = 81841, upload-time = "2026-06-20T23:48:17.262Z" }, + { url = "https://files.pythonhosted.org/packages/15/ff/63ad96f98eb58a742b1a20d80f21da88924405910149950b912368150468/wrapt-2.2.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb18fc51e813df0d9c98049e3bf2298a5495a648602040e21fa3c7329371159e", size = 167882, upload-time = "2026-06-20T23:48:18.764Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/8bb62d8933df7acf3247194e6e9fc68edf9d2fa203252c89c94b319dd472/wrapt-2.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94b00b00f806eb3ef2abe9049ed45994a81ee9284884d96e6b8314927c6cea3d", size = 167411, upload-time = "2026-06-20T23:48:20.315Z" }, + { url = "https://files.pythonhosted.org/packages/17/09/8789dcb09ee1de715727db7521aabbb68ffa68dfade3a49468440cfced49/wrapt-2.2.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:62415fd095bc590b842b6d092f2b5d9ccbaeb7e0b28535c03dcea2718b48636b", size = 158607, upload-time = "2026-06-20T23:48:21.728Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/66e02562d53ee67d841f175e38e3c993c2d78a3e104c576cad61c028b43c/wrapt-2.2.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a41e758d80dc0ab8c210f641ac892009d356cf1f955d97db544c8dd317b4d14c", size = 166367, upload-time = "2026-06-20T23:48:23.177Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a3/832ac4e41222fb263b3042d42c2f08d305db7d0f0c9b1d3a271a9eede8f6/wrapt-2.2.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b84cd4058001c9727b0e9980b7a9e66325b5ca748b1b578e822cade1bc6b304f", size = 157176, upload-time = "2026-06-20T23:48:24.711Z" }, + { url = "https://files.pythonhosted.org/packages/b7/01/1bd5e4d2df9c0178989ac8da9186543465388588ee2ef153e2591accebef/wrapt-2.2.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:26fc73a1b15e0946d2942b9a4426d162b51676338327dc067ccd8d2d76385f94", size = 167025, upload-time = "2026-06-20T23:48:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/1c/69/583ed25291ab53e1ec117135fb1c33425e2f46d2bc8f29c17f7a94cf4274/wrapt-2.2.2-cp313-cp313-win32.whl", hash = "sha256:3c4095803491f6ef72128914c28ec05bbad9758433bb35f6715a3e9c8e46fb2d", size = 77605, upload-time = "2026-06-20T23:48:27.643Z" }, + { url = "https://files.pythonhosted.org/packages/29/68/e69fc6d06e1523c68e0d00f95c9aed1158ce9908ee41603f7f2eae3d5db6/wrapt-2.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:2cb07f414fab25dbe6b5c7398e1491423a5c81a6209533639969a6c928d474a4", size = 80508, upload-time = "2026-06-20T23:48:29.013Z" }, + { url = "https://files.pythonhosted.org/packages/55/21/fe7a393d9e5dc0923bed8f5d857e9dcff210f1fa0888c02cc8f3ffaa55aa/wrapt-2.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:1fc7691f070220215cccb2a20836b9adbaecb8ff22ad47abe63de5f110994fac", size = 79565, upload-time = "2026-06-20T23:48:30.429Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e5/c120d13bf5091164f68c3c1657e84f16f57e71d978421b626393ac5bd7eb/wrapt-2.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ec8f83949028366531383603139403cac7a826e4011955813cdd640017845ce5", size = 83264, upload-time = "2026-06-20T23:48:31.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b0/d4a1eb97e0e286625bdf21bc7f702637f9607787ffbbdb5ec14d50c79dbf/wrapt-2.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b481fb0c40d9fd90a5809911208da700987d373a20a4709dc9e3944af7a6bec", size = 83791, upload-time = "2026-06-20T23:48:33.482Z" }, + { url = "https://files.pythonhosted.org/packages/18/1e/f060df47755e87b57684cee7bfc1362b204df55fac96ffebc0631b697b79/wrapt-2.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0065a3b657cec06813b4241d2462ccec287f6863103d7445b725fb3a889736f9", size = 203399, upload-time = "2026-06-20T23:48:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/c4/de/2316a757a1abb6453700b79d83e532146dcef2611348282d4d8889792161/wrapt-2.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30f7424af5c5c345b7f26490e097f74a2ef45b3d08b664dc33571aee3bd3b56c", size = 210461, upload-time = "2026-06-20T23:48:36.569Z" }, + { url = "https://files.pythonhosted.org/packages/ed/29/d1160785ae18ca2495a6d82a21154103d74f656c9fd457fb35f6b11b965a/wrapt-2.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07fdcb012821859168641acf68afad61ef9783cf37100af85f152550e9677194", size = 195313, upload-time = "2026-06-20T23:48:38.175Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2d/7caa9598ae61a9cf0989cc501739cbeeb7d650ab3193cca1407b9af0c6ab/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f90038ab58fafb584801ca62d72384d7d5225d93c76f7b773c22fae545bd8066", size = 206116, upload-time = "2026-06-20T23:48:39.804Z" }, + { url = "https://files.pythonhosted.org/packages/ac/02/281ea1088b8650d865f311b35cf86fd21df89128e2909714f1161e01c9d0/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c5d7825491bfa2d08b97e9557768987952c7b9ae687d06c3320b40a37ccb7f20", size = 192668, upload-time = "2026-06-20T23:48:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/be/7d/976e2d5b4b5c5babda40974edd54d0a5585cb60132ed86b46f4b80239b16/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ad520e6daa9bbf136f14de735474dbec7dcc0891f718e1d274ce8dc92e645af", size = 198891, upload-time = "2026-06-20T23:48:43.056Z" }, + { url = "https://files.pythonhosted.org/packages/59/b7/e47651797c097f75a37e2ce86dcf04048ff576f3a674f7c558df7b5e9622/wrapt-2.2.2-cp313-cp313t-win32.whl", hash = "sha256:25904acb9475f46c24fe0423dbc8fda8cc5fbc282ab3dc6e72e919748c53f4e9", size = 78537, upload-time = "2026-06-20T23:48:44.509Z" }, + { url = "https://files.pythonhosted.org/packages/d1/6f/9fa5d59fb06d890defb5a8f727ce6a14d2932c8760153f96956628559fee/wrapt-2.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:305d4c247d61c4115794a169141823c62f719525ddb90b23aa332741c77d2c28", size = 82005, upload-time = "2026-06-20T23:48:46.391Z" }, + { url = "https://files.pythonhosted.org/packages/15/80/4c7bd9873d1f9f7d138d93556b500469dbe24f42710b877519c2b9eb380d/wrapt-2.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c20279cd1a29800815d7b2d6338b60a6c6e78263f9d6e62e0eda251ba9cae2d0", size = 80762, upload-time = "2026-06-20T23:48:47.964Z" }, + { url = "https://files.pythonhosted.org/packages/24/05/7fd9c3f83b2c74cbfc572a0b88aa37431e04bd8aed70d2c0efd3464206de/wrapt-2.2.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0e64826f920c42d9d9f87e8cc09ffae66c51ede12d59061a5a426deb9aa71745", size = 81341, upload-time = "2026-06-20T23:48:49.39Z" }, + { url = "https://files.pythonhosted.org/packages/4b/68/1bfa43100dd90d4ef74a05897b86275cf57e1313ca14aae2545bc9f872c9/wrapt-2.2.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcaa5e1451bd8751d7bd1568dfa3321c78092a52a7ecb5d1a0f18a5791e1fd00", size = 81921, upload-time = "2026-06-20T23:48:50.986Z" }, + { url = "https://files.pythonhosted.org/packages/74/eb/df7b7f0b631dbbc750f39be27d8b55f65777d8ac86da80e12be41a644c4b/wrapt-2.2.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0abfd648dac9ac9c5b3aa9b523d27f1789046640b58dcd5652a720ddb325e1fc", size = 167713, upload-time = "2026-06-20T23:48:52.598Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9a/d1bd36f6d088c8e652a9383cabbd49af30b8c576302a7eccddbab6963e3f/wrapt-2.2.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4bfd8d1eb438153eff8b8cfe87f032ba65731e1ce06138b5090f745a33f6f95", size = 166779, upload-time = "2026-06-20T23:48:54.33Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ae/24ffacd4187fac2740a1972093929e836dea092d42c87d728cd98fee11a6/wrapt-2.2.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c427c9d06d859848a69f0d928fe28b5c33a941b2265d10a0e1f15cd244f1ee33", size = 158407, upload-time = "2026-06-20T23:48:55.944Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ed/974427668249a356051e8d67d47fa54ef6c777f0fcf3bae9d292c047d4b6/wrapt-2.2.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4250b43d1a129d947e083c4dc6baf333c9bb34edd26f912d5b0457841fc858ab", size = 166594, upload-time = "2026-06-20T23:48:57.617Z" }, + { url = "https://files.pythonhosted.org/packages/fb/5f/e1d7c6e4523f78db2fbd7826babd0348da1d5e0834c4f918b9ab5757dfae/wrapt-2.2.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:173e5bb5ca350a6e0abab60b7ec7cdd7992a814cb14b4de670a28f067f105663", size = 157068, upload-time = "2026-06-20T23:48:59.171Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c1/7ebd1027f00700c0b0233b20aceef2b4784294ed64971424c4a78e069e34/wrapt-2.2.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:aa14b01804bce36c6d63d7b6a4f55df390f29f8648cc13a1f40b166f4d54680d", size = 166470, upload-time = "2026-06-20T23:49:00.737Z" }, + { url = "https://files.pythonhosted.org/packages/99/eb/974e471a6a978b8180186b8a9dc5ae3361ce269a967190b709b8ce17abfb/wrapt-2.2.2-cp314-cp314-win32.whl", hash = "sha256:58f9f8d637c9a6e245c6ef5b109b67ec187d2faed23d1405656b51d96e0a5b56", size = 78062, upload-time = "2026-06-20T23:49:02.327Z" }, + { url = "https://files.pythonhosted.org/packages/49/ec/e1281156cdc7a66693838ad7a0865ad641c74abd337a957d668b575aaffb/wrapt-2.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:385cb1866f20479e83299af585375bfa0a4b0c6c9907a981483ea782ea8ae406", size = 80832, upload-time = "2026-06-20T23:49:03.837Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/1b6b5ddd94005a2dac97a4490c9838f3154977850d633abcb65b30089437/wrapt-2.2.2-cp314-cp314-win_arm64.whl", hash = "sha256:8ffbeaea6771a6eba6e6eeb09767864995726bc8240bb54baf88a9bb1db34d5c", size = 80029, upload-time = "2026-06-20T23:49:05.237Z" }, + { url = "https://files.pythonhosted.org/packages/b0/33/9ebcf8aafe91c601127cbd93708c16aa8f688f34a10bf004046803ecdc4f/wrapt-2.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:09f811d43f6f33ec7515f0be76b159569f4057ab54d3e079c3204dddb90afa2a", size = 83357, upload-time = "2026-06-20T23:49:06.632Z" }, + { url = "https://files.pythonhosted.org/packages/39/38/ec45b635153327b52e52732a0ea980e5f00b7efba65f9e018828f1e69daa/wrapt-2.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a795d3c06e5fbf9ea2f13196180b77aeab1b4685917256ee0d014cc163d90063", size = 83794, upload-time = "2026-06-20T23:49:08.098Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ea/1a89e6d3b7a83c3affe5c09cde77792c947e63e4bc85ad84cd5bb9abb0d8/wrapt-2.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:45c2f2768e790c9f8db90f239ef23a2af8e7570f25a35619ef902df4a738447f", size = 203362, upload-time = "2026-06-20T23:49:09.811Z" }, + { url = "https://files.pythonhosted.org/packages/19/d8/3b58763d9863b5a73771c0d97110f9595d248db454009e07e1535ee905a4/wrapt-2.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbf00ee0cb55ec24e2b0995a71942b85b21a066db8f3f46e1dbfdb9433ffba81", size = 210449, upload-time = "2026-06-20T23:49:11.521Z" }, + { url = "https://files.pythonhosted.org/packages/2d/6f/17fd9e053103d8be148d20d5d7505facc72d5fe1f9127973904ceaed79cf/wrapt-2.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2252f77663651b89255895f58cc6ac08fcb206d4371813e5af61bb62d4f7689c", size = 195349, upload-time = "2026-06-20T23:49:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/d0d1ccaaa12cb7dccf28a23f0279a608ba498f71e81d949d5ed54bcfd5c1/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2cd7181ab1c31192ff5219269830744b5a62020b3a6d433588c4f1c95b8f8bff", size = 206099, upload-time = "2026-06-20T23:49:15.051Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/e8aa07b619890a2aa6cde1931b1887abb08820721b564a5f80b7ca3f3aa0/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6fe35fd51b74867d8b80174c277bd6bbf6a73e443f908129dc531c4b688a20d5", size = 192728, upload-time = "2026-06-20T23:49:16.854Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f0/1819fb50f0d3c9bd758d8a83b56f1b470dee8b5b8eac8702b7c137cea9d4/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11d95fc2fbad3163596c39d440e6f21ca9fccece74b56e30a37ac2fca786a07c", size = 198842, upload-time = "2026-06-20T23:49:18.504Z" }, + { url = "https://files.pythonhosted.org/packages/67/7c/e88313f16a99930b899ef970d91c281544a470749a359decad994483bbda/wrapt-2.2.2-cp314-cp314t-win32.whl", hash = "sha256:d8a15813215f33fa83667bfc978b300e35669ea8bb424e970a1426bcb7bc6cca", size = 79059, upload-time = "2026-06-20T23:49:20.107Z" }, + { url = "https://files.pythonhosted.org/packages/a0/4f/ac12fda57a55068a094ec42851fb0a40e8489d8941863d517452de62e507/wrapt-2.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d09db0f7e8357060d3c38fc22a018aba683a796bf184360fd1a58f6fc180dc77", size = 82462, upload-time = "2026-06-20T23:49:21.631Z" }, + { url = "https://files.pythonhosted.org/packages/48/a7/df732dac86d9b2027c56bd163dbc883e037b16c3469614752e148d219c61/wrapt-2.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:f32fe639c39561ccc187bcae17e9271be0eb45f1c2952510d2f29b33ab577347", size = 81182, upload-time = "2026-06-20T23:49:23.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d2/6317eb6d4554855bbf12d61857774af34747bf88a42c19bf306de67e2fa3/wrapt-2.2.2-py3-none-any.whl", hash = "sha256:5bad217350f19ce99ca5b5e71d406765ea86fe541628426772b657375ee1c048", size = 61460, upload-time = "2026-06-20T23:49:42.966Z" }, +] diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..3cda8cd --- /dev/null +++ b/vercel.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "fastapi", + "installCommand": "pip install -e \".[apps]\" && pip uninstall -y uvloop watchfiles httptools openpyxl et-xmlfile redis prometheus-client" +}