From 0f20e018adac4e7ba6b976fb38e4490cc0753f90 Mon Sep 17 00:00:00 2001 From: M1ndsmith Date: Fri, 24 Jul 2026 16:17:48 +0100 Subject: [PATCH 01/23] refactoring elements --- .github/workflows/ci.yml | 81 + .gitignore | 14 +- README.md | 460 +- adapters/__init__.py | 19 + adapters/archive.py | 127 + adapters/db_models.py | 149 + adapters/factory.py | 52 + adapters/io_files.py | 117 + adapters/persistence.py | 189 + adapters/persistence_tsdb.py | 515 + adapters/render_plotly.py | 146 + adapters/stream.py | 72 + adapters/stream_engine.py | 225 + adapters/stream_sources.py | 311 + agent_config/agent_prompts/__init__.py | 56 - agent_config/agent_prompts/prompts.json | 20 - agent_config/config.yaml | 38 - agent_config/config_loader.py | 151 - alembic.ini | 47 + api/__init__.py | 2 - api/main.py | 149 - apps/__init__.py | 1 + apps/api/__init__.py | 1 + apps/api/main.py | 694 + apps/cli/__init__.py | 1 + apps/cli/main.py | 264 + apps/config.py | 228 + apps/config.yaml | 41 + control_chart_system/__init__.py | 24 - control_chart_system/control_chart_agent.py | 32 - .../control_chart_pipeline.py | 623 - control_chart_system/control_chart_tools.py | 279 - data_samples/capability_excellent.csv | 52 - data_samples/capability_high_variation.csv | 52 - data_samples/capability_off_center.csv | 52 - data_samples/capability_skewed_data.csv | 32 - data_samples/msa_bias_study.csv | 12 - data_samples/msa_gage_rr_excellent.csv | 62 - data_samples/msa_gage_rr_poor.csv | 62 - data_samples/msa_linearity_study.csv | 17 - data_samples/msa_stability_study.csv | 22 - data_samples/spc_c_chart_data.csv | 22 - data_samples/spc_individual_in_control.csv | 42 - .../spc_individual_out_of_control.csv | 32 - data_samples/spc_np_chart_data.csv | 22 - data_samples/spc_p_chart_data.csv | 22 - data_samples/spc_subgroup_data.csv | 52 - data_samples/spc_subgroup_stable.csv | 52 - data_samples/spc_u_chart_data.csv | 22 - deploy/compose/docker-compose.yml | 140 + deploy/docker/Dockerfile.api | 30 + deploy/docker/Dockerfile.frontend | 35 + deploy/docker/Dockerfile.mqtt_bridge | 24 + deploy/docker/Dockerfile.stream_engine | 26 + deploy/mosquitto/mosquitto.conf | 3 + docs/api.md | 146 + docs/cli.md | 104 + docs/concepts.md | 105 + docs/configuration.md | 100 + docs/deployment.md | 104 + docs/development.md | 110 + docs/index.md | 41 + docs/pipeline.md | 125 + docs/python-api.md | 165 + env.example | 48 +- frontend/.env.example | 2 + frontend/.eslintrc.json | 3 + frontend/.gitignore | 11 + frontend/README.md | 29 + frontend/e2e/home.spec.ts | 24 + frontend/next-env.d.ts | 5 + frontend/next.config.js | 16 + frontend/package-lock.json | 11085 ++++++++++++++++ frontend/package.json | 36 + frontend/playwright.config.ts | 16 + frontend/postcss.config.js | 6 + frontend/public/.gitkeep | 0 frontend/src/__tests__/formatLimits.test.ts | 31 + frontend/src/app/analyze/page.tsx | 159 + frontend/src/app/capability/page.tsx | 140 + frontend/src/app/globals.css | 55 + frontend/src/app/layout.tsx | 21 + frontend/src/app/live/page.tsx | 224 + frontend/src/app/login/page.tsx | 65 + frontend/src/app/msa/page.tsx | 150 + frontend/src/app/page.tsx | 114 + frontend/src/app/runs/[run_id]/page.tsx | 197 + frontend/src/app/runs/page.tsx | 80 + frontend/src/components/Checklist.tsx | 67 + frontend/src/components/ControlChart.tsx | 148 + frontend/src/components/GateList.tsx | 44 + frontend/src/components/Layout.tsx | 126 + frontend/src/components/Providers.tsx | 21 + frontend/src/components/ui.tsx | 181 + frontend/tailwind.config.ts | 37 + frontend/tsconfig.json | 23 + frontend/vitest.config.ts | 14 + migrations/env.py | 52 + migrations/script.py.mako | 21 + migrations/versions/001_initial.py | 137 + msa_system/__init__.py | 26 - msa_system/msa_agent.py | 30 - msa_system/msa_pipeline.py | 709 - msa_system/msa_tools.py | 337 - process_capability_system/__init__.py | 26 - process_capability_system/capability_agent.py | 30 - .../capability_pipeline.py | 975 -- process_capability_system/capability_tools.py | 349 - pyproject.toml | 128 + refactoring elements/messy_brainstorming.md | 198 + refactoring elements/spc_mvp_doc.html | 995 ++ requirements.txt | 36 +- sample_data/__init__.py | 255 + sample_data/__main__.py | 29 + scripts/manufacturing_sim.py | 142 + scripts/run_live_protocol.py | 333 + scripts/run_live_protocol.sh | 55 + services/__init__.py | 1 + services/mqtt_bridge/__init__.py | 1 + services/mqtt_bridge/main.py | 158 + services/stream_engine/__init__.py | 1 + services/stream_engine/main.py | 138 + spc | 18 - spc_cli.py | 261 - spc_core/__init__.py | 142 + spc_core/capability.py | 295 + spc_core/charts.py | 303 + spc_core/cleaning.py | 119 + spc_core/constants.py | 109 + spc_core/cusum.py | 110 + spc_core/evaluator.py | 108 + spc_core/ewma.py | 109 + spc_core/ingest.py | 167 + spc_core/limits.py | 236 + spc_core/models.py | 156 + spc_core/msa.py | 308 + spc_core/msa_stream.py | 123 + spc_core/multimodal.py | 97 + spc_core/normality.py | 243 + spc_core/pipeline.py | 373 + spc_core/report.py | 242 + spc_core/rules.py | 216 + temp_uploads/capability_excellent.csv | 52 - ...apability_excellent_capability_report.html | 133 - temp_uploads/msa_gage_rr_excellent.csv | 62 - .../msa_gage_rr_excellent_msa_report.html | 36 - temp_uploads/spc_c_chart_data.csv | 22 - ...spc_c_chart_data_control_chart_report.html | 53 - temp_uploads/spc_individual_in_control.csv | 42 - ...idual_in_control_control_chart_report.html | 53 - .../spc_individual_out_of_control.csv | 32 - ...l_out_of_control_control_chart_report.html | 53 - temp_uploads/spc_np_chart_data.csv | 22 - ...pc_np_chart_data_control_chart_report.html | 53 - temp_uploads/spc_p_chart_data.csv | 22 - ...spc_p_chart_data_control_chart_report.html | 53 - temp_uploads/spc_subgroup_stable.csv | 52 - ..._subgroup_stable_control_chart_report.html | 53 - temp_uploads/spc_u_chart_data.csv | 22 - ...spc_u_chart_data_control_chart_report.html | 53 - tests/__init__.py | 1 + tests/conftest.py | 31 + tests/integration/__init__.py | 1 + tests/integration/test_e2e_batch.py | 47 + tests/integration/test_realtime_gated.py | 34 + tests/load/locustfile.py | 25 + tests/unit/__init__.py | 1 + tests/unit/test_api.py | 58 + tests/unit/test_capability.py | 75 + tests/unit/test_cleaning.py | 48 + tests/unit/test_constants.py | 54 + tests/unit/test_evaluator.py | 50 + tests/unit/test_ewma.py | 41 + tests/unit/test_hypothesis_limits.py | 44 + tests/unit/test_ingest.py | 49 + tests/unit/test_limits.py | 128 + tests/unit/test_msa.py | 61 + tests/unit/test_msa_stream.py | 38 + tests/unit/test_normality.py | 55 + tests/unit/test_persistence.py | 33 + tests/unit/test_pipeline.py | 47 + tests/unit/test_rules.py | 62 + tests/unit/test_stream_engine.py | 121 + uv.lock | 2450 ++++ 184 files changed, 27431 insertions(+), 6081 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 adapters/__init__.py create mode 100644 adapters/archive.py create mode 100644 adapters/db_models.py create mode 100644 adapters/factory.py create mode 100644 adapters/io_files.py create mode 100644 adapters/persistence.py create mode 100644 adapters/persistence_tsdb.py create mode 100644 adapters/render_plotly.py create mode 100644 adapters/stream.py create mode 100644 adapters/stream_engine.py create mode 100644 adapters/stream_sources.py delete mode 100644 agent_config/agent_prompts/__init__.py delete mode 100644 agent_config/agent_prompts/prompts.json delete mode 100644 agent_config/config.yaml delete mode 100644 agent_config/config_loader.py create mode 100644 alembic.ini delete mode 100644 api/__init__.py delete mode 100644 api/main.py create mode 100644 apps/__init__.py create mode 100644 apps/api/__init__.py create mode 100644 apps/api/main.py create mode 100644 apps/cli/__init__.py create mode 100644 apps/cli/main.py create mode 100644 apps/config.py create mode 100644 apps/config.yaml delete mode 100644 control_chart_system/__init__.py delete mode 100644 control_chart_system/control_chart_agent.py delete mode 100644 control_chart_system/control_chart_pipeline.py delete mode 100644 control_chart_system/control_chart_tools.py delete mode 100644 data_samples/capability_excellent.csv delete mode 100644 data_samples/capability_high_variation.csv delete mode 100644 data_samples/capability_off_center.csv delete mode 100644 data_samples/capability_skewed_data.csv delete mode 100644 data_samples/msa_bias_study.csv delete mode 100644 data_samples/msa_gage_rr_excellent.csv delete mode 100644 data_samples/msa_gage_rr_poor.csv delete mode 100644 data_samples/msa_linearity_study.csv delete mode 100644 data_samples/msa_stability_study.csv delete mode 100644 data_samples/spc_c_chart_data.csv delete mode 100644 data_samples/spc_individual_in_control.csv delete mode 100644 data_samples/spc_individual_out_of_control.csv delete mode 100644 data_samples/spc_np_chart_data.csv delete mode 100644 data_samples/spc_p_chart_data.csv delete mode 100644 data_samples/spc_subgroup_data.csv delete mode 100644 data_samples/spc_subgroup_stable.csv delete mode 100644 data_samples/spc_u_chart_data.csv create mode 100644 deploy/compose/docker-compose.yml create mode 100644 deploy/docker/Dockerfile.api create mode 100644 deploy/docker/Dockerfile.frontend create mode 100644 deploy/docker/Dockerfile.mqtt_bridge create mode 100644 deploy/docker/Dockerfile.stream_engine create mode 100644 deploy/mosquitto/mosquitto.conf create mode 100644 docs/api.md create mode 100644 docs/cli.md create mode 100644 docs/concepts.md create mode 100644 docs/configuration.md create mode 100644 docs/deployment.md create mode 100644 docs/development.md create mode 100644 docs/index.md create mode 100644 docs/pipeline.md create mode 100644 docs/python-api.md create mode 100644 frontend/.env.example create mode 100644 frontend/.eslintrc.json create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/e2e/home.spec.ts create mode 100644 frontend/next-env.d.ts create mode 100644 frontend/next.config.js create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/playwright.config.ts create mode 100644 frontend/postcss.config.js create mode 100644 frontend/public/.gitkeep create mode 100644 frontend/src/__tests__/formatLimits.test.ts create mode 100644 frontend/src/app/analyze/page.tsx create mode 100644 frontend/src/app/capability/page.tsx create mode 100644 frontend/src/app/globals.css create mode 100644 frontend/src/app/layout.tsx create mode 100644 frontend/src/app/live/page.tsx create mode 100644 frontend/src/app/login/page.tsx create mode 100644 frontend/src/app/msa/page.tsx create mode 100644 frontend/src/app/page.tsx create mode 100644 frontend/src/app/runs/[run_id]/page.tsx create mode 100644 frontend/src/app/runs/page.tsx create mode 100644 frontend/src/components/Checklist.tsx create mode 100644 frontend/src/components/ControlChart.tsx create mode 100644 frontend/src/components/GateList.tsx create mode 100644 frontend/src/components/Layout.tsx create mode 100644 frontend/src/components/Providers.tsx create mode 100644 frontend/src/components/ui.tsx create mode 100644 frontend/tailwind.config.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vitest.config.ts create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/001_initial.py delete mode 100644 msa_system/__init__.py delete mode 100644 msa_system/msa_agent.py delete mode 100644 msa_system/msa_pipeline.py delete mode 100644 msa_system/msa_tools.py delete mode 100644 process_capability_system/__init__.py delete mode 100644 process_capability_system/capability_agent.py delete mode 100644 process_capability_system/capability_pipeline.py delete mode 100644 process_capability_system/capability_tools.py create mode 100644 pyproject.toml create mode 100644 refactoring elements/messy_brainstorming.md create mode 100644 refactoring elements/spc_mvp_doc.html create mode 100644 sample_data/__init__.py create mode 100644 sample_data/__main__.py create mode 100755 scripts/manufacturing_sim.py create mode 100755 scripts/run_live_protocol.py create mode 100755 scripts/run_live_protocol.sh create mode 100644 services/__init__.py create mode 100644 services/mqtt_bridge/__init__.py create mode 100644 services/mqtt_bridge/main.py create mode 100644 services/stream_engine/__init__.py create mode 100644 services/stream_engine/main.py delete mode 100755 spc delete mode 100755 spc_cli.py create mode 100644 spc_core/__init__.py create mode 100644 spc_core/capability.py create mode 100644 spc_core/charts.py create mode 100644 spc_core/cleaning.py create mode 100644 spc_core/constants.py create mode 100644 spc_core/cusum.py create mode 100644 spc_core/evaluator.py create mode 100644 spc_core/ewma.py create mode 100644 spc_core/ingest.py create mode 100644 spc_core/limits.py create mode 100644 spc_core/models.py create mode 100644 spc_core/msa.py create mode 100644 spc_core/msa_stream.py create mode 100644 spc_core/multimodal.py create mode 100644 spc_core/normality.py create mode 100644 spc_core/pipeline.py create mode 100644 spc_core/report.py create mode 100644 spc_core/rules.py delete mode 100644 temp_uploads/capability_excellent.csv delete mode 100644 temp_uploads/capability_excellent_capability_report.html delete mode 100644 temp_uploads/msa_gage_rr_excellent.csv delete mode 100644 temp_uploads/msa_gage_rr_excellent_msa_report.html delete mode 100644 temp_uploads/spc_c_chart_data.csv delete mode 100644 temp_uploads/spc_c_chart_data_control_chart_report.html delete mode 100644 temp_uploads/spc_individual_in_control.csv delete mode 100644 temp_uploads/spc_individual_in_control_control_chart_report.html delete mode 100644 temp_uploads/spc_individual_out_of_control.csv delete mode 100644 temp_uploads/spc_individual_out_of_control_control_chart_report.html delete mode 100644 temp_uploads/spc_np_chart_data.csv delete mode 100644 temp_uploads/spc_np_chart_data_control_chart_report.html delete mode 100644 temp_uploads/spc_p_chart_data.csv delete mode 100644 temp_uploads/spc_p_chart_data_control_chart_report.html delete mode 100644 temp_uploads/spc_subgroup_stable.csv delete mode 100644 temp_uploads/spc_subgroup_stable_control_chart_report.html delete mode 100644 temp_uploads/spc_u_chart_data.csv delete mode 100644 temp_uploads/spc_u_chart_data_control_chart_report.html create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/test_e2e_batch.py create mode 100644 tests/integration/test_realtime_gated.py create mode 100644 tests/load/locustfile.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_api.py create mode 100644 tests/unit/test_capability.py create mode 100644 tests/unit/test_cleaning.py create mode 100644 tests/unit/test_constants.py create mode 100644 tests/unit/test_evaluator.py create mode 100644 tests/unit/test_ewma.py create mode 100644 tests/unit/test_hypothesis_limits.py create mode 100644 tests/unit/test_ingest.py create mode 100644 tests/unit/test_limits.py create mode 100644 tests/unit/test_msa.py create mode 100644 tests/unit/test_msa_stream.py create mode 100644 tests/unit/test_normality.py create mode 100644 tests/unit/test_persistence.py create mode 100644 tests/unit/test_pipeline.py create mode 100644 tests/unit/test_rules.py create mode 100644 tests/unit/test_stream_engine.py create mode 100644 uv.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4fa44eb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,81 @@ +# 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 tests || true + - name: Pytest + run: PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 pytest -q --tb=short + - 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 + + 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 || true + - 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..d532f45 100644 --- a/.gitignore +++ b/.gitignore @@ -166,11 +166,21 @@ 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/ +frontend/node_modules/ +frontend/.next/ +frontend/out/ +deploy/compose/data/ +*.parquet diff --git a/README.md b/README.md index f4b7b30..142455b 100644 --- a/README.md +++ b/README.md @@ -1,433 +1,91 @@ -# 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. ---- - -## Overview - -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. - -No more: - -* 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. - ---- +Phase I establishes and freezes versioned limits; Phase II evaluates new data against those limits. No stubs — every advertised `ChartType` has real math. ## 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 - -* **Capability Indices:** Cp, Cpk, Pp, Ppk, Cpm -* **Normality Testing:** Anderson–Darling, Shapiro–Wilk, Kolmogorov–Smirnov -* **Process Centering and Variation Analysis** - ---- - -## Quick Start - -### 1. Installation - -```bash -# Clone the repository -git clone https://github.com/M1ndSmith/ASPC.git -cd ASPC +- 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 +- Docker Compose stack: Redpanda, Mosquitto, TimescaleDB, Redis, stream engine, MQTT bridge, UI -# Create and activate virtual environment -python -m venv aspcvenv -source aspcvenv/bin/activate # On Windows: aspcvenv\Scripts\activate +## Architecture -# 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" +spc_core/ Pure statistics (Shewhart, EWMA, CUSUM, MSA, capability, gated pipeline) +adapters/ I/O, SQLite/TimescaleDB, Plotly, Kafka/MQTT sources, stream engine +apps/api/ FastAPI — JWT + API-key auth, REST, SSE replay, WebSocket live +apps/cli/ aspc CLI +services/stream_engine/ Kafka consumer → Phase II eval → Tier1/Tier2 + Redis +services/mqtt_bridge/ MQTT → Redpanda bridge +frontend/ Next.js operator dashboard +deploy/compose/ Full stack orchestration +migrations/ Alembic (Timescale hypertables + analysis tables) +sample_data/ Deterministic synthetic datasets for tests and demos +docs/ Full documentation ``` -### 3. Start the API Server - -```bash -uvicorn api.main:app --host 0.0.0.0 --port 8000 -``` - -Server available at: [http://localhost:8000](http://localhost:8000) - ---- +## Quick start -## Using the CLI - -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 +uv venv && source .venv/bin/activate +uv pip install -e ".[dev]" -# Run control chart analysis -./spc chat control-charts "Analyze this data" -f your_data.csv +uv run python -m sample_data --out examples/data +aspc control-chart -f examples/data/spc_individual_out_of_control.csv --json -# Run MSA study -./spc chat msa "Run a Gage R&R study" -f measurement_data.csv - -# 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** - -```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: +Dashboard: ```bash -./spc --url http://remote-server:8000 chat msa "Run study" -f data.csv +cd frontend && cp .env.example .env.local && npm install && npm run dev +# http://localhost:3000 ``` -Extended Timeout: +Full stack: ```bash -./spc chat control-charts "Analyze large dataset" -f big.csv --timeout 300 +docker compose -f deploy/compose/docker-compose.yml up -d --build ``` -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"]) -``` - -### curl Example - -```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 -``` - -**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 - -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 - -```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 - -### Manufacturing - -* Monitor process stability using control charts -* Validate measurement systems for precision -* Assess process capability for continuous improvement - -### Research & Development - -* 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 -``` - ---- - -## 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 - ---- +| Service | Port | +|---------|------| +| API | 8000 | +| Frontend | 3000 | +| Redpanda | 19092 | +| Mosquitto | 1883 | +| TimescaleDB | 5433 | +| Redis | 6379 | +| Grafana (ops) | 3001 | -## Credits +## Documentation -Built with: +| Guide | Description | +|-------|-------------| +| [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 | +| [docs/development.md](docs/development.md) | Tests, lint, `sample_data`, CI | -* **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..10cf1db --- /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 datetime import datetime +from pathlib import Path +from typing import Any, Iterable, Mapping, Sequence + + +_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..bb9cbc9 --- /dev/null +++ b/adapters/db_models.py @@ -0,0 +1,149 @@ +"""SQLAlchemy 2.0 declarative models for ASPC persistence (SQLite + TimescaleDB).""" +from __future__ import annotations + +from datetime import datetime, timezone + +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(timezone.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) + 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) + 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"), + ) + + 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) + + +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) + + +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) + meta: Mapped[dict | None] = mapped_column(JSON, nullable=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/factory.py b/adapters/factory.py new file mode 100644 index 0000000..403af75 --- /dev/null +++ b/adapters/factory.py @@ -0,0 +1,52 @@ +"""Repository factory — select SQLite or TimescaleDB backend.""" +from __future__ import annotations + +from pathlib import Path +from typing import Any, Optional + +from adapters.persistence import Repository, SQLiteRepository + + +def get_repository( + cfg: Any = None, + backend: Optional[str] = None, + *, + sqlite_path: str | Path | None = None, + timescale_dsn: Optional[str] = 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'." + ) diff --git a/adapters/io_files.py b/adapters/io_files.py new file mode 100644 index 0000000..4039638 --- /dev/null +++ b/adapters/io_files.py @@ -0,0 +1,117 @@ +"""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 +from pathlib import Path +from typing import Any, Optional + + +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: Optional[int] = None, + allowed_extensions: Optional[list[str]] = 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 / name + target.write_bytes(content) + return target + + +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..d8d9f74 --- /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 datetime, timezone +from pathlib import Path +from typing import Any, Optional +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: Optional[dict] = None) -> str: + ... + + @abstractmethod + def get_limits(self, version: str) -> Optional[dict[str, Any]]: + ... + + @abstractmethod + def save_run(self, analysis_type: str, report: dict[str, Any], + limits_version: Optional[str] = None, + source_file: Optional[str] = None, + user_id: Optional[str] = None) -> str: + ... + + @abstractmethod + def get_run(self, run_id: str) -> Optional[dict[str, Any]]: + ... + + @abstractmethod + def list_runs(self, analysis_type: Optional[str] = None, limit: int = 50) -> list[dict]: + ... + + @abstractmethod + def save_audit(self, event: str, detail: dict[str, Any], + user_id: Optional[str] = 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: Optional[dict] = None) -> str: + now = datetime.now(timezone.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) -> Optional[dict[str, Any]]: + 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: Optional[str] = None, + source_file: Optional[str] = None, + user_id: Optional[str] = None) -> str: + run_id = str(uuid4()) + now = datetime.now(timezone.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) -> Optional[dict[str, Any]]: + 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: Optional[str] = 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: Optional[str] = None) -> str: + event_id = str(uuid4()) + now = datetime.now(timezone.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..72f145b --- /dev/null +++ b/adapters/persistence_tsdb.py @@ -0,0 +1,515 @@ +"""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 time +from datetime import datetime, timezone +from typing import Any, Optional +from uuid import uuid4 + +try: + from sqlalchemy import create_engine, select, text + from sqlalchemy.dialects.postgresql import insert as pg_insert + from sqlalchemy.engine import Engine + 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 + +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; ignore duplicate errors via DO block. + conn.execute( + text( + """ + DO $$ + BEGIN + PERFORM add_retention_policy( + 'raw_measurements', + INTERVAL '90 days', + if_not_exists => TRUE + ); + EXCEPTION + WHEN undefined_function THEN + NULL; + WHEN OTHERS THEN + NULL; + END $$; + """ + ) + ) + + +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) + if 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: Optional[dict] = None, + ) -> str: + now = datetime.now(timezone.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) -> Optional[dict[str, Any]]: + 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: Optional[str] = None, + source_file: Optional[str] = None, + user_id: Optional[str] = None, + ) -> str: + run_id = str(uuid4()) + now = datetime.now(timezone.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) -> Optional[dict[str, Any]]: + 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: Optional[str] = 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: Optional[str] = None, + ) -> str: + event_id = str(uuid4()) + now = datetime.now(timezone.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 ---------------------------------------- + + def save_raw_measurement( + self, + stream_key: str, + ts: datetime, + value: float, + **meta: Any, + ) -> None: + if ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.utc) + row_id = int(meta.get("id") or time.time_ns()) + with self._session() as session: + session.add( + RawMeasurementRow( + 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"), + ) + ) + session.commit() + + def save_ooc_event( + self, + stream_key: str, + ts: datetime, + *, + limits_version: Optional[str], + index: int, + value: float, + rule_id: str, + rule_name: str, + description: str, + side: Optional[str] = None, + ) -> bool: + """Insert an OOC event. Returns True if inserted, False if duplicate.""" + if ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.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: Optional[str] = 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: Optional[str] = None, + ) -> Optional[dict[str, Any]]: + """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(timezone.utc) + row.acked_by = acked_by + session.commit() + session.refresh(row) + return _ooc_to_dict(row) + + def save_capability( + self, + run_id: str, + *, + cpk: Optional[float] = None, + ppk: Optional[float] = None, + sigma_level: Optional[float] = 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(timezone.utc), + ) + session.add(row) + session.commit() + session.refresh(row) + return int(row.id) + + def register_stream( + self, + stream_key: str, + *, + topic: Optional[str] = None, + limits_version: Optional[str] = None, + chart_type: Optional[str] = None, + ruleset: str = "nelson", + active: bool = True, + meta: Optional[dict] = None, + ) -> str: + now = datetime.now(timezone.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) -> Optional[dict[str, Any]]: + 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, + "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/render_plotly.py b/adapters/render_plotly.py new file mode 100644 index 0000000..c9e5a1f --- /dev/null +++ b/adapters/render_plotly.py @@ -0,0 +1,146 @@ +"""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 typing import Optional + +from spc_core.report import CapabilityReport, MSAReport, SPCReport + + +def render_control_chart_html(report: SPCReport, title: Optional[str] = None) -> str: + try: + import plotly.graph_objects as go + from plotly.subplots import make_subplots + import plotly.io as pio + 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: Optional[str] = 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..3f8824c --- /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 pathlib import Path +from typing import Callable, Iterator, Optional + +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: Optional[Callable[[Signal], 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..55fbd21 --- /dev/null +++ b/adapters/stream_engine.py @@ -0,0 +1,225 @@ +"""Keyed Phase II stream engine — frozen limits, Tier-1 raw + Tier-2 OOC + Redis live.""" +from __future__ import annotations + +import json +import logging +from datetime import datetime, timezone +from typing import Any, Optional, Protocol + +from spc_core.evaluator import Phase2Evaluator +from spc_core.models import ControlLimits, Signal + +logger = logging.getLogger(__name__) + + +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: Optional[str], + index: int, + value: float, + rule_id: str, + rule_name: str, + description: str, + side: Optional[str] = None, + ) -> bool: ... + + def get_limits(self, version: str) -> Optional[dict[str, Any]]: ... + + +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:{key}``. + """ + + def __init__( + self, + repo: StreamRepository, + *, + redis_client: Any = None, + redis_url: Optional[str] = None, + ): + self.repo = repo + self._evaluators: dict[str, Phase2Evaluator] = {} + self._limits: dict[str, ControlLimits] = {} + self._limits_versions: dict[str, str] = {} + self._rulesets: dict[str, str] = {} + self._redis = redis_client + self._redis_url = redis_url + + 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``.""" + self._evaluators[stream_key] = Phase2Evaluator(limits, ruleset=ruleset) + self._limits[stream_key] = limits + self._limits_versions[stream_key] = limits.version + self._rulesets[stream_key] = ruleset + + 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, + ts: datetime | None = None, + **meta: Any, + ) -> list[Signal]: + """Evaluate one observation; write raw + any OOC events; publish live; return signals.""" + ev = self._evaluators.get(stream_key) + if ev is None: + raise KeyError(f"Stream '{stream_key}' is not registered") + if ts is None: + ts = datetime.now(timezone.utc) + elif ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.utc) + + limits_version = self._limits_versions[stream_key] + self.repo.save_raw_measurement( + stream_key, + ts, + float(value), + limits_version=limits_version, + quality_flag=meta.get("quality_flag"), + machine_id=meta.get("machine_id"), + gage_id=meta.get("gage_id"), + ) + + signals = ev.observe(float(value)) + for sig in signals: + 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, + ) + + primary = self._limits[stream_key].primary + # Scalar limits for the Live dashboard (variable P/U charts use first/index 0). + 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 + payload = { + "type": "point", + "stream_key": stream_key, + "value": float(value), + "timestamp": ts.isoformat(), + "ts": ts.isoformat(), + "index": signals[0].index if signals else None, + "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 + ], + "ooc": bool(signals), + } + self._publish(stream_key, payload) + return signals + + def handle_message(self, msg: dict[str, Any]) -> list[Signal]: + """Handle a source dict ``{key, value, timestamp}`` (plus optional meta).""" + 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") + } + return self.handle_observation(key, float(value), ts, **meta) + + def _publish(self, stream_key: str, payload: dict[str, Any]) -> None: + client = self._get_redis() + if client is None: + return + channel = f"spc:live:{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 _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..545681f --- /dev/null +++ b/adapters/stream_sources.py @@ -0,0 +1,311 @@ +"""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 queue +import threading +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, AsyncIterator, Iterator, Optional + +from adapters.stream import ObservationSource + + +@dataclass +class Observation: + """Normalized measurement from a live source.""" + + key: str + ts: datetime + value: 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(timezone.utc), + value=float(text), + raw={"value": float(text)}, + ) + + if not isinstance(data, dict): + return Observation( + key=default_key, + ts=datetime.now(timezone.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(timezone.utc) + elif isinstance(raw_ts, datetime): + ts = raw_ts if raw_ts.tzinfo else raw_ts.replace(tzinfo=timezone.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=timezone.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}") + return Observation(key=key, ts=ts, value=float(value), 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: Optional[float] = 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 + + consumer = AIOKafkaConsumer( + self.topic, + bootstrap_servers=self.bootstrap_servers, + group_id=self.group_id, + auto_offset_reset=self.auto_offset_reset, + enable_auto_commit=True, + ) + self._consumer = consumer + await consumer.start() + try: + 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) + ) + obs = _parse_payload(msg.value or b"", default_key=key_hint or self.default_key) + yield obs.as_dict() + finally: + await consumer.stop() + 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: Optional[str] = None, + password: Optional[str] = None, + default_key: Optional[str] = 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: Optional[float] = 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: Optional[float] = None) -> Iterator[dict[str, Any]]: + import paho.mqtt.client as mqtt + + q: queue.Queue[dict[str, Any] | BaseException | None] = queue.Queue(maxsize=256) + + 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) + q.put(obs.as_dict()) + except BaseException as exc: # noqa: BLE001 + q.put(exc) + + client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) + if self.username is not None: + client.username_pw_set(self.username, self.password) + client.on_message = _on_message + client.connect(self.host, self.port) + client.subscribe(self.topic) + client.loop_start() + try: + while True: + item = q.get(timeout=timeout) if timeout else q.get() + if isinstance(item, BaseException): + raise item + yield item + finally: + client.loop_stop() + client.disconnect() + + 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 + + async with aiomqtt.Client(**kwargs) as client: + await client.subscribe(self.topic) + 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() 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/main.py b/apps/api/main.py new file mode 100644 index 0000000..0647dc5 --- /dev/null +++ b/apps/api/main.py @@ -0,0 +1,694 @@ +"""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 json +import os +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Optional + +from fastapi import ( + Depends, + FastAPI, + File, + Form, + Header, + HTTPException, + Query, + 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, Field + +from adapters.factory import get_repository +from adapters.io_files import FileReadError, load_columns, save_upload +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, + 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) + +app = FastAPI( + title="ASPC — Statistical Process Control API", + description="Correct, tested SPC core with batch analysis and Phase II streaming.", + version="2.0.0", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=cfg.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +_bearer = HTTPBearer(auto_error=False) + +# 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 ------------------------------------------------------------- + +def _create_access_token(subject: str) -> 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(timezone.utc) + timedelta(minutes=cfg.jwt_expire_minutes) + payload = {"sub": subject, "exp": expire} + return jwt.encode(payload, cfg.jwt_secret, algorithm=cfg.jwt_algorithm) + + +def get_current_user( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer), +) -> dict[str, Any]: + """Validate Bearer JWT. When auth is disabled, return anonymous user.""" + if not cfg.auth_enabled: + return {"username": "anonymous", "auth": "disabled"} + if credentials is None or not credentials.credentials: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + 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( + credentials.credentials, + cfg.jwt_secret, + algorithms=[cfg.jwt_algorithm], + ) + username = data.get("sub") + if not username: + raise HTTPException(401, "Invalid token") + return {"username": username, "auth": "jwt"} + except JWTError as exc: + raise HTTPException(401, "Invalid or expired token") from exc + + +def require_api_key(x_api_key: Optional[str] = Header(None, alias="X-API-Key")) -> str: + """Require X-API-Key for ingest / stream mutation endpoints.""" + keys = cfg.api_keys + # Also accept env-only keys if yaml list empty + 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: + # Dev mode: no keys configured — allow with warning header path + return x_api_key or "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 + + +# ---- response models ---------------------------------------------------------- + +class AnalyzeResponse(BaseModel): + status: str = "success" + run_id: str + analysis_type: str + report: dict[str, Any] + html_report: Optional[str] = None + checklist: Optional[dict[str, Any]] = None + + +class HealthResponse(BaseModel): + status: str + version: str = "2.0.0" + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + + +class StreamRegisterRequest(BaseModel): + stream_key: str + topic: Optional[str] = None + chart_type: Optional[str] = None + ruleset: str = "nelson" + meta: Optional[dict[str, Any]] = None + + +class GoLiveRequest(BaseModel): + limits_version: str + ruleset: Optional[str] = None + + +# ---- helpers ------------------------------------------------------------------ + +def _save_file(file: UploadFile) -> Path: + content = file.file.read() + try: + return save_upload( + content, + 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 _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(): + return HealthResponse(status="healthy") + + +@app.get("/metrics") +async def metrics(): + 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) + + +@app.post("/auth/token", response_model=TokenResponse) +async def login_for_access_token(form: OAuth2PasswordRequestForm = Depends()): + """MVP auth: accept any username if password matches ASPC_ADMIN_PASSWORD (default admin).""" + expected = os.getenv("ASPC_ADMIN_PASSWORD", cfg.admin_password) + if form.password != expected: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + token = _create_access_token(form.username) + return TokenResponse(access_token=token) + + +@app.post("/analyze/control-chart", response_model=AnalyzeResponse) +async def analyze_cc( + file: UploadFile = File(...), + value_col: Optional[str] = Form(None), + subgroup_col: Optional[str] = Form(None), + sample_size_col: Optional[str] = Form(None), + opportunity_col: Optional[str] = Form(None), + chart_type: Optional[str] = Form(None), + ruleset: Optional[str] = Form(None), + user_id: Optional[str] = Form(None), + include_records: bool = Form(False), + _user: dict = Depends(get_current_user), +): + t0 = time.perf_counter() + 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") + + values = columns[cmap.value_col] + ct = ChartType(chart_type) if chart_type else None + pipeline = 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, + ) + 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") + + repo.save_limits( + report_dict["limits"], report.limits.version, + report.chart_type.value, meta={"source_file": str(path)}, + ) + run_id = repo.save_run( + "control_chart", report_dict, + limits_version=report.limits.version, + source_file=str(path), + user_id=user_id or _user.get("username"), + ) + + html_path = None + if cfg.config["reports"]["auto_generate"]: + html = render_control_chart_html(report) + html_path = str(save_html(html, Path(cfg.report_dir) / 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) +async def analyze_cap( + file: UploadFile = File(...), + usl: float = Form(...), + lsl: float = Form(...), + target: Optional[float] = Form(None), + value_col: Optional[str] = Form(None), + subgroup_col: Optional[str] = Form(None), + user_id: Optional[str] = Form(None), + _user: dict = Depends(get_current_user), +): + if usl <= lsl: + raise HTTPException(400, f"USL ({usl}) must be greater than LSL ({lsl})") + + 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) + 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_id or _user.get("username"), + ) + html_path = None + if cfg.config["reports"]["auto_generate"]: + html = render_capability_html(report) + html_path = str(save_html(html, Path(cfg.report_dir) / 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) +async def analyze_msa( + file: UploadFile = File(...), + study_type: Optional[str] = Form(None), + method: str = Form("anova"), + tolerance: Optional[float] = Form(None), + part_col: Optional[str] = Form(None), + operator_col: Optional[str] = Form(None), + measurement_col: Optional[str] = Form(None), + trial_col: Optional[str] = Form(None), + reference_col: Optional[str] = Form(None), + user_id: Optional[str] = Form(None), + _user: dict = Depends(get_current_user), +): + 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}") + + report_dict = report.model_dump(mode="json") + run_id = repo.save_run( + "msa", report_dict, source_file=str(path), + user_id=user_id or _user.get("username"), + ) + html_path = None + if cfg.config["reports"]["auto_generate"]: + html = render_msa_html(report) + html_path = str(save_html(html, Path(cfg.report_dir) / f"{run_id}_msa.html")) + + return AnalyzeResponse( + run_id=run_id, analysis_type="msa", + report=report_dict, html_report=html_path, + ) + + +@app.get("/runs") +async def list_runs( + analysis_type: Optional[str] = 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): + """Serve a previously generated HTML report, or rebuild from stored JSON.""" + path = Path(cfg.report_dir) + for suffix in ("_control_chart.html", "_capability.html", "_msa.html"): + candidate = path / f"{run_id}{suffix}" + if candidate.exists(): + return HTMLResponse(candidate.read_text(encoding="utf-8")) + + run = repo.get_run(run_id) + if not run: + raise HTTPException(404, f"Run not found: {run_id}") + report = run["report"] + html = f"""Run {run_id} +

    {run['analysis_type']}

    +
    {json.dumps(report, indent=2, default=str)}
    """ + return HTMLResponse(html) + + +@app.post("/streams/register") +async def register_stream( + body: StreamRegisterRequest, + _key: str = Depends(require_api_key), + _user: dict = Depends(get_current_user), +): + if not hasattr(repo, "register_stream"): + raise HTTPException( + 501, + "Stream registry requires TimescaleDB backend (persistence.backend=timescale)", + ) + key = repo.register_stream( + body.stream_key, + topic=body.topic, + chart_type=body.chart_type, + ruleset=body.ruleset, + active=False, + meta=body.meta, + ) + repo.save_audit( + "stream_register", + {"stream_key": key, "topic": body.topic}, + user_id=_user.get("username"), + ) + return {"stream_key": key, "active": False} + + +@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(get_current_user), +): + if not hasattr(repo, "register_stream"): + 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}") + ruleset = body.ruleset or cfg.ruleset + repo.register_stream( + stream_key, + limits_version=body.limits_version, + chart_type=stored.get("chart_type"), + ruleset=ruleset, + active=True, + ) + 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, + } + + +@app.get("/streams") +async def list_streams( + active_only: bool = False, + _user: dict = Depends(get_current_user), +): + if not hasattr(repo, "list_streams"): + return {"streams": []} + return {"streams": repo.list_streams(active_only=active_only)} + + +@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 hasattr(repo, "ack_alert"): + 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.""" + await websocket.accept() + 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: + # Keepalive / detect client disconnect + try: + await asyncio.wait_for(websocket.receive_text(), timeout=0.01) + except asyncio.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="Path to a CSV already on the server"), + 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.""" + stored = repo.get_limits(limits_version) + if not stored: + raise HTTPException(404, f"Limits version not found: {limits_version}") + + limits = _limits_from_stored(stored) + + if not Path(file_path).exists(): + raise HTTPException(404, f"File not found: {file_path}") + + def event_gen(): + source = FileReplaySource(file_path, 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") + + +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/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..f1b1c76 --- /dev/null +++ b/apps/cli/main.py @@ -0,0 +1,264 @@ +"""ASPC CLI — thin consumer of spc_core (no LLM).""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +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, + analyze_control_chart, + bias_study, + capability_analysis, + check_autocorrelation, + 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) + + 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 + + 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 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/config.py b/apps/config.py new file mode 100644 index 0000000..3d28f20 --- /dev/null +++ b/apps/config.py @@ -0,0 +1,228 @@ +"""Application config — YAML + env overrides.""" +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any, Optional + +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": ["*"], + }, + "auth": { + "enabled": True, + "jwt_secret": "change-me-in-production", + "jwt_algorithm": "HS256", + "jwt_expire_minutes": 60, + "admin_password": "admin", + "api_keys": [], + }, + "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: Optional[str | Path] = 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_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") + + redis = self.config.setdefault("redis", {}) + if os.getenv("ASPC_REDIS_URL"): + redis["url"] = os.environ["ASPC_REDIS_URL"] + + 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"] + + @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) -> Optional[str]: + 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_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 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: Optional[Config] = 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..e42caf2 --- /dev/null +++ b/apps/config.yaml @@ -0,0 +1,41 @@ +# ASPC application configuration + +api: + host: "0.0.0.0" + port: 8000 + cors_origins: ["*"] + +auth: + enabled: true + jwt_secret: "change-me-in-production" + jwt_algorithm: "HS256" + jwt_expire_minutes: 60 + admin_password: "admin" + api_keys: [] + +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" + +kafka: + bootstrap: "localhost:9092" + topic: "spc.measurements" + +spc: + ruleset: "nelson" + min_phase1_points: 25 + acf_threshold: 0.2 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/docker-compose.yml b/deploy/compose/docker-compose.yml new file mode 100644 index 0000000..2056a2d --- /dev/null +++ b/deploy/compose/docker-compose.yml @@ -0,0 +1,140 @@ +# ASPC production stack — Redpanda, Mosquitto, TimescaleDB, Redis, API, stream engine, frontend. +# Usage: docker compose -f deploy/compose/docker-compose.yml up -d + +services: + redpanda: + image: docker.redpanda.com/redpandadata/redpanda:v24.2.4 + 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 + ports: + - "19092:19092" + - "9644:9644" + healthcheck: + test: ["CMD-SHELL", "rpk cluster health | grep -q 'Healthy'"] + interval: 10s + timeout: 5s + retries: 10 + + mosquitto: + image: eclipse-mosquitto:2.0 + ports: + - "1883:1883" + volumes: + - ../mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro + + timescaledb: + image: timescale/timescaledb:latest-pg16 + environment: + POSTGRES_USER: aspc + POSTGRES_PASSWORD: aspc + POSTGRES_DB: aspc + ports: + - "5433:5432" + volumes: + - tsdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U aspc -d aspc"] + interval: 5s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + + api: + build: + context: ../.. + dockerfile: deploy/docker/Dockerfile.api + environment: + ASPC_PERSISTENCE_BACKEND: timescale + ASPC_TIMESCALE_DSN: postgresql+asyncpg://aspc:aspc@timescaledb:5432/aspc + ASPC_REDIS_URL: redis://redis:6379/0 + ASPC_KAFKA_BOOTSTRAP: redpanda:9092 + ASPC_JWT_SECRET: change-me-in-production + ASPC_API_KEYS: demokey + ASPC_CORS_ORIGINS: http://localhost:3000 + ports: + - "8000:8000" + depends_on: + timescaledb: + condition: service_healthy + redis: + condition: service_healthy + + stream-engine: + build: + context: ../.. + dockerfile: deploy/docker/Dockerfile.stream_engine + environment: + ASPC_TIMESCALE_DSN: postgresql+asyncpg://aspc:aspc@timescaledb:5432/aspc + ASPC_REDIS_URL: redis://redis:6379/0 + ASPC_KAFKA_BOOTSTRAP: redpanda:9092 + ASPC_KAFKA_TOPIC: spc.measurements + depends_on: + redpanda: + condition: service_healthy + timescaledb: + condition: service_healthy + redis: + condition: service_healthy + + mqtt-bridge: + build: + context: ../.. + dockerfile: deploy/docker/Dockerfile.mqtt_bridge + 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 + - redpanda + + frontend: + build: + context: ../.. + dockerfile: deploy/docker/Dockerfile.frontend + environment: + NEXT_PUBLIC_API_URL: http://localhost:8000 + NEXT_PUBLIC_WS_URL: ws://localhost:8000 + ports: + - "3000:3000" + depends_on: + - api + + grafana: + image: grafana/grafana:11.1.0 + profiles: ["ops"] + ports: + - "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..9ab33d2 --- /dev/null +++ b/deploy/docker/Dockerfile.api @@ -0,0 +1,30 @@ +# 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 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 + +EXPOSE 8000 + +CMD ["aspc-api"] diff --git a/deploy/docker/Dockerfile.frontend b/deploy/docker/Dockerfile.frontend new file mode 100644 index 0000000..2df5ef2 --- /dev/null +++ b/deploy/docker/Dockerfile.frontend @@ -0,0 +1,35 @@ +# 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 +ARG NEXT_PUBLIC_API_URL=http://localhost:8000 +ARG NEXT_PUBLIC_WS_URL=ws://localhost:8000 +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL +ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL +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..b089b7c --- /dev/null +++ b/deploy/docker/Dockerfile.mqtt_bridge @@ -0,0 +1,24 @@ +# 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 + +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..9fad6ec --- /dev/null +++ b/deploy/docker/Dockerfile.stream_engine @@ -0,0 +1,26 @@ +# 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 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/mosquitto/mosquitto.conf b/deploy/mosquitto/mosquitto.conf new file mode 100644 index 0000000..0e3b61e --- /dev/null +++ b/deploy/mosquitto/mosquitto.conf @@ -0,0 +1,3 @@ +listener 1883 +allow_anonymous true +persistence false diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..151d25f --- /dev/null +++ b/docs/api.md @@ -0,0 +1,146 @@ +# 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`, default `admin`; any username is accepted): + +```bash +curl -s -X POST http://localhost:8000/auth/token \ + -d 'username=operator&password=admin' +# → {"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**, the handler allows the request in dev mode (`"dev"`) + +```bash +curl -H "X-API-Key: demokey" -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}` | +| `GET` | `/metrics` | no | Prometheus text (if `prometheus-client` installed) | + +### Auth + +| Method | Path | Body | +|--------|------|------| +| `POST` | `/auth/token` | OAuth2 password form: `username`, `password` | + +### 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`, `user_id`, `include_records` (bool). + +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`, `user_id`. + +### 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 (no auth) | + +### 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`. First message: `{"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 defaults to `http://localhost:3000` for the dashboard. + +See [configuration.md](configuration.md) and [deployment.md](deployment.md). diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..7e3c51c --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,104 @@ +# 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 +``` + +## 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..cae043d --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,100 @@ +# 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` | `["*"]` | CORS allow list | + +### `auth` + +| Key | Default | Meaning | +|-----|---------|---------| +| `enabled` | `true` | Require JWT on protected routes | +| `jwt_secret` | `change-me-in-production` | HS256 secret | +| `jwt_algorithm` | `HS256` | JWT algorithm | +| `jwt_expire_minutes` | `60` | Token lifetime | +| `admin_password` | `admin` | Password accepted by `/auth/token` | +| `api_keys` | `[]` | Allowed `X-API-Key` values | + +### `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` | + +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..6af56a2 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,104 @@ +# Deployment + +## Docker Compose (full stack) + +Compose file: [`deploy/compose/docker-compose.yml`](../deploy/compose/docker-compose.yml). + +```bash +docker compose -f deploy/compose/docker-compose.yml up -d --build +``` + +| Service | Role | Host port | +|---------|------|-----------| +| `api` | FastAPI | 8000 | +| `frontend` | Next.js dashboard | 3000 | +| `redpanda` | Kafka-compatible broker | 19092 (external), 9644 admin | +| `mosquitto` | MQTT broker | 1883 | +| `timescaledb` | Tier-1/Tier-2 persistence | 5433 (host → container 5432) | +| `redis` | Live alert pub/sub | 6379 | +| `stream-engine` | Kafka → Phase II eval → DB + Redis | — | +| `mqtt-bridge` | MQTT → Redpanda | — | +| `grafana` | Ops dashboards (`--profile ops`) | 3001 | + +Default API env in Compose: + +- `ASPC_PERSISTENCE_BACKEND=timescale` +- `ASPC_TIMESCALE_DSN=postgresql+asyncpg://aspc:aspc@timescaledb:5432/aspc` +- `ASPC_REDIS_URL=redis://redis:6379/0` +- `ASPC_KAFKA_BOOTSTRAP=redpanda:9092` +- `ASPC_API_KEYS=demokey` +- `ASPC_CORS_ORIGINS=http://localhost:3000` + +Frontend: `NEXT_PUBLIC_API_URL=http://localhost:8000`, `NEXT_PUBLIC_WS_URL=ws://localhost:8000`. + +Ops profile: + +```bash +docker compose -f deploy/compose/docker-compose.yml --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}`. +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 +- 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..b56ed0a --- /dev/null +++ b/docs/development.md @@ -0,0 +1,110 @@ +# 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 | +| `frontend/` | Next.js operator UI | +| `tests/` | unit + integration | +| `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 + +# Frontend +cd frontend && npm test +``` + +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`). + +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 tests +mypy spc_core adapters apps # packages listed in tool.mypy +``` + +CI currently runs ruff non-blocking (`|| true`); treat failures as real before merge. + +## 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** — setup-uv, `uv pip install --system -e ".[dev]"`, ruff, pytest, acceptance script (matrix 3.11 / 3.12) +2. **frontend** — npm ci/lint/test/build +3. **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..c952b9b --- /dev/null +++ b/docs/index.md @@ -0,0 +1,41 @@ +# 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 | +|-----|----------|----------| +| [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` | + +## 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/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..631dbad --- /dev/null +++ b/docs/python-api.md @@ -0,0 +1,165 @@ +# 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) +``` + +## 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..3c94c72 100644 --- a/env.example +++ b/env.example @@ -1,31 +1,25 @@ -# 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 -# ======================================== -# LLM API Keys (based on your chosen provider in config.yaml) -# ======================================== +# Auth +ASPC_AUTH_ENABLED=true +ASPC_JWT_SECRET=change-me-in-production +ASPC_ADMIN_PASSWORD=admin +# Comma-separated API keys for ingest / stream endpoints +# ASPC_API_KEYS=key1,key2 -# 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://localhost:8000 -# 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 - -# Anthropic API Key (if using Anthropic/Claude provider) -# Get your key at: https://console.anthropic.com/ -# ANTHROPIC_API_KEY=your_anthropic_api_key_here - -# 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) +# Persistence (sqlite | timescale) +# ASPC_PERSISTENCE_BACKEND=sqlite +# ASPC_SQLITE_PATH=aspc.db +# ASPC_TIMESCALE_DSN=postgresql+asyncpg://aspc:aspc@localhost:5432/aspc +# Streaming +# ASPC_REDIS_URL=redis://localhost:6379/0 +# ASPC_KAFKA_BOOTSTRAP=localhost:9092 +# ASPC_KAFKA_TOPIC=spc.measurements diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..3729a5b --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,2 @@ +NEXT_PUBLIC_API_URL=http://localhost:8000 +NEXT_PUBLIC_WS_URL=ws://localhost: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..7bf1990 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,11 @@ +node_modules +.next +out +dist +coverage +playwright-report +test-results +.env +.env.local +*.log +.DS_Store diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..04a78ed --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,29 @@ +# 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). API defaults to `http://localhost:8000`. + +## 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 smoke (skips if server down) | + +## Env + +- `NEXT_PUBLIC_API_URL` — REST base (default `http://localhost:8000`) +- `NEXT_PUBLIC_WS_URL` — WebSocket base (default `ws://localhost:8000`) diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts new file mode 100644 index 0000000..0ac2cc3 --- /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: /dashboard|aspc|overview/i })).toBeVisible({ + timeout: 10_000, + }); + await expect(page.getByRole("link", { name: /live/i })).toBeVisible(); +}); 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..dc1856c --- /dev/null +++ b/frontend/next.config.js @@ -0,0 +1,16 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: "standalone", + reactStrictMode: true, + transpilePackages: ["react-plotly.js"], + 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..42a4d23 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,16 @@ +import { defineConfig, devices } from "@playwright/test"; + +const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:3000"; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: "list", + use: { + baseURL, + trace: "on-first-retry", + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], +}); 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..08847f6 --- /dev/null +++ b/frontend/src/app/analyze/page.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { FormEvent, useMemo, useState } from "react"; +import { Checklist } from "@/components/Checklist"; +import { ControlChart } from "@/components/ControlChart"; +import { GateList } from "@/components/GateList"; +import { + ErrorBanner, + FileField, + PageHeader, + Panel, + PrimaryButton, + SelectInput, + Spinner, +} from "@/components/ui"; +import { ApiError, api } from "@/lib/api"; +import { formatLimits } from "@/lib/format"; +import type { AnalyzeResponse, Gate, Phase1Checklist, SPCReport } from "@/lib/types"; + +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 [file, setFile] = useState(null); + const [chartType, setChartType] = 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); + setResult(null); + try { + const fd = new FormData(); + fd.append("file", file); + if (chartType) fd.append("chart_type", chartType); + 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?.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) ?? []; + + return ( +
    + + + {error && } + + +
    +
    + +
    + + setChartType(e.target.value)} + > + + + + + + + + + + + + +
    + + {busy ? "Running…" : "Run analysis"} + + {busy && } +
    +
    +
    + + {result && ( +
    + +
    +
    + Run ID + {result.run_id} +
    +
    + Type + {result.analysis_type} +
    + {primary && ( +
    + Limits + {formatLimits(primary)} +
    + )} +
    +
    + + {report && primary && ( + + )} + +
    + + + + + + +
    + + {!report && ( + +
    +                {JSON.stringify(result.report, null, 2)}
    +              
    +
    + )} +
    + )} +
    + ); +} diff --git a/frontend/src/app/capability/page.tsx b/frontend/src/app/capability/page.tsx new file mode 100644 index 0000000..174fcde --- /dev/null +++ b/frontend/src/app/capability/page.tsx @@ -0,0 +1,140 @@ +"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..611df2f --- /dev/null +++ b/frontend/src/app/globals.css @@ -0,0 +1,55 @@ +@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap"); + +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + color-scheme: dark; + --aspc-bg: #080b10; + --aspc-cyan: #00d4ff; +} + +html, +body { + min-height: 100%; +} + +body { + background: + radial-gradient(ellipse 80% 50% at 10% -10%, rgba(0, 212, 255, 0.08), transparent), + radial-gradient(ellipse 60% 40% at 100% 0%, rgba(0, 80, 120, 0.12), transparent), + var(--aspc-bg); + color: #e8eef7; + font-family: "IBM Plex Sans", system-ui, sans-serif; + -webkit-font-smoothing: antialiased; +} + +* { + box-sizing: border-box; +} + +::selection { + background: rgba(0, 212, 255, 0.25); +} + +/* Subtle grid texture */ +body::before { + content: ""; + pointer-events: none; + position: fixed; + inset: 0; + background-image: + linear-gradient(rgba(26, 35, 50, 0.35) 1px, transparent 1px), + linear-gradient(90deg, rgba(26, 35, 50, 0.35) 1px, transparent 1px); + background-size: 48px 48px; + mask-image: radial-gradient(ellipse at center, black 20%, transparent 75%); + opacity: 0.4; + z-index: 0; +} + +#__next, +body > div:first-child { + position: relative; + z-index: 1; +} 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/page.tsx b/frontend/src/app/live/page.tsx new file mode 100644 index 0000000..bb21694 --- /dev/null +++ b/frontend/src/app/live/page.tsx @@ -0,0 +1,224 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +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; + +interface AlertItem extends Signal { + ts?: string; +} + +export default function LivePage() { + const streamsQ = useQuery({ + queryKey: ["streams"], + queryFn: api.listStreams, + retry: false, + }); + + const [streamKey, setStreamKey] = useState(""); + const [manualKey, setManualKey] = useState("line-1"); + const [connected, setConnected] = useState(false); + const [error, setError] = useState(null); + const [values, setValues] = useState([]); + const [ooc, setOoc] = useState([]); + const [ucl, setUcl] = useState(0); + const [cl, setCl] = useState(0); + const [lcl, setLcl] = useState(0); + const [alerts, setAlerts] = useState([]); + const sockRef = useRef(null); + + const activeKey = streamKey || manualKey; + + const onMessage = useCallback((msg: LivePointMessage) => { + 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.type === "point" || msg.value !== undefined) { + const v = msg.value; + if (typeof v === "number") { + setValues((prev) => { + const next = [...prev, v].slice(-MAX_POINTS); + return next; + }); + 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") { + setOoc((prev) => { + const idx = msg.index ?? values.length; + return [...prev, idx].slice(-MAX_POINTS); + }); + setAlerts((prev) => { + const added = sigs.map((s) => ({ ...s, ts: msg.ts })); + 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, + }); + } + return [...added, ...prev].slice(0, 50); + }); + } + + if (msg.type === "error") { + setError(msg.message || "WebSocket error"); + } + }, [values.length]); + + function disconnect() { + sockRef.current?.close(); + sockRef.current = null; + setConnected(false); + } + + function connect() { + setError(null); + disconnect(); + setValues([]); + setOoc([]); + setAlerts([]); + const handle = connectLiveSocket(activeKey, onMessage, { + token: getToken(), + onOpen: () => setConnected(true), + onClose: () => setConnected(false), + onError: () => setError("WebSocket connection failed"), + }); + sockRef.current = handle; + } + + useEffect(() => () => disconnect(), []); + + const streamOptions = useMemo( + () => streamsQ.data?.streams?.filter((s) => s.active) ?? [], + [streamsQ.data], + ); + + return ( +
    + + {connected ? "Connected" : "Disconnected"} + + } + /> + + {error && } + + +
    + setStreamKey(e.target.value)} + > + + {streamOptions.map((s) => ( + + ))} + +
    + + setManualKey(e.target.value)} + className="w-full rounded-lg border border-aspc-border bg-aspc-bg px-3 py-2 text-sm outline-none focus:border-aspc-cyan/50 disabled:opacity-50" + /> +
    +
    + + Connect + + +
    +
    + {streamsQ.isError && ( +

    + Could not list streams — enter a stream key manually. +

    + )} +
    + +
    +
    + + {!connected && values.length === 0 && ( +

    Connect to a stream to receive points.

    + )} +
    + + {alerts.length === 0 &&

    No alerts yet.

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

      {a.description || a.rule_name}

      +
      + value={a.value} + {a.ts ? ` · ${formatTimestamp(a.ts)}` : ""} +
      +
    • + ))} +
    + {streamsQ.isLoading && } +
    +
    +
    + ); +} diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx new file mode 100644 index 0000000..a537035 --- /dev/null +++ b/frontend/src/app/login/page.tsx @@ -0,0 +1,65 @@ +"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

    +

    JWT auth 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..d281b08 --- /dev/null +++ b/frontend/src/app/msa/page.tsx @@ -0,0 +1,150 @@ +"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)}
    +            
    + )} +
    + )} + + +
    +

    Coming online

    +

    + Live bias EWMA (α=0.2), rolling R, and calibration alerts from ContinuousMSA will render + here once the stream engine publishes drift metrics on the selected stream. +

    +
    +
    + +
    + ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx new file mode 100644 index 0000000..ed062d1 --- /dev/null +++ b/frontend/src/app/page.tsx @@ -0,0 +1,114 @@ +"use client"; + +import Link from "next/link"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import { formatTimestamp, shortId } from "@/lib/format"; +import { ErrorBanner, PageHeader, Panel, Spinner, StatCard } from "@/components/ui"; + +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 healthy = health.data?.status === "healthy" || health.data?.status === "ok"; + const liveCount = streams.data?.streams?.filter((s) => s.active).length ?? null; + const recent = runs.data?.runs ?? []; + + return ( +
    + + + {(health.isError || runs.isError) && ( + + )} + +
    + + + + + + Analyze + + · + + Live + + + } + /> +
    + + + {runs.isLoading && } + {!runs.isLoading && recent.length === 0 && ( +

    No runs yet. Upload a file on Analyze.

    + )} + {recent.length > 0 && ( +
    + + + + + + + + + + + {recent.map((r) => ( + + + + + + + ))} + +
    RunTypeCreatedLimits
    + + {shortId(r.run_id, 12)} + + {r.analysis_type}{formatTimestamp(r.created_at)} + {r.limits_version ? shortId(r.limits_version) : "—"} +
    +
    + )} +
    +
    + ); +} 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..9316dec --- /dev/null +++ b/frontend/src/app/runs/[run_id]/page.tsx @@ -0,0 +1,197 @@ +"use client"; + +import Link from "next/link"; +import { useParams } from "next/navigation"; +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, Spinner } from "@/components/ui"; +import { api, reportUrl } from "@/lib/api"; +import { formatLimits, formatTimestamp, shortId } from "@/lib/format"; +import type { Gate, Phase1Checklist, 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; +} + +export default function RunDetailPage() { + const params = useParams(); + const runId = String(params.run_id ?? ""); + + 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 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; + + return ( +
    + + ← All runs + + } + /> + + {error && } + {isLoading && } + + {data && ( +
    + +
    +
    +
    Run ID
    +
    {data.run_id}
    +
    +
    +
    Type
    +
    {data.analysis_type}
    +
    +
    +
    Created
    +
    {formatTimestamp(data.created_at)}
    +
    +
    +
    Source
    +
    {data.source_file || "—"}
    +
    + {data.limits_version && ( +
    +
    Limits version
    +
    {data.limits_version}
    +
    + )} +
    + +
    + + {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..d6fdb55 --- /dev/null +++ b/frontend/src/app/runs/page.tsx @@ -0,0 +1,80 @@ +"use client"; + +import Link from "next/link"; +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"; + +export default function RunsPage() { + const { data, isLoading, error, refetch, isFetching } = useQuery({ + queryKey: ["runs"], + queryFn: () => api.listRuns({ limit: 100 }), + }); + + const runs = data?.runs ?? []; + + return ( +
    + refetch()} + className="rounded-lg border border-aspc-border px-3 py-1.5 text-xs text-aspc-muted hover:text-aspc-text" + > + {isFetching ? "Refreshing…" : "Refresh"} + + } + /> + + {error && } + + + {isLoading && } + {!isLoading && runs.length === 0 && ( +

    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)}
    +
    + )} +
    +
    + ); +} diff --git a/frontend/src/components/Checklist.tsx b/frontend/src/components/Checklist.tsx new file mode 100644 index 0000000..04c978d --- /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..65624ec --- /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: "#FF4D4F", width: 1.5, dash: "dash" }, + hoverinfo: "y+name", + }, + { + x, + y: clSeries, + type: "scatter", + mode: "lines", + name: "CL", + line: { color: "#00D4FF", width: 1.5 }, + hoverinfo: "y+name", + }, + { + x, + y: lclSeries, + type: "scatter", + mode: "lines", + name: "LCL", + line: { color: "#FF4D4F", width: 1.5, dash: "dash" }, + hoverinfo: "y+name", + }, + { + x: inControlX, + y: inControlY, + type: "scatter", + mode: "lines+markers", + name: "Value", + line: { color: "#E8EEF7", width: 1.5 }, + marker: { color: "#E8EEF7", size: 6 }, + }, + { + x: oocX, + y: oocY, + type: "scatter", + mode: "markers", + name: "OOC", + marker: { color: "#FF4D4F", size: 10, symbol: "x", line: { width: 2, color: "#FF4D4F" } }, + }, + ]; + + const layout = { + title: { text: title, font: { color: "#E8EEF7", size: 14 }, x: 0, xanchor: "left" }, + paper_bgcolor: "rgba(0,0,0,0)", + plot_bgcolor: "#0D1219", + font: { color: "#6B7A90", family: "IBM Plex Sans, sans-serif", size: 11 }, + margin: { t: 40, r: 16, b: 40, l: 48 }, + height, + xaxis: { + title: "Subgroup / Index", + gridcolor: "#1A2332", + zeroline: false, + color: "#6B7A90", + }, + yaxis: { + title: "Value", + gridcolor: "#1A2332", + zeroline: false, + color: "#6B7A90", + }, + 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..0a96b7e --- /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..fdf2d25 --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,126 @@ +"use client"; + +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { useEffect, useState, type ReactNode } from "react"; +import { getToken, logout } from "@/lib/api"; + +const NAV = [ + { href: "/", label: "Overview" }, + { href: "/live", label: "Live" }, + { href: "/analyze", label: "Analyze" }, + { href: "/capability", label: "Capability" }, + { href: "/msa", label: "MSA" }, + { href: "/runs", label: "Runs" }, +]; + +export function Layout({ children }: { children: ReactNode }) { + const pathname = usePathname(); + const router = useRouter(); + const [open, setOpen] = useState(false); + const [authed, setAuthed] = useState(false); + const isLogin = pathname === "/login"; + + useEffect(() => { + setAuthed(!!getToken()); + }, [pathname]); + + 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..833f208 --- /dev/null +++ b/frontend/src/components/ui.tsx @@ -0,0 +1,181 @@ +"use client"; + +import type { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from "react"; + +export function PageHeader({ + title, + subtitle, + actions, +}: { + title: string; + subtitle?: string; + actions?: ReactNode; +}) { + return ( +
    +
    +

    {title}

    + {subtitle &&

    {subtitle}

    } +
    + {actions} +
    + ); +} + +export function Panel({ + title, + children, + className = "", +}: { + title?: string; + children: ReactNode; + className?: string; +}) { + return ( +
    + {title && ( +

    + {title} +

    + )} + {children} +
    + ); +} + +export function StatCard({ + label, + value, + hint, + tone = "default", +}: { + label: string; + value: ReactNode; + hint?: string; + tone?: "default" | "ok" | "warn" | "stop" | "cyan"; +}) { + const toneClass = + tone === "ok" + ? "text-aspc-ok" + : tone === "warn" + ? "text-aspc-warn" + : tone === "stop" + ? "text-aspc-stop" + : tone === "cyan" + ? "text-aspc-cyan" + : "text-aspc-text"; + + return ( +
    +
    {label}
    +
    {value}
    + {hint &&
    {hint}
    } +
    + ); +} + +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,.xlsx", + 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-lg border border-aspc-border bg-aspc-bg px-3 py-2 text-sm file:mr-3 file:rounded file:border-0 file:bg-aspc-cyan/15 file:px-3 file:py-1 file:text-xs file:font-medium file:text-aspc-cyan" + /> +
    + ); +} + +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/tailwind.config.ts b/frontend/tailwind.config.ts new file mode 100644 index 0000000..b62536c --- /dev/null +++ b/frontend/tailwind.config.ts @@ -0,0 +1,37 @@ +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: "#080B10", + panel: "#0D1219", + border: "#1A2332", + muted: "#6B7A90", + text: "#E8EEF7", + cyan: "#00D4FF", + "cyan-dim": "#0099BB", + warn: "#F5A623", + stop: "#FF4D4F", + ok: "#2ECC71", + }, + }, + fontFamily: { + sans: ["IBM Plex Sans", "system-ui", "sans-serif"], + mono: ["IBM Plex Mono", "ui-monospace", "monospace"], + }, + boxShadow: { + glow: "0 0 24px rgba(0, 212, 255, 0.15)", + }, + }, + }, + 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/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/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..27a5cac --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,128 @@ +[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", +] + +[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", + "slowapi>=0.1.9", + "structlog>=24.1", + "prometheus-client>=0.20", + "redis>=5.0", +] +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", + "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", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +asyncio_mode = "auto" +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"] + +[tool.mypy] +python_version = "3.11" +ignore_missing_imports = true +warn_return_any = false +warn_unused_ignores = true +check_untyped_defs = true +packages = ["spc_core", "adapters", "apps"] diff --git a/refactoring elements/messy_brainstorming.md b/refactoring elements/messy_brainstorming.md new file mode 100644 index 0000000..4a48133 --- /dev/null +++ b/refactoring elements/messy_brainstorming.md @@ -0,0 +1,198 @@ +Can spc be done in realtime, or only with the known way of gathering data, do measurement system then spc analysis + +Great question. SPC absolutely can be done in real-time — in fact, that's its most powerful form. Let me break this down. +Traditional (Batch) SPC Flow + +The "known way" you're describing: + + Define measurement system → validate with Gauge R&R + Collect baseline data (typically 25–30 subgroups) + Calculate control limits from that historical data + Plot and analyze + +This is fine for process qualification or retrospective analysis, but it's reactive — you're looking at what already happened. +Real-Time SPC + +Yes, fully possible and widely used in manufacturing and high-throughput systems. The mechanics: + +Phase I vs Phase II distinction is the key concept here: + + Phase I: Establish control limits from historical/baseline data (the batch approach) + Phase II: Stream new measurements against those frozen limits in real-time + +Once you have Phase I limits, every new data point can be evaluated as it arrives — you're just checking: does this point fall outside ±3σ? Does it trigger a Western Electric run rule? + +Real-time SPC in practice: + + CNC machines streaming dimensional measurements every cycle + Semiconductor fabs running SPC on every wafer + Injection molding with in-line sensors updating X̄-R charts per shot + Automated test equipment pushing pass/fail + measurement data to SPC dashboards live + +The infrastructure it requires: + + A data acquisition layer (sensors, CMMs, automated gauges) feeding a stream + A rules engine evaluating each point against pre-established limits + Alerting when out-of-control signals are detected (Nelson/Western Electric rules) + Optional: adaptive control limits if the process is known to drift (EWMA charts handle this better than Shewhart for slow drift) + +The Measurement System Still Comes First + +One thing that doesn't change: you still need a validated measurement system before any SPC — real-time or not. If your gauge has excessive variation, your control chart is noise. Gauge R&R as % of tolerance should be <10% (acceptable up to 30% depending on context) before you trust the stream. +When Real-Time SPC Gets Interesting Technically + + Short run SPC: Not enough historical data to establish limits → use standardized charts (Z-MR) or Bayesian updating of limits as data arrives + EWMA/CUSUM: Better than Shewhart for detecting small shifts in real-time streams; they weight recent observations more heavily + Autocorrelated data: High-frequency sensors often produce autocorrelated measurements — standard Shewhart charts give false alarms; need residual-based SPC + +Are you thinking about implementing real-time SPC in a system? I can go deeper on the architecture or the statistical side of it. + +The data cleaning for spc, both real-time and static data, is far different than data cleaning for machine learning or data analysis + +Exactly right, and it's an underappreciated distinction. Most data engineering content treats cleaning as a generic pipeline step, but SPC has fundamentally different objectives that invert some standard practices. +Why the Objective Differs + +ML/DA cleaning goal: maximize signal, remove noise, make data representative of the underlying pattern + +SPC cleaning goal: preserve the process as it actually ran — including the noise, the outliers, the drift — because those are the signal + +Throwing away an outlier in ML is often correct. Throwing away an outlier in SPC before Phase I analysis potentially removes evidence of a special cause, which means your control limits get computed on a falsely stable process. +Specific Inversions + +Outlier handling + + ML: remove or clip outliers to prevent skewing the model + SPC: flag outliers, investigate root cause, only remove if you can assign a documented special cause (wrong setup, known equipment fault). Unassigned outliers stay in + +Missing data imputation + + ML: impute with mean, median, interpolation — standard practice + SPC: imputation is generally forbidden for control chart data. A missing observation is a missing subgroup. Carrying forward or interpolating manufactures false stability. You either skip the subgroup or note the gap explicitly + +Normalization / transformation + + ML: normalize freely to improve model convergence + SPC: transformation (e.g. log for skewed data) must be justified by the underlying distribution and done before limit calculation — and the chart now lives in transformed space, which complicates operator interpretation + +Duplicate removal + + ML: duplicates bias training, remove them + SPC: a duplicate reading might indicate a frozen sensor or a transcription error — it's a data quality event worth flagging, not silently dropping + +Drift correction / detrending + + ML: detrending is common preprocessing to stationarize a series + SPC: drift is what you're trying to detect. Removing it before charting defeats the entire purpose. If drift is expected (tool wear, batch-to-batch variation), you use regression control charts or EWMA — you don't subtract it out upstream + +Real-Time vs Static Cleaning Differences Within SPC + +Even inside SPC, real-time and static cleaning diverge: + +Static (Phase I batch) + + You have the full dataset, so you can do iterative limit calculation: compute limits → identify out-of-control points → investigate → remove if assignable cause documented → recompute → repeat until stable + This iterative exclusion process is legitimate in Phase I because you're defining what "in control" looks like + Cleaning is a human-in-the-loop process + +Real-Time (Phase II streaming) + + You cannot retroactively recompute limits based on what you just saw — limits are frozen from Phase I + Cleaning logic must be deterministic and rule-based: sensor range checks, rate-of-change filters, gauge communication fault flags + The key distinction: you're filtering measurement system failures, not process variation. A reading of -999 from a disconnected sensor gets dropped. A reading of +4σ from a real process event does not — that's your out-of-control signal + Latency matters: cleaning logic has to run in the same pipeline stage as ingestion, before the rules engine evaluates the point + +The Core Principle + +SPC cleaning asks: did the process actually produce this value, and was it measured correctly? + +If yes to both — no matter how extreme — it stays. Everything else is measurement system failure, not process data, and gets flagged with a documented reason rather than silently discarded. + +That discipline is what makes control limits meaningful. The moment you start cleaning for convenience rather than documented cause, your limits no longer reflect the true process distribution. + +Are you building a cleaning pipeline for one of these contexts specifically? + + +Claude is AI and can make mistakes. Please double-check responses. + +https://github.com/M1ndSmith/ASPC +Regardless of agentic side. What are the key critics of the core spc engine, +What spc engine components architecture and tech stack will be good for even real time data, both computer side and core spc side +Fetched: GitHub - M1ndSmith/ASPC: AI-powered Statistical Process Control, MSA, and Process Capability analysis. Upload data, ask in plain English, get charts and reports. + +Now let me look at the core SPC source code to give you specific critiques. +Searched the web + +I can see enough from the README and architecture to give you a thorough critique. Let me now give you a deep, structured analysis. +Part 1: Key Critiques of the Core SPC Engine + +Based on the architecture and stack visible in the repo (pandas/numpy/scipy + Plotly + FastAPI + LangChain), here are the real problems: +1. Batch-Only, File-Centric Data Model + +The entire ingestion pipeline is built around CSV uploads — multipart/form-data via HTTP POST. There is no streaming input, no incremental update, no windowed computation. Every "analysis" is a full re-computation over a static snapshot. This isn't SPC, it's retrospective statistical reporting dressed up as SPC. Real SPC is about detecting process shifts as they happen, not after you upload a file. +2. No Persistent Control Limit State + +Proper SPC requires Phase I (establishing control limits from baseline data) and Phase II (applying those frozen limits to new incoming data). The engine appears to recompute limits from the uploaded data every time, meaning limits shift with each run. This violates the fundamental principle of SPC — limits must be fixed from a stable reference period and applied forward. +3. Western Electric / Nelson Rules Are Likely Incomplete or Stateless + +Out-of-control detection requires maintaining run history — e.g., 8 consecutive points on one side of the centerline, 6 points trending in a direction. Without a stateful buffer of prior observations, only point-in-time violations (e.g., beyond 3-sigma) can be detected, missing the pattern-based rules that catch subtler process shifts. +4. Chart Selection Heuristics May Be Unreliable + +"Automatic chart selection based on dataset characteristics" is described but the selection logic appears to be inference from column names and data shape, not a rigorous assessment. Choosing between I-MR, Xbar-R, and Xbar-S has real statistical consequences (e.g., Xbar-S is preferred for subgroup sizes > 8). Misclassification can produce wrong control limits and missed signals. +5. No Subgroup Integrity Validation + +For attribute charts (P, NP, C, U), the engine needs to validate that subgroup sizes are appropriate (e.g., large enough for the binomial approximation to hold). For C charts, the assumption is a constant area of opportunity. There's no indication these domain constraints are enforced. +6. Normality Assumption in Capability Analysis Is Potentially Mishandled + +The system runs Anderson-Darling, Shapiro-Wilk, and KS tests, which is good. But if normality is rejected, it's unclear whether the engine shifts to non-parametric capability indices (e.g., percentile-based Pp/Ppk) or just proceeds anyway. Reporting Cp/Cpk on non-normal data is statistically incorrect and dangerous in a manufacturing context. +7. Temp File Storage Is Not Production Safe + +Reports and uploads go to temp_uploads/. This is a single-node, ephemeral, unmanaged file system approach. It creates data integrity issues on restart, blocks horizontal scaling, and introduces privacy/isolation problems if multiple users share the same process. +8. No Audit Trail or Versioning + +In regulated manufacturing (ISO, IATF 16949, FDA 21 CFR Part 11), SPC results require traceability — who ran the analysis, on what data, with what limits, at what time. The system has thread IDs for conversational memory but no immutable audit log of statistical computations. +Part 2: Architecture for a Real-Time SPC Engine + +Here's what a real-time-capable SPC system looks like, split into the two layers you asked about. +Compute Side (Infrastructure / Data Pipeline) + +The core challenge is that real-time SPC means per-observation, low-latency, stateful computation — not batch jobs. + +Data Ingestion: Replace CSV upload with a streaming message broker. Apache Kafka or Redpanda is the right choice — sensors/PLCs/SCADA systems publish measurements to topics partitioned by process/machine. MQTT is also common at the edge (OPC-UA → MQTT → Kafka bridge is a standard industrial pattern). + +Stream Processing Engine: Apache Flink or Kafka Streams for stateful stream processing. Flink specifically handles keyed state (per process stream), windowed aggregations (rolling subgroups of n=5), and event-time semantics correctly. This is where control limit lookups, subgroup formation, and rule evaluation happen — in-flight, per event. + +State Store: Control limits (Phase I parameters), process baselines, and rule buffers (the last 8 points for Nelson rules) must live in a fast, persistent key-value store. Redis with persistence, or RocksDB (embedded in Flink), works well. Limits must be versioned so you can track when they were last recalculated. + +Time-Series Database: InfluxDB or TimescaleDB for storing the raw measurements and computed statistics (mean, range, sigma, OOC flags) with timestamps. This enables both real-time dashboards and retrospective Phase I analysis. +Core SPC Engine (Statistical Computation) + +The SPC computation layer needs to be redesigned as a stateful, incremental, event-driven library, not a batch function. + +Language: Python is fine for the math, but the hot path (per-observation rule evaluation) should be in Rust or C++ with Python bindings (via PyO3) if you need sub-millisecond latency at high throughput. For most manufacturing use cases (dozens to low hundreds of measurements per second), pure Python with NumPy is sufficient. + +Control Limit Management: A ControlLimitStore class that is initialized from Phase I data, serialized, and immutable during Phase II. Recalculation is a deliberate, audited operation, not automatic. + +Stateful Rule Buffer: A circular buffer (e.g., collections.deque(maxlen=8)) per chart stream that carries the last N observations. Nelson/Western Electric rules are evaluated over this buffer on every new point. + +Chart Engine: Each chart type (XbarR, IMR, P, etc.) is a separate class implementing a common SPCChart interface with methods like add_observation(value, subgroup_id) → SPCSignal. This makes it testable, composable, and serializable. + +Capability as a Separate Service: Capability analysis (Cp, Cpk, etc.) is a periodic Phase I function, not a real-time one. It belongs as a scheduled job or on-demand service, separate from the control chart engine. + +API Layer: Replace the single synchronous FastAPI HTTP endpoint with a WebSocket or Server-Sent Events endpoint for pushing OOC alerts to dashboards in real time. The REST API is fine for configuration and historical queries. +Summary Table +Concern Current ASPC Real-Time Architecture +Data input CSV upload Kafka/MQTT stream +Computation model Batch, stateless Streaming, stateful (Flink) +Control limits Recomputed each run Phase I frozen, stored in Redis +Rule evaluation Point-in-time only Windowed, buffer-based +Storage temp files InfluxDB + S3 for reports +API REST POST WebSocket + REST +Language hot path Python Python + optional Rust bindings +Audit None Immutable event log (Kafka topic) + +The ASPC repo is a solid prototype for retrospective quality analysis with a nice AI interpretation layer on top. The gap to real-time SPC is less about the AI pieces and more about re-architecting the data ingestion and computation model from a file-in/file-out pattern to a continuous stream-processing pipeline with proper stateful control limit management. + + +Claude is AI and can make mistakes. Please double-check cited sources. + + diff --git a/refactoring elements/spc_mvp_doc.html b/refactoring elements/spc_mvp_doc.html new file mode 100644 index 0000000..93c8118 --- /dev/null +++ b/refactoring elements/spc_mvp_doc.html @@ -0,0 +1,995 @@ + + + + + +SPC Data Preparation Pipeline — MVP Document + + + + + +
    + + +
    +
    MVP SPECIFICATION · v1.0
    +

    Industrial SPC
    Data Preparation
    Pipeline

    +

    + A decisional pipeline architecture for data ingestion, cleaning, measurement system validation, + and SPC-ready output — distinct from ML and analytics cleaning methodologies. +

    +
    +
    STATUS
    Draft for Review
    +
    DATE
    May 2026
    +
    DOMAIN
    Industrial Quality Engineering
    +
    STACK
    Python · Polars · Pandas · TimescaleDB / QuestDB
    +
    STANDARDS
    AIAG MSA-4 · AIAG SPC · ISO 7870 · Six Sigma DMAIC
    +
    +
    + + + + + +
    +
    +
    01
    +
    +
    Master Pipeline Overview
    +
    End-to-end decisional flow from raw sensor data to SPC-ready output
    +
    +
    + +
    +
    +
    SPC cleaning ≠ ML cleaning. In ML you optimize prediction accuracy and can transform freely. In SPC, every step must preserve the physical meaning of variation. False signals caused by bad cleaning are as harmful as real defects going undetected.
    +
    + +
    +
    FIG 1.1 MASTER DECISIONAL PIPELINE — END TO END
    +
    +flowchart TD + A([🔴 RAW DATA ARRIVAL]) --> B{Ingestion Mode?} + + B -->|Batch / Static| C[Load Full Dataset\nPandas or Polars] + B -->|Real-Time Stream| D[Stream Ingest\nKafka → Polars] + + C --> E[Step 1: MSA / Gage R&R\nValidate Measurement System] + D --> E + + E --> F{%GRR < 10%?} + F -->|NO — > 30%| G[🔴 STOP\nFix Measurement System] + F -->|CONDITIONAL 10-30%| H[⚠️ Document Risk\nConsult Quality Eng] + F -->|YES — < 10%| I[Step 2: Independence Check\nACF / PACF Plot] + H --> I + + I --> J{Autocorrelated?} + J -->|YES| K[Route: EWMA / CUSUM\nor ARIMA residuals] + J -->|NO| L[Step 3: Normality Test\nShapiro-Wilk / Anderson-Darling] + + L --> M{Distribution?} + M -->|Normal| N[Standard Shewhart Path] + M -->|Skewed / Non-Normal| O[Transform Path\nBox-Cox / Log / Johnson] + M -->|Multimodal| P[🔴 STOP — Stratify\nby Machine / Shift / Lot] + + O --> Q{Transform Successful?} + Q -->|YES| N + Q -->|NO| R[Non-Parametric Chart Path\nXmr + no zone tests] + + N --> S[Step 4: Outlier Classification\nSpecial vs Common Cause] + K --> S + R --> S + + S --> T[Step 5: Missing Value\nClassification & Handling] + T --> U[Step 6: Tool Selection\nPandas / Polars / Hybrid] + U --> V[Step 7: Storage\nTimescaleDB or QuestDB] + V --> W([✅ SPC-READY OUTPUT\nPhase I Baseline Locked]) + + style A fill:#1a0808,stroke:#FF6B35,color:#FF9060 + style G fill:#1a0808,stroke:#FF6B35,color:#FF6B35 + style P fill:#1a0808,stroke:#FF4EA3,color:#FF4EA3 + style W fill:#081a0d,stroke:#39FF6E,color:#39FF6E + style N fill:#081218,stroke:#00D4FF,color:#00D4FF + style K fill:#0d0818,stroke:#B57AFF,color:#B57AFF +
    +
    + +
    +
    +
    The three cardinal rules of SPC data preparation: (1) Never silently remove data — every gap must be traced to a root cause. (2) Preserve temporal order — SPC is time-ordered analysis. (3) Validate the measurement system BEFORE cleaning process data.
    +
    +
    + + +
    +
    +
    02
    +
    +
    Normality & Distribution Decision Tree
    +
    The most critical branching point — determines chart type, capability index, and transformation strategy
    +
    +
    + +
    + SPC assumes independence and approximate normality. Autocorrelation must be checked first — it invalidates standard control limits entirely. Non-normality, while important, is secondary and is handled through transformation or chart selection. Per Donald Wheeler and Irving Burr's research on 27 distributions, Shewhart 3σ charts remain robust for most non-normal data when zone tests are omitted. +
    + +
    +
    FIG 2.1 NORMALITY ASSESSMENT & DISTRIBUTION PATH SELECTION
    +
    +flowchart TD + A([Data Sample Ready]) --> B[Step 1: Time Series Plot\n+ ACF/PACF Plot] + B --> C{Lag-1 ACF > ±0.2?} + + C -->|YES — Autocorrelated| D[ARIMA/EWMA Route\nDo NOT use Shewhart] + C -->|NO — Independent| E[Step 2: Shapiro-Wilk\np-value test n < 2000\nOR Anderson-Darling n < 5000] + + E --> F[Step 3: Check Skewness & Kurtosis\n|Skew| threshold 1.0\n|Kurt| threshold 3.5] + F --> G[Step 4: Visual\nHistogram + Q-Q Plot] + + G --> H{Result?} + + H -->|Normal\np > 0.05, Skew < 1| I[✅ NORMAL PATH] + H -->|Skewed\nSkew > 1| J[⚠️ SKEWED PATH] + H -->|Heavy Tails\nKurt > 3.5| K[⚠️ HEAVY TAIL PATH] + H -->|Bimodal\nor 2+ peaks| L[🔴 MULTIMODAL PATH] + + I --> I1[Use X-bar R or X-bar S charts\nAll 8 Nelson rules apply\nStandard Cp Cpk Pp Ppk\nNo transformation needed] + + J --> J1{Try Transformation} + J1 --> J2[Log if right-skewed\nSqrt if count data\nBox-Cox if general\nJohnson if complex] + J2 --> J3{Shapiro-Wilk passes\nafter transform?} + J3 -->|YES| I + J3 -->|NO| K1 + + K --> K1[Wheeler Approach\nXmr chart only\nApply ONLY points-outside-limits rule\nSkip all zone tests\nUse Cnpk non-parametric Cpk] + + L --> L1[STRATIFY IMMEDIATELY\nBy machine, shift, lot, spindle\nRun separate charts per stratum\nMixing streams invalidates all limits] + + D --> D1[EWMA α=0.2 to 0.4\nor CUSUM k=0.5 h=5\nor fit ARIMA then chart residuals\non Xmr chart] + + style I fill:#081a0d,stroke:#39FF6E,color:#39FF6E + style I1 fill:#081a0d,stroke:#39FF6E,color:#8FD9A8 + style K1 fill:#0d0a1a,stroke:#B57AFF,color:#C9A8FF + style L1 fill:#1a0808,stroke:#FF4EA3,color:#FF9AAD + style D1 fill:#0d0818,stroke:#B57AFF,color:#C9A8FF +
    +
    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    DISTRIBUTION TYPETEST METHODCHART SELECTIONCAPABILITY INDEXZONE TESTS
    NormalShapiro-Wilk p > 0.05X̄-R / X̄-S / XmrCp, Cpk, Pp, PpkALL 8 rules
    Skewed (transformable)Box-Cox / Log → retestX̄-R on transformed dataCp/Cpk on transformedALL 8 rules
    Non-normal (robust)Wheeler / Burr criterionXmr (individuals)Cnpk, percentile-basedPoints outside only
    AutocorrelatedACF lag-1 > ±0.2EWMA or CUSUMModel-based estimateEWMA/CUSUM rules
    MultimodalHistogram / dip testStratify — no single chartPer stratumAfter stratification
    +
    +
    + + +
    +
    +
    03
    +
    +
    Ingestion Mode Architecture
    +
    Pipeline strategy diverges at the ingestion boundary — static and real-time have fundamentally different cleaning sequences
    +
    +
    + +
    +
    FIG 3.1 STATIC vs REAL-TIME INGESTION PIPELINE
    +
    +flowchart LR + subgraph STATIC ["📦 STATIC / BATCH MODE"] + direction TB + S1[CSV / ERP / LIMS\nDump] --> S2[Load full dataset\nPandas or Polars] + S2 --> S3[Full MSA\nPhase I Gage R&R] + S3 --> S4[Normality tests\non full population] + S4 --> S5[Classify ALL outliers\nwith root-cause metadata] + S5 --> S6[Compute Phase I\ncontrol limits] + S6 --> S7[(Store baseline +\nlimits in DB)] + end + + subgraph REALTIME ["⚡ REAL-TIME / STREAMING MODE"] + direction TB + R1[PLC / SCADA / IoT\nOPC-UA · MQTT] --> R2[Message Broker\nKafka or NATS] + R2 --> R3[Polars Streaming\nProcessor] + R3 --> R4{Within pre-computed\nUCL / LCL?} + R4 -->|IN CONTROL| R5[Write to TSDB\nwith quality flag] + R4 -->|OUT OF CONTROL| R6[Raise SPC alert\n+ write flagged record] + R5 --> R7[Incremental\nEWMA / CUSUM update] + R6 --> R7 + R7 --> R8[Dashboard /\nGrafana / Dash] + end + + PRE[["🔒 Phase I MUST be\ncompleted OFFLINE\nbefore real-time goes live"]] --> REALTIME + + style PRE fill:#1a0f00,stroke:#FFD449,color:#FFD449 + style STATIC fill:#080B10,stroke:#00D4FF + style REALTIME fill:#080B10,stroke:#39FF6E +
    +
    + +
    +
    +
    Critical rule for real-time SPC: Control limits are FIXED in Phase II. Never recompute UCL/LCL from the streaming data mid-run. Recalculation is only triggered by a documented, validated process change and requires a new Phase I study.
    +
    + +
    +
    +
    Static Tools
    +
    Pandas (≤ 1M rows) · Polars (> 1M rows) → SQLite for prototyping → PostgreSQL/TimescaleDB for production
    +
    +
    +
    Real-Time Tools
    +
    Polars Streaming API · Apache Kafka / NATS · QuestDB or TimescaleDB · Grafana for dashboards
    +
    +
    +
    Connectivity Protocols
    +
    OPC-UA (preferred for industrial) · MQTT (IoT sensors) · Modbus TCP (legacy PLCs) · REST for lab LIMS systems
    +
    +
    +
    + + +
    +
    +
    04
    +
    +
    Pandas vs Polars Decision Framework
    +
    Not a religion — a decision based on data volume, pipeline type, and ecosystem requirements
    +
    +
    + +
    +
    FIG 4.1 TOOL SELECTION DECISION TREE FOR SPC PIPELINES
    +
    +flowchart TD + A([New SPC Pipeline]) --> B{Dataset size?} + + B -->|< 500k rows| C{Pipeline type?} + B -->|500k – 2M rows| D{Real-time or batch?} + B -->|> 2M rows| E[Use POLARS] + + C -->|Exploratory / notebook| F[Use PANDAS] + C -->|Production pipeline| G{Statsmodels / scipy needed?} + G -->|YES, heavily| F + G -->|Occasionally| H[Hybrid: Polars pipeline\n→ .to_pandas for stats only] + + D -->|Real-time streaming| E + D -->|Batch| I{Complex multi-machine\nGroupBy subgrouping?} + I -->|YES| E + I -->|NO| F + + E --> J{Python SPC stats\nlibraries needed?} + J -->|YES| K[Polars pipeline\n+ .to_pandas at stats step] + J -->|NO| L[Pure Polars pipeline] + + F --> M[Pandas pipeline] + H --> K + + K --> N([✅ Hybrid Architecture\nBest of both]) + L --> O([✅ Pure Polars\nMax performance]) + M --> P([✅ Pandas\nMax ecosystem]) + + style N fill:#081a0d,stroke:#39FF6E,color:#39FF6E + style O fill:#081a0d,stroke:#39FF6E,color:#39FF6E + style P fill:#081218,stroke:#00D4FF,color:#00D4FF +
    +
    + +
    + + + + + + + + + + + + + + +
    DIMENSIONPANDASPOLARSWINNER
    Memory for operations5–10× dataset size2–4× dataset sizePolars
    Multi-core utilizationSingle-threadedAll cores by defaultPolars
    CSV 1.6 GB read speed~95 seconds~12 seconds (8×)Polars
    GroupBy / subgroupingSlower, familiar API2–10× faster, parallelPolars
    Streaming / real-timeNot designed for itNative streaming APIPolars
    SPC / stats ecosystemFull: scipy, statsmodelsGrowing; use .to_pandas()Pandas
    Exploratory analysisFast, flexible, familiarImmutable, stricter APIPandas
    Production safetyMutable — risky in pipelinesImmutable — pipeline-safePolars
    +
    + +
    +
    +
    Recommended Architecture: Use Polars for the full ingestion and cleaning pipeline. Call .to_pandas() only at the point where scipy/statsmodels SPC statistics are needed. This hybrid approach gives Polars performance with full Python SPC ecosystem access.
    +
    +
    + + +
    +
    +
    05
    +
    +
    MSA & Gage R&R Flow
    +
    Run before any data cleaning — a bad gage invalidates everything downstream
    +
    +
    + +
    + Total observed variation = σ²_process + σ²_measurement_system. If measurement system variation is large, SPC charts show ghosts — you adjust machines that run perfectly because the gage lied to you. The AIAG MSA-4 standard defines the acceptance criteria: %GRR < 10% acceptable, 10–30% conditional, > 30% unacceptable — stop. +
    + +
    +
    FIG 5.1 MSA / GAGE R&R COMPLETE FLOW — STATIC AND REAL-TIME
    +
    +flowchart TD + A([Start: Data Pipeline\nInitialization]) --> B{Measurement\nSystem Type?} + + B -->|Manual / Periodic\nStatic Gage| C[STATIC MSA — AIAG MSA-4] + B -->|Automated Inline\nContinuous Sensor| D[REAL-TIME CONTINUOUS MSA] + + subgraph STATIC ["STATIC GAGE R&R — CROSSED STUDY"] + C --> C1[Select 10 representative parts\nspanning full process range] + C1 --> C2[Assign 2-3 appraisers\nblind to each other] + C2 --> C3[Each appraiser measures\neach part 2-3 times\nMin 60 measurements total] + C3 --> C4[ANOVA decomposition\nσ²_repeatability + σ²_reproducibility + σ²_part] + C4 --> C5[Compute %GRR\n= σ_GRR / σ_tolerance × 100] + C5 --> C6[Compute NDC\nNumber of Distinct Categories] + end + + subgraph REALTIME ["REAL-TIME CONTINUOUS MSA"] + D --> D1[Insert certified\nreference standards\nat fixed intervals in stream] + D1 --> D2[Record deviation from\nknown reference value] + D2 --> D3[EWMA of bias\nα = 0.2] + D3 --> D4[Rolling R chart\non repeat reference measurements] + D4 --> D5{EWMA bias >\n±10% tolerance?} + D5 -->|YES| D6[🔴 Trigger calibration alert\nSuspend measurements] + D5 -->|NO| D7[✅ Gage healthy\nContinue monitoring] + end + + C6 --> E{%GRR result?} + E -->|< 10%| F[✅ ACCEPTABLE\nProceed to data cleaning] + E -->|10 – 30%| G[⚠️ CONDITIONAL\nDocument risk level] + E -->|> 30%| H[🔴 UNACCEPTABLE\nSTOP — fix gage first] + E --> I{NDC ≥ 5?} + I -->|NO| H + I -->|YES| F + + G --> J{Cp of process\n> 2.0?} + J -->|YES| F + J -->|NO| H + + style F fill:#081a0d,stroke:#39FF6E,color:#39FF6E + style H fill:#1a0808,stroke:#FF6B35,color:#FF6B35 + style D6 fill:#1a0808,stroke:#FF6B35,color:#FF6B35 + style D7 fill:#081a0d,stroke:#39FF6E,color:#39FF6E +
    +
    + +
    +
    +
    NDC Rule
    +
    NDC = 1.41 × (σ_part / σ_GRR). Must be ≥ 5. NDC < 5 means the gage cannot discriminate between parts sufficiently for SPC analysis.
    +
    +
    +
    Resolution Rule
    +
    Gage resolution ≤ tolerance/10 (the "10:1 rule"). For ±0.1 mm tolerance (0.2 mm total), gage must resolve ≤ 0.02 mm.
    +
    +
    +
    Calibration Interval
    +
    Real-time EWMA drift detection enables predictive calibration — trigger on statistical drift, not just fixed calendar intervals.
    +
    +
    +
    + + +
    +
    +
    06
    +
    +
    Missing Value Classification & Handling
    +
    In SPC, a missing value is a quality event — not an unknown. The cause determines the treatment.
    +
    +
    + +
    +
    +
    Cardinal rule: Never impute with mean/median for control chart data — it artificially reduces variation and narrows control limits. Never silently drop records. Every missing value must be classified and flagged with a data_quality_flag.
    +
    + +
    +
    FIG 6.1 MISSING VALUE CLASSIFICATION DECISION TREE
    +
    +flowchart TD + A([Missing Value Detected]) --> B{WHY is it missing?\nRoot cause?} + + B -->|Equipment failure\nSensor dropout| C[Flag: MISSING_SENSOR\nDo NOT impute\nPlot visible gap on chart\nInvestigate before continuing] + + B -->|Communication / IT\nerror — brief dropout| D{Gap length?} + D -->|≤ 3 consecutive samples| E[Forward-fill LOCF\nFlag: IMPUTED_LOCF\nMark on chart] + D -->|> 3 consecutive samples| C + + B -->|Scheduled maintenance\nPlanned downtime| F[Flag: EXCLUDED_MAINTENANCE\nExclude from SPC calc\nRestart chart after maintenance\nNew start point — do NOT connect] + + B -->|Subgroup incomplete\ne.g. n=4 instead of n=5| G{Enough for valid\nsubgroup?} + G -->|YES — use smaller n| H[Use reduced subgroup\nAdjust control limit constants\nfor actual n used] + G -->|NO| I[Flag: EXCLUDED_INCOMPLETE\nExclude from chart entirely] + + B -->|Human error\nData entry gap| J{Value recoverable\nfrom backup record?} + J -->|YES| K[Restore original value\nFlag: RESTORED_FROM_BACKUP\nDocument source] + J -->|NO| L[Flag: MISSING_HUMAN\nDo NOT impute for control chart\nKNN acceptable for Cpk only] + + E --> M[✅ Proceed to pipeline] + H --> M + K --> M + C --> N[🔴 HOLD — investigate\nbefore Phase II] + F --> M + I --> M + L --> O{Purpose?} + O -->|Control chart| N + O -->|Capability study Cpk| P[KNN or linear interpolation\nDocument clearly] + P --> M + + style N fill:#1a0808,stroke:#FF6B35,color:#FF6B35 + style M fill:#081a0d,stroke:#39FF6E,color:#39FF6E +
    +
    + +
    + + + + + + + + + + + + +
    MISSING TYPEFLAG VALUECONTROL CHART?CAPABILITY (Cpk)?
    Sensor dropoutMISSING_SENSORGap — do not imputeExclude from study
    Comms error ≤ 3 ptsIMPUTED_LOCFForward-fill + flagAcceptable
    Planned maintenanceEXCLUDED_MAINTENANCEExclude + new startExclude period
    Incomplete subgroupEXCLUDED_INCOMPLETEAdjust n or excludeExclude
    Human error — recoverableRESTORED_FROM_BACKUPUse restored valueUse restored value
    Human error — not recoverableMISSING_HUMANNever imputeKNN acceptable
    +
    +
    + + +
    +
    +
    07
    +
    +
    Storage Architecture
    +
    Always persist raw and cleaned data with full provenance — in-memory only is fragile and non-auditable
    +
    +
    + +
    +
    FIG 7.1 STORAGE SELECTION DECISION TREE
    +
    +flowchart TD + A([Storage Decision]) --> B{Data ingestion rate?} + + B -->|Low-rate\nbatch or lab| C{Team familiar\nwith PostgreSQL?} + C -->|YES| D[TimescaleDB\nPostgreSQL extension] + C -->|NO — prototype only| E[SQLite\nfile-based] + + B -->|Medium-rate\n100–10k samples/sec| F{Need full SQL\nand JOINs?} + F -->|YES| D + F -->|NO| G[QuestDB\nor TimescaleDB] + + B -->|High-rate\n> 10k samples/sec| H[QuestDB\nhigh-frequency preferred] + + D --> I[✅ TimescaleDB\nBest for most SPC\nPostgreSQL compatible\nContinuous aggregates\n90–95% compression] + + H --> J[✅ QuestDB\nUltra-high freq\n12-36× faster ingest\nSIMD vectorized] + + E --> K[⚠️ SQLite\nPrototype only\nNo concurrent writes\nNot for production] + + G --> L{Volume growing\n> 100M rows?} + L -->|YES| H + L -->|NO| D + + I --> M[Two-Tier Architecture] + J --> M + M --> N["TIER 1: Raw TSDB\nQuestDB or TimescaleDB\nEvery raw reading\n30-90 day hot retention\nThen Parquet cold archive"] + M --> O["TIER 2: SPC Analysis DB\nPostgreSQL or TimescaleDB\nCleaned subgroups\nControl limits history\nCpk records — permanent"] + + style I fill:#081a0d,stroke:#39FF6E,color:#39FF6E + style J fill:#081218,stroke:#00D4FF,color:#00D4FF + style K fill:#1a1000,stroke:#FFD449,color:#FFD449 +
    +
    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    DATABASEBEST FORINGEST SPEEDQUERY POWERVERDICT
    QuestDBHigh-freq sensors, semiconductor, pharma inline12–36× fastest43–418× faster analyticsHigh-volume SPC
    TimescaleDBMost industrial SPC, multi-user quality systemsGoodFull SQL + window fnsRecommended default
    PostgreSQLQuality records, traceability, ERP integrationModerateFull SQL ecosystemTier 2 only
    SQLiteLab data, offline Cpk studies, prototypingLowBasic SQLPrototype only
    InfluxDB 3Simple metrics dashboardsGoodLimited for SPCAvoid for SPC
    +
    +
    + + +
    +
    +
    08
    +
    +
    SPC-Ready Output, Chart Routing & Phase I Checklist
    +
    Final output schema, control chart selection logic, and Phase I go-live validation gate
    +
    +
    + +
    +
    FIG 8.1 CONTROL CHART SELECTION ROUTING — BY DATA TYPE
    +
    +flowchart TD + A([SPC-Ready Dataset]) --> B{Data type?} + + B -->|Variable / Continuous| C{Distribution?} + B -->|Attribute: defectives| G[Attribute Path] + B -->|Attribute: defect counts| H[Count Path] + + C -->|Normal + subgroup n ≥ 2| D{Subgroup size?} + D -->|n = 2 to 10| D1[X-bar R Chart\nCp Cpk Pp Ppk\nAll 8 Nelson rules] + D -->|n > 10| D2[X-bar S Chart\nCp Cpk Pp Ppk\nAll 8 Nelson rules] + D -->|n = 1 individuals| D3[Xmr Chart\nCp Cpk with caution\nAll Nelson rules] + + C -->|Non-normal after transform| E[Xmr Chart\nPoints-outside-limits rule only\nCnpk non-parametric] + C -->|Autocorrelated| F[EWMA α=0.2-0.4\nor CUSUM k=0.5 h=5\nModel-based Cpk] + + G --> G1{Sample size\nper inspection?} + G1 -->|Variable n| G2[p-Chart\nproportion defective\nDPMO → Sigma level] + G1 -->|Fixed n| G3[np-Chart\nnumber defective\nDPMO → Sigma level] + + H --> H1{Inspection unit\nsize fixed?} + H1 -->|YES — fixed area| H2[c-Chart\ndefects per unit\nDPU → DPMO → Z] + H1 -->|NO — variable area| H3[u-Chart\ndefects per unit area\nDPU → DPMO → Z] + + D1 & D2 & D3 --> SIX1[Six Sigma:\nBaseline σ process\nDMAIC Measure phase] + E & F --> SIX2[Six Sigma:\nAdvanced process control\nBlack Belt territory] + G2 & G3 & H2 & H3 --> SIX3[Six Sigma:\nDPMO → Sigma Level\nZ = NORM.INV 1 - DPMO/1M] + + style D1 fill:#081218,stroke:#00D4FF,color:#7ADCF7 + style D2 fill:#081218,stroke:#00D4FF,color:#7ADCF7 + style D3 fill:#081218,stroke:#00D4FF,color:#7ADCF7 + style E fill:#0d0818,stroke:#B57AFF,color:#C9A8FF + style F fill:#0d0818,stroke:#B57AFF,color:#C9A8FF + style G2 fill:#1a0f00,stroke:#FFD449,color:#FFE07A + style G3 fill:#1a0f00,stroke:#FFD449,color:#FFE07A + style H2 fill:#081a0d,stroke:#39FF6E,color:#8FD9A8 + style H3 fill:#081a0d,stroke:#39FF6E,color:#8FD9A8 +
    +
    + +
    +
    REQUIRED OUTPUT SCHEMA — MINIMUM FIELDS
    +
    + + + + + + + + + + + + + + + + +
    FIELDTYPEREQUIREDPURPOSE
    timestampdatetime UTCALWAYSTime-ordered; no gaps beyond defined interval
    subgroup_idintegerALWAYSLinks all measurements in a rational subgroup
    measurement_valuefloat64ALWAYSOriginal or transformed (must document which)
    data_quality_flagenumALWAYSORIGINAL / IMPUTED / EXCLUDED / MISSING
    distribution_flagenumALWAYSNORMAL / TRANSFORMED / NON_NORMAL_RAW
    transform_appliedstring / nullIF TRANSFORMEDe.g. "LOG", "BOXCOX(λ=0.31)", "NONE"
    phaseenumALWAYSPHASE_I or PHASE_II
    ucl / lcl / centerlinefloat64PHASE IIFixed from Phase I — must not drift
    gage_idstringALWAYSLinks to MSA / calibration record
    machine_idstringIF MULTI-MACHINEStratification and traceability key
    +
    +
    + +
    PHASE I GO-LIVE VALIDATION CHECKLIST
    +
    +
      +
    • MSA %GRR < 10% of tolerance AND NDC ≥ 5 confirmed and documented
    • +
    • Normality test result documented; distribution path chosen and recorded
    • +
    • ACF/PACF checked — autocorrelation addressed if present
    • +
    • All outliers investigated — special causes removed with root-cause notes; common causes retained
    • +
    • Missing data: 100% classified with data_quality_flag; no silent imputation on control chart data
    • +
    • Minimum 25 subgroups (AIAG) used for limit computation
    • +
    • Control limits computed and FROZEN — stored in DB with version hash
    • +
    • Transformation documented, reversible, and applied consistently to future data
    • +
    • Gage calibration date confirmed within active calibration interval
    • +
    • Phase II monitoring mode enabled — limits locked, alerts configured
    • +
    +
    +
    + +
    + + +
    +
    INDUSTRIAL SPC DATA PIPELINE — MVP DOCUMENT v1.0
    +
    Standards: AIAG MSA-4 · AIAG SPC · ISO 7870 · Six Sigma DMAIC
    +
    Stack: Python · Polars · Pandas · TimescaleDB / QuestDB · Kafka
    +
    + +
    + + + + diff --git a/requirements.txt b/requirements.txt index 16cef29..1870543 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,21 +1,19 @@ -# Core Data Science & Statistics -pandas>=2.0.0 -numpy>=1.24.0 -plotly>=5.14.0 -scipy>=1.10.0 +# ASPC v2 dependencies +# Prefer: uv pip install -e ".[dev]" +# This file is kept for backwards-compatible installs. -# 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 +numpy>=1.26 +scipy>=1.11 +pydantic>=2.5 +python-dotenv>=1.0 +pyyaml>=6.0 +# Optional (also available via extras in pyproject.toml) +# polars>=0.20 +# plotly>=5.18 +# fastapi>=0.110 +# uvicorn[standard]>=0.27 +# python-multipart>=0.0.9 +# pytest>=7.4 +# httpx>=0.27 +# requests>=2.31 diff --git a/sample_data/__init__.py b/sample_data/__init__.py new file mode 100644 index 0000000..8c4001f --- /dev/null +++ b/sample_data/__init__.py @@ -0,0 +1,255 @@ +"""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 pathlib import Path +from typing import Any, Mapping + +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/manufacturing_sim.py b/scripts/manufacturing_sim.py new file mode 100755 index 0000000..eed1079 --- /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 datetime import datetime, timezone +from typing import Iterator + +import numpy as np + + +def _now_iso() -> str: + return datetime.now(timezone.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/run_live_protocol.py b/scripts/run_live_protocol.py new file mode 100755 index 0000000..29097cc --- /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 datetime, timezone +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 uuid + import urllib.request + + 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(timezone.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..4540602 --- /dev/null +++ b/services/mqtt_bridge/main.py @@ -0,0 +1,158 @@ +"""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 datetime +from typing import Any, Optional + +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.""" + 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, + ) + + def send(key: str, payload: dict[str, Any]) -> None: + producer.send(topic, key=key, value=payload) + producer.flush() + + def close() -> None: + 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) + loop.run_until_complete(producer.start()) + + def send(key: str, payload: dict[str, Any]) -> None: + data = json.dumps(payload, default=str).encode("utf-8") + loop.run_until_complete( + producer.send_and_wait(topic, value=data, key=key.encode("utf-8")) + ) + + def close() -> None: + 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}") + ts = msg.get("timestamp") or msg.get("ts") + if isinstance(ts, datetime): + ts = ts.isoformat() + elif ts is None: + from datetime import timezone + + ts = datetime.now(timezone.utc).isoformat() + return {"key": key, "value": float(value), "timestamp": ts} + + +def main(argv: Optional[list[str]] = 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..24c7b7b --- /dev/null +++ b/services/stream_engine/main.py @@ -0,0 +1,138 @@ +"""CLI entry: consume Kafka measurements and run the Phase II StreamEngine.""" +from __future__ import annotations + +import argparse +import logging +import signal +import sys +from typing import Optional + +from adapters.factory import get_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: Optional[list[str]] = 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 + + # Prefer Timescale when configured; fall back to sqlite for local demos. + 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) + + engine = StreamEngine(repo, redis_url=redis_url) + + # 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 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..8b9ed2f --- /dev/null +++ b/spc_core/__init__.py @@ -0,0 +1,142 @@ +"""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, 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", + "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..c337f53 --- /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 +from typing import Optional + +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: Optional[list[np.ndarray]]) -> 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: Optional[float] = None + cpk: Optional[float] = None + cpu: Optional[float] = None + cpl: Optional[float] = None + cpm: Optional[float] = None + pp: Optional[float] = None + ppk: Optional[float] = None + ppu: Optional[float] = None + ppl: Optional[float] = None + observed_dpmo: float = 0.0 + expected_dpmo: Optional[float] = None + z_bench: Optional[float] = None + sigma_level: Optional[float] = 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: Optional[float]) -> 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 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: Optional[str] = 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") + 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: + # Fall through to nonparametric if transform of specs fails. + pass + + res = nonparametric_capability(arr, usl, lsl, target, subgroups) + res.notes["normality"] = { + "is_normal": False, + "transform_tried": tr.applied, + "transform_became_normal": tr.became_normal, + "path": "nonparametric", + } + return res + + +def _transform_specs(usl, lsl, target, tr): + """Apply the same transform used on data to the specification limits.""" + from .normality import apply_transform + + 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": + from scipy.stats import 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": + from scipy.stats import yeojohnson + + # 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..4c23c5a --- /dev/null +++ b/spc_core/charts.py @@ -0,0 +1,303 @@ +"""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 +from typing import Optional + +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, + 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: Optional[list[float]] = None + secondary_name: Optional[str] = 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: Optional[str] = None + phase: Phase = Phase.PHASE_I + + @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: Optional[str] = None, + machine_id: Optional[str] = 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: Optional, 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 + lcl = secondary_limits.lcl if not isinstance(secondary_limits.lcl, list) else None + if ucl is None: + return [] + sigma = (float(ucl) - center) / 3.0 if ucl is not None else 0.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: Optional[ChartType] = 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) + subgroup_size = int(np.median([len(s) for s in subs])) + 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 = np.asarray(sample_sizes, dtype=float) + limits = L.p_limits(arr, n) + plotted = (arr / n).tolist() + dtype = DataType.ATTRIBUTE + + elif chart_type == ChartType.NP: + n = float(np.asarray(sample_sizes)[0]) if sample_sizes is not None else float(arr.size) + limits = L.np_limits(arr, n) + plotted = arr.tolist() + subgroup_size = int(n) + 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) + 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}") + + # ---- run-rule signals (Shewhart paths) ---- + if chart_type not in (ChartType.EWMA, ChartType.CUSUM): + if chart_type in (ChartType.P, ChartType.U): + ev = Phase2Evaluator(limits, ruleset=ruleset) + signals = [] + for v in plotted: + signals.extend(ev.observe(float(v))) + else: + primary = limits.primary + center = primary.center + sigma = limits.sigma if (limits.sigma and limits.sigma > 0) else ( + (float(primary.ucl) - center) / 3.0 if not isinstance(primary.ucl, list) else 0.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, + ) diff --git a/spc_core/cleaning.py b/spc_core/cleaning.py new file mode 100644 index 0000000..6709a5a --- /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 typing import Optional + +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[Optional[float]] # 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: Optional[list[Optional[str]]] = 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[Optional[float]] = [] + 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: Optional[float] = 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 + + 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..c6a441f --- /dev/null +++ b/spc_core/cusum.py @@ -0,0 +1,110 @@ +"""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 +from typing import Optional + +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: Optional[ControlLimits] = 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: Optional[float] = None, + sigma: Optional[float] = 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..62118cb --- /dev/null +++ b/spc_core/evaluator.py @@ -0,0 +1,108 @@ +"""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 -> ``observe(value)`` where value is the proportion / rate; limits + vary per point, so only the beyond-limit and one-sided-run rules apply. +""" +from __future__ import annotations + +from .models import ChartType, ControlLimits, Signal +from .rules import RuleEngine + +_SUBGROUP_CHARTS = {ChartType.XBAR_R, ChartType.XBAR_S} +_VARIABLE_LIMIT_CHARTS = {ChartType.P, ChartType.U} + + +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 + self._variable = limits.chart_type in _VARIABLE_LIMIT_CHARTS + self._engine = None if self._variable else RuleEngine(center, sigma, ruleset) + + @property + def is_subgroup_chart(self) -> bool: + return self.limits.chart_type in _SUBGROUP_CHARTS + + 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._variable: + return self._observe_variable(value) + return self._engine.add(float(value)) + + def observe_subgroup(self, values) -> list[Signal]: + if not self.is_subgroup_chart: + raise ValueError( + f"{self.limits.chart_type.value} is not a subgroup chart; use observe()." + ) + self._i += 1 + mean = float(sum(values) / len(values)) + return self._engine.add(mean) + + def _observe_variable(self, value: float) -> list[Signal]: + """P/U charts: per-point limits, so evaluate rule 1 and one-sided runs only.""" + comp = self.limits.primary + i = self._i + ucl = comp.ucl_at(i) if isinstance(comp.ucl, list) and i < len(comp.ucl) else ( + comp.ucl if not isinstance(comp.ucl, list) else comp.ucl[-1] + ) + lcl = comp.lcl_at(i) if isinstance(comp.lcl, list) and i < len(comp.lcl) else ( + comp.lcl if not isinstance(comp.lcl, list) else comp.lcl[-1] + ) + out: list[Signal] = [] + # Inclusive: a point exactly on UCL/LCL is out of control. + if 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 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._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")) + 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..0716b34 --- /dev/null +++ b/spc_core/ewma.py @@ -0,0 +1,109 @@ +"""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 +from typing import Optional + +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: Optional[ControlLimits] = 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: Optional[float] = None, + sigma: Optional[float] = 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) + + 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/ingest.py b/spc_core/ingest.py new file mode 100644 index 0000000..127e3c4 --- /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, Optional + +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: Optional[str] = None + subgroup_col: Optional[str] = None + sample_size_col: Optional[str] = None + opportunity_col: Optional[str] = None + part_col: Optional[str] = None + operator_col: Optional[str] = None + trial_col: Optional[str] = None + reference_col: Optional[str] = None + date_col: Optional[str] = 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]) -> Optional[str]: + 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, ...]) -> Optional[str]: + 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: Optional[str] = None, + subgroup_col: Optional[str] = None, + sample_size_col: Optional[str] = None, + opportunity_col: Optional[str] = None, + part_col: Optional[str] = None, + operator_col: Optional[str] = None, + trial_col: Optional[str] = None, + reference_col: Optional[str] = None, + date_col: Optional[str] = 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..13b844f --- /dev/null +++ b/spc_core/models.py @@ -0,0 +1,156 @@ +"""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 datetime, timezone +from enum import Enum +from typing import Optional, Union + +from pydantic import BaseModel, ConfigDict, 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: Union[float, list[float]] + lcl: Union[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: Optional[float] = None + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + source_n_points: Optional[int] = 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())) + + @property + def version(self) -> str: + 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: Optional[str] = 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: Optional[datetime] = None + subgroup_id: Optional[int] = None + measurement_value: float + data_quality_flag: QualityFlag = QualityFlag.ORIGINAL + distribution_flag: DistributionFlag = DistributionFlag.NORMAL + transform_applied: Optional[str] = None + phase: Phase = Phase.PHASE_I + ucl: Optional[float] = None + lcl: Optional[float] = None + centerline: Optional[float] = None + gage_id: Optional[str] = None + machine_id: Optional[str] = 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..a9537af --- /dev/null +++ b/spc_core/msa.py @@ -0,0 +1,308 @@ +"""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 +from typing import Optional + +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: Optional[float] = None + detail: dict = field(default_factory=dict) + + +def gage_rr_anova(parts, operators, measurements, tolerance: Optional[float] = 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: + # Fall back to minimum cell count for a conservative balanced subset warning. + counts = _cell_counts(parts, operators) + if not counts: + raise ValueError("Gage R&R requires at least one trial per part-operator cell") + n_trials = min(counts.values()) + if n_trials < 1: + raise ValueError("Gage R&R requires at least one trial per part-operator cell") + n = len(y) + 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 = {} + 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: Optional[float] = 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..5ec6a86 --- /dev/null +++ b/spc_core/msa_stream.py @@ -0,0 +1,123 @@ +"""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 +from typing import Optional + +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: Optional[float] = 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) -> Optional[CalibrationAlert]: + """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..2e20885 --- /dev/null +++ b/spc_core/multimodal.py @@ -0,0 +1,97 @@ +"""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 # type: ignore + + dip, p, _ = _diptest(arr, is_data_sorted=False) + 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) + peaks = 0 + for i in range(1, len(hist) - 1): + if hist[i] > hist[i - 1] and hist[i] > hist[i + 1] and hist[i] > hist.mean(): + peaks += 1 + + is_mm = (p < alpha and dip > 0) or (peaks >= 2 and p < 0.15) + 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..9876210 --- /dev/null +++ b/spc_core/normality.py @@ -0,0 +1,243 @@ +"""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 +from typing import Optional + +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: Optional[float] = None + shapiro_p: Optional[float] = None + anderson_stat: Optional[float] = None + anderson_critical: Optional[float] = None + ks_stat: Optional[float] = None + ks_p: Optional[float] = None + skewness: Optional[float] = None + kurtosis: Optional[float] = 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: Optional[float] = None + became_normal: Optional[bool] = 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 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..45ae079 --- /dev/null +++ b/spc_core/pipeline.py @@ -0,0 +1,373 @@ +"""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, Optional + +import numpy as np + +from .charts import ControlChartResult, analyze_control_chart +from .cleaning import classify_missing +from .models import ChartType, DistributionFlag, Phase, QualityFlag +from .msa import GageRRResult, gage_rr_anova, gage_resolution_gate, ndc_gate +from .multimodal import MultimodalResult, check_multimodal +from .normality import ( + AutocorrelationResult, + NormalityResult, + TransformResult, + apply_transform, + check_autocorrelation, + check_normality, +) + + +@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: Optional[NormalityResult] = None + autocorrelation: Optional[AutocorrelationResult] = None + multimodal: Optional[MultimodalResult] = None + transform: Optional[TransformResult] = None + msa: Optional[GageRRResult] = None + stopped: bool = False + chart_route: str = "shewhart" + + @property + def limits_version(self) -> str: + return self.chart.limits.version + + def gate_status(self, step: str) -> Optional[str]: + 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: Optional[ChartType] = 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: Optional[float] = None, + gage_resolution: Optional[float] = None, + # Missing-value reasons aligned with values (optional). + missing_reasons=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) + working = arr.copy() + dist_flag = DistributionFlag.NORMAL + transform_applied = None + transform: Optional[TransformResult] = None + normality: Optional[NormalityResult] = None + acf: Optional[AutocorrelationResult] = None + multimodal: Optional[MultimodalResult] = None + msa_result: Optional[GageRRResult] = None + route = "shewhart" + active_ruleset = ruleset + active_chart = chart_type + + # ---- 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.", + )) + + # ---- 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)] + + # ---- 3. Autocorrelation ---- + acf = check_autocorrelation(clean, threshold=acf_threshold) + if 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 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 + 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}, + )) + elif force_wheeler or True: + # Wheeler robust path: I-MR + points-outside-only. + active_chart = ChartType.I_MR + active_ruleset = "wheeler" + dist_flag = DistributionFlag.NON_NORMAL_RAW + route = "wheeler" + gates.append(Gate( + step="normality", status="warn", + reason=( + "Non-normal after transform; using Wheeler robust path " + "(I-MR, points-outside-limits only)." + ), + detail={"transform_tried": transform.applied if transform else None}, + )) + + # ---- 5. Chart ---- + 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 ---- + 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}, + )) + + stopped = any(g.status == "stop" for g in gates) + return PipelineResult( + chart=chart, gates=gates, normality=normality, autocorrelation=acf, + multimodal=multimodal, transform=transform, msa=msa_result, + stopped=stopped, 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 + _add( + "limits_frozen", + bool(pipeline.chart.limits.version), + f"Limits version {pipeline.chart.limits.version}.", + ) + + # 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, + } diff --git a/spc_core/report.py b/spc_core/report.py new file mode 100644 index 0000000..26017bc --- /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 datetime, timezone +from typing import Any, Optional + +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: Optional[list[float]] = None + secondary_name: Optional[str] = None + signals: list[Signal] = Field(default_factory=list) + subgroup_size: int = 1 + summary: dict[str, Any] = Field(default_factory=dict) + normality: Optional[dict[str, Any]] = None + autocorrelation: Optional[dict[str, Any]] = None + gates: Optional[list[dict[str, Any]]] = None + checklist: Optional[dict[str, Any]] = None + records: Optional[list[dict[str, Any]]] = None + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + source_file: Optional[str] = None + + @classmethod + def from_chart_result( + cls, + result: ControlChartResult, + normality: Optional[NormalityResult] = None, + autocorrelation: Optional[AutocorrelationResult] = None, + source_file: Optional[str] = None, + phase: Phase = Phase.PHASE_I, + gates: Optional[list] = None, + checklist: Optional[dict] = 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: Optional[dict[str, Any]] = None + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + source_file: Optional[str] = None + + @classmethod + def from_capability( + cls, + result: CapabilityResult, + normality: Optional[NormalityResult] = None, + source_file: Optional[str] = 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(timezone.utc)) + source_file: Optional[str] = None + + @classmethod + def from_gage_rr(cls, result: GageRRResult, source_file: Optional[str] = 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: Optional[str] = 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: Optional[str] = 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: Optional[str] = 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: Optional[NormalityResult]) -> Optional[dict[str, Any]]: + 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: Optional[AutocorrelationResult]) -> Optional[dict[str, Any]]: + 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..f9d9d19 --- /dev/null +++ b/spc_core/rules.py @@ -0,0 +1,216 @@ +"""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 typing import Optional + +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: Optional[str] = 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/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..b231dc9 --- /dev/null +++ b/tests/integration/test_realtime_gated.py @@ -0,0 +1,34 @@ +"""Service-gated integration tests — skip when brokers/DB are unavailable.""" +from __future__ import annotations + +import os + +import pytest + + +pytestmark = 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/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..72237c2 --- /dev/null +++ b/tests/unit/test_api.py @@ -0,0 +1,58 @@ +"""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", "") + +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.config as config_mod + import apps.api.main as api_main + + 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..0e954b3 --- /dev/null +++ b/tests/unit/test_capability.py @@ -0,0 +1,75 @@ +"""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") diff --git a/tests/unit/test_cleaning.py b/tests/unit/test_cleaning.py new file mode 100644 index 0000000..840e063 --- /dev/null +++ b/tests/unit/test_cleaning.py @@ -0,0 +1,48 @@ +"""SPC cleaning — missing-value classifier (never silent impute).""" +from __future__ import annotations + +import math + +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_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_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..ea8095b --- /dev/null +++ b/tests/unit/test_ingest.py @@ -0,0 +1,49 @@ +"""Ingest / column auto-detection (deduped from 3 legacy pipelines).""" +from __future__ import annotations + +from adapters.io_files import FileReadError, read_csv, safe_filename +from spc_core.ingest import detect_columns, ingest +import pytest + + +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..c116aaf --- /dev/null +++ b/tests/unit/test_limits.py @@ -0,0 +1,128 @@ +"""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, c_limits, p_limits, np_limits, u_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..a69a49a --- /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 result.grr_percent > result.grr_percent - 1 # just ensure computed + # Poor fixture typically has high GRR + assert result.grr_percent >= 0 + + +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_normality.py b/tests/unit/test_normality.py new file mode 100644 index 0000000..9e28ab2 --- /dev/null +++ b/tests/unit/test_normality.py @@ -0,0 +1,55 @@ +"""Normality, autocorrelation gate, transforms.""" +from __future__ import annotations + +import numpy as np +import pytest + +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..0bf71f1 --- /dev/null +++ b/tests/unit/test_pipeline.py @@ -0,0 +1,47 @@ +"""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_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..933c879 --- /dev/null +++ b/tests/unit/test_rules.py @@ -0,0 +1,62 @@ +"""Stateful Nelson / Western Electric run-rule engine.""" +from __future__ import annotations + +import pytest + +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_stream_engine.py b/tests/unit/test_stream_engine.py new file mode 100644 index 0000000..4cfa34f --- /dev/null +++ b/tests/unit/test_stream_engine.py @@ -0,0 +1,121 @@ +"""Unit tests for StreamEngine OOC detection with a fake in-memory repository.""" +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Optional + +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: Optional[str], + index: int, + value: float, + rule_id: str, + rule_name: str, + description: str, + side: Optional[str] = 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=timezone.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=timezone.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=timezone.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 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..3b28271 --- /dev/null +++ b/uv.lock @@ -0,0 +1,2450 @@ +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 = "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 = "fastapi" }, + { name = "greenlet" }, + { 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 = "fastapi" }, + { 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 = "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 = "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 = "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 = "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 = "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 = "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 = "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" }, +] From c01b8765600360ae13fd02d6b5b9aba1d04059a0 Mon Sep 17 00:00:00 2001 From: Samad El-joaydi Date: Sun, 26 Jul 2026 03:47:09 +0100 Subject: [PATCH 02/23] Delete refactoring elements directory --- refactoring elements/messy_brainstorming.md | 198 ---- refactoring elements/spc_mvp_doc.html | 995 -------------------- 2 files changed, 1193 deletions(-) delete mode 100644 refactoring elements/messy_brainstorming.md delete mode 100644 refactoring elements/spc_mvp_doc.html diff --git a/refactoring elements/messy_brainstorming.md b/refactoring elements/messy_brainstorming.md deleted file mode 100644 index 4a48133..0000000 --- a/refactoring elements/messy_brainstorming.md +++ /dev/null @@ -1,198 +0,0 @@ -Can spc be done in realtime, or only with the known way of gathering data, do measurement system then spc analysis - -Great question. SPC absolutely can be done in real-time — in fact, that's its most powerful form. Let me break this down. -Traditional (Batch) SPC Flow - -The "known way" you're describing: - - Define measurement system → validate with Gauge R&R - Collect baseline data (typically 25–30 subgroups) - Calculate control limits from that historical data - Plot and analyze - -This is fine for process qualification or retrospective analysis, but it's reactive — you're looking at what already happened. -Real-Time SPC - -Yes, fully possible and widely used in manufacturing and high-throughput systems. The mechanics: - -Phase I vs Phase II distinction is the key concept here: - - Phase I: Establish control limits from historical/baseline data (the batch approach) - Phase II: Stream new measurements against those frozen limits in real-time - -Once you have Phase I limits, every new data point can be evaluated as it arrives — you're just checking: does this point fall outside ±3σ? Does it trigger a Western Electric run rule? - -Real-time SPC in practice: - - CNC machines streaming dimensional measurements every cycle - Semiconductor fabs running SPC on every wafer - Injection molding with in-line sensors updating X̄-R charts per shot - Automated test equipment pushing pass/fail + measurement data to SPC dashboards live - -The infrastructure it requires: - - A data acquisition layer (sensors, CMMs, automated gauges) feeding a stream - A rules engine evaluating each point against pre-established limits - Alerting when out-of-control signals are detected (Nelson/Western Electric rules) - Optional: adaptive control limits if the process is known to drift (EWMA charts handle this better than Shewhart for slow drift) - -The Measurement System Still Comes First - -One thing that doesn't change: you still need a validated measurement system before any SPC — real-time or not. If your gauge has excessive variation, your control chart is noise. Gauge R&R as % of tolerance should be <10% (acceptable up to 30% depending on context) before you trust the stream. -When Real-Time SPC Gets Interesting Technically - - Short run SPC: Not enough historical data to establish limits → use standardized charts (Z-MR) or Bayesian updating of limits as data arrives - EWMA/CUSUM: Better than Shewhart for detecting small shifts in real-time streams; they weight recent observations more heavily - Autocorrelated data: High-frequency sensors often produce autocorrelated measurements — standard Shewhart charts give false alarms; need residual-based SPC - -Are you thinking about implementing real-time SPC in a system? I can go deeper on the architecture or the statistical side of it. - -The data cleaning for spc, both real-time and static data, is far different than data cleaning for machine learning or data analysis - -Exactly right, and it's an underappreciated distinction. Most data engineering content treats cleaning as a generic pipeline step, but SPC has fundamentally different objectives that invert some standard practices. -Why the Objective Differs - -ML/DA cleaning goal: maximize signal, remove noise, make data representative of the underlying pattern - -SPC cleaning goal: preserve the process as it actually ran — including the noise, the outliers, the drift — because those are the signal - -Throwing away an outlier in ML is often correct. Throwing away an outlier in SPC before Phase I analysis potentially removes evidence of a special cause, which means your control limits get computed on a falsely stable process. -Specific Inversions - -Outlier handling - - ML: remove or clip outliers to prevent skewing the model - SPC: flag outliers, investigate root cause, only remove if you can assign a documented special cause (wrong setup, known equipment fault). Unassigned outliers stay in - -Missing data imputation - - ML: impute with mean, median, interpolation — standard practice - SPC: imputation is generally forbidden for control chart data. A missing observation is a missing subgroup. Carrying forward or interpolating manufactures false stability. You either skip the subgroup or note the gap explicitly - -Normalization / transformation - - ML: normalize freely to improve model convergence - SPC: transformation (e.g. log for skewed data) must be justified by the underlying distribution and done before limit calculation — and the chart now lives in transformed space, which complicates operator interpretation - -Duplicate removal - - ML: duplicates bias training, remove them - SPC: a duplicate reading might indicate a frozen sensor or a transcription error — it's a data quality event worth flagging, not silently dropping - -Drift correction / detrending - - ML: detrending is common preprocessing to stationarize a series - SPC: drift is what you're trying to detect. Removing it before charting defeats the entire purpose. If drift is expected (tool wear, batch-to-batch variation), you use regression control charts or EWMA — you don't subtract it out upstream - -Real-Time vs Static Cleaning Differences Within SPC - -Even inside SPC, real-time and static cleaning diverge: - -Static (Phase I batch) - - You have the full dataset, so you can do iterative limit calculation: compute limits → identify out-of-control points → investigate → remove if assignable cause documented → recompute → repeat until stable - This iterative exclusion process is legitimate in Phase I because you're defining what "in control" looks like - Cleaning is a human-in-the-loop process - -Real-Time (Phase II streaming) - - You cannot retroactively recompute limits based on what you just saw — limits are frozen from Phase I - Cleaning logic must be deterministic and rule-based: sensor range checks, rate-of-change filters, gauge communication fault flags - The key distinction: you're filtering measurement system failures, not process variation. A reading of -999 from a disconnected sensor gets dropped. A reading of +4σ from a real process event does not — that's your out-of-control signal - Latency matters: cleaning logic has to run in the same pipeline stage as ingestion, before the rules engine evaluates the point - -The Core Principle - -SPC cleaning asks: did the process actually produce this value, and was it measured correctly? - -If yes to both — no matter how extreme — it stays. Everything else is measurement system failure, not process data, and gets flagged with a documented reason rather than silently discarded. - -That discipline is what makes control limits meaningful. The moment you start cleaning for convenience rather than documented cause, your limits no longer reflect the true process distribution. - -Are you building a cleaning pipeline for one of these contexts specifically? - - -Claude is AI and can make mistakes. Please double-check responses. - -https://github.com/M1ndSmith/ASPC -Regardless of agentic side. What are the key critics of the core spc engine, -What spc engine components architecture and tech stack will be good for even real time data, both computer side and core spc side -Fetched: GitHub - M1ndSmith/ASPC: AI-powered Statistical Process Control, MSA, and Process Capability analysis. Upload data, ask in plain English, get charts and reports. - -Now let me look at the core SPC source code to give you specific critiques. -Searched the web - -I can see enough from the README and architecture to give you a thorough critique. Let me now give you a deep, structured analysis. -Part 1: Key Critiques of the Core SPC Engine - -Based on the architecture and stack visible in the repo (pandas/numpy/scipy + Plotly + FastAPI + LangChain), here are the real problems: -1. Batch-Only, File-Centric Data Model - -The entire ingestion pipeline is built around CSV uploads — multipart/form-data via HTTP POST. There is no streaming input, no incremental update, no windowed computation. Every "analysis" is a full re-computation over a static snapshot. This isn't SPC, it's retrospective statistical reporting dressed up as SPC. Real SPC is about detecting process shifts as they happen, not after you upload a file. -2. No Persistent Control Limit State - -Proper SPC requires Phase I (establishing control limits from baseline data) and Phase II (applying those frozen limits to new incoming data). The engine appears to recompute limits from the uploaded data every time, meaning limits shift with each run. This violates the fundamental principle of SPC — limits must be fixed from a stable reference period and applied forward. -3. Western Electric / Nelson Rules Are Likely Incomplete or Stateless - -Out-of-control detection requires maintaining run history — e.g., 8 consecutive points on one side of the centerline, 6 points trending in a direction. Without a stateful buffer of prior observations, only point-in-time violations (e.g., beyond 3-sigma) can be detected, missing the pattern-based rules that catch subtler process shifts. -4. Chart Selection Heuristics May Be Unreliable - -"Automatic chart selection based on dataset characteristics" is described but the selection logic appears to be inference from column names and data shape, not a rigorous assessment. Choosing between I-MR, Xbar-R, and Xbar-S has real statistical consequences (e.g., Xbar-S is preferred for subgroup sizes > 8). Misclassification can produce wrong control limits and missed signals. -5. No Subgroup Integrity Validation - -For attribute charts (P, NP, C, U), the engine needs to validate that subgroup sizes are appropriate (e.g., large enough for the binomial approximation to hold). For C charts, the assumption is a constant area of opportunity. There's no indication these domain constraints are enforced. -6. Normality Assumption in Capability Analysis Is Potentially Mishandled - -The system runs Anderson-Darling, Shapiro-Wilk, and KS tests, which is good. But if normality is rejected, it's unclear whether the engine shifts to non-parametric capability indices (e.g., percentile-based Pp/Ppk) or just proceeds anyway. Reporting Cp/Cpk on non-normal data is statistically incorrect and dangerous in a manufacturing context. -7. Temp File Storage Is Not Production Safe - -Reports and uploads go to temp_uploads/. This is a single-node, ephemeral, unmanaged file system approach. It creates data integrity issues on restart, blocks horizontal scaling, and introduces privacy/isolation problems if multiple users share the same process. -8. No Audit Trail or Versioning - -In regulated manufacturing (ISO, IATF 16949, FDA 21 CFR Part 11), SPC results require traceability — who ran the analysis, on what data, with what limits, at what time. The system has thread IDs for conversational memory but no immutable audit log of statistical computations. -Part 2: Architecture for a Real-Time SPC Engine - -Here's what a real-time-capable SPC system looks like, split into the two layers you asked about. -Compute Side (Infrastructure / Data Pipeline) - -The core challenge is that real-time SPC means per-observation, low-latency, stateful computation — not batch jobs. - -Data Ingestion: Replace CSV upload with a streaming message broker. Apache Kafka or Redpanda is the right choice — sensors/PLCs/SCADA systems publish measurements to topics partitioned by process/machine. MQTT is also common at the edge (OPC-UA → MQTT → Kafka bridge is a standard industrial pattern). - -Stream Processing Engine: Apache Flink or Kafka Streams for stateful stream processing. Flink specifically handles keyed state (per process stream), windowed aggregations (rolling subgroups of n=5), and event-time semantics correctly. This is where control limit lookups, subgroup formation, and rule evaluation happen — in-flight, per event. - -State Store: Control limits (Phase I parameters), process baselines, and rule buffers (the last 8 points for Nelson rules) must live in a fast, persistent key-value store. Redis with persistence, or RocksDB (embedded in Flink), works well. Limits must be versioned so you can track when they were last recalculated. - -Time-Series Database: InfluxDB or TimescaleDB for storing the raw measurements and computed statistics (mean, range, sigma, OOC flags) with timestamps. This enables both real-time dashboards and retrospective Phase I analysis. -Core SPC Engine (Statistical Computation) - -The SPC computation layer needs to be redesigned as a stateful, incremental, event-driven library, not a batch function. - -Language: Python is fine for the math, but the hot path (per-observation rule evaluation) should be in Rust or C++ with Python bindings (via PyO3) if you need sub-millisecond latency at high throughput. For most manufacturing use cases (dozens to low hundreds of measurements per second), pure Python with NumPy is sufficient. - -Control Limit Management: A ControlLimitStore class that is initialized from Phase I data, serialized, and immutable during Phase II. Recalculation is a deliberate, audited operation, not automatic. - -Stateful Rule Buffer: A circular buffer (e.g., collections.deque(maxlen=8)) per chart stream that carries the last N observations. Nelson/Western Electric rules are evaluated over this buffer on every new point. - -Chart Engine: Each chart type (XbarR, IMR, P, etc.) is a separate class implementing a common SPCChart interface with methods like add_observation(value, subgroup_id) → SPCSignal. This makes it testable, composable, and serializable. - -Capability as a Separate Service: Capability analysis (Cp, Cpk, etc.) is a periodic Phase I function, not a real-time one. It belongs as a scheduled job or on-demand service, separate from the control chart engine. - -API Layer: Replace the single synchronous FastAPI HTTP endpoint with a WebSocket or Server-Sent Events endpoint for pushing OOC alerts to dashboards in real time. The REST API is fine for configuration and historical queries. -Summary Table -Concern Current ASPC Real-Time Architecture -Data input CSV upload Kafka/MQTT stream -Computation model Batch, stateless Streaming, stateful (Flink) -Control limits Recomputed each run Phase I frozen, stored in Redis -Rule evaluation Point-in-time only Windowed, buffer-based -Storage temp files InfluxDB + S3 for reports -API REST POST WebSocket + REST -Language hot path Python Python + optional Rust bindings -Audit None Immutable event log (Kafka topic) - -The ASPC repo is a solid prototype for retrospective quality analysis with a nice AI interpretation layer on top. The gap to real-time SPC is less about the AI pieces and more about re-architecting the data ingestion and computation model from a file-in/file-out pattern to a continuous stream-processing pipeline with proper stateful control limit management. - - -Claude is AI and can make mistakes. Please double-check cited sources. - - diff --git a/refactoring elements/spc_mvp_doc.html b/refactoring elements/spc_mvp_doc.html deleted file mode 100644 index 93c8118..0000000 --- a/refactoring elements/spc_mvp_doc.html +++ /dev/null @@ -1,995 +0,0 @@ - - - - - -SPC Data Preparation Pipeline — MVP Document - - - - - -
    - - -
    -
    MVP SPECIFICATION · v1.0
    -

    Industrial SPC
    Data Preparation
    Pipeline

    -

    - A decisional pipeline architecture for data ingestion, cleaning, measurement system validation, - and SPC-ready output — distinct from ML and analytics cleaning methodologies. -

    -
    -
    STATUS
    Draft for Review
    -
    DATE
    May 2026
    -
    DOMAIN
    Industrial Quality Engineering
    -
    STACK
    Python · Polars · Pandas · TimescaleDB / QuestDB
    -
    STANDARDS
    AIAG MSA-4 · AIAG SPC · ISO 7870 · Six Sigma DMAIC
    -
    -
    - - - - - -
    -
    -
    01
    -
    -
    Master Pipeline Overview
    -
    End-to-end decisional flow from raw sensor data to SPC-ready output
    -
    -
    - -
    -
    -
    SPC cleaning ≠ ML cleaning. In ML you optimize prediction accuracy and can transform freely. In SPC, every step must preserve the physical meaning of variation. False signals caused by bad cleaning are as harmful as real defects going undetected.
    -
    - -
    -
    FIG 1.1 MASTER DECISIONAL PIPELINE — END TO END
    -
    -flowchart TD - A([🔴 RAW DATA ARRIVAL]) --> B{Ingestion Mode?} - - B -->|Batch / Static| C[Load Full Dataset\nPandas or Polars] - B -->|Real-Time Stream| D[Stream Ingest\nKafka → Polars] - - C --> E[Step 1: MSA / Gage R&R\nValidate Measurement System] - D --> E - - E --> F{%GRR < 10%?} - F -->|NO — > 30%| G[🔴 STOP\nFix Measurement System] - F -->|CONDITIONAL 10-30%| H[⚠️ Document Risk\nConsult Quality Eng] - F -->|YES — < 10%| I[Step 2: Independence Check\nACF / PACF Plot] - H --> I - - I --> J{Autocorrelated?} - J -->|YES| K[Route: EWMA / CUSUM\nor ARIMA residuals] - J -->|NO| L[Step 3: Normality Test\nShapiro-Wilk / Anderson-Darling] - - L --> M{Distribution?} - M -->|Normal| N[Standard Shewhart Path] - M -->|Skewed / Non-Normal| O[Transform Path\nBox-Cox / Log / Johnson] - M -->|Multimodal| P[🔴 STOP — Stratify\nby Machine / Shift / Lot] - - O --> Q{Transform Successful?} - Q -->|YES| N - Q -->|NO| R[Non-Parametric Chart Path\nXmr + no zone tests] - - N --> S[Step 4: Outlier Classification\nSpecial vs Common Cause] - K --> S - R --> S - - S --> T[Step 5: Missing Value\nClassification & Handling] - T --> U[Step 6: Tool Selection\nPandas / Polars / Hybrid] - U --> V[Step 7: Storage\nTimescaleDB or QuestDB] - V --> W([✅ SPC-READY OUTPUT\nPhase I Baseline Locked]) - - style A fill:#1a0808,stroke:#FF6B35,color:#FF9060 - style G fill:#1a0808,stroke:#FF6B35,color:#FF6B35 - style P fill:#1a0808,stroke:#FF4EA3,color:#FF4EA3 - style W fill:#081a0d,stroke:#39FF6E,color:#39FF6E - style N fill:#081218,stroke:#00D4FF,color:#00D4FF - style K fill:#0d0818,stroke:#B57AFF,color:#B57AFF -
    -
    - -
    -
    -
    The three cardinal rules of SPC data preparation: (1) Never silently remove data — every gap must be traced to a root cause. (2) Preserve temporal order — SPC is time-ordered analysis. (3) Validate the measurement system BEFORE cleaning process data.
    -
    -
    - - -
    -
    -
    02
    -
    -
    Normality & Distribution Decision Tree
    -
    The most critical branching point — determines chart type, capability index, and transformation strategy
    -
    -
    - -
    - SPC assumes independence and approximate normality. Autocorrelation must be checked first — it invalidates standard control limits entirely. Non-normality, while important, is secondary and is handled through transformation or chart selection. Per Donald Wheeler and Irving Burr's research on 27 distributions, Shewhart 3σ charts remain robust for most non-normal data when zone tests are omitted. -
    - -
    -
    FIG 2.1 NORMALITY ASSESSMENT & DISTRIBUTION PATH SELECTION
    -
    -flowchart TD - A([Data Sample Ready]) --> B[Step 1: Time Series Plot\n+ ACF/PACF Plot] - B --> C{Lag-1 ACF > ±0.2?} - - C -->|YES — Autocorrelated| D[ARIMA/EWMA Route\nDo NOT use Shewhart] - C -->|NO — Independent| E[Step 2: Shapiro-Wilk\np-value test n < 2000\nOR Anderson-Darling n < 5000] - - E --> F[Step 3: Check Skewness & Kurtosis\n|Skew| threshold 1.0\n|Kurt| threshold 3.5] - F --> G[Step 4: Visual\nHistogram + Q-Q Plot] - - G --> H{Result?} - - H -->|Normal\np > 0.05, Skew < 1| I[✅ NORMAL PATH] - H -->|Skewed\nSkew > 1| J[⚠️ SKEWED PATH] - H -->|Heavy Tails\nKurt > 3.5| K[⚠️ HEAVY TAIL PATH] - H -->|Bimodal\nor 2+ peaks| L[🔴 MULTIMODAL PATH] - - I --> I1[Use X-bar R or X-bar S charts\nAll 8 Nelson rules apply\nStandard Cp Cpk Pp Ppk\nNo transformation needed] - - J --> J1{Try Transformation} - J1 --> J2[Log if right-skewed\nSqrt if count data\nBox-Cox if general\nJohnson if complex] - J2 --> J3{Shapiro-Wilk passes\nafter transform?} - J3 -->|YES| I - J3 -->|NO| K1 - - K --> K1[Wheeler Approach\nXmr chart only\nApply ONLY points-outside-limits rule\nSkip all zone tests\nUse Cnpk non-parametric Cpk] - - L --> L1[STRATIFY IMMEDIATELY\nBy machine, shift, lot, spindle\nRun separate charts per stratum\nMixing streams invalidates all limits] - - D --> D1[EWMA α=0.2 to 0.4\nor CUSUM k=0.5 h=5\nor fit ARIMA then chart residuals\non Xmr chart] - - style I fill:#081a0d,stroke:#39FF6E,color:#39FF6E - style I1 fill:#081a0d,stroke:#39FF6E,color:#8FD9A8 - style K1 fill:#0d0a1a,stroke:#B57AFF,color:#C9A8FF - style L1 fill:#1a0808,stroke:#FF4EA3,color:#FF9AAD - style D1 fill:#0d0818,stroke:#B57AFF,color:#C9A8FF -
    -
    - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    DISTRIBUTION TYPETEST METHODCHART SELECTIONCAPABILITY INDEXZONE TESTS
    NormalShapiro-Wilk p > 0.05X̄-R / X̄-S / XmrCp, Cpk, Pp, PpkALL 8 rules
    Skewed (transformable)Box-Cox / Log → retestX̄-R on transformed dataCp/Cpk on transformedALL 8 rules
    Non-normal (robust)Wheeler / Burr criterionXmr (individuals)Cnpk, percentile-basedPoints outside only
    AutocorrelatedACF lag-1 > ±0.2EWMA or CUSUMModel-based estimateEWMA/CUSUM rules
    MultimodalHistogram / dip testStratify — no single chartPer stratumAfter stratification
    -
    -
    - - -
    -
    -
    03
    -
    -
    Ingestion Mode Architecture
    -
    Pipeline strategy diverges at the ingestion boundary — static and real-time have fundamentally different cleaning sequences
    -
    -
    - -
    -
    FIG 3.1 STATIC vs REAL-TIME INGESTION PIPELINE
    -
    -flowchart LR - subgraph STATIC ["📦 STATIC / BATCH MODE"] - direction TB - S1[CSV / ERP / LIMS\nDump] --> S2[Load full dataset\nPandas or Polars] - S2 --> S3[Full MSA\nPhase I Gage R&R] - S3 --> S4[Normality tests\non full population] - S4 --> S5[Classify ALL outliers\nwith root-cause metadata] - S5 --> S6[Compute Phase I\ncontrol limits] - S6 --> S7[(Store baseline +\nlimits in DB)] - end - - subgraph REALTIME ["⚡ REAL-TIME / STREAMING MODE"] - direction TB - R1[PLC / SCADA / IoT\nOPC-UA · MQTT] --> R2[Message Broker\nKafka or NATS] - R2 --> R3[Polars Streaming\nProcessor] - R3 --> R4{Within pre-computed\nUCL / LCL?} - R4 -->|IN CONTROL| R5[Write to TSDB\nwith quality flag] - R4 -->|OUT OF CONTROL| R6[Raise SPC alert\n+ write flagged record] - R5 --> R7[Incremental\nEWMA / CUSUM update] - R6 --> R7 - R7 --> R8[Dashboard /\nGrafana / Dash] - end - - PRE[["🔒 Phase I MUST be\ncompleted OFFLINE\nbefore real-time goes live"]] --> REALTIME - - style PRE fill:#1a0f00,stroke:#FFD449,color:#FFD449 - style STATIC fill:#080B10,stroke:#00D4FF - style REALTIME fill:#080B10,stroke:#39FF6E -
    -
    - -
    -
    -
    Critical rule for real-time SPC: Control limits are FIXED in Phase II. Never recompute UCL/LCL from the streaming data mid-run. Recalculation is only triggered by a documented, validated process change and requires a new Phase I study.
    -
    - -
    -
    -
    Static Tools
    -
    Pandas (≤ 1M rows) · Polars (> 1M rows) → SQLite for prototyping → PostgreSQL/TimescaleDB for production
    -
    -
    -
    Real-Time Tools
    -
    Polars Streaming API · Apache Kafka / NATS · QuestDB or TimescaleDB · Grafana for dashboards
    -
    -
    -
    Connectivity Protocols
    -
    OPC-UA (preferred for industrial) · MQTT (IoT sensors) · Modbus TCP (legacy PLCs) · REST for lab LIMS systems
    -
    -
    -
    - - -
    -
    -
    04
    -
    -
    Pandas vs Polars Decision Framework
    -
    Not a religion — a decision based on data volume, pipeline type, and ecosystem requirements
    -
    -
    - -
    -
    FIG 4.1 TOOL SELECTION DECISION TREE FOR SPC PIPELINES
    -
    -flowchart TD - A([New SPC Pipeline]) --> B{Dataset size?} - - B -->|< 500k rows| C{Pipeline type?} - B -->|500k – 2M rows| D{Real-time or batch?} - B -->|> 2M rows| E[Use POLARS] - - C -->|Exploratory / notebook| F[Use PANDAS] - C -->|Production pipeline| G{Statsmodels / scipy needed?} - G -->|YES, heavily| F - G -->|Occasionally| H[Hybrid: Polars pipeline\n→ .to_pandas for stats only] - - D -->|Real-time streaming| E - D -->|Batch| I{Complex multi-machine\nGroupBy subgrouping?} - I -->|YES| E - I -->|NO| F - - E --> J{Python SPC stats\nlibraries needed?} - J -->|YES| K[Polars pipeline\n+ .to_pandas at stats step] - J -->|NO| L[Pure Polars pipeline] - - F --> M[Pandas pipeline] - H --> K - - K --> N([✅ Hybrid Architecture\nBest of both]) - L --> O([✅ Pure Polars\nMax performance]) - M --> P([✅ Pandas\nMax ecosystem]) - - style N fill:#081a0d,stroke:#39FF6E,color:#39FF6E - style O fill:#081a0d,stroke:#39FF6E,color:#39FF6E - style P fill:#081218,stroke:#00D4FF,color:#00D4FF -
    -
    - -
    - - - - - - - - - - - - - - -
    DIMENSIONPANDASPOLARSWINNER
    Memory for operations5–10× dataset size2–4× dataset sizePolars
    Multi-core utilizationSingle-threadedAll cores by defaultPolars
    CSV 1.6 GB read speed~95 seconds~12 seconds (8×)Polars
    GroupBy / subgroupingSlower, familiar API2–10× faster, parallelPolars
    Streaming / real-timeNot designed for itNative streaming APIPolars
    SPC / stats ecosystemFull: scipy, statsmodelsGrowing; use .to_pandas()Pandas
    Exploratory analysisFast, flexible, familiarImmutable, stricter APIPandas
    Production safetyMutable — risky in pipelinesImmutable — pipeline-safePolars
    -
    - -
    -
    -
    Recommended Architecture: Use Polars for the full ingestion and cleaning pipeline. Call .to_pandas() only at the point where scipy/statsmodels SPC statistics are needed. This hybrid approach gives Polars performance with full Python SPC ecosystem access.
    -
    -
    - - -
    -
    -
    05
    -
    -
    MSA & Gage R&R Flow
    -
    Run before any data cleaning — a bad gage invalidates everything downstream
    -
    -
    - -
    - Total observed variation = σ²_process + σ²_measurement_system. If measurement system variation is large, SPC charts show ghosts — you adjust machines that run perfectly because the gage lied to you. The AIAG MSA-4 standard defines the acceptance criteria: %GRR < 10% acceptable, 10–30% conditional, > 30% unacceptable — stop. -
    - -
    -
    FIG 5.1 MSA / GAGE R&R COMPLETE FLOW — STATIC AND REAL-TIME
    -
    -flowchart TD - A([Start: Data Pipeline\nInitialization]) --> B{Measurement\nSystem Type?} - - B -->|Manual / Periodic\nStatic Gage| C[STATIC MSA — AIAG MSA-4] - B -->|Automated Inline\nContinuous Sensor| D[REAL-TIME CONTINUOUS MSA] - - subgraph STATIC ["STATIC GAGE R&R — CROSSED STUDY"] - C --> C1[Select 10 representative parts\nspanning full process range] - C1 --> C2[Assign 2-3 appraisers\nblind to each other] - C2 --> C3[Each appraiser measures\neach part 2-3 times\nMin 60 measurements total] - C3 --> C4[ANOVA decomposition\nσ²_repeatability + σ²_reproducibility + σ²_part] - C4 --> C5[Compute %GRR\n= σ_GRR / σ_tolerance × 100] - C5 --> C6[Compute NDC\nNumber of Distinct Categories] - end - - subgraph REALTIME ["REAL-TIME CONTINUOUS MSA"] - D --> D1[Insert certified\nreference standards\nat fixed intervals in stream] - D1 --> D2[Record deviation from\nknown reference value] - D2 --> D3[EWMA of bias\nα = 0.2] - D3 --> D4[Rolling R chart\non repeat reference measurements] - D4 --> D5{EWMA bias >\n±10% tolerance?} - D5 -->|YES| D6[🔴 Trigger calibration alert\nSuspend measurements] - D5 -->|NO| D7[✅ Gage healthy\nContinue monitoring] - end - - C6 --> E{%GRR result?} - E -->|< 10%| F[✅ ACCEPTABLE\nProceed to data cleaning] - E -->|10 – 30%| G[⚠️ CONDITIONAL\nDocument risk level] - E -->|> 30%| H[🔴 UNACCEPTABLE\nSTOP — fix gage first] - E --> I{NDC ≥ 5?} - I -->|NO| H - I -->|YES| F - - G --> J{Cp of process\n> 2.0?} - J -->|YES| F - J -->|NO| H - - style F fill:#081a0d,stroke:#39FF6E,color:#39FF6E - style H fill:#1a0808,stroke:#FF6B35,color:#FF6B35 - style D6 fill:#1a0808,stroke:#FF6B35,color:#FF6B35 - style D7 fill:#081a0d,stroke:#39FF6E,color:#39FF6E -
    -
    - -
    -
    -
    NDC Rule
    -
    NDC = 1.41 × (σ_part / σ_GRR). Must be ≥ 5. NDC < 5 means the gage cannot discriminate between parts sufficiently for SPC analysis.
    -
    -
    -
    Resolution Rule
    -
    Gage resolution ≤ tolerance/10 (the "10:1 rule"). For ±0.1 mm tolerance (0.2 mm total), gage must resolve ≤ 0.02 mm.
    -
    -
    -
    Calibration Interval
    -
    Real-time EWMA drift detection enables predictive calibration — trigger on statistical drift, not just fixed calendar intervals.
    -
    -
    -
    - - -
    -
    -
    06
    -
    -
    Missing Value Classification & Handling
    -
    In SPC, a missing value is a quality event — not an unknown. The cause determines the treatment.
    -
    -
    - -
    -
    -
    Cardinal rule: Never impute with mean/median for control chart data — it artificially reduces variation and narrows control limits. Never silently drop records. Every missing value must be classified and flagged with a data_quality_flag.
    -
    - -
    -
    FIG 6.1 MISSING VALUE CLASSIFICATION DECISION TREE
    -
    -flowchart TD - A([Missing Value Detected]) --> B{WHY is it missing?\nRoot cause?} - - B -->|Equipment failure\nSensor dropout| C[Flag: MISSING_SENSOR\nDo NOT impute\nPlot visible gap on chart\nInvestigate before continuing] - - B -->|Communication / IT\nerror — brief dropout| D{Gap length?} - D -->|≤ 3 consecutive samples| E[Forward-fill LOCF\nFlag: IMPUTED_LOCF\nMark on chart] - D -->|> 3 consecutive samples| C - - B -->|Scheduled maintenance\nPlanned downtime| F[Flag: EXCLUDED_MAINTENANCE\nExclude from SPC calc\nRestart chart after maintenance\nNew start point — do NOT connect] - - B -->|Subgroup incomplete\ne.g. n=4 instead of n=5| G{Enough for valid\nsubgroup?} - G -->|YES — use smaller n| H[Use reduced subgroup\nAdjust control limit constants\nfor actual n used] - G -->|NO| I[Flag: EXCLUDED_INCOMPLETE\nExclude from chart entirely] - - B -->|Human error\nData entry gap| J{Value recoverable\nfrom backup record?} - J -->|YES| K[Restore original value\nFlag: RESTORED_FROM_BACKUP\nDocument source] - J -->|NO| L[Flag: MISSING_HUMAN\nDo NOT impute for control chart\nKNN acceptable for Cpk only] - - E --> M[✅ Proceed to pipeline] - H --> M - K --> M - C --> N[🔴 HOLD — investigate\nbefore Phase II] - F --> M - I --> M - L --> O{Purpose?} - O -->|Control chart| N - O -->|Capability study Cpk| P[KNN or linear interpolation\nDocument clearly] - P --> M - - style N fill:#1a0808,stroke:#FF6B35,color:#FF6B35 - style M fill:#081a0d,stroke:#39FF6E,color:#39FF6E -
    -
    - -
    - - - - - - - - - - - - -
    MISSING TYPEFLAG VALUECONTROL CHART?CAPABILITY (Cpk)?
    Sensor dropoutMISSING_SENSORGap — do not imputeExclude from study
    Comms error ≤ 3 ptsIMPUTED_LOCFForward-fill + flagAcceptable
    Planned maintenanceEXCLUDED_MAINTENANCEExclude + new startExclude period
    Incomplete subgroupEXCLUDED_INCOMPLETEAdjust n or excludeExclude
    Human error — recoverableRESTORED_FROM_BACKUPUse restored valueUse restored value
    Human error — not recoverableMISSING_HUMANNever imputeKNN acceptable
    -
    -
    - - -
    -
    -
    07
    -
    -
    Storage Architecture
    -
    Always persist raw and cleaned data with full provenance — in-memory only is fragile and non-auditable
    -
    -
    - -
    -
    FIG 7.1 STORAGE SELECTION DECISION TREE
    -
    -flowchart TD - A([Storage Decision]) --> B{Data ingestion rate?} - - B -->|Low-rate\nbatch or lab| C{Team familiar\nwith PostgreSQL?} - C -->|YES| D[TimescaleDB\nPostgreSQL extension] - C -->|NO — prototype only| E[SQLite\nfile-based] - - B -->|Medium-rate\n100–10k samples/sec| F{Need full SQL\nand JOINs?} - F -->|YES| D - F -->|NO| G[QuestDB\nor TimescaleDB] - - B -->|High-rate\n> 10k samples/sec| H[QuestDB\nhigh-frequency preferred] - - D --> I[✅ TimescaleDB\nBest for most SPC\nPostgreSQL compatible\nContinuous aggregates\n90–95% compression] - - H --> J[✅ QuestDB\nUltra-high freq\n12-36× faster ingest\nSIMD vectorized] - - E --> K[⚠️ SQLite\nPrototype only\nNo concurrent writes\nNot for production] - - G --> L{Volume growing\n> 100M rows?} - L -->|YES| H - L -->|NO| D - - I --> M[Two-Tier Architecture] - J --> M - M --> N["TIER 1: Raw TSDB\nQuestDB or TimescaleDB\nEvery raw reading\n30-90 day hot retention\nThen Parquet cold archive"] - M --> O["TIER 2: SPC Analysis DB\nPostgreSQL or TimescaleDB\nCleaned subgroups\nControl limits history\nCpk records — permanent"] - - style I fill:#081a0d,stroke:#39FF6E,color:#39FF6E - style J fill:#081218,stroke:#00D4FF,color:#00D4FF - style K fill:#1a1000,stroke:#FFD449,color:#FFD449 -
    -
    - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    DATABASEBEST FORINGEST SPEEDQUERY POWERVERDICT
    QuestDBHigh-freq sensors, semiconductor, pharma inline12–36× fastest43–418× faster analyticsHigh-volume SPC
    TimescaleDBMost industrial SPC, multi-user quality systemsGoodFull SQL + window fnsRecommended default
    PostgreSQLQuality records, traceability, ERP integrationModerateFull SQL ecosystemTier 2 only
    SQLiteLab data, offline Cpk studies, prototypingLowBasic SQLPrototype only
    InfluxDB 3Simple metrics dashboardsGoodLimited for SPCAvoid for SPC
    -
    -
    - - -
    -
    -
    08
    -
    -
    SPC-Ready Output, Chart Routing & Phase I Checklist
    -
    Final output schema, control chart selection logic, and Phase I go-live validation gate
    -
    -
    - -
    -
    FIG 8.1 CONTROL CHART SELECTION ROUTING — BY DATA TYPE
    -
    -flowchart TD - A([SPC-Ready Dataset]) --> B{Data type?} - - B -->|Variable / Continuous| C{Distribution?} - B -->|Attribute: defectives| G[Attribute Path] - B -->|Attribute: defect counts| H[Count Path] - - C -->|Normal + subgroup n ≥ 2| D{Subgroup size?} - D -->|n = 2 to 10| D1[X-bar R Chart\nCp Cpk Pp Ppk\nAll 8 Nelson rules] - D -->|n > 10| D2[X-bar S Chart\nCp Cpk Pp Ppk\nAll 8 Nelson rules] - D -->|n = 1 individuals| D3[Xmr Chart\nCp Cpk with caution\nAll Nelson rules] - - C -->|Non-normal after transform| E[Xmr Chart\nPoints-outside-limits rule only\nCnpk non-parametric] - C -->|Autocorrelated| F[EWMA α=0.2-0.4\nor CUSUM k=0.5 h=5\nModel-based Cpk] - - G --> G1{Sample size\nper inspection?} - G1 -->|Variable n| G2[p-Chart\nproportion defective\nDPMO → Sigma level] - G1 -->|Fixed n| G3[np-Chart\nnumber defective\nDPMO → Sigma level] - - H --> H1{Inspection unit\nsize fixed?} - H1 -->|YES — fixed area| H2[c-Chart\ndefects per unit\nDPU → DPMO → Z] - H1 -->|NO — variable area| H3[u-Chart\ndefects per unit area\nDPU → DPMO → Z] - - D1 & D2 & D3 --> SIX1[Six Sigma:\nBaseline σ process\nDMAIC Measure phase] - E & F --> SIX2[Six Sigma:\nAdvanced process control\nBlack Belt territory] - G2 & G3 & H2 & H3 --> SIX3[Six Sigma:\nDPMO → Sigma Level\nZ = NORM.INV 1 - DPMO/1M] - - style D1 fill:#081218,stroke:#00D4FF,color:#7ADCF7 - style D2 fill:#081218,stroke:#00D4FF,color:#7ADCF7 - style D3 fill:#081218,stroke:#00D4FF,color:#7ADCF7 - style E fill:#0d0818,stroke:#B57AFF,color:#C9A8FF - style F fill:#0d0818,stroke:#B57AFF,color:#C9A8FF - style G2 fill:#1a0f00,stroke:#FFD449,color:#FFE07A - style G3 fill:#1a0f00,stroke:#FFD449,color:#FFE07A - style H2 fill:#081a0d,stroke:#39FF6E,color:#8FD9A8 - style H3 fill:#081a0d,stroke:#39FF6E,color:#8FD9A8 -
    -
    - -
    -
    REQUIRED OUTPUT SCHEMA — MINIMUM FIELDS
    -
    - - - - - - - - - - - - - - - - -
    FIELDTYPEREQUIREDPURPOSE
    timestampdatetime UTCALWAYSTime-ordered; no gaps beyond defined interval
    subgroup_idintegerALWAYSLinks all measurements in a rational subgroup
    measurement_valuefloat64ALWAYSOriginal or transformed (must document which)
    data_quality_flagenumALWAYSORIGINAL / IMPUTED / EXCLUDED / MISSING
    distribution_flagenumALWAYSNORMAL / TRANSFORMED / NON_NORMAL_RAW
    transform_appliedstring / nullIF TRANSFORMEDe.g. "LOG", "BOXCOX(λ=0.31)", "NONE"
    phaseenumALWAYSPHASE_I or PHASE_II
    ucl / lcl / centerlinefloat64PHASE IIFixed from Phase I — must not drift
    gage_idstringALWAYSLinks to MSA / calibration record
    machine_idstringIF MULTI-MACHINEStratification and traceability key
    -
    -
    - -
    PHASE I GO-LIVE VALIDATION CHECKLIST
    -
    -
      -
    • MSA %GRR < 10% of tolerance AND NDC ≥ 5 confirmed and documented
    • -
    • Normality test result documented; distribution path chosen and recorded
    • -
    • ACF/PACF checked — autocorrelation addressed if present
    • -
    • All outliers investigated — special causes removed with root-cause notes; common causes retained
    • -
    • Missing data: 100% classified with data_quality_flag; no silent imputation on control chart data
    • -
    • Minimum 25 subgroups (AIAG) used for limit computation
    • -
    • Control limits computed and FROZEN — stored in DB with version hash
    • -
    • Transformation documented, reversible, and applied consistently to future data
    • -
    • Gage calibration date confirmed within active calibration interval
    • -
    • Phase II monitoring mode enabled — limits locked, alerts configured
    • -
    -
    -
    - -
    - - -
    -
    INDUSTRIAL SPC DATA PIPELINE — MVP DOCUMENT v1.0
    -
    Standards: AIAG MSA-4 · AIAG SPC · ISO 7870 · Six Sigma DMAIC
    -
    Stack: Python · Polars · Pandas · TimescaleDB / QuestDB · Kafka
    -
    - -
    - - - - From 1eaea2a1a60914c9ec1eba522e502c74c9e0c33f Mon Sep 17 00:00:00 2001 From: root Date: Sat, 25 Jul 2026 01:04:57 +0100 Subject: [PATCH 03/23] fix(core): Phase 1 correctness and gate enforcement Stop silent wrong limits: remove force_wheeler or True, preserve subgroup charts on Wheeler path, block freeze on STOP gates, reject go-live when checklist failed, fix variable-n/EWMA/CUSUM Phase II, unbalanced Gage R&R fallback, and serialize ControlLimits.version. Co-authored-by: Cursor --- apps/api/main.py | 33 ++++- spc_core/charts.py | 16 ++- spc_core/evaluator.py | 213 ++++++++++++++++++++++++---- spc_core/ewma.py | 2 + spc_core/models.py | 7 +- spc_core/msa.py | 18 ++- spc_core/pipeline.py | 68 ++++++--- tests/unit/test_hardening_phase1.py | 167 ++++++++++++++++++++++ 8 files changed, 464 insertions(+), 60 deletions(-) create mode 100644 tests/unit/test_hardening_phase1.py diff --git a/apps/api/main.py b/apps/api/main.py index 0647dc5..9c68de6 100644 --- a/apps/api/main.py +++ b/apps/api/main.py @@ -332,13 +332,23 @@ async def analyze_cc( ) report_dict = report.model_dump(mode="json") - repo.save_limits( - report_dict["limits"], report.limits.version, - report.chart_type.value, meta={"source_file": str(path)}, - ) + limits_meta = { + "source_file": str(path), + "frozen": pipeline.frozen, + "stopped": pipeline.stopped, + "checklist_passed": bool(checklist.get("passed")), + "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, + limits_version=report.limits.version if pipeline.frozen else None, source_file=str(path), user_id=user_id or _user.get("username"), ) @@ -560,6 +570,19 @@ async def go_live( 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 repo.register_stream( stream_key, diff --git a/spc_core/charts.py b/spc_core/charts.py index 4c23c5a..fca8d86 100644 --- a/spc_core/charts.py +++ b/spc_core/charts.py @@ -247,6 +247,8 @@ def analyze_control_chart( elif chart_type == ChartType.P: n = np.asarray(sample_sizes, dtype=float) + if np.any(n <= 0): + raise ValueError("P chart sample sizes must be > 0") limits = L.p_limits(arr, n) plotted = (arr / n).tolist() dtype = DataType.ATTRIBUTE @@ -265,6 +267,8 @@ def analyze_control_chart( 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 @@ -274,16 +278,22 @@ def analyze_control_chart( # ---- run-rule signals (Shewhart paths) ---- if chart_type not in (ChartType.EWMA, ChartType.CUSUM): - if chart_type in (ChartType.P, ChartType.U): + 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: - primary = limits.primary center = primary.center sigma = limits.sigma if (limits.sigma and limits.sigma > 0) else ( - (float(primary.ucl) - center) / 3.0 if not isinstance(primary.ucl, list) else 0.0 + (float(primary.ucl) - center) / 3.0 ) signals = R.evaluate_series(plotted, center=center, sigma=sigma, ruleset=ruleset) diff --git a/spc_core/evaluator.py b/spc_core/evaluator.py index 62118cb..43386eb 100644 --- a/spc_core/evaluator.py +++ b/spc_core/evaluator.py @@ -7,16 +7,18 @@ 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 -> ``observe(value)`` where value is the proportion / rate; limits - vary per point, so only the beyond-limit and one-sided-run rules apply. +* 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 typing import Optional + from .models import ChartType, ControlLimits, Signal from .rules import RuleEngine _SUBGROUP_CHARTS = {ChartType.XBAR_R, ChartType.XBAR_S} -_VARIABLE_LIMIT_CHARTS = {ChartType.P, ChartType.U} class Phase2Evaluator: @@ -37,12 +39,94 @@ def __init__(self, limits: ControlLimits, ruleset: str = "nelson"): sigma = (ucl - center) / 3.0 if ucl is not None else 0.0 self._center = center self._sigma = sigma - self._variable = limits.chart_type in _VARIABLE_LIMIT_CHARTS - self._engine = None if self._variable else RuleEngine(center, sigma, ruleset) + + # 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: Optional[RuleEngine] = 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 + return self.limits.chart_type in _SUBGROUP_CHARTS and not self._variable + + def seed_state( + self, + *, + index: int = -1, + values: Optional[list[float]] = 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: @@ -50,39 +134,55 @@ def observe(self, value: float) -> list[Signal]: 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(value) + 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 not self.is_subgroup_chart: + 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]: - """P/U charts: per-point limits, so evaluate rule 1 and one-sided runs only.""" + """Per-point limits (P/U, variable-n Xbar, EWMA list limits).""" comp = self.limits.primary i = self._i - ucl = comp.ucl_at(i) if isinstance(comp.ucl, list) and i < len(comp.ucl) else ( - comp.ucl if not isinstance(comp.ucl, list) else comp.ucl[-1] - ) - lcl = comp.lcl_at(i) if isinstance(comp.lcl, list) and i < len(comp.lcl) else ( - comp.lcl if not isinstance(comp.lcl, list) else comp.lcl[-1] - ) + 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 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 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")) + 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: @@ -90,10 +190,69 @@ def _observe_variable(self, value: float) -> list[Signal]: else: self._side_run = 1 if side != 0 else 0 self._prev_side = side - if 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")) + 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", + )) + 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 diff --git a/spc_core/ewma.py b/spc_core/ewma.py index 0716b34..ca5e5d7 100644 --- a/spc_core/ewma.py +++ b/spc_core/ewma.py @@ -61,6 +61,8 @@ def ewma_chart( 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] = [] diff --git a/spc_core/models.py b/spc_core/models.py index 13b844f..1c6db0f 100644 --- a/spc_core/models.py +++ b/spc_core/models.py @@ -13,7 +13,7 @@ from enum import Enum from typing import Optional, Union -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, computed_field class ChartType(str, Enum): @@ -104,8 +104,13 @@ 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, diff --git a/spc_core/msa.py b/spc_core/msa.py index a9537af..0fdfe2b 100644 --- a/spc_core/msa.py +++ b/spc_core/msa.py @@ -109,13 +109,17 @@ def gage_rr_anova(parts, operators, measurements, tolerance: Optional[float] = N n_parts, n_ops = len(uparts), len(uops) balanced, n_trials = _is_balanced(parts, operators, n_parts, n_ops) if not balanced: - # Fall back to minimum cell count for a conservative balanced subset warning. - counts = _cell_counts(parts, operators) - if not counts: - raise ValueError("Gage R&R requires at least one trial per part-operator cell") - n_trials = min(counts.values()) - if n_trials < 1: - raise ValueError("Gage R&R requires at least one trial per part-operator cell") + # 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 n = len(y) if n_trials < 1: raise ValueError("Gage R&R requires at least one trial per part-operator cell") diff --git a/spc_core/pipeline.py b/spc_core/pipeline.py index 45ae079..7423e9a 100644 --- a/spc_core/pipeline.py +++ b/spc_core/pipeline.py @@ -45,6 +45,7 @@ class PipelineResult: transform: Optional[TransformResult] = None msa: Optional[GageRRResult] = None stopped: bool = False + frozen: bool = True chart_route: str = "shewhart" @property @@ -204,22 +205,35 @@ def establish( reason=f"Non-normal; transform {transform.label} restored normality.", detail={"transform": transform.applied, "lambda": transform.lam}, )) - elif force_wheeler or True: - # Wheeler robust path: I-MR + points-outside-only. - active_chart = ChartType.I_MR + 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 " - "(I-MR, points-outside-limits only)." + f"({chart_note})." ), - detail={"transform_tried": transform.applied if transform else None}, + detail={ + "transform_tried": transform.applied if transform else None, + "force_wheeler": force_wheeler, + "subgroup_preserved": subgroup_ids is not None, + }, )) - # ---- 5. Chart ---- + # ---- 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, @@ -244,18 +258,34 @@ def establish( }, )) - # ---- 6. Freeze gate ---- - 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}, - )) - + # ---- 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, chart_route=route, + stopped=stopped, frozen=frozen, chart_route=route, ) @@ -325,11 +355,15 @@ def _add(name: str, passed: bool, reason: str) -> None: f"{n_pts} points/subgroups (need >= {min_subgroups}).", ) - # 7. Limits frozen with version hash + # 7. Limits frozen with version hash (blocked when STOP gates fired) _add( "limits_frozen", - bool(pipeline.chart.limits.version), - f"Limits version {pipeline.chart.limits.version}.", + 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 diff --git a/tests/unit/test_hardening_phase1.py b/tests/unit/test_hardening_phase1.py new file mode 100644 index 0000000..7b5aae2 --- /dev/null +++ b/tests/unit/test_hardening_phase1.py @@ -0,0 +1,167 @@ +"""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.limits import xbar_r_limits, build_subgroups +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 From 668fe4df0dafeecdb458569c3d601190ed9d0d38 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 25 Jul 2026 01:08:24 +0100 Subject: [PATCH 04/23] fix(security): Phase 2 auth hardening and path safety Fail closed on missing API keys, validate WebSocket JWT, bcrypt admin login with fixed username, refuse default JWT secret, sanitize reports and stream replay paths, stream uploads with size check, wire rate limits, tighten CORS, and move compose secrets to env_file. Co-authored-by: Cursor --- adapters/io_files.py | 51 ++++++++ adapters/persistence_tsdb.py | 6 +- apps/api/main.py | 198 +++++++++++++++++++++++------- apps/config.py | 16 ++- deploy/compose/.env.example | 14 +++ deploy/compose/docker-compose.yml | 77 ++++++++++-- env.example | 9 +- tests/unit/test_api.py | 2 + tests/unit/test_security.py | 136 ++++++++++++++++++++ 9 files changed, 450 insertions(+), 59 deletions(-) create mode 100644 deploy/compose/.env.example create mode 100644 tests/unit/test_security.py diff --git a/adapters/io_files.py b/adapters/io_files.py index 4039638..2f7e793 100644 --- a/adapters/io_files.py +++ b/adapters/io_files.py @@ -106,6 +106,57 @@ def save_upload(content: bytes, dest_dir: str | Path, filename: str, return target +def save_upload_stream( + stream, + dest_dir: str | Path, + filename: str, + *, + max_bytes: Optional[int] = None, + allowed_extensions: Optional[list[str]] = 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 / 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 diff --git a/adapters/persistence_tsdb.py b/adapters/persistence_tsdb.py index 72f145b..0c3bcfe 100644 --- a/adapters/persistence_tsdb.py +++ b/adapters/persistence_tsdb.py @@ -5,6 +5,7 @@ """ from __future__ import annotations +import os import time from datetime import datetime, timezone from typing import Any, Optional @@ -92,7 +93,10 @@ def __init__(self, dsn: str | Engine, *, echo: bool = False, init: bool = True): 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) - if init: + # 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: diff --git a/apps/api/main.py b/apps/api/main.py index 9c68de6..01a3b41 100644 --- a/apps/api/main.py +++ b/apps/api/main.py @@ -27,6 +27,7 @@ Header, HTTPException, Query, + Request, UploadFile, WebSocket, WebSocketDisconnect, @@ -38,7 +39,7 @@ from pydantic import BaseModel, Field from adapters.factory import get_repository -from adapters.io_files import FileReadError, load_columns, save_upload +from adapters.io_files import FileReadError, load_columns, resolve_under, save_upload_stream from adapters.render_plotly import ( render_capability_html, render_control_chart_html, @@ -82,6 +83,54 @@ _bearer = HTTPBearer(auto_error=False) +# Rate limiting (slowapi) — soft-fail if not installed +try: + from slowapi import Limiter, _rate_limit_exceeded_handler + from slowapi.errors import RateLimitExceeded + from slowapi.util import get_remote_address + + limiter = Limiter(key_func=get_remote_address, default_limits=[]) + app.state.limiter = limiter + app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + _RATE_LIMIT = True +except ImportError: # pragma: no cover + limiter = None + _RATE_LIMIT = False + +# Password hashing (bcrypt directly — passlib+bcrypt version skew is common) +try: + import bcrypt as _bcrypt +except ImportError: # pragma: no cover + _bcrypt = None + +_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 _bcrypt is None: + _admin_password_hash = raw.encode("utf-8") + return _admin_password_hash + 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() + if _bcrypt is None: + return plain.encode("utf-8") == stored + try: + return bool(_bcrypt.checkpw(plain.encode("utf-8"), stored)) + except Exception: + return False + + # Prometheus metrics (optional dependency) try: from prometheus_client import CONTENT_TYPE_LATEST, Counter, Histogram, generate_latest @@ -93,6 +142,19 @@ _PROM = False +@app.on_event("startup") +def _startup_security_checks() -> None: + """Refuse insecure production defaults unless ASPC_DEV_INSECURE=1.""" + _ensure_password_hash() + if not cfg.auth_enabled: + return + if cfg.jwt_secret == "change-me-in-production" and not cfg.dev_insecure: + 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." + ) + + # ---- auth helpers ------------------------------------------------------------- def _create_access_token(subject: str) -> str: @@ -105,6 +167,21 @@ def _create_access_token(subject: str) -> str: 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") + return {"username": username, "auth": "jwt"} + + def get_current_user( credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer), ) -> dict[str, Any]: @@ -117,39 +194,39 @@ def get_current_user( detail="Not authenticated", headers={"WWW-Authenticate": "Bearer"}, ) - 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( - credentials.credentials, - cfg.jwt_secret, - algorithms=[cfg.jwt_algorithm], - ) - username = data.get("sub") - if not username: - raise HTTPException(401, "Invalid token") - return {"username": username, "auth": "jwt"} - except JWTError as exc: - raise HTTPException(401, "Invalid or expired token") from exc + return _decode_token(credentials.credentials) def require_api_key(x_api_key: Optional[str] = Header(None, alias="X-API-Key")) -> str: - """Require X-API-Key for ingest / stream mutation endpoints.""" + """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 - # Also accept env-only keys if yaml list empty 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: - # Dev mode: no keys configured — allow with warning header path - return x_api_key or "dev" + 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 = __import__("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): @@ -187,10 +264,9 @@ class GoLiveRequest(BaseModel): # ---- helpers ------------------------------------------------------------------ def _save_file(file: UploadFile) -> Path: - content = file.file.read() try: - return save_upload( - content, + return save_upload_stream( + file.file, cfg.temp_upload_dir, file.filename or "upload.csv", max_bytes=cfg.max_file_size_bytes, @@ -263,11 +339,25 @@ async def metrics(): 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) -async def login_for_access_token(form: OAuth2PasswordRequestForm = Depends()): - """MVP auth: accept any username if password matches ASPC_ADMIN_PASSWORD (default admin).""" - expected = os.getenv("ASPC_ADMIN_PASSWORD", cfg.admin_password) - if form.password != expected: +@_rate_limit("10/minute") +async def login_for_access_token( + request: Request, + form: OAuth2PasswordRequestForm = Depends(), +): + """Issue a JWT for the configured admin user (username + bcrypt password).""" + if form.username != cfg.admin_username or not _verify_password(form.password): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password", @@ -513,19 +603,24 @@ async def get_run(run_id: str, _user: dict = Depends(get_current_user)): @app.get("/reports/{run_id}") -async def get_report_html(run_id: str): +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.""" - path = Path(cfg.report_dir) + 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"): - candidate = path / f"{run_id}{suffix}" - if candidate.exists(): + 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(run_id) + run = repo.get_run(safe_id) if not run: - raise HTTPException(404, f"Run not found: {run_id}") + raise HTTPException(404, f"Run not found: {safe_id}") report = run["report"] - html = f"""Run {run_id} + html = f"""Run {safe_id}

    {run['analysis_type']}

    {json.dumps(report, indent=2, default=str)}
    """ return HTMLResponse(html) @@ -629,8 +724,21 @@ async def ack_alert( @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.""" +async def ws_live(websocket: WebSocket, stream_key: str, token: Optional[str] = Query(None)): + """Subscribe to Redis channel ``spc:live:{stream_key}`` and forward messages. + + Requires a valid JWT ``token`` query parameter when auth is enabled. + """ + if cfg.auth_enabled: + if not token: + await websocket.close(code=1008, reason="Missing token") + return + try: + _decode_token(token) + except HTTPException: + await websocket.close(code=1008, reason="Invalid or expired token") + return + await websocket.accept() channel = f"spc:live:{stream_key}" try: @@ -659,7 +767,6 @@ async def ws_live(websocket: WebSocket, stream_key: str): payload = {"raw": data} await websocket.send_json(payload) else: - # Keepalive / detect client disconnect try: await asyncio.wait_for(websocket.receive_text(), timeout=0.01) except asyncio.TimeoutError: @@ -676,23 +783,30 @@ async def ws_live(websocket: WebSocket, stream_key: str): @app.get("/stream/replay") async def stream_replay( - file_path: str = Query(..., description="Path to a CSV already on the server"), + 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.""" + """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) - if not Path(file_path).exists(): + 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(file_path, value_col=value_col, delay_s=0.0) + 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" diff --git a/apps/config.py b/apps/config.py index 3d28f20..2172866 100644 --- a/apps/config.py +++ b/apps/config.py @@ -17,13 +17,14 @@ "api": { "host": "0.0.0.0", "port": 8000, - "cors_origins": ["*"], + "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": [], }, @@ -102,12 +103,16 @@ def _apply_env_overrides(self) -> None: 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") redis = self.config.setdefault("redis", {}) if os.getenv("ASPC_REDIS_URL"): @@ -193,6 +198,10 @@ def jwt_algorithm(self) -> str: 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") @@ -205,6 +214,11 @@ def api_keys(self) -> list[str]: 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 ruleset(self) -> str: return self.config["spc"]["ruleset"] diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example new file mode 100644 index 0000000..3b9f6a9 --- /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 +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 index 2056a2d..9bec52b 100644 --- a/deploy/compose/docker-compose.yml +++ b/deploy/compose/docker-compose.yml @@ -1,9 +1,12 @@ # ASPC production stack — Redpanda, Mosquitto, TimescaleDB, Redis, API, stream engine, frontend. -# Usage: docker compose -f deploy/compose/docker-compose.yml up -d +# 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: unless-stopped command: - redpanda - start @@ -32,6 +35,7 @@ services: mosquitto: image: eclipse-mosquitto:2.0 + restart: unless-stopped ports: - "1883:1883" volumes: @@ -39,22 +43,26 @@ services: timescaledb: image: timescale/timescaledb:latest-pg16 + restart: unless-stopped + env_file: + - .env environment: - POSTGRES_USER: aspc - POSTGRES_PASSWORD: aspc - POSTGRES_DB: aspc + POSTGRES_USER: ${POSTGRES_USER:-aspc} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in deploy/compose/.env} + POSTGRES_DB: ${POSTGRES_DB:-aspc} ports: - "5433:5432" volumes: - tsdata:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U aspc -d aspc"] + 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: unless-stopped ports: - "6379:6379" healthcheck: @@ -63,36 +71,73 @@ services: 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: unless-stopped + env_file: + - .env environment: ASPC_PERSISTENCE_BACKEND: timescale - ASPC_TIMESCALE_DSN: postgresql+asyncpg://aspc:aspc@timescaledb:5432/aspc + 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_JWT_SECRET: change-me-in-production - ASPC_API_KEYS: demokey - ASPC_CORS_ORIGINS: http://localhost:3000 + 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} + # Disable create_all race — migrations ran in migrate service + ASPC_TSDB_INIT: "0" 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: unless-stopped + env_file: + - .env environment: - ASPC_TIMESCALE_DSN: postgresql+asyncpg://aspc:aspc@timescaledb:5432/aspc + 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: @@ -104,6 +149,7 @@ services: build: context: ../.. dockerfile: deploy/docker/Dockerfile.mqtt_bridge + restart: unless-stopped environment: ASPC_MQTT_HOST: mosquitto ASPC_MQTT_PORT: "1883" @@ -111,24 +157,29 @@ services: ASPC_KAFKA_BOOTSTRAP: redpanda:9092 ASPC_KAFKA_TOPIC: spc.measurements depends_on: - - mosquitto - - redpanda + mosquitto: + condition: service_started + redpanda: + condition: service_healthy frontend: build: context: ../.. dockerfile: deploy/docker/Dockerfile.frontend + restart: unless-stopped environment: NEXT_PUBLIC_API_URL: http://localhost:8000 NEXT_PUBLIC_WS_URL: ws://localhost:8000 ports: - "3000:3000" depends_on: - - api + api: + condition: service_healthy grafana: image: grafana/grafana:11.1.0 profiles: ["ops"] + restart: unless-stopped ports: - "3001:3000" volumes: diff --git a/env.example b/env.example index 3c94c72..5dd885b 100644 --- a/env.example +++ b/env.example @@ -5,19 +5,24 @@ # Auth ASPC_AUTH_ENABLED=true ASPC_JWT_SECRET=change-me-in-production +ASPC_ADMIN_USERNAME=admin ASPC_ADMIN_PASSWORD=admin -# Comma-separated API keys for ingest / stream endpoints +# Comma-separated API keys for ingest / stream endpoints (required unless ASPC_DEV_INSECURE=1) # ASPC_API_KEYS=key1,key2 +# Local-only escape hatch (never in production): +# ASPC_DEV_INSECURE=1 # API # ASPC_API_HOST=0.0.0.0 # ASPC_API_PORT=8000 -# ASPC_CORS_ORIGINS=http://localhost:3000,http://localhost:8000 +# ASPC_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 # 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 # Streaming # ASPC_REDIS_URL=redis://localhost:6379/0 diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index 72237c2..82fc9c4 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -8,6 +8,8 @@ # 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 diff --git a/tests/unit/test_security.py b/tests/unit/test_security.py new file mode 100644 index 0000000..d278294 --- /dev/null +++ b/tests/unit/test_security.py @@ -0,0 +1,136 @@ +"""Security hardening tests — auth, traversal, API keys, go-live gates.""" +from __future__ import annotations + +import os +from pathlib import Path + +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( + f"/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"] From a527f18af35c7f723c8411d6a50d834c431f4246 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 25 Jul 2026 01:11:04 +0100 Subject: [PATCH 05/23] =?UTF-8?q?fix(stream):=20Phase=203=20durability=20?= =?UTF-8?q?=E2=80=94=20restore,=20idempotency,=20ops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore Phase II evaluator index/buffer after restart, evict stream state, use deterministic raw-measurement ids with conflict ignore, manual Kafka commits with reconnect backoff, MQTT reconnect, migrate service in compose, real /health probes, and reject SQLite for streaming. Co-authored-by: Cursor --- adapters/factory.py | 10 +++ adapters/persistence_tsdb.py | 118 ++++++++++++++++++------- adapters/stream_engine.py | 91 +++++++++++++++++-- adapters/stream_sources.py | 145 ++++++++++++++++++++++--------- apps/api/main.py | 23 ++++- docs/deployment.md | 15 ++-- services/stream_engine/main.py | 8 +- tests/unit/test_stream_engine.py | 51 +++++++++++ 8 files changed, 377 insertions(+), 84 deletions(-) diff --git a/adapters/factory.py b/adapters/factory.py index 403af75..42fea2d 100644 --- a/adapters/factory.py +++ b/adapters/factory.py @@ -50,3 +50,13 @@ def get_repository( 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/persistence_tsdb.py b/adapters/persistence_tsdb.py index 0c3bcfe..af497a1 100644 --- a/adapters/persistence_tsdb.py +++ b/adapters/persistence_tsdb.py @@ -5,16 +5,18 @@ """ from __future__ import annotations +import hashlib +import logging import os -import time from datetime import datetime, timezone from typing import Any, Optional from uuid import uuid4 try: - from sqlalchemy import create_engine, select, text + 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( @@ -22,6 +24,8 @@ "Install with: pip install 'aspc[tsdb]'" ) from exc +logger = logging.getLogger(__name__) + from adapters.db_models import ( AnalysisRunRow, AuditLogRow, @@ -62,26 +66,24 @@ def init_schema(engine: Engine) -> None: "'raw_measurements', 'ts', if_not_exists => TRUE)" ) ) - # Retention may already exist on re-init; ignore duplicate errors via DO block. - conn.execute( - text( - """ - DO $$ - BEGIN - PERFORM add_retention_policy( + # 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 ); - EXCEPTION - WHEN undefined_function THEN - NULL; - WHEN OTHERS THEN - NULL; - END $$; - """ + """ + ) + ) + except Exception as exc: # noqa: BLE001 + logger.warning( + "Timescale retention policy not applied on raw_measurements: %s", + exc, ) - ) class TimescaleDBRepository(Repository): @@ -244,6 +246,12 @@ def save_audit( # --- 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, @@ -253,21 +261,73 @@ def save_raw_measurement( ) -> None: if ts.tzinfo is None: ts = ts.replace(tzinfo=timezone.utc) - row_id = int(meta.get("id") or time.time_ns()) + 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: - session.add( - RawMeasurementRow( - 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"), + if self.engine.dialect.name == "postgresql": + stmt = ( + pg_insert(RawMeasurementRow) + .values(**values) + .on_conflict_do_nothing(constraint="pk_raw_measurements") ) + session.execute(stmt) + session.commit() + return + try: + session.add(RawMeasurementRow(**values)) + 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 ) - session.commit() + + 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, diff --git a/adapters/stream_engine.py b/adapters/stream_engine.py index 55fbd21..1ae2746 100644 --- a/adapters/stream_engine.py +++ b/adapters/stream_engine.py @@ -3,6 +3,7 @@ import json import logging +from collections import OrderedDict from datetime import datetime, timezone from typing import Any, Optional, Protocol @@ -11,6 +12,11 @@ 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 + class StreamRepository(Protocol): """Minimal persistence surface required by :class:`StreamEngine`.""" @@ -51,14 +57,23 @@ def __init__( *, redis_client: Any = None, redis_url: Optional[str] = None, + max_streams: int = _DEFAULT_MAX_STREAMS, + restore_state: bool = True, ): + 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: dict[str, Phase2Evaluator] = {} + self._evaluators: OrderedDict[str, Phase2Evaluator] = OrderedDict() self._limits: dict[str, ControlLimits] = {} self._limits_versions: dict[str, str] = {} self._rulesets: dict[str, str] = {} self._redis = redis_client self._redis_url = redis_url + self._max_streams = max(1, int(max_streams)) + self._restore_state = restore_state def _get_redis(self) -> Any: if self._redis is not None: @@ -79,12 +94,58 @@ def register( limits: ControlLimits, ruleset: str = "nelson", ) -> None: - """Attach a frozen Phase I limit set to ``stream_key``.""" - self._evaluators[stream_key] = Phase2Evaluator(limits, ruleset=ruleset) + """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) + 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 + def _restore_evaluator(self, stream_key: str, ev: Phase2Evaluator) -> None: + count_fn = getattr(self.repo, "count_raw_measurements", None) + recent_fn = getattr(self.repo, "recent_raw_measurements", None) + if not callable(count_fn) or not callable(recent_fn): + return + try: + count = int(count_fn(stream_key)) + 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) + def load_limits( self, stream_key: str, @@ -114,6 +175,9 @@ def handle_observation( 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(timezone.utc) elif ts.tzinfo is None: @@ -132,7 +196,7 @@ def handle_observation( signals = ev.observe(float(value)) for sig in signals: - self.repo.save_ooc_event( + inserted = self.repo.save_ooc_event( stream_key, ts, limits_version=limits_version, @@ -143,18 +207,25 @@ def handle_observation( 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 - # Scalar limits for the Live dashboard (variable P/U charts use first/index 0). 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 + # Always publish the stream index so the dashboard can map markers. payload = { "type": "point", "stream_key": stream_key, "value": float(value), "timestamp": ts.isoformat(), "ts": ts.isoformat(), - "index": signals[0].index if signals else None, + "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, @@ -208,6 +279,14 @@ def _publish(self, stream_key: str, payload: dict[str, Any]) -> None: 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 diff --git a/adapters/stream_sources.py b/adapters/stream_sources.py index 545681f..bc4a831 100644 --- a/adapters/stream_sources.py +++ b/adapters/stream_sources.py @@ -158,27 +158,46 @@ def __iter__(self) -> Iterator[float]: async def __aiter__(self) -> AsyncIterator[dict[str, Any]]: from aiokafka import AIOKafkaConsumer - consumer = AIOKafkaConsumer( - self.topic, - bootstrap_servers=self.bootstrap_servers, - group_id=self.group_id, - auto_offset_reset=self.auto_offset_reset, - enable_auto_commit=True, - ) - self._consumer = consumer - await consumer.start() - try: - 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) - ) - obs = _parse_payload(msg.value or b"", default_key=key_hint or self.default_key) - yield obs.as_dict() - finally: - await consumer.stop() - self._consumer = None + 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: + # Poison message: commit past it so it is not retried forever + await consumer.commit() + raise + 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): @@ -229,9 +248,12 @@ def iter_sync(self, *, timeout: Optional[float] = None) -> Iterator[dict[str, An yield from super().iter_sync(timeout=timeout) def _iter_paho(self, *, timeout: Optional[float] = 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: @@ -240,17 +262,49 @@ def _on_message(_client, _userdata, message) -> None: 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) - q.put(obs.as_dict()) + try: + q.put(obs.as_dict(), timeout=5.0) + except queue.Full: + pass # drop under backpressure rather than block the network thread except BaseException as exc: # noqa: BLE001 q.put(exc) - client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) + 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 - client.connect(self.host, self.port) - client.subscribe(self.topic) - client.loop_start() + 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() @@ -258,8 +312,12 @@ def _on_message(_client, _userdata, message) -> None: raise item yield item finally: + stop.set() client.loop_stop() - client.disconnect() + try: + client.disconnect() + except Exception: + pass async def __aiter__(self) -> AsyncIterator[dict[str, Any]]: if self._backend != "aiomqtt": @@ -297,15 +355,24 @@ def _runner() -> None: if self.password is not None: kwargs["password"] = self.password - async with aiomqtt.Client(**kwargs) as client: - await client.subscribe(self.topic) - 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() + 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/apps/api/main.py b/apps/api/main.py index 01a3b41..dbca54d 100644 --- a/apps/api/main.py +++ b/apps/api/main.py @@ -241,6 +241,7 @@ class AnalyzeResponse(BaseModel): class HealthResponse(BaseModel): status: str version: str = "2.0.0" + checks: Optional[dict[str, str]] = None class TokenResponse(BaseModel): @@ -327,7 +328,27 @@ async def root(): @app.get("/health", response_model=HealthResponse) async def health(): - return HealthResponse(status="healthy") + 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") diff --git a/docs/deployment.md b/docs/deployment.md index 6af56a2..1c89299 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -5,11 +5,13 @@ Compose file: [`deploy/compose/docker-compose.yml`](../deploy/compose/docker-compose.yml). ```bash -docker compose -f deploy/compose/docker-compose.yml up -d --build +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 ``` | 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 | 19092 (external), 9644 admin | @@ -20,14 +22,13 @@ docker compose -f deploy/compose/docker-compose.yml up -d --build | `mqtt-bridge` | MQTT → Redpanda | — | | `grafana` | Ops dashboards (`--profile ops`) | 3001 | -Default API env in Compose: +Secrets come from `deploy/compose/.env` (never commit real values): -- `ASPC_PERSISTENCE_BACKEND=timescale` -- `ASPC_TIMESCALE_DSN=postgresql+asyncpg://aspc:aspc@timescaledb:5432/aspc` -- `ASPC_REDIS_URL=redis://redis:6379/0` -- `ASPC_KAFKA_BOOTSTRAP=redpanda:9092` -- `ASPC_API_KEYS=demokey` +- `POSTGRES_PASSWORD`, `ASPC_JWT_SECRET`, `ASPC_API_KEYS`, `ASPC_ADMIN_PASSWORD` - `ASPC_CORS_ORIGINS=http://localhost:3000` +- `ASPC_TSDB_INIT=0` on api/stream-engine so only the migrate service applies schema + +Deferred ops (configure in your environment, not shipped as defaults): Mosquitto TLS/auth, Kafka DLQ, continuous aggregates, image digest pins. Frontend: `NEXT_PUBLIC_API_URL=http://localhost:8000`, `NEXT_PUBLIC_WS_URL=ws://localhost:8000`. diff --git a/services/stream_engine/main.py b/services/stream_engine/main.py index 24c7b7b..b77c91c 100644 --- a/services/stream_engine/main.py +++ b/services/stream_engine/main.py @@ -7,7 +7,7 @@ import sys from typing import Optional -from adapters.factory import get_repository +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 @@ -44,11 +44,15 @@ def main(argv: Optional[list[str]] = None) -> int: topic = args.topic or cfg.kafka_topic redis_url = args.redis_url or cfg.redis_url - # Prefer Timescale when configured; fall back to sqlite for local demos. 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 engine = StreamEngine(repo, redis_url=redis_url) diff --git a/tests/unit/test_stream_engine.py b/tests/unit/test_stream_engine.py index 4cfa34f..243f556 100644 --- a/tests/unit/test_stream_engine.py +++ b/tests/unit/test_stream_engine.py @@ -119,3 +119,54 @@ def test_unregistered_stream_raises(): 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=timezone.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(): + from adapters.persistence import SQLiteRepository + import pytest + + repo = SQLiteRepository(":memory:") + with pytest.raises(TypeError, match="streaming"): + StreamEngine(repo) From aaf520812a7cd0c681a813593e186dd639d1c11f Mon Sep 17 00:00:00 2001 From: root Date: Sat, 25 Jul 2026 01:15:02 +0100 Subject: [PATCH 06/23] chore: Phase 4 CI, lint, frontend, packaging hardening Make CI fail on ruff/eslint, add mypy for spc_core, run gated integration job against Timescale/Redis, clear ruff findings, fix live OOC index mapping and WebSocket reconnect, auth-guard pages, 401 redirect, drop xlsx accept, refresh requirements.txt, and keep sample_data out of the wheel. Co-authored-by: Cursor --- .github/workflows/ci.yml | 53 +++++++++++++++++- adapters/archive.py | 4 +- adapters/db_models.py | 4 +- adapters/factory.py | 6 +- adapters/io_files.py | 10 ++-- adapters/persistence.py | 42 +++++++------- adapters/persistence_tsdb.py | 62 ++++++++++----------- adapters/render_plotly.py | 7 +-- adapters/stream.py | 4 +- adapters/stream_engine.py | 16 +++--- adapters/stream_sources.py | 27 ++++----- apps/api/main.py | 70 ++++++++++++------------ apps/cli/main.py | 4 +- apps/config.py | 9 ++- frontend/src/app/live/page.tsx | 25 ++++++--- frontend/src/components/Layout.tsx | 8 ++- frontend/src/components/ui.tsx | 2 +- pyproject.toml | 17 ++++-- requirements.txt | 33 ++++++----- sample_data/__init__.py | 3 +- scripts/manufacturing_sim.py | 6 +- scripts/run_live_protocol.py | 6 +- services/mqtt_bridge/main.py | 10 ++-- services/stream_engine/main.py | 3 +- spc_core/capability.py | 38 ++++++------- spc_core/charts.py | 36 ++++++------ spc_core/cleaning.py | 10 ++-- spc_core/cusum.py | 7 +-- spc_core/evaluator.py | 6 +- spc_core/ewma.py | 7 +-- spc_core/ingest.py | 42 +++++++------- spc_core/models.py | 31 +++++------ spc_core/msa.py | 10 ++-- spc_core/msa_stream.py | 5 +- spc_core/multimodal.py | 2 +- spc_core/normality.py | 21 ++++--- spc_core/pipeline.py | 32 +++++------ spc_core/report.py | 62 ++++++++++----------- spc_core/rules.py | 3 +- tests/integration/test_realtime_gated.py | 12 ++-- tests/unit/test_api.py | 2 +- tests/unit/test_cleaning.py | 2 - tests/unit/test_hardening_phase1.py | 1 - tests/unit/test_ingest.py | 3 +- tests/unit/test_limits.py | 6 +- tests/unit/test_msa.py | 6 +- tests/unit/test_normality.py | 1 - tests/unit/test_rules.py | 2 - tests/unit/test_security.py | 5 +- tests/unit/test_stream_engine.py | 19 ++++--- 50 files changed, 431 insertions(+), 371 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4fa44eb..7c4f533 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,9 +25,11 @@ jobs: - 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 tests || true + run: ruff check spc_core adapters apps services sample_data scripts tests + - name: Mypy (spc_core) + run: mypy spc_core --ignore-missing-imports - name: Pytest - run: PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 pytest -q --tb=short + run: PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 pytest -q --tb=short -m "not integration" - name: Acceptance — no stubs, all ChartTypes real run: | python - <<'PY' @@ -53,6 +55,51 @@ jobs: 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: @@ -66,7 +113,7 @@ jobs: cache: npm cache-dependency-path: frontend/package-lock.json - run: npm ci || npm install - - run: npm run lint || true + - run: npm run lint - run: npm run test - run: npm run build diff --git a/adapters/archive.py b/adapters/archive.py index 10cf1db..58acea1 100644 --- a/adapters/archive.py +++ b/adapters/archive.py @@ -2,10 +2,10 @@ 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, Iterable, Mapping, Sequence - +from typing import Any _COLUMNS = ( "stream_key", diff --git a/adapters/db_models.py b/adapters/db_models.py index bb9cbc9..726857d 100644 --- a/adapters/db_models.py +++ b/adapters/db_models.py @@ -1,7 +1,7 @@ """SQLAlchemy 2.0 declarative models for ASPC persistence (SQLite + TimescaleDB).""" from __future__ import annotations -from datetime import datetime, timezone +from datetime import UTC, datetime from sqlalchemy import ( JSON, @@ -20,7 +20,7 @@ def _utcnow() -> datetime: - return datetime.now(timezone.utc) + return datetime.now(UTC) class Base(DeclarativeBase): diff --git a/adapters/factory.py b/adapters/factory.py index 42fea2d..ffba767 100644 --- a/adapters/factory.py +++ b/adapters/factory.py @@ -2,17 +2,17 @@ from __future__ import annotations from pathlib import Path -from typing import Any, Optional +from typing import Any from adapters.persistence import Repository, SQLiteRepository def get_repository( cfg: Any = None, - backend: Optional[str] = None, + backend: str | None = None, *, sqlite_path: str | Path | None = None, - timescale_dsn: Optional[str] = None, + timescale_dsn: str | None = None, ) -> Repository: """Return a :class:`Repository` for the requested backend. diff --git a/adapters/io_files.py b/adapters/io_files.py index 2f7e793..1c7f414 100644 --- a/adapters/io_files.py +++ b/adapters/io_files.py @@ -7,7 +7,7 @@ import csv import re from pathlib import Path -from typing import Any, Optional +from typing import Any class FileReadError(Exception): @@ -88,8 +88,8 @@ def load_columns(path: str | Path) -> dict[str, list[Any]]: def save_upload(content: bytes, dest_dir: str | Path, filename: str, - max_bytes: Optional[int] = None, - allowed_extensions: Optional[list[str]] = None) -> Path: + 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) @@ -111,8 +111,8 @@ def save_upload_stream( dest_dir: str | Path, filename: str, *, - max_bytes: Optional[int] = None, - allowed_extensions: Optional[list[str]] = None, + 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).""" diff --git a/adapters/persistence.py b/adapters/persistence.py index d8d9f74..031d071 100644 --- a/adapters/persistence.py +++ b/adapters/persistence.py @@ -10,9 +10,9 @@ import json import sqlite3 from abc import ABC, abstractmethod -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path -from typing import Any, Optional +from typing import Any from uuid import uuid4 @@ -21,31 +21,31 @@ class Repository(ABC): @abstractmethod def save_limits(self, limits_payload: dict[str, Any], version: str, - chart_type: str, meta: Optional[dict] = None) -> str: + chart_type: str, meta: dict | None = None) -> str: ... @abstractmethod - def get_limits(self, version: str) -> Optional[dict[str, Any]]: + def get_limits(self, version: str) -> dict[str, Any] | None: ... @abstractmethod def save_run(self, analysis_type: str, report: dict[str, Any], - limits_version: Optional[str] = None, - source_file: Optional[str] = None, - user_id: Optional[str] = None) -> str: + limits_version: str | None = None, + source_file: str | None = None, + user_id: str | None = None) -> str: ... @abstractmethod - def get_run(self, run_id: str) -> Optional[dict[str, Any]]: + def get_run(self, run_id: str) -> dict[str, Any] | None: ... @abstractmethod - def list_runs(self, analysis_type: Optional[str] = None, limit: int = 50) -> list[dict]: + 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: Optional[str] = None) -> str: + user_id: str | None = None) -> str: ... @@ -93,8 +93,8 @@ def _init_schema(self) -> None: ) def save_limits(self, limits_payload: dict[str, Any], version: str, - chart_type: str, meta: Optional[dict] = None) -> str: - now = datetime.now(timezone.utc).isoformat() + 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 " @@ -104,7 +104,7 @@ def save_limits(self, limits_payload: dict[str, Any], version: str, ) return version - def get_limits(self, version: str) -> Optional[dict[str, Any]]: + 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,) @@ -120,11 +120,11 @@ def get_limits(self, version: str) -> Optional[dict[str, Any]]: } def save_run(self, analysis_type: str, report: dict[str, Any], - limits_version: Optional[str] = None, - source_file: Optional[str] = None, - user_id: Optional[str] = None) -> str: + limits_version: str | None = None, + source_file: str | None = None, + user_id: str | None = None) -> str: run_id = str(uuid4()) - now = datetime.now(timezone.utc).isoformat() + now = datetime.now(UTC).isoformat() with self._connect() as conn: conn.execute( "INSERT INTO analysis_runs " @@ -141,7 +141,7 @@ def save_run(self, analysis_type: str, report: dict[str, Any], ) return run_id - def get_run(self, run_id: str) -> Optional[dict[str, Any]]: + 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,) @@ -158,7 +158,7 @@ def get_run(self, run_id: str) -> Optional[dict[str, Any]]: "created_at": row["created_at"], } - def list_runs(self, analysis_type: Optional[str] = None, limit: int = 50) -> list[dict]: + 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( @@ -177,9 +177,9 @@ def list_runs(self, analysis_type: Optional[str] = None, limit: int = 50) -> lis return [dict(r) for r in rows] def save_audit(self, event: str, detail: dict[str, Any], - user_id: Optional[str] = None) -> str: + user_id: str | None = None) -> str: event_id = str(uuid4()) - now = datetime.now(timezone.utc).isoformat() + now = datetime.now(UTC).isoformat() with self._connect() as conn: conn.execute( "INSERT INTO audit_log (event_id, event, detail, user_id, created_at) " diff --git a/adapters/persistence_tsdb.py b/adapters/persistence_tsdb.py index af497a1..a198fe2 100644 --- a/adapters/persistence_tsdb.py +++ b/adapters/persistence_tsdb.py @@ -8,8 +8,8 @@ import hashlib import logging import os -from datetime import datetime, timezone -from typing import Any, Optional +from datetime import UTC, datetime +from typing import Any from uuid import uuid4 try: @@ -111,9 +111,9 @@ def save_limits( limits_payload: dict[str, Any], version: str, chart_type: str, - meta: Optional[dict] = None, + meta: dict | None = None, ) -> str: - now = datetime.now(timezone.utc) + now = datetime.now(UTC) with self._session() as session: row = session.get(ControlLimitRow, version) if row is None: @@ -132,7 +132,7 @@ def save_limits( session.commit() return version - def get_limits(self, version: str) -> Optional[dict[str, Any]]: + def get_limits(self, version: str) -> dict[str, Any] | None: with self._session() as session: row = session.get(ControlLimitRow, version) if row is None: @@ -151,12 +151,12 @@ def save_run( self, analysis_type: str, report: dict[str, Any], - limits_version: Optional[str] = None, - source_file: Optional[str] = None, - user_id: Optional[str] = None, + limits_version: str | None = None, + source_file: str | None = None, + user_id: str | None = None, ) -> str: run_id = str(uuid4()) - now = datetime.now(timezone.utc) + now = datetime.now(UTC) with self._session() as session: session.add( AnalysisRunRow( @@ -182,7 +182,7 @@ def save_run( ) return run_id - def get_run(self, run_id: str) -> Optional[dict[str, Any]]: + 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: @@ -200,7 +200,7 @@ def get_run(self, run_id: str) -> Optional[dict[str, Any]]: } def list_runs( - self, analysis_type: Optional[str] = None, limit: int = 50 + 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( @@ -227,10 +227,10 @@ def save_audit( self, event: str, detail: dict[str, Any], - user_id: Optional[str] = None, + user_id: str | None = None, ) -> str: event_id = str(uuid4()) - now = datetime.now(timezone.utc) + now = datetime.now(UTC) with self._session() as session: session.add( AuditLogRow( @@ -260,7 +260,7 @@ def save_raw_measurement( **meta: Any, ) -> None: if ts.tzinfo is None: - ts = ts.replace(tzinfo=timezone.utc) + 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) ) @@ -334,17 +334,17 @@ def save_ooc_event( stream_key: str, ts: datetime, *, - limits_version: Optional[str], + limits_version: str | None, index: int, value: float, rule_id: str, rule_name: str, description: str, - side: Optional[str] = None, + 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=timezone.utc) + ts = ts.replace(tzinfo=UTC) with self._session() as session: if self.engine.dialect.name == "postgresql": stmt = ( @@ -397,7 +397,7 @@ def save_ooc_event( def list_ooc_events( self, - stream_key: Optional[str] = None, + stream_key: str | None = None, *, unacked_only: bool = False, limit: int = 100, @@ -414,15 +414,15 @@ def ack_alert( self, event_id: int, *, - acked_by: Optional[str] = None, - ) -> Optional[dict[str, Any]]: + 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(timezone.utc) + row.acked_at = datetime.now(UTC) row.acked_by = acked_by session.commit() session.refresh(row) @@ -432,9 +432,9 @@ def save_capability( self, run_id: str, *, - cpk: Optional[float] = None, - ppk: Optional[float] = None, - sigma_level: Optional[float] = None, + cpk: float | None = None, + ppk: float | None = None, + sigma_level: float | None = None, ) -> int: with self._session() as session: row = CapabilityHistoryRow( @@ -442,7 +442,7 @@ def save_capability( cpk=cpk, ppk=ppk, sigma_level=sigma_level, - created_at=datetime.now(timezone.utc), + created_at=datetime.now(UTC), ) session.add(row) session.commit() @@ -453,14 +453,14 @@ def register_stream( self, stream_key: str, *, - topic: Optional[str] = None, - limits_version: Optional[str] = None, - chart_type: Optional[str] = None, + topic: str | None = None, + limits_version: str | None = None, + chart_type: str | None = None, ruleset: str = "nelson", active: bool = True, - meta: Optional[dict] = None, + meta: dict | None = None, ) -> str: - now = datetime.now(timezone.utc) + now = datetime.now(UTC) with self._session() as session: row = session.get(StreamRegistryRow, stream_key) if row is None: @@ -490,7 +490,7 @@ def register_stream( session.commit() return stream_key - def get_stream(self, stream_key: str) -> Optional[dict[str, Any]]: + 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: diff --git a/adapters/render_plotly.py b/adapters/render_plotly.py index c9e5a1f..36c3770 100644 --- a/adapters/render_plotly.py +++ b/adapters/render_plotly.py @@ -5,16 +5,15 @@ from __future__ import annotations from pathlib import Path -from typing import Optional from spc_core.report import CapabilityReport, MSAReport, SPCReport -def render_control_chart_html(report: SPCReport, title: Optional[str] = None) -> str: +def render_control_chart_html(report: SPCReport, title: str | None = None) -> str: try: import plotly.graph_objects as go - from plotly.subplots import make_subplots 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]" @@ -122,7 +121,7 @@ def render_capability_html(report: CapabilityReport, title: str = "Process Capab """ -def render_msa_html(report: MSAReport, title: Optional[str] = None) -> str: +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}" diff --git a/adapters/stream.py b/adapters/stream.py index 3f8824c..975ebfa 100644 --- a/adapters/stream.py +++ b/adapters/stream.py @@ -10,8 +10,8 @@ import csv import time from abc import ABC, abstractmethod +from collections.abc import Callable, Iterator from pathlib import Path -from typing import Callable, Iterator, Optional from spc_core.evaluator import Phase2Evaluator from spc_core.models import ControlLimits, Signal @@ -51,7 +51,7 @@ def stream_evaluate( source: ObservationSource, limits: ControlLimits, ruleset: str = "nelson", - on_signal: Optional[Callable[[Signal], None]] = None, + on_signal: Callable[[Signal], None] | None = None, ) -> list[Signal]: """Feed a source into Phase2Evaluator; optionally call ``on_signal`` for each hit. diff --git a/adapters/stream_engine.py b/adapters/stream_engine.py index 1ae2746..cddc44a 100644 --- a/adapters/stream_engine.py +++ b/adapters/stream_engine.py @@ -4,8 +4,8 @@ import json import logging from collections import OrderedDict -from datetime import datetime, timezone -from typing import Any, Optional, Protocol +from datetime import UTC, datetime +from typing import Any, Protocol from spc_core.evaluator import Phase2Evaluator from spc_core.models import ControlLimits, Signal @@ -30,16 +30,16 @@ def save_ooc_event( stream_key: str, ts: datetime, *, - limits_version: Optional[str], + limits_version: str | None, index: int, value: float, rule_id: str, rule_name: str, description: str, - side: Optional[str] = None, + side: str | None = None, ) -> bool: ... - def get_limits(self, version: str) -> Optional[dict[str, Any]]: ... + def get_limits(self, version: str) -> dict[str, Any] | None: ... class StreamEngine: @@ -56,7 +56,7 @@ def __init__( repo: StreamRepository, *, redis_client: Any = None, - redis_url: Optional[str] = None, + redis_url: str | None = None, max_streams: int = _DEFAULT_MAX_STREAMS, restore_state: bool = True, ): @@ -179,9 +179,9 @@ def handle_observation( self._evaluators.move_to_end(stream_key) if ts is None: - ts = datetime.now(timezone.utc) + ts = datetime.now(UTC) elif ts.tzinfo is None: - ts = ts.replace(tzinfo=timezone.utc) + ts = ts.replace(tzinfo=UTC) limits_version = self._limits_versions[stream_key] self.repo.save_raw_measurement( diff --git a/adapters/stream_sources.py b/adapters/stream_sources.py index bc4a831..41564e2 100644 --- a/adapters/stream_sources.py +++ b/adapters/stream_sources.py @@ -9,9 +9,10 @@ import json import queue import threading +from collections.abc import AsyncIterator, Iterator from dataclasses import dataclass, field -from datetime import datetime, timezone -from typing import Any, AsyncIterator, Iterator, Optional +from datetime import UTC, datetime +from typing import Any from adapters.stream import ObservationSource @@ -46,7 +47,7 @@ def _parse_payload(payload: bytes | str | dict, *, default_key: str = "default") # Bare numeric payload return Observation( key=default_key, - ts=datetime.now(timezone.utc), + ts=datetime.now(UTC), value=float(text), raw={"value": float(text)}, ) @@ -54,7 +55,7 @@ def _parse_payload(payload: bytes | str | dict, *, default_key: str = "default") if not isinstance(data, dict): return Observation( key=default_key, - ts=datetime.now(timezone.utc), + ts=datetime.now(UTC), value=float(data), raw={"value": float(data)}, ) @@ -62,15 +63,15 @@ def _parse_payload(payload: bytes | str | dict, *, default_key: str = "default") 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(timezone.utc) + ts = datetime.now(UTC) elif isinstance(raw_ts, datetime): - ts = raw_ts if raw_ts.tzinfo else raw_ts.replace(tzinfo=timezone.utc) + 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=timezone.utc) + ts = datetime.fromtimestamp(epoch, tz=UTC) else: ts = datetime.fromisoformat(str(raw_ts).replace("Z", "+00:00")) @@ -88,7 +89,7 @@ class _AsyncSourceBase: async def __aiter__(self) -> AsyncIterator[dict[str, Any]]: raise TypeError(f"{type(self).__name__} must implement async __aiter__") - def iter_sync(self, *, timeout: Optional[float] = None) -> Iterator[dict[str, Any]]: + 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() @@ -213,9 +214,9 @@ def __init__( topic: str, *, port: int = 1883, - username: Optional[str] = None, - password: Optional[str] = None, - default_key: Optional[str] = None, + username: str | None = None, + password: str | None = None, + default_key: str | None = None, ): self._backend: str try: @@ -241,13 +242,13 @@ def __iter__(self) -> Iterator[float]: for msg in self.iter_sync(): yield float(msg["value"]) - def iter_sync(self, *, timeout: Optional[float] = None) -> Iterator[dict[str, Any]]: + 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: Optional[float] = None) -> Iterator[dict[str, Any]]: + def _iter_paho(self, *, timeout: float | None = None) -> Iterator[dict[str, Any]]: import time as _time import paho.mqtt.client as mqtt diff --git a/apps/api/main.py b/apps/api/main.py index dbca54d..a3c4642 100644 --- a/apps/api/main.py +++ b/apps/api/main.py @@ -15,9 +15,9 @@ import json import os import time -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from pathlib import Path -from typing import Any, Optional +from typing import Any from fastapi import ( Depends, @@ -36,7 +36,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse, PlainTextResponse, StreamingResponse from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, OAuth2PasswordRequestForm -from pydantic import BaseModel, Field +from pydantic import BaseModel from adapters.factory import get_repository from adapters.io_files import FileReadError, load_columns, resolve_under, save_upload_stream @@ -162,7 +162,7 @@ def _create_access_token(subject: str) -> str: 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(timezone.utc) + timedelta(minutes=cfg.jwt_expire_minutes) + expire = datetime.now(UTC) + timedelta(minutes=cfg.jwt_expire_minutes) payload = {"sub": subject, "exp": expire} return jwt.encode(payload, cfg.jwt_secret, algorithm=cfg.jwt_algorithm) @@ -183,7 +183,7 @@ def _decode_token(token: str) -> dict[str, Any]: def get_current_user( - credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer), + credentials: HTTPAuthorizationCredentials | None = Depends(_bearer), ) -> dict[str, Any]: """Validate Bearer JWT. When auth is disabled, return anonymous user.""" if not cfg.auth_enabled: @@ -197,7 +197,7 @@ def get_current_user( return _decode_token(credentials.credentials) -def require_api_key(x_api_key: Optional[str] = Header(None, alias="X-API-Key")) -> str: +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``. @@ -234,14 +234,14 @@ class AnalyzeResponse(BaseModel): run_id: str analysis_type: str report: dict[str, Any] - html_report: Optional[str] = None - checklist: Optional[dict[str, Any]] = None + html_report: str | None = None + checklist: dict[str, Any] | None = None class HealthResponse(BaseModel): status: str version: str = "2.0.0" - checks: Optional[dict[str, str]] = None + checks: dict[str, str] | None = None class TokenResponse(BaseModel): @@ -251,15 +251,15 @@ class TokenResponse(BaseModel): class StreamRegisterRequest(BaseModel): stream_key: str - topic: Optional[str] = None - chart_type: Optional[str] = None + topic: str | None = None + chart_type: str | None = None ruleset: str = "nelson" - meta: Optional[dict[str, Any]] = None + meta: dict[str, Any] | None = None class GoLiveRequest(BaseModel): limits_version: str - ruleset: Optional[str] = None + ruleset: str | None = None # ---- helpers ------------------------------------------------------------------ @@ -391,13 +391,13 @@ async def login_for_access_token( @app.post("/analyze/control-chart", response_model=AnalyzeResponse) async def analyze_cc( file: UploadFile = File(...), - value_col: Optional[str] = Form(None), - subgroup_col: Optional[str] = Form(None), - sample_size_col: Optional[str] = Form(None), - opportunity_col: Optional[str] = Form(None), - chart_type: Optional[str] = Form(None), - ruleset: Optional[str] = Form(None), - user_id: Optional[str] = Form(None), + 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), + user_id: str | None = Form(None), include_records: bool = Form(False), _user: dict = Depends(get_current_user), ): @@ -486,10 +486,10 @@ async def analyze_cap( file: UploadFile = File(...), usl: float = Form(...), lsl: float = Form(...), - target: Optional[float] = Form(None), - value_col: Optional[str] = Form(None), - subgroup_col: Optional[str] = Form(None), - user_id: Optional[str] = Form(None), + target: float | None = Form(None), + value_col: str | None = Form(None), + subgroup_col: str | None = Form(None), + user_id: str | None = Form(None), _user: dict = Depends(get_current_user), ): if usl <= lsl: @@ -531,15 +531,15 @@ async def analyze_cap( @app.post("/analyze/msa", response_model=AnalyzeResponse) async def analyze_msa( file: UploadFile = File(...), - study_type: Optional[str] = Form(None), + study_type: str | None = Form(None), method: str = Form("anova"), - tolerance: Optional[float] = Form(None), - part_col: Optional[str] = Form(None), - operator_col: Optional[str] = Form(None), - measurement_col: Optional[str] = Form(None), - trial_col: Optional[str] = Form(None), - reference_col: Optional[str] = Form(None), - user_id: Optional[str] = Form(None), + 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_id: str | None = Form(None), _user: dict = Depends(get_current_user), ): path = _save_file(file) @@ -608,7 +608,7 @@ async def analyze_msa( @app.get("/runs") async def list_runs( - analysis_type: Optional[str] = None, + analysis_type: str | None = None, limit: int = Query(50, le=500), _user: dict = Depends(get_current_user), ): @@ -745,7 +745,7 @@ async def ack_alert( @app.websocket("/ws/live/{stream_key}") -async def ws_live(websocket: WebSocket, stream_key: str, token: Optional[str] = Query(None)): +async def ws_live(websocket: WebSocket, stream_key: str, token: str | None = Query(None)): """Subscribe to Redis channel ``spc:live:{stream_key}`` and forward messages. Requires a valid JWT ``token`` query parameter when auth is enabled. @@ -790,7 +790,7 @@ async def ws_live(websocket: WebSocket, stream_key: str, token: Optional[str] = else: try: await asyncio.wait_for(websocket.receive_text(), timeout=0.01) - except asyncio.TimeoutError: + except TimeoutError: pass except WebSocketDisconnect: break diff --git a/apps/cli/main.py b/apps/cli/main.py index f1b1c76..12928b5 100644 --- a/apps/cli/main.py +++ b/apps/cli/main.py @@ -4,7 +4,6 @@ import argparse import json import sys -from pathlib import Path from adapters.io_files import FileReadError, load_columns from adapters.persistence import SQLiteRepository @@ -17,10 +16,8 @@ from apps.config import get_config from spc_core import ( ChartType, - analyze_control_chart, bias_study, capability_analysis, - check_autocorrelation, check_normality, gage_rr_anova, gage_rr_range, @@ -84,6 +81,7 @@ def main(argv: list[str] | None = None) -> int: if args.cmd == "serve": import uvicorn + from apps.api.main import app uvicorn.run( app, diff --git a/apps/config.py b/apps/config.py index 2172866..6a6ef69 100644 --- a/apps/config.py +++ b/apps/config.py @@ -3,7 +3,7 @@ import os from pathlib import Path -from typing import Any, Optional +from typing import Any try: import yaml @@ -12,7 +12,6 @@ from dotenv import load_dotenv - DEFAULTS: dict[str, Any] = { "api": { "host": "0.0.0.0", @@ -59,7 +58,7 @@ class Config: - def __init__(self, config_path: Optional[str | Path] = None): + def __init__(self, config_path: str | Path | None = None): root = Path(__file__).resolve().parents[1] # repo root (apps/ -> ASPC/) load_dotenv(root / ".env") @@ -171,7 +170,7 @@ def sqlite_path(self) -> str: return self.config["persistence"]["sqlite_path"] @property - def timescale_dsn(self) -> Optional[str]: + def timescale_dsn(self) -> str | None: return self.config["persistence"].get("timescale_dsn") @property @@ -232,7 +231,7 @@ def min_phase1_points(self) -> int: return int(self.config["spc"].get("min_phase1_points") or 25) -_config: Optional[Config] = None +_config: Config | None = None def get_config() -> Config: diff --git a/frontend/src/app/live/page.tsx b/frontend/src/app/live/page.tsx index bb21694..3c6e7dc 100644 --- a/frontend/src/app/live/page.tsx +++ b/frontend/src/app/live/page.tsx @@ -28,6 +28,7 @@ export default function LivePage() { 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); @@ -47,9 +48,10 @@ export default function LivePage() { if (msg.type === "point" || msg.value !== undefined) { const v = msg.value; if (typeof v === "number") { - setValues((prev) => { - const next = [...prev, v].slice(-MAX_POINTS); - return next; + 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); @@ -59,9 +61,16 @@ export default function LivePage() { const sigs = msg.signals || (msg.signal ? [msg.signal] : []); if (sigs.length || msg.type === "alert") { - setOoc((prev) => { - const idx = msg.index ?? values.length; - return [...prev, idx].slice(-MAX_POINTS); + // Map server stream index → chart array index within the sliding window + 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) => ({ ...s, ts: msg.ts })); @@ -82,7 +91,7 @@ export default function LivePage() { if (msg.type === "error") { setError(msg.message || "WebSocket error"); } - }, [values.length]); + }, []); function disconnect() { sockRef.current?.close(); @@ -95,9 +104,11 @@ export default function LivePage() { 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"), diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index fdf2d25..653f4af 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -22,8 +22,12 @@ export function Layout({ children }: { children: ReactNode }) { const isLogin = pathname === "/login"; useEffect(() => { - setAuthed(!!getToken()); - }, [pathname]); + const token = getToken(); + setAuthed(!!token); + if (!token && !isLogin) { + router.replace("/login"); + } + }, [pathname, isLogin, router]); if (isLogin) { return <>{children}; diff --git a/frontend/src/components/ui.tsx b/frontend/src/components/ui.tsx index 833f208..c09e519 100644 --- a/frontend/src/components/ui.tsx +++ b/frontend/src/components/ui.tsx @@ -96,7 +96,7 @@ export function Spinner({ label = "Loading…" }: { label?: string }) { export function FileField({ id, label, - accept = ".csv,.parquet,.xlsx", + accept = ".csv,.parquet,.pq", onChange, required, }: { diff --git a/pyproject.toml b/pyproject.toml index 27a5cac..f12c2ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,13 +99,15 @@ packages = [ "services", "services.stream_engine", "services.mqtt_bridge", - "sample_data", ] [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.*", @@ -117,12 +119,19 @@ target-version = "py311" [tool.ruff.lint] select = ["E", "F", "I", "UP", "B"] -ignore = ["E501"] +ignore = ["E501", "UP042", "B905"] + +[tool.ruff.lint.per-file-ignores] +"apps/api/main.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.11" +python_version = "3.12" ignore_missing_imports = true warn_return_any = false warn_unused_ignores = true check_untyped_defs = true -packages = ["spc_core", "adapters", "apps"] +packages = ["spc_core"] diff --git a/requirements.txt b/requirements.txt index 1870543..9a71155 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,19 +1,24 @@ -# ASPC v2 dependencies -# Prefer: uv pip install -e ".[dev]" -# This file is kept for backwards-compatible installs. - +# 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 -python-dotenv>=1.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 (also available via extras in pyproject.toml) -# polars>=0.20 -# plotly>=5.18 -# fastapi>=0.110 -# uvicorn[standard]>=0.27 -# python-multipart>=0.0.9 -# pytest>=7.4 -# httpx>=0.27 -# requests>=2.31 +# 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/sample_data/__init__.py b/sample_data/__init__.py index 8c4001f..1b3fa39 100644 --- a/sample_data/__init__.py +++ b/sample_data/__init__.py @@ -5,8 +5,9 @@ """ from __future__ import annotations +from collections.abc import Mapping from pathlib import Path -from typing import Any, Mapping +from typing import Any import numpy as np diff --git a/scripts/manufacturing_sim.py b/scripts/manufacturing_sim.py index eed1079..a50ac26 100755 --- a/scripts/manufacturing_sim.py +++ b/scripts/manufacturing_sim.py @@ -20,14 +20,14 @@ import json import sys import time -from datetime import datetime, timezone -from typing import Iterator +from collections.abc import Iterator +from datetime import UTC, datetime import numpy as np def _now_iso() -> str: - return datetime.now(timezone.utc).isoformat() + return datetime.now(UTC).isoformat() def generate_phases( diff --git a/scripts/run_live_protocol.py b/scripts/run_live_protocol.py index 29097cc..a147830 100755 --- a/scripts/run_live_protocol.py +++ b/scripts/run_live_protocol.py @@ -12,7 +12,7 @@ import sys import threading import time -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path ROOT = Path(__file__).resolve().parents[1] @@ -71,8 +71,8 @@ def get_token() -> str: def multipart_analyze(token: str, path: Path) -> dict: """POST /analyze/control-chart with file upload (stdlib multipart).""" - import uuid import urllib.request + import uuid boundary = f"----aspc{uuid.uuid4().hex}" file_bytes = path.read_bytes() @@ -315,7 +315,7 @@ def main() -> int: result = { "passed": passed, - "finished_at": datetime.now(timezone.utc).isoformat(), + "finished_at": datetime.now(UTC).isoformat(), "limits_version": limits_version, "run_id": analyze.get("run_id"), "redis_messages": len(collector.messages), diff --git a/services/mqtt_bridge/main.py b/services/mqtt_bridge/main.py index 4540602..03fc6e6 100644 --- a/services/mqtt_bridge/main.py +++ b/services/mqtt_bridge/main.py @@ -7,8 +7,8 @@ import os import signal import sys -from datetime import datetime -from typing import Any, Optional +from datetime import UTC, datetime +from typing import Any logger = logging.getLogger("aspc.mqtt_bridge") @@ -37,6 +37,7 @@ def close() -> None: try: import asyncio + from aiokafka import AIOKafkaProducer except ImportError as exc: raise ImportError( @@ -70,13 +71,12 @@ def _normalise(msg: dict[str, Any]) -> dict[str, Any]: if isinstance(ts, datetime): ts = ts.isoformat() elif ts is None: - from datetime import timezone - ts = datetime.now(timezone.utc).isoformat() + ts = datetime.now(UTC).isoformat() return {"key": key, "value": float(value), "timestamp": ts} -def main(argv: Optional[list[str]] = None) -> int: +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", diff --git a/services/stream_engine/main.py b/services/stream_engine/main.py index b77c91c..634c401 100644 --- a/services/stream_engine/main.py +++ b/services/stream_engine/main.py @@ -5,7 +5,6 @@ import logging import signal import sys -from typing import Optional from adapters.factory import get_repository, require_streaming_repository from adapters.stream_engine import StreamEngine @@ -15,7 +14,7 @@ logger = logging.getLogger("aspc.stream_engine") -def main(argv: Optional[list[str]] = None) -> int: +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", diff --git a/spc_core/capability.py b/spc_core/capability.py index c337f53..92387cd 100644 --- a/spc_core/capability.py +++ b/spc_core/capability.py @@ -10,7 +10,6 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Optional import numpy as np from scipy import stats @@ -42,7 +41,7 @@ def sigma_to_dpmo(z_bench: float) -> float: return float((1.0 - stats.norm.cdf(z_bench)) * 1_000_000.0) -def _sigma_within(values: np.ndarray, subgroups: Optional[list[np.ndarray]]) -> float: +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: @@ -64,19 +63,19 @@ class CapabilityResult: method: str # "parametric" | "nonparametric" | "transformed" sigma_within: float sigma_overall: float - cp: Optional[float] = None - cpk: Optional[float] = None - cpu: Optional[float] = None - cpl: Optional[float] = None - cpm: Optional[float] = None - pp: Optional[float] = None - ppk: Optional[float] = None - ppu: Optional[float] = None - ppl: Optional[float] = None + 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: Optional[float] = None - z_bench: Optional[float] = None - sigma_level: Optional[float] = None + 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 @@ -84,7 +83,7 @@ class CapabilityResult: notes: dict = field(default_factory=dict) -def _rate(cpk: Optional[float]) -> str: +def _rate(cpk: float | None) -> str: if cpk is None: return "Unknown" if cpk >= 1.67: @@ -112,7 +111,7 @@ 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 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) @@ -203,7 +202,7 @@ def _finish(arr, usl, lsl, target, mean, sig_w, sig_o, method, def capability_analysis(values, usl, lsl, target=None, subgroups=None, - force_method: Optional[str] = None) -> CapabilityResult: + 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. @@ -238,7 +237,7 @@ def capability_analysis(values, usl, lsl, target=None, subgroups=None, "path": "transformed_parametric", } return res - except Exception as exc: + except Exception: # Fall through to nonparametric if transform of specs fails. pass @@ -254,7 +253,6 @@ def capability_analysis(values, usl, lsl, target=None, subgroups=None, def _transform_specs(usl, lsl, target, tr): """Apply the same transform used on data to the specification limits.""" - from .normality import apply_transform if tr.applied == "LOG": if min(usl, lsl, target if target is not None else usl) <= 0: @@ -264,7 +262,6 @@ def _transform_specs(usl, lsl, target, tr): return np.log(usl), np.log(lsl), np.log(target) if target is not None else None if tr.applied == "BOXCOX": - from scipy.stats import boxcox lam = tr.lam def _bc(x): @@ -274,7 +271,6 @@ def _bc(x): return float(_bc(usl)), float(_bc(lsl)), float(_bc(target)) if target is not None else None if tr.applied == "YEO-JOHNSON": - from scipy.stats import yeojohnson # yeojohnson on a scalar needs the fitted lambda. def _yj(x, lam): diff --git a/spc_core/charts.py b/spc_core/charts.py index fca8d86..3db159b 100644 --- a/spc_core/charts.py +++ b/spc_core/charts.py @@ -16,7 +16,6 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Optional import numpy as np @@ -30,6 +29,7 @@ ControlLimits, DataType, DistributionFlag, + LimitSet, Phase, QualityFlag, Signal, @@ -79,13 +79,13 @@ class ControlChartResult: subgroup_size: int limits: ControlLimits plotted_values: list[float] - secondary_values: Optional[list[float]] = None - secondary_name: Optional[str] = None + 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: Optional[str] = None + transform_applied: str | None = None phase: Phase = Phase.PHASE_I @property @@ -99,8 +99,8 @@ def to_records( timestamps=None, subgroup_ids=None, quality_flags=None, - gage_id: Optional[str] = None, - machine_id: Optional[str] = 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 @@ -140,15 +140,14 @@ def to_records( return records -def _secondary_signals(secondary_values, secondary_limits: Optional, ruleset: str) -> list[Signal]: +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 - lcl = secondary_limits.lcl if not isinstance(secondary_limits.lcl, list) else None if ucl is None: return [] - sigma = (float(ucl) - center) / 3.0 if ucl is not None else 0.0 + 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) @@ -159,7 +158,7 @@ def analyze_control_chart( subgroup_ids=None, sample_sizes=None, opportunities=None, - chart_type: Optional[ChartType] = None, + chart_type: ChartType | None = None, ruleset: str = "nelson", ewma_lambda: float = 0.2, ewma_L: float = 3.0, @@ -246,18 +245,18 @@ def analyze_control_chart( dtype = DataType.CONTINUOUS elif chart_type == ChartType.P: - n = np.asarray(sample_sizes, dtype=float) - if np.any(n <= 0): + 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) - plotted = (arr / n).tolist() + limits = L.p_limits(arr, n_arr) + plotted = (arr / n_arr).tolist() dtype = DataType.ATTRIBUTE elif chart_type == ChartType.NP: - n = float(np.asarray(sample_sizes)[0]) if sample_sizes is not None else float(arr.size) - limits = L.np_limits(arr, n) + 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) + subgroup_size = int(n_np) dtype = DataType.ATTRIBUTE elif chart_type == ChartType.C: @@ -276,6 +275,9 @@ def analyze_control_chart( 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 diff --git a/spc_core/cleaning.py b/spc_core/cleaning.py index 6709a5a..4363c2f 100644 --- a/spc_core/cleaning.py +++ b/spc_core/cleaning.py @@ -9,7 +9,6 @@ import math from dataclasses import dataclass, field -from typing import Optional from .models import QualityFlag @@ -26,7 +25,7 @@ @dataclass class CleaningResult: - values: list[Optional[float]] # cleaned values (LOCF may fill a few points) + 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) @@ -51,7 +50,7 @@ def range_check(values, low: float, high: float) -> list[bool]: return out -def classify_missing(values, reasons: Optional[list[Optional[str]]] = None, +def classify_missing(values, reasons: list[str | None] | None = None, locf_max: int = 3) -> CleaningResult: """Classify and handle missing values without silent imputation. @@ -66,7 +65,7 @@ def classify_missing(values, reasons: Optional[list[Optional[str]]] = None, values = list(values) reasons = reasons or [None] * len(values) flags: list[QualityFlag] = [] - cleaned: list[Optional[float]] = [] + cleaned: list[float | None] = [] usable: list[bool] = [] counts: dict[str, int] = {} @@ -84,7 +83,7 @@ def classify_missing(values, reasons: Optional[list[Optional[str]]] = None, else: i += 1 - last_valid: Optional[float] = None + last_valid: float | None = None for idx, v in enumerate(values): reason = (reasons[idx] or "").lower() if reasons[idx] else None @@ -106,6 +105,7 @@ def classify_missing(values, reasons: Optional[list[Optional[str]]] = None, 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) diff --git a/spc_core/cusum.py b/spc_core/cusum.py index c6a441f..9f25919 100644 --- a/spc_core/cusum.py +++ b/spc_core/cusum.py @@ -12,7 +12,6 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Optional import numpy as np @@ -30,7 +29,7 @@ class CUSUMResult: c_minus: list[float] decision_interval: float signals: list[Signal] = field(default_factory=list) - limits: Optional[ControlLimits] = None + limits: ControlLimits | None = None def _estimate_sigma_mr(values: np.ndarray) -> float: @@ -44,8 +43,8 @@ def cusum_chart( values, k: float = 0.5, h: float = 5.0, - target: Optional[float] = None, - sigma: Optional[float] = None, + target: float | None = None, + sigma: float | None = None, ) -> CUSUMResult: """Compute tabular two-sided CUSUM with decision interval h·σ.""" if k <= 0: diff --git a/spc_core/evaluator.py b/spc_core/evaluator.py index 43386eb..4fd9165 100644 --- a/spc_core/evaluator.py +++ b/spc_core/evaluator.py @@ -13,8 +13,6 @@ """ from __future__ import annotations -from typing import Optional - from .models import ChartType, ControlLimits, Signal from .rules import RuleEngine @@ -65,7 +63,7 @@ def __init__(self, limits: ControlLimits, ruleset: str = "nelson"): self._c_minus = 0.0 if self._ewma or self._cusum or self._variable: - self._engine: Optional[RuleEngine] = None + self._engine: RuleEngine | None = None else: self._engine = RuleEngine(center, sigma, ruleset) @@ -82,7 +80,7 @@ def seed_state( self, *, index: int = -1, - values: Optional[list[float]] = None, + values: list[float] | None = None, ) -> None: """Restore evaluator continuity after restart. diff --git a/spc_core/ewma.py b/spc_core/ewma.py index ca5e5d7..329eece 100644 --- a/spc_core/ewma.py +++ b/spc_core/ewma.py @@ -12,7 +12,6 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Optional import numpy as np @@ -31,7 +30,7 @@ class EWMAResult: lcl: list[float] center: float signals: list[Signal] = field(default_factory=list) - limits: Optional[ControlLimits] = None + limits: ControlLimits | None = None def _estimate_sigma_mr(values: np.ndarray) -> float: @@ -45,8 +44,8 @@ def ewma_chart( values, lam: float = 0.2, L: float = 3.0, - target: Optional[float] = None, - sigma: Optional[float] = None, + 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): diff --git a/spc_core/ingest.py b/spc_core/ingest.py index 127e3c4..936fbcd 100644 --- a/spc_core/ingest.py +++ b/spc_core/ingest.py @@ -6,7 +6,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Optional +from typing import Any import numpy as np @@ -18,15 +18,15 @@ @dataclass class ColumnMap: - value_col: Optional[str] = None - subgroup_col: Optional[str] = None - sample_size_col: Optional[str] = None - opportunity_col: Optional[str] = None - part_col: Optional[str] = None - operator_col: Optional[str] = None - trial_col: Optional[str] = None - reference_col: Optional[str] = None - date_col: Optional[str] = None + 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) @@ -49,7 +49,7 @@ def _numeric_columns(columns: dict[str, list[Any]]) -> list[str]: return out -def _pick_measurement(numeric_cols: list[str]) -> Optional[str]: +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 @@ -62,7 +62,7 @@ def _pick_measurement(numeric_cols: list[str]) -> Optional[str]: return candidates[0] -def _find_by_names(columns: dict[str, list[Any]], names: tuple[str, ...]) -> Optional[str]: +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: @@ -75,15 +75,15 @@ def _find_by_names(columns: dict[str, list[Any]], names: tuple[str, ...]) -> Opt def detect_columns(columns: dict[str, list[Any]], - value_col: Optional[str] = None, - subgroup_col: Optional[str] = None, - sample_size_col: Optional[str] = None, - opportunity_col: Optional[str] = None, - part_col: Optional[str] = None, - operator_col: Optional[str] = None, - trial_col: Optional[str] = None, - reference_col: Optional[str] = None, - date_col: Optional[str] = None) -> 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) -> ColumnMap: """Auto-detect standard SPC/MSA column roles from a column dict.""" cmap = ColumnMap( value_col=value_col, diff --git a/spc_core/models.py b/spc_core/models.py index 1c6db0f..b2ebf11 100644 --- a/spc_core/models.py +++ b/spc_core/models.py @@ -9,9 +9,8 @@ import hashlib import json -from datetime import datetime, timezone +from datetime import UTC, datetime from enum import Enum -from typing import Optional, Union from pydantic import BaseModel, ConfigDict, Field, computed_field @@ -70,8 +69,8 @@ class LimitSet(BaseModel): model_config = ConfigDict(frozen=True) center: float - ucl: Union[float, list[float]] - lcl: Union[float, list[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 @@ -94,9 +93,9 @@ class ControlLimits(BaseModel): # 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: Optional[float] = None - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - source_n_points: Optional[int] = None + 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 @@ -132,7 +131,7 @@ class Signal(BaseModel): index: int value: float description: str - side: Optional[str] = None # "upper" | "lower" | None + 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}" @@ -146,16 +145,16 @@ class SPCRecord(BaseModel): against. """ - timestamp: Optional[datetime] = None - subgroup_id: Optional[int] = None + 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: Optional[str] = None + transform_applied: str | None = None phase: Phase = Phase.PHASE_I - ucl: Optional[float] = None - lcl: Optional[float] = None - centerline: Optional[float] = None - gage_id: Optional[str] = None - machine_id: Optional[str] = None + 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 index 0fdfe2b..57f6da9 100644 --- a/spc_core/msa.py +++ b/spc_core/msa.py @@ -7,7 +7,6 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Optional import numpy as np from scipy import stats @@ -95,11 +94,11 @@ class GageRRResult: part_percent: float ndc: int acceptability: str - grr_percent_tolerance: Optional[float] = None + grr_percent_tolerance: float | None = None detail: dict = field(default_factory=dict) -def gage_rr_anova(parts, operators, measurements, tolerance: Optional[float] = None) -> GageRRResult: +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) @@ -120,7 +119,6 @@ def gage_rr_anova(parts, operators, measurements, tolerance: Optional[float] = N } result.method = "Range (unbalanced fallback)" return result - n = len(y) if n_trials < 1: raise ValueError("Gage R&R requires at least one trial per part-operator cell") @@ -132,7 +130,7 @@ def gage_rr_anova(parts, operators, measurements, tolerance: Optional[float] = N 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 = {} + 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 @@ -184,7 +182,7 @@ def gage_rr_anova(parts, operators, measurements, tolerance: Optional[float] = N ) -def gage_rr_range(parts, operators, measurements, tolerance: Optional[float] = None) -> GageRRResult: +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) diff --git a/spc_core/msa_stream.py b/spc_core/msa_stream.py index 5ec6a86..7fc5549 100644 --- a/spc_core/msa_stream.py +++ b/spc_core/msa_stream.py @@ -8,7 +8,6 @@ from collections import deque from dataclasses import dataclass, field -from typing import Optional import numpy as np @@ -33,7 +32,7 @@ class ContinuousMSAState: 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: Optional[float] = None + _last_ref: float | None = None _n: int = 0 @property @@ -60,7 +59,7 @@ def __init__( alpha=alpha, tolerance=tolerance, alert_fraction=alert_fraction, ) - def observe_reference(self, measured: float, reference: float) -> Optional[CalibrationAlert]: + 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 diff --git a/spc_core/multimodal.py b/spc_core/multimodal.py index 2e20885..4a63a55 100644 --- a/spc_core/multimodal.py +++ b/spc_core/multimodal.py @@ -64,7 +64,7 @@ def check_multimodal(values, alpha: float = 0.05) -> MultimodalResult: ) try: - from diptest import diptest as _diptest # type: ignore + from diptest import diptest as _diptest dip, p, _ = _diptest(arr, is_data_sorted=False) dip, p = float(dip), float(p) diff --git a/spc_core/normality.py b/spc_core/normality.py index 9876210..04904b7 100644 --- a/spc_core/normality.py +++ b/spc_core/normality.py @@ -6,7 +6,6 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Optional import numpy as np from scipy import stats @@ -19,14 +18,14 @@ class NormalityResult: tests_passed: int total_tests: int confidence: str - shapiro_stat: Optional[float] = None - shapiro_p: Optional[float] = None - anderson_stat: Optional[float] = None - anderson_critical: Optional[float] = None - ks_stat: Optional[float] = None - ks_p: Optional[float] = None - skewness: Optional[float] = None - kurtosis: Optional[float] = None + 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) @@ -194,8 +193,8 @@ class TransformResult: applied: str # "NONE" | "LOG" | "BOXCOX" | "YEO-JOHNSON" label: str # human-readable, includes lambda where relevant values: np.ndarray - lam: Optional[float] = None - became_normal: Optional[bool] = None + lam: float | None = None + became_normal: bool | None = None def apply_transform(values, method: str = "auto", alpha: float = 0.05) -> TransformResult: diff --git a/spc_core/pipeline.py b/spc_core/pipeline.py index 7423e9a..3491919 100644 --- a/spc_core/pipeline.py +++ b/spc_core/pipeline.py @@ -8,14 +8,14 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Optional +from typing import Any import numpy as np from .charts import ControlChartResult, analyze_control_chart from .cleaning import classify_missing from .models import ChartType, DistributionFlag, Phase, QualityFlag -from .msa import GageRRResult, gage_rr_anova, gage_resolution_gate, ndc_gate +from .msa import GageRRResult, gage_resolution_gate, gage_rr_anova, ndc_gate from .multimodal import MultimodalResult, check_multimodal from .normality import ( AutocorrelationResult, @@ -39,11 +39,11 @@ class Gate: class PipelineResult: chart: ControlChartResult gates: list[Gate] - normality: Optional[NormalityResult] = None - autocorrelation: Optional[AutocorrelationResult] = None - multimodal: Optional[MultimodalResult] = None - transform: Optional[TransformResult] = None - msa: Optional[GageRRResult] = None + 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" @@ -52,7 +52,7 @@ class PipelineResult: def limits_version(self) -> str: return self.chart.limits.version - def gate_status(self, step: str) -> Optional[str]: + def gate_status(self, step: str) -> str | None: for g in self.gates: if g.step == step: return g.status @@ -65,15 +65,15 @@ def establish( subgroup_ids=None, sample_sizes=None, opportunities=None, - chart_type: Optional[ChartType] = 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: Optional[float] = None, - gage_resolution: Optional[float] = None, + msa_tolerance: float | None = None, + gage_resolution: float | None = None, # Missing-value reasons aligned with values (optional). missing_reasons=None, # Prefer EWMA over CUSUM when autocorrelated. @@ -86,11 +86,11 @@ def establish( working = arr.copy() dist_flag = DistributionFlag.NORMAL transform_applied = None - transform: Optional[TransformResult] = None - normality: Optional[NormalityResult] = None - acf: Optional[AutocorrelationResult] = None - multimodal: Optional[MultimodalResult] = None - msa_result: Optional[GageRRResult] = 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 diff --git a/spc_core/report.py b/spc_core/report.py index 26017bc..09e0b92 100644 --- a/spc_core/report.py +++ b/spc_core/report.py @@ -6,8 +6,8 @@ """ from __future__ import annotations -from datetime import datetime, timezone -from typing import Any, Optional +from datetime import UTC, datetime +from typing import Any from pydantic import BaseModel, Field @@ -26,31 +26,31 @@ class SPCReport(BaseModel): phase: Phase = Phase.PHASE_I limits: ControlLimits plotted_values: list[float] - secondary_values: Optional[list[float]] = None - secondary_name: Optional[str] = None + 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: Optional[dict[str, Any]] = None - autocorrelation: Optional[dict[str, Any]] = None - gates: Optional[list[dict[str, Any]]] = None - checklist: Optional[dict[str, Any]] = None - records: Optional[list[dict[str, Any]]] = None - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - source_file: Optional[str] = None + 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: Optional[NormalityResult] = None, - autocorrelation: Optional[AutocorrelationResult] = None, - source_file: Optional[str] = None, + normality: NormalityResult | None = None, + autocorrelation: AutocorrelationResult | None = None, + source_file: str | None = None, phase: Phase = Phase.PHASE_I, - gates: Optional[list] = None, - checklist: Optional[dict] = None, + gates: list | None = None, + checklist: dict | None = None, include_records: bool = False, - ) -> "SPCReport": + ) -> SPCReport: records = None if include_records: records = [r.model_dump(mode="json") for r in result.to_records()] @@ -86,17 +86,17 @@ def from_chart_result( class CapabilityReport(BaseModel): analysis_type: str = "capability" result: dict[str, Any] - normality: Optional[dict[str, Any]] = None - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - source_file: Optional[str] = None + 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: Optional[NormalityResult] = None, - source_file: Optional[str] = None, - ) -> "CapabilityReport": + normality: NormalityResult | None = None, + source_file: str | None = None, + ) -> CapabilityReport: return cls( result=_capability_dict(result), normality=_normality_dict(normality), @@ -108,11 +108,11 @@ class MSAReport(BaseModel): analysis_type: str = "msa" study_type: str result: dict[str, Any] - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - source_file: Optional[str] = None + 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: Optional[str] = None) -> "MSAReport": + def from_gage_rr(cls, result: GageRRResult, source_file: str | None = None) -> MSAReport: return cls( study_type="Gage R&R", result={ @@ -136,7 +136,7 @@ def from_gage_rr(cls, result: GageRRResult, source_file: Optional[str] = None) - ) @classmethod - def from_bias(cls, result: BiasResult, source_file: Optional[str] = None) -> "MSAReport": + def from_bias(cls, result: BiasResult, source_file: str | None = None) -> MSAReport: return cls( study_type="Bias", result={ @@ -152,7 +152,7 @@ def from_bias(cls, result: BiasResult, source_file: Optional[str] = None) -> "MS ) @classmethod - def from_linearity(cls, result: LinearityResult, source_file: Optional[str] = None) -> "MSAReport": + def from_linearity(cls, result: LinearityResult, source_file: str | None = None) -> MSAReport: return cls( study_type="Linearity", result={ @@ -167,7 +167,7 @@ def from_linearity(cls, result: LinearityResult, source_file: Optional[str] = No ) @classmethod - def from_stability(cls, result: StabilityResult, source_file: Optional[str] = None) -> "MSAReport": + def from_stability(cls, result: StabilityResult, source_file: str | None = None) -> MSAReport: return cls( study_type="Stability", result={ @@ -184,7 +184,7 @@ def from_stability(cls, result: StabilityResult, source_file: Optional[str] = No ) -def _normality_dict(n: Optional[NormalityResult]) -> Optional[dict[str, Any]]: +def _normality_dict(n: NormalityResult | None) -> dict[str, Any] | None: if n is None: return None return { @@ -200,7 +200,7 @@ def _normality_dict(n: Optional[NormalityResult]) -> Optional[dict[str, Any]]: } -def _acf_dict(a: Optional[AutocorrelationResult]) -> Optional[dict[str, Any]]: +def _acf_dict(a: AutocorrelationResult | None) -> dict[str, Any] | None: if a is None: return None return { diff --git a/spc_core/rules.py b/spc_core/rules.py index f9d9d19..6daa0e1 100644 --- a/spc_core/rules.py +++ b/spc_core/rules.py @@ -12,7 +12,6 @@ from collections import deque from dataclasses import dataclass -from typing import Optional from .models import Signal @@ -200,7 +199,7 @@ def _run_beyond(b: list[_Pt], length: int, zone_ge: int) -> bool: return False return all(p.zone >= zone_ge for p in b[-length:]) - def _sig(self, rule_id: str, pt: _Pt, side: Optional[str] = None, catalog=None) -> Signal: + 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, diff --git a/tests/integration/test_realtime_gated.py b/tests/integration/test_realtime_gated.py index b231dc9..36cf309 100644 --- a/tests/integration/test_realtime_gated.py +++ b/tests/integration/test_realtime_gated.py @@ -5,11 +5,13 @@ import pytest - -pytestmark = pytest.mark.skipif( - os.environ.get("ASPC_INTEGRATION") != "1", - reason="Set ASPC_INTEGRATION=1 with compose stack up to run", -) +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(): diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index 82fc9c4..2ddd0d3 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -21,8 +21,8 @@ def client(tmp_path_factory): os.environ["ASPC_PERSISTENCE_BACKEND"] = "sqlite" os.environ["ASPC_SQLITE_PATH"] = str(db) # Re-import config/repo cleanly - import apps.config as config_mod import apps.api.main as api_main + import apps.config as config_mod config_mod._config = None api_main.cfg = config_mod.get_config() diff --git a/tests/unit/test_cleaning.py b/tests/unit/test_cleaning.py index 840e063..ec233ef 100644 --- a/tests/unit/test_cleaning.py +++ b/tests/unit/test_cleaning.py @@ -1,8 +1,6 @@ """SPC cleaning — missing-value classifier (never silent impute).""" from __future__ import annotations -import math - from spc_core.cleaning import classify_missing, range_check from spc_core.models import QualityFlag diff --git a/tests/unit/test_hardening_phase1.py b/tests/unit/test_hardening_phase1.py index 7b5aae2..646f8cf 100644 --- a/tests/unit/test_hardening_phase1.py +++ b/tests/unit/test_hardening_phase1.py @@ -7,7 +7,6 @@ from spc_core.charts import analyze_control_chart from spc_core.evaluator import Phase2Evaluator from spc_core.ewma import ewma_chart -from spc_core.limits import xbar_r_limits, build_subgroups from spc_core.models import ChartType, ControlLimits from spc_core.msa import gage_rr_anova from spc_core.pipeline import establish, phase1_checklist diff --git a/tests/unit/test_ingest.py b/tests/unit/test_ingest.py index ea8095b..996101b 100644 --- a/tests/unit/test_ingest.py +++ b/tests/unit/test_ingest.py @@ -1,9 +1,10 @@ """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 -import pytest def test_detect_measurement_prefers_named_column(): diff --git a/tests/unit/test_limits.py b/tests/unit/test_limits.py index c116aaf..715db87 100644 --- a/tests/unit/test_limits.py +++ b/tests/unit/test_limits.py @@ -5,7 +5,11 @@ 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, c_limits, p_limits, np_limits, u_limits +from spc_core.limits import ( + imr_limits, + xbar_r_limits, + xbar_s_limits, +) from spc_core.models import ChartType, DataType diff --git a/tests/unit/test_msa.py b/tests/unit/test_msa.py index a69a49a..bf2f815 100644 --- a/tests/unit/test_msa.py +++ b/tests/unit/test_msa.py @@ -29,9 +29,9 @@ def test_gage_rr_report_has_grr_percent(dataset): def test_gage_rr_poor(dataset): cols = dataset("msa_gage_rr_poor") result = gage_rr_anova(cols["Part"], cols["Operator"], cols["Measurement"]) - assert result.grr_percent > result.grr_percent - 1 # just ensure computed - # Poor fixture typically has high GRR - assert result.grr_percent >= 0 + 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): diff --git a/tests/unit/test_normality.py b/tests/unit/test_normality.py index 9e28ab2..3fbf34e 100644 --- a/tests/unit/test_normality.py +++ b/tests/unit/test_normality.py @@ -2,7 +2,6 @@ from __future__ import annotations import numpy as np -import pytest from spc_core.normality import apply_transform, check_autocorrelation, check_normality diff --git a/tests/unit/test_rules.py b/tests/unit/test_rules.py index 933c879..80de132 100644 --- a/tests/unit/test_rules.py +++ b/tests/unit/test_rules.py @@ -1,8 +1,6 @@ """Stateful Nelson / Western Electric run-rule engine.""" from __future__ import annotations -import pytest - from spc_core.rules import RuleEngine, evaluate_series diff --git a/tests/unit/test_security.py b/tests/unit/test_security.py index d278294..1cda035 100644 --- a/tests/unit/test_security.py +++ b/tests/unit/test_security.py @@ -1,9 +1,6 @@ """Security hardening tests — auth, traversal, API keys, go-live gates.""" from __future__ import annotations -import os -from pathlib import Path - import pytest pytest.importorskip("fastapi") @@ -126,7 +123,7 @@ def test_go_live_rejects_unfrozen_limits(secure_client, tmp_path): meta={"frozen": False, "stopped": True, "checklist_passed": False}, ) r = secure_client.post( - f"/streams/line-x/go-live", + "/streams/line-x/go-live", json={"limits_version": version}, headers={"Authorization": f"Bearer {token}", "X-API-Key": "test-key"}, ) diff --git a/tests/unit/test_stream_engine.py b/tests/unit/test_stream_engine.py index 243f556..0282c62 100644 --- a/tests/unit/test_stream_engine.py +++ b/tests/unit/test_stream_engine.py @@ -1,8 +1,8 @@ """Unit tests for StreamEngine OOC detection with a fake in-memory repository.""" from __future__ import annotations -from datetime import datetime, timezone -from typing import Any, Optional +from datetime import UTC, datetime +from typing import Any from adapters.stream_engine import StreamEngine from spc_core.limits import imr_limits @@ -27,13 +27,13 @@ def save_ooc_event( stream_key: str, ts: datetime, *, - limits_version: Optional[str], + limits_version: str | None, index: int, value: float, rule_id: str, rule_name: str, description: str, - side: Optional[str] = None, + side: str | None = None, ) -> bool: key = (stream_key, ts.isoformat(), rule_id) if any( @@ -62,7 +62,7 @@ def test_handle_observation_detects_ooc_and_persists(): engine = StreamEngine(repo) engine.register("line-a", limits, ruleset="nelson") - ts = datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc) + ts = datetime(2026, 1, 15, 12, 0, 0, tzinfo=UTC) # In-control point signals = engine.handle_observation("line-a", 10.0, ts) assert signals == [] @@ -71,7 +71,7 @@ def test_handle_observation_detects_ooc_and_persists(): assert repo.ooc == [] # Beyond UCL — must fire rule 1 - ooc_ts = datetime(2026, 1, 15, 12, 0, 1, tzinfo=timezone.utc) + 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) @@ -87,7 +87,7 @@ def test_ooc_writes_are_idempotent(): engine = StreamEngine(repo) engine.register("line-b", limits) - ts = datetime(2026, 1, 15, 13, 0, 0, tzinfo=timezone.utc) + 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) @@ -146,7 +146,7 @@ def recent_raw_measurements(self, stream_key: str, *, limit: int = 15): repo = RestoringRepo() engine = StreamEngine(repo) engine.register("line-d", limits) - ts0 = datetime(2026, 1, 15, 14, 0, 0, tzinfo=timezone.utc) + 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) @@ -164,9 +164,10 @@ def recent_raw_measurements(self, stream_key: str, *, limit: int = 15): def test_sqlite_repo_rejected_by_stream_engine(): - from adapters.persistence import SQLiteRepository import pytest + from adapters.persistence import SQLiteRepository + repo = SQLiteRepository(":memory:") with pytest.raises(TypeError, match="streaming"): StreamEngine(repo) From c9a1fdd52817ccb5fc63a74c8b4a362e150fc0eb Mon Sep 17 00:00:00 2001 From: M1ndsmith Date: Sun, 26 Jul 2026 17:19:52 +0100 Subject: [PATCH 07/23] refoctoring update --- README.md | 15 + docs/aspc_system_doc.html | 1197 +++++++++++++++++++++++++ docs/development.md | 13 +- docs/index.md | 6 +- docs/overview/benchmarking.md | 131 +++ docs/overview/capabilities.md | 121 +++ docs/overview/problem-and-solution.md | 126 +++ docs/overview/use-cases.md | 106 +++ spc_core/charts.py | 12 +- spc_core/multimodal.py | 45 +- spc_core/normality.py | 4 + spc_core/pipeline.py | 85 +- 12 files changed, 1847 insertions(+), 14 deletions(-) create mode 100644 docs/aspc_system_doc.html create mode 100644 docs/overview/benchmarking.md create mode 100644 docs/overview/capabilities.md create mode 100644 docs/overview/problem-and-solution.md create mode 100644 docs/overview/use-cases.md diff --git a/README.md b/README.md index 142455b..c6afc78 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,19 @@ Correct, tested SPC for **batch** and **real-time** work: Shewhart / EWMA / CUSU Phase I establishes and freezes versioned limits; Phase II evaluates new data against those limits. No stubs — every advertised `ChartType` has real math. +## Why ASPC? + +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: + +| 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 | + +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 - Gated Phase I pipeline (`establish`) with ok / warn / stop gates and a 10-item go-live checklist @@ -70,6 +83,8 @@ docker compose -f deploy/compose/docker-compose.yml up -d --build | Guide | Description | |-------|-------------| +| [docs/overview/problem-and-solution.md](docs/overview/problem-and-solution.md) | Why ASPC — problem, solution, architecture | +| [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` | 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/development.md b/docs/development.md index b56ed0a..5d332c0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -30,6 +30,8 @@ uv lock | `apps/` | FastAPI + CLI + config | | `services/` | stream-engine, mqtt-bridge | | `sample_data/` | Deterministic synthetic datasets | +| `resilience_data/` | Standards-mapped sad/happy path corpus | +| `benchmarks/` | Performance + accuracy harnesses | | `frontend/` | Next.js operator UI | | `tests/` | unit + integration | | `docs/` | This documentation | @@ -75,12 +77,21 @@ PY Configured in [`pyproject.toml`](../pyproject.toml): ```bash -ruff check spc_core adapters apps services sample_data tests +ruff check spc_core adapters apps services sample_data resilience_data scripts tests benchmarks mypy spc_core adapters apps # packages listed in tool.mypy ``` CI currently runs ruff non-blocking (`|| true`); treat failures as real before merge. +## 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. diff --git a/docs/index.md b/docs/index.md index c952b9b..04f32a4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -25,6 +25,10 @@ Go-live is gated by `phase1_checklist()` (10 items). Streams register and activa | Doc | Audience | Contents | |-----|----------|----------| +| [overview/problem-and-solution.md](overview/problem-and-solution.md) | Decision-makers & practitioners | Problem, solution, architecture diagrams | +| [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 | @@ -32,7 +36,7 @@ Go-live is gated by `phase1_checklist()` (10 items). Streams register and activa | [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` | +| [development.md](development.md) | Contributors | uv, tests, CI, `sample_data`, benchmarks | ## Quick links 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/problem-and-solution.md b/docs/overview/problem-and-solution.md new file mode 100644 index 0000000..54cc329 --- /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 *which* rule fired and *which* gate status was set, not just “some 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/spc_core/charts.py b/spc_core/charts.py index 3db159b..de79a64 100644 --- a/spc_core/charts.py +++ b/spc_core/charts.py @@ -87,6 +87,7 @@ class ControlChartResult: 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: @@ -230,7 +231,14 @@ def analyze_control_chart( elif chart_type in (ChartType.XBAR_R, ChartType.XBAR_S): subs = L.build_subgroups(arr, subgroup_ids) - subgroup_size = int(np.median([len(s) for s in subs])) + 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] @@ -311,5 +319,5 @@ def analyze_control_chart( 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, + secondary_signals=sec_signals, summary=summary, ruleset_applied=ruleset, ) diff --git a/spc_core/multimodal.py b/spc_core/multimodal.py index 4a63a55..424a8c4 100644 --- a/spc_core/multimodal.py +++ b/spc_core/multimodal.py @@ -66,7 +66,7 @@ def check_multimodal(values, alpha: float = 0.05) -> MultimodalResult: try: from diptest import diptest as _diptest - dip, p, _ = _diptest(arr, is_data_sorted=False) + dip, p = _diptest(arr) dip, p = float(dip), float(p) except ImportError: dip = _dip_statistic(arr) @@ -75,12 +75,43 @@ def check_multimodal(values, alpha: float = 0.05) -> MultimodalResult: p = min(max(p, 0.0), 1.0) hist, _ = np.histogram(arr, bins=min(20, max(5, arr.size // 5)), density=True) - peaks = 0 - for i in range(1, len(hist) - 1): - if hist[i] > hist[i - 1] and hist[i] > hist[i + 1] and hist[i] > hist.mean(): - peaks += 1 - - is_mm = (p < alpha and dip > 0) or (peaks >= 2 and p < 0.15) + # 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 " diff --git a/spc_core/normality.py b/spc_core/normality.py index 04904b7..3e98bdc 100644 --- a/spc_core/normality.py +++ b/spc_core/normality.py @@ -204,6 +204,10 @@ def apply_transform(values, method: str = "auto", alpha: float = 0.05) -> Transf """ 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) diff --git a/spc_core/pipeline.py b/spc_core/pipeline.py index 3491919..861a377 100644 --- a/spc_core/pipeline.py +++ b/spc_core/pipeline.py @@ -13,7 +13,7 @@ import numpy as np from .charts import ControlChartResult, analyze_control_chart -from .cleaning import classify_missing +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 @@ -26,6 +26,10 @@ 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: @@ -76,6 +80,8 @@ def establish( 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, @@ -83,6 +89,11 @@ def establish( """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 @@ -94,6 +105,15 @@ def establish( 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: @@ -134,6 +154,37 @@ def establish( 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) @@ -157,10 +208,29 @@ def establish( 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: + 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( @@ -175,7 +245,16 @@ def establish( )) # ---- 4. Normality + multimodal (only if not already routed to EWMA/CUSUM) ---- - if active_chart not in (ChartType.EWMA, ChartType.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( From 2419e4b06759b6a17d819fdb29ac2a3bea1c8a10 Mon Sep 17 00:00:00 2001 From: M1ndsmith Date: Sun, 26 Jul 2026 17:40:07 +0100 Subject: [PATCH 08/23] gitignore update --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index d532f45..6553867 100644 --- a/.gitignore +++ b/.gitignore @@ -179,8 +179,13 @@ aspc.db var/uploads/ var/reports/ examples/data/ +resilience_data/JUDGMENT.md frontend/node_modules/ frontend/.next/ frontend/out/ deploy/compose/data/ *.parquet + +# Local working notes (keep on disk, never commit) +refactoring elements/ +benchmarks/ \ No newline at end of file From 195883f9d3940346969aed7d7c5a6b24b2f49b76 Mon Sep 17 00:00:00 2001 From: M1ndsmith Date: Sun, 26 Jul 2026 19:09:33 +0100 Subject: [PATCH 09/23] Fix Vercel build: track frontend/src/lib client modules. Root .gitignore used a blanket lib/ rule that hid the Next.js API client from git, so Vercel clones failed with module not found for @/lib/api. Co-authored-by: Cursor --- .gitignore | 5 +- frontend/src/lib/api.ts | 161 +++++++++++++++++++++++++++++++++++++ frontend/src/lib/format.ts | 52 ++++++++++++ frontend/src/lib/types.ts | 106 ++++++++++++++++++++++++ frontend/src/lib/ws.ts | 87 ++++++++++++++++++++ 5 files changed, 409 insertions(+), 2 deletions(-) create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/format.ts create mode 100644 frontend/src/lib/types.ts create mode 100644 frontend/src/lib/ws.ts diff --git a/.gitignore b/.gitignore index 6553867..6abf599 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/ diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..ffcb704 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,161 @@ +import type { + AnalyzeResponse, + RunDetail, + RunSummary, + StreamInfo, + TokenResponse, +} from "./types"; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"; +const TOKEN_KEY = "aspc_token"; + +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 { + throw new ApiError(`Cannot reach API at ${API_URL}`, 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 { + throw new ApiError(`Cannot reach API at ${API_URL}`, 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 { + throw new ApiError(`Cannot reach API at ${API_URL}`, 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; + } + }, +}; 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..bd21e45 --- /dev/null +++ b/frontend/src/lib/types.ts @@ -0,0 +1,106 @@ +/** 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; +} + +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..0ada3ab --- /dev/null +++ b/frontend/src/lib/ws.ts @@ -0,0 +1,87 @@ +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 { + const params = new URLSearchParams(); + if (options.token) params.set("token", options.token); + const qs = params.toString(); + const url = `${WS_URL}/ws/live/${encodeURIComponent(streamKey)}${qs ? `?${qs}` : ""}`; + + 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; + 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; + }, + }; +} From 81cdc002413a0d8172645151302cc4e9f7900d92 Mon Sep 17 00:00:00 2001 From: M1ndsmith Date: Mon, 27 Jul 2026 03:25:37 +0100 Subject: [PATCH 10/23] Add slim Vercel deploy path for frontend + API only. --- deploy/vercel/.env.example | 22 ++++++++++ deploy/vercel/Dockerfile.api | 38 +++++++++++++++++ deploy/vercel/README.md | 83 ++++++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+) create mode 100644 deploy/vercel/.env.example create mode 100644 deploy/vercel/Dockerfile.api create mode 100644 deploy/vercel/README.md diff --git a/deploy/vercel/.env.example b/deploy/vercel/.env.example new file mode 100644 index 0000000..b8bce64 --- /dev/null +++ b/deploy/vercel/.env.example @@ -0,0 +1,22 @@ +# Copy values into the Vercel dashboard — do not commit real secrets. +# Two projects only: aspc-api (Dockerfile) + aspc-web (frontend/). No separate DB. + +# --- API project --- +ASPC_AUTH_ENABLED=true +ASPC_JWT_SECRET=replace-with-long-random-string +ASPC_ADMIN_USERNAME=admin +ASPC_ADMIN_PASSWORD=replace-with-strong-password +ASPC_API_KEYS=replace-with-api-key +# After you know the frontend URL: +ASPC_CORS_ORIGINS=https://your-frontend.vercel.app + +# SQLite inside the API container (ephemeral on Vercel — demo OK) +ASPC_PERSISTENCE_BACKEND=sqlite +ASPC_SQLITE_PATH=/tmp/aspc.db + +# Never on a public deploy: +# ASPC_DEV_INSECURE=1 + +# --- Frontend project (Vercel → frontend/ root) --- +# NEXT_PUBLIC_API_URL=https://your-api.vercel.app +# NEXT_PUBLIC_WS_URL=wss://your-api.vercel.app diff --git a/deploy/vercel/Dockerfile.api b/deploy/vercel/Dockerfile.api new file mode 100644 index 0000000..f41772a --- /dev/null +++ b/deploy/vercel/Dockerfile.api @@ -0,0 +1,38 @@ +# 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 +COPY resilience_data ./resilience_data + +# No tsdb/stream extras — SQLite + apps only +RUN uv pip install --system --no-cache -e ".[apps,data,render]" + +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..672e00a --- /dev/null +++ b/deploy/vercel/README.md @@ -0,0 +1,83 @@ +# Slim deploy: frontend + API only (Vercel) + +This folder is **additive** — it does not change Compose, `spc_core`, or the full streaming stack. + +**Only two things:** the UI (like local `:3000`) and the API (like local `:8000`). No extra DB service, no Supabase/Neon, no MQTT/Kafka. + +| Included | Not included | +|----------|----------------| +| Next.js UI (`frontend/`) | Mosquitto / MQTT / Kafka / Redis | +| FastAPI SPC core (Analyze, MSA, capability, auth) | stream-engine, mqtt-bridge | +| SQLite inside the API container | Live Monitoring + manufacturing sim | + +SQLite on Vercel is **ephemeral** (cold starts can wipe saved runs). Fine for a brother demo of Analyze; not for permanent history. + +For Live + sim, keep using local Compose (+ ngrok). See [docs/deployment.md](../../docs/deployment.md). + +## Layout + +| File | Role | +|------|------| +| [`Dockerfile.api`](Dockerfile.api) | API image; build context = **repo root** | +| [`.env.example`](.env.example) | Env vars for API + frontend | + +## 1. API on Vercel (Docker) + +1. Push this repo to GitHub. +2. [Vercel](https://vercel.com) → **Add New Project** → import the repo. +3. Project name e.g. `aspc-api`. +4. **Root Directory:** repository root (`.`). +5. Use this Dockerfile — Vercel looks for `Dockerfile.vercel` at the repo root: + + ```bash + # from repo root, only for this deploy (optional; do not have to commit) + cp deploy/vercel/Dockerfile.api Dockerfile.vercel + vercel --prod + ``` + + See [Vercel Docker docs](https://vercel.com/kb/guide/does-vercel-support-docker-deployments). + +6. Set env vars from [`.env.example`](.env.example) (API section). Leave `ASPC_CORS_ORIGINS` until the frontend URL exists, then update and redeploy. + +7. Deploy → note the API URL, e.g. `https://aspc-api-xxx.vercel.app`. + +```bash +curl -sS https://aspc-api-xxx.vercel.app/health +``` + +## 2. Frontend on Vercel (Next.js) + +1. **Add New Project** again → same repo. +2. Project name e.g. `aspc-web`. +3. **Root Directory:** `frontend`. +4. Env: + + ```text + NEXT_PUBLIC_API_URL=https://aspc-api-xxx.vercel.app + NEXT_PUBLIC_WS_URL=wss://aspc-api-xxx.vercel.app + ``` + +5. Deploy → note the UI URL. +6. On **aspc-api** set `ASPC_CORS_ORIGINS=https://aspc-web-xxx.vercel.app` → redeploy API. + +Open the UI → login → **Analyze**. **Live** will not work here. + +## 3. Local test of the slim API image + +```bash +cd /path/to/ASPC +docker build -f deploy/vercel/Dockerfile.api -t aspc-api:slim . +docker run --rm -p 8000:8000 \ + -e ASPC_DEV_INSECURE=1 \ + -e ASPC_JWT_SECRET=dev \ + -e ASPC_ADMIN_PASSWORD=admin \ + -e ASPC_API_KEYS=devkey \ + aspc-api:slim +``` + +## Brother demo choice + +| Goal | Use | +|------|-----| +| Analyze in the cloud (UI + API only) | This slim Vercel path | +| Live + manufacturing sim | Compose on your PC + ngrok | From ddf3a3c5bb1fc3a6ae56932304519c64d4579145 Mon Sep 17 00:00:00 2001 From: M1ndsmith Date: Mon, 27 Jul 2026 03:27:56 +0100 Subject: [PATCH 11/23] Add Dockerfile.vercel for slim API deploy --- Dockerfile.vercel | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 Dockerfile.vercel diff --git a/Dockerfile.vercel b/Dockerfile.vercel new file mode 100644 index 0000000..f41772a --- /dev/null +++ b/Dockerfile.vercel @@ -0,0 +1,38 @@ +# 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 +COPY resilience_data ./resilience_data + +# No tsdb/stream extras — SQLite + apps only +RUN uv pip install --system --no-cache -e ".[apps,data,render]" + +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"] From bc32f9e6f664edcfcf8a0f6026c374aee5ef24e9 Mon Sep 17 00:00:00 2001 From: M1ndsmith Date: Mon, 27 Jul 2026 03:42:47 +0100 Subject: [PATCH 12/23] Fix Next config so Vercel frontend builds without standalone --- deploy/vercel/README.md | 12 ++++++++---- frontend/next.config.js | 3 ++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/deploy/vercel/README.md b/deploy/vercel/README.md index 672e00a..41ad114 100644 --- a/deploy/vercel/README.md +++ b/deploy/vercel/README.md @@ -49,16 +49,20 @@ curl -sS https://aspc-api-xxx.vercel.app/health 1. **Add New Project** again → same repo. 2. Project name e.g. `aspc-web`. -3. **Root Directory:** `frontend`. -4. Env: +3. **Root Directory:** `frontend` (Settings → General → Root Directory). +4. Framework Preset: **Next.js**. Build command / output leave default. +5. Env: ```text NEXT_PUBLIC_API_URL=https://aspc-api-xxx.vercel.app NEXT_PUBLIC_WS_URL=wss://aspc-api-xxx.vercel.app ``` -5. Deploy → note the UI URL. -6. On **aspc-api** set `ASPC_CORS_ORIGINS=https://aspc-web-xxx.vercel.app` → redeploy API. +6. Deploy → open the UI URL (not the API project URL). +7. On **aspc-api** set `ASPC_CORS_ORIGINS=https://aspc-web-xxx.vercel.app` → redeploy API. + +If you see Vercel `404: NOT_FOUND` on the UI: Root Directory must be `frontend`, and +`next.config.js` must not force `output: "standalone"` on Vercel (already gated via `VERCEL` env). Open the UI → login → **Analyze**. **Live** will not work here. diff --git a/frontend/next.config.js b/frontend/next.config.js index dc1856c..312db28 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -1,6 +1,7 @@ /** @type {import('next').NextConfig} */ const nextConfig = { - output: "standalone", + // Docker Compose needs standalone; Vercel provides its own output and breaks with it. + ...(process.env.VERCEL ? {} : { output: "standalone" }), reactStrictMode: true, transpilePackages: ["react-plotly.js"], webpack: (config) => { From 96f0ceffbcc6709a24b748c4aa9411f0d4fbe8c8 Mon Sep 17 00:00:00 2001 From: M1ndsmith Date: Mon, 27 Jul 2026 04:14:44 +0100 Subject: [PATCH 13/23] Configure Vercel FastAPI entrypoint; drop root Dockerfile.vercel --- Dockerfile.vercel | 38 ------------ deploy/vercel/.env.example | 26 +++----- deploy/vercel/README.md | 123 +++++++++++++++++++++---------------- deploy/vercel/vercel.json | 4 ++ pyproject.toml | 13 ++++ vercel.json | 4 ++ 6 files changed, 100 insertions(+), 108 deletions(-) delete mode 100644 Dockerfile.vercel create mode 100644 deploy/vercel/vercel.json create mode 100644 vercel.json diff --git a/Dockerfile.vercel b/Dockerfile.vercel deleted file mode 100644 index f41772a..0000000 --- a/Dockerfile.vercel +++ /dev/null @@ -1,38 +0,0 @@ -# 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 -COPY resilience_data ./resilience_data - -# No tsdb/stream extras — SQLite + apps only -RUN uv pip install --system --no-cache -e ".[apps,data,render]" - -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/.env.example b/deploy/vercel/.env.example index b8bce64..b8d985c 100644 --- a/deploy/vercel/.env.example +++ b/deploy/vercel/.env.example @@ -1,22 +1,16 @@ -# Copy values into the Vercel dashboard — do not commit real secrets. -# Two projects only: aspc-api (Dockerfile) + aspc-web (frontend/). No separate DB. +# Copy values into the Vercel API project — do not commit real secrets. -# --- API project --- ASPC_AUTH_ENABLED=true -ASPC_JWT_SECRET=replace-with-long-random-string +ASPC_JWT_SECRET=some-long-random-string ASPC_ADMIN_USERNAME=admin -ASPC_ADMIN_PASSWORD=replace-with-strong-password -ASPC_API_KEYS=replace-with-api-key -# After you know the frontend URL: -ASPC_CORS_ORIGINS=https://your-frontend.vercel.app - -# SQLite inside the API container (ephemeral on Vercel — demo OK) +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 +# Needed when using admin/admin or default-looking secrets on a demo: +ASPC_DEV_INSECURE=1 -# Never on a public deploy: -# ASPC_DEV_INSECURE=1 - -# --- Frontend project (Vercel → frontend/ root) --- -# NEXT_PUBLIC_API_URL=https://your-api.vercel.app -# NEXT_PUBLIC_WS_URL=wss://your-api.vercel.app +# Frontend project (Root Directory = frontend): +# NEXT_PUBLIC_API_URL=https://YOUR-API.vercel.app +# NEXT_PUBLIC_WS_URL=wss://YOUR-API.vercel.app diff --git a/deploy/vercel/README.md b/deploy/vercel/README.md index 41ad114..e3b4a0e 100644 --- a/deploy/vercel/README.md +++ b/deploy/vercel/README.md @@ -1,87 +1,102 @@ # Slim deploy: frontend + API only (Vercel) -This folder is **additive** — it does not change Compose, `spc_core`, or the full streaming stack. +This folder is **additive** — it does not change Compose or the streaming stack. -**Only two things:** the UI (like local `:3000`) and the API (like local `:8000`). No extra DB service, no Supabase/Neon, no MQTT/Kafka. +**Only two things:** UI (like `:3000`) and API (like `:8000`). No MQTT/Kafka/Redis/DB service. | Included | Not included | |----------|----------------| -| Next.js UI (`frontend/`) | Mosquitto / MQTT / Kafka / Redis | -| FastAPI SPC core (Analyze, MSA, capability, auth) | stream-engine, mqtt-bridge | -| SQLite inside the API container | Live Monitoring + manufacturing sim | +| 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) | | -SQLite on Vercel is **ephemeral** (cold starts can wipe saved runs). Fine for a brother demo of Analyze; not for permanent history. +## Preferred: native FastAPI (not Docker) -For Live + sim, keep using local Compose (+ ngrok). See [docs/deployment.md](../../docs/deployment.md). +Vercel Framework Preset: **FastAPI** (not “Other”, not Docker). -## Layout +### 0. One-time in the repo (already done if you pulled latest) -| File | Role | -|------|------| -| [`Dockerfile.api`](Dockerfile.api) | API image; build context = **repo root** | -| [`.env.example`](.env.example) | Env vars for API + frontend | +`pyproject.toml` contains: -## 1. API on Vercel (Docker) +```toml +[tool.vercel] +entrypoint = "apps.api.main:app" +``` + +If a root `Dockerfile.vercel` exists, **rename or delete it** for this project so Vercel does not try Docker instead: + +```bash +git rm -f Dockerfile.vercel # or: mv Dockerfile.vercel Dockerfile.vercel.bak +git push +``` -1. Push this repo to GitHub. -2. [Vercel](https://vercel.com) → **Add New Project** → import the repo. -3. Project name e.g. `aspc-api`. -4. **Root Directory:** repository root (`.`). -5. Use this Dockerfile — Vercel looks for `Dockerfile.vercel` at the repo root: +For the API project, copy the install helper to the repo root (API root = `.`): - ```bash - # from repo root, only for this deploy (optional; do not have to commit) - cp deploy/vercel/Dockerfile.api Dockerfile.vercel - vercel --prod - ``` +```bash +cp deploy/vercel/vercel.json ./vercel.json +git add vercel.json pyproject.toml +git commit -m "Configure Vercel FastAPI entrypoint and install extras" +git push +``` - See [Vercel Docker docs](https://vercel.com/kb/guide/does-vercel-support-docker-deployments). +(`frontend/` is a separate Vercel project with Root Directory `frontend`, so this root `vercel.json` does not affect the UI.) + +### 1. API project (`aspc-api` / `aspc-plum` / …) + +1. Framework: **FastAPI** +2. Root Directory: **`.`** (repo root) +3. Install Command (if not using root `vercel.json`): + `pip install -e ".[apps,data,render]"` +4. Env: + +```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_CORS_ORIGINS=https://aspc-web.vercel.app +ASPC_DEV_INSECURE=1 +``` -6. Set env vars from [`.env.example`](.env.example) (API section). Leave `ASPC_CORS_ORIGINS` until the frontend URL exists, then update and redeploy. +`ASPC_DEV_INSECURE=1` is required if you use password `admin` or a default JWT secret (startup refuses those otherwise). Use only for a private demo. -7. Deploy → note the API URL, e.g. `https://aspc-api-xxx.vercel.app`. +5. Redeploy. +6. Check: ```bash -curl -sS https://aspc-api-xxx.vercel.app/health +curl -sS https://YOUR-API.vercel.app/health +curl -sS https://YOUR-API.vercel.app/ ``` -## 2. Frontend on Vercel (Next.js) +Expect JSON, not `NOT_FOUND`. + +### 2. Frontend project (`aspc-web`) -1. **Add New Project** again → same repo. -2. Project name e.g. `aspc-web`. -3. **Root Directory:** `frontend` (Settings → General → Root Directory). -4. Framework Preset: **Next.js**. Build command / output leave default. -5. Env: +1. Root Directory: **`frontend`** +2. Framework: **Next.js** +3. Env (must match the **working** API URL): - ```text - NEXT_PUBLIC_API_URL=https://aspc-api-xxx.vercel.app - NEXT_PUBLIC_WS_URL=wss://aspc-api-xxx.vercel.app - ``` +```text +NEXT_PUBLIC_API_URL=https://YOUR-API.vercel.app +NEXT_PUBLIC_WS_URL=wss://YOUR-API.vercel.app +``` -6. Deploy → open the UI URL (not the API project URL). -7. On **aspc-api** set `ASPC_CORS_ORIGINS=https://aspc-web-xxx.vercel.app` → redeploy API. +4. Redeploy frontend after changing these (baked at build time). -If you see Vercel `404: NOT_FOUND` on the UI: Root Directory must be `frontend`, and -`next.config.js` must not force `output: "standalone"` on Vercel (already gated via `VERCEL` env). +### 3. Login -Open the UI → login → **Analyze**. **Live** will not work here. +Use `ASPC_ADMIN_USERNAME` / `ASPC_ADMIN_PASSWORD` from the API env (e.g. `admin` / `admin` if set as above). -## 3. Local test of the slim API image +## Optional: Docker API image -```bash -cd /path/to/ASPC -docker build -f deploy/vercel/Dockerfile.api -t aspc-api:slim . -docker run --rm -p 8000:8000 \ - -e ASPC_DEV_INSECURE=1 \ - -e ASPC_JWT_SECRET=dev \ - -e ASPC_ADMIN_PASSWORD=admin \ - -e ASPC_API_KEYS=devkey \ - aspc-api:slim -``` +[`Dockerfile.api`](Dockerfile.api) remains for local slim image tests or if you prefer Docker later. Native FastAPI is simpler on Vercel. ## Brother demo choice | Goal | Use | |------|-----| -| Analyze in the cloud (UI + API only) | This slim Vercel path | +| Analyze in the cloud | This slim Vercel path | | Live + manufacturing sim | Compose on your PC + ngrok | diff --git a/deploy/vercel/vercel.json b/deploy/vercel/vercel.json new file mode 100644 index 0000000..35996d1 --- /dev/null +++ b/deploy/vercel/vercel.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "installCommand": "pip install -e \".[apps,data,render]\"" +} diff --git a/pyproject.toml b/pyproject.toml index f12c2ba..a7ea500 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,9 @@ 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] @@ -99,8 +102,14 @@ packages = [ "services", "services.stream_engine", "services.mqtt_bridge", + "resilience_data", + "resilience_data.generators", + "sample_data", ] +[tool.setuptools.package-data] +resilience_data = ["MANIFEST.json", "README.md", "cases/**/*.csv"] + [tool.pytest.ini_options] testpaths = ["tests"] python_files = ["test_*.py"] @@ -135,3 +144,7 @@ warn_return_any = false warn_unused_ignores = true check_untyped_defs = true packages = ["spc_core"] + +# Vercel native FastAPI deploy (slim UI+API path). See deploy/vercel/README.md. +[tool.vercel] +entrypoint = "apps.api.main:app" diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..35996d1 --- /dev/null +++ b/vercel.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "installCommand": "pip install -e \".[apps,data,render]\"" +} From a46fbc8c03470e5d87279d2a62bcb3bf9c43167f Mon Sep 17 00:00:00 2001 From: M1ndsmith Date: Mon, 27 Jul 2026 22:20:50 +0100 Subject: [PATCH 14/23] Fix Vercel install by dropping untracked resilience_data from setuptools. Co-authored-by: Cursor --- pyproject.toml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a7ea500..64f0948 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,13 +102,12 @@ packages = [ "services", "services.stream_engine", "services.mqtt_bridge", - "resilience_data", - "resilience_data.generators", "sample_data", ] -[tool.setuptools.package-data] -resilience_data = ["MANIFEST.json", "README.md", "cases/**/*.csv"] +# 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"] From 0541aa53af965f2b82e7020a67f3bbead25062b2 Mon Sep 17 00:00:00 2001 From: M1ndsmith Date: Mon, 27 Jul 2026 22:30:31 +0100 Subject: [PATCH 15/23] Switch slim Vercel API to Dockerfile.vercel past the 225MB limit. Scientific deps (numpy/scipy) exceed the FastAPI serverless bundle cap; container deploy plus Large Functions is the workable path. Co-authored-by: Cursor --- Dockerfile.vercel | 33 ++++++++++++++++++++++ deploy/vercel/Dockerfile.api | 5 ++-- deploy/vercel/README.md | 55 +++++++++++------------------------- deploy/vercel/vercel.json | 3 +- pyproject.toml | 3 +- vercel.json | 3 +- 6 files changed, 55 insertions(+), 47 deletions(-) create mode 100644 Dockerfile.vercel diff --git a/Dockerfile.vercel b/Dockerfile.vercel new file mode 100644 index 0000000..439f51f --- /dev/null +++ b/Dockerfile.vercel @@ -0,0 +1,33 @@ +# Slim ASPC API for Vercel container functions (UI + API only). +# Detected automatically when present at the repo root. +# Build locally: docker build -f Dockerfile.vercel -t aspc-api: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 + +# apps only — skip polars/plotly to keep the image lean; CSV Analyze still works. +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 + +CMD ["sh", "-c", "export ASPC_API_PORT=\"${PORT:-${ASPC_API_PORT:-8000}}\" && exec aspc-api"] diff --git a/deploy/vercel/Dockerfile.api b/deploy/vercel/Dockerfile.api index f41772a..82fa4c4 100644 --- a/deploy/vercel/Dockerfile.api +++ b/deploy/vercel/Dockerfile.api @@ -21,10 +21,9 @@ COPY adapters ./adapters COPY apps ./apps COPY services ./services COPY sample_data ./sample_data -COPY resilience_data ./resilience_data -# No tsdb/stream extras — SQLite + apps only -RUN uv pip install --system --no-cache -e ".[apps,data,render]" +# 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 diff --git a/deploy/vercel/README.md b/deploy/vercel/README.md index e3b4a0e..3ed5219 100644 --- a/deploy/vercel/README.md +++ b/deploy/vercel/README.md @@ -10,44 +10,15 @@ This folder is **additive** — it does not change Compose or the streaming stac | FastAPI SPC core (Analyze, MSA, capability, auth) | stream-engine, Live + sim | | SQLite inside the API (`/tmp/aspc.db`, ephemeral) | | -## Preferred: native FastAPI (not Docker) +## Preferred: container image (`Dockerfile.vercel`) -Vercel Framework Preset: **FastAPI** (not “Other”, not Docker). +Native FastAPI serverless packaging pulls in numpy/scipy and exceeds Vercel’s default ~225 MB function limit. The root [`Dockerfile.vercel`](../../Dockerfile.vercel) deploys the API as a container function instead. -### 0. One-time in the repo (already done if you pulled latest) +### 1. API project (`aspc-api` / …) -`pyproject.toml` contains: - -```toml -[tool.vercel] -entrypoint = "apps.api.main:app" -``` - -If a root `Dockerfile.vercel` exists, **rename or delete it** for this project so Vercel does not try Docker instead: - -```bash -git rm -f Dockerfile.vercel # or: mv Dockerfile.vercel Dockerfile.vercel.bak -git push -``` - -For the API project, copy the install helper to the repo root (API root = `.`): - -```bash -cp deploy/vercel/vercel.json ./vercel.json -git add vercel.json pyproject.toml -git commit -m "Configure Vercel FastAPI entrypoint and install extras" -git push -``` - -(`frontend/` is a separate Vercel project with Root Directory `frontend`, so this root `vercel.json` does not affect the UI.) - -### 1. API project (`aspc-api` / `aspc-plum` / …) - -1. Framework: **FastAPI** +1. Framework: **Other** (or leave auto-detect — root `Dockerfile.vercel` is enough) 2. Root Directory: **`.`** (repo root) -3. Install Command (if not using root `vercel.json`): - `pip install -e ".[apps,data,render]"` -4. Env: +3. Env: ```text ASPC_AUTH_ENABLED=true @@ -59,12 +30,15 @@ ASPC_PERSISTENCE_BACKEND=sqlite ASPC_SQLITE_PATH=/tmp/aspc.db ASPC_CORS_ORIGINS=https://aspc-web.vercel.app ASPC_DEV_INSECURE=1 +VERCEL_SUPPORT_LARGE_FUNCTIONS=1 ``` -`ASPC_DEV_INSECURE=1` is required if you use password `admin` or a default JWT secret (startup refuses those otherwise). Use only for a private demo. +`ASPC_DEV_INSECURE=1` is required if you use password `admin` or a default JWT secret (startup refuses those otherwise). Demo only. + +`VERCEL_SUPPORT_LARGE_FUNCTIONS=1` opts existing projects into Fluid large functions (needed for scientific Python deps). -5. Redeploy. -6. Check: +4. Redeploy from latest `aspc-refactor`. +5. Check: ```bash curl -sS https://YOUR-API.vercel.app/health @@ -90,9 +64,12 @@ NEXT_PUBLIC_WS_URL=wss://YOUR-API.vercel.app Use `ASPC_ADMIN_USERNAME` / `ASPC_ADMIN_PASSWORD` from the API env (e.g. `admin` / `admin` if set as above). -## Optional: Docker API image +## Local slim image -[`Dockerfile.api`](Dockerfile.api) remains for local slim image tests or if you prefer Docker later. Native FastAPI is simpler on Vercel. +```bash +docker build -f Dockerfile.vercel -t aspc-api:vercel . +# or: docker build -f deploy/vercel/Dockerfile.api -t aspc-api:slim . +``` ## Brother demo choice diff --git a/deploy/vercel/vercel.json b/deploy/vercel/vercel.json index 35996d1..20fc77e 100644 --- a/deploy/vercel/vercel.json +++ b/deploy/vercel/vercel.json @@ -1,4 +1,3 @@ { - "$schema": "https://openapi.vercel.sh/vercel.json", - "installCommand": "pip install -e \".[apps,data,render]\"" + "$schema": "https://openapi.vercel.sh/vercel.json" } diff --git a/pyproject.toml b/pyproject.toml index 64f0948..4e07253 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -144,6 +144,7 @@ warn_unused_ignores = true check_untyped_defs = true packages = ["spc_core"] -# Vercel native FastAPI deploy (slim UI+API path). See deploy/vercel/README.md. +# 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/vercel.json b/vercel.json index 35996d1..20fc77e 100644 --- a/vercel.json +++ b/vercel.json @@ -1,4 +1,3 @@ { - "$schema": "https://openapi.vercel.sh/vercel.json", - "installCommand": "pip install -e \".[apps,data,render]\"" + "$schema": "https://openapi.vercel.sh/vercel.json" } From 26e0332333363c28c0dbe82cd99c6a22ed04ec81 Mon Sep 17 00:00:00 2001 From: M1ndsmith Date: Mon, 27 Jul 2026 23:10:03 +0100 Subject: [PATCH 16/23] Fix Vercel API deploy: clear Docker noop path, use FastAPI + slim install. Root Dockerfile.vercel produced empty Ready deploys without Container Images; FastAPI with Large Functions is the working path. Co-authored-by: Cursor --- Dockerfile.vercel | 33 ------------------------- deploy/vercel/README.md | 51 +++++++++++---------------------------- deploy/vercel/vercel.json | 4 ++- vercel.json | 4 ++- 4 files changed, 20 insertions(+), 72 deletions(-) delete mode 100644 Dockerfile.vercel diff --git a/Dockerfile.vercel b/Dockerfile.vercel deleted file mode 100644 index 439f51f..0000000 --- a/Dockerfile.vercel +++ /dev/null @@ -1,33 +0,0 @@ -# Slim ASPC API for Vercel container functions (UI + API only). -# Detected automatically when present at the repo root. -# Build locally: docker build -f Dockerfile.vercel -t aspc-api: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 - -# apps only — skip polars/plotly to keep the image lean; CSV Analyze still works. -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 - -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 index 3ed5219..9e827b1 100644 --- a/deploy/vercel/README.md +++ b/deploy/vercel/README.md @@ -10,15 +10,12 @@ This folder is **additive** — it does not change Compose or the streaming stac | FastAPI SPC core (Analyze, MSA, capability, auth) | stream-engine, Live + sim | | SQLite inside the API (`/tmp/aspc.db`, ephemeral) | | -## Preferred: container image (`Dockerfile.vercel`) +## API project (`aspc` → aspc-plum.vercel.app) -Native FastAPI serverless packaging pulls in numpy/scipy and exceeds Vercel’s default ~225 MB function limit. The root [`Dockerfile.vercel`](../../Dockerfile.vercel) deploys the API as a container function instead. - -### 1. API project (`aspc-api` / …) - -1. Framework: **Other** (or leave auto-detect — root `Dockerfile.vercel` is enough) -2. Root Directory: **`.`** (repo root) -3. Env: +1. Framework: **FastAPI** +2. Root Directory: **empty / `.`** (never set this to a Dockerfile path) +3. Install Command: `pip install -e ".[apps]"` +4. Env (at least): ```text ASPC_AUTH_ENABLED=true @@ -33,47 +30,27 @@ ASPC_DEV_INSECURE=1 VERCEL_SUPPORT_LARGE_FUNCTIONS=1 ``` -`ASPC_DEV_INSECURE=1` is required if you use password `admin` or a default JWT secret (startup refuses those otherwise). Demo only. +`VERCEL_SUPPORT_LARGE_FUNCTIONS=1` is required so numpy/scipy fit past the default ~225 MB function limit. -`VERCEL_SUPPORT_LARGE_FUNCTIONS=1` opts existing projects into Fluid large functions (needed for scientific Python deps). +Do **not** put a root `Dockerfile.vercel` unless Container Images are enabled for the team — otherwise deploys can become empty 2s “Ready” no-ops. -4. Redeploy from latest `aspc-refactor`. 5. Check: ```bash -curl -sS https://YOUR-API.vercel.app/health -curl -sS https://YOUR-API.vercel.app/ +curl -sS https://aspc-plum.vercel.app/health ``` -Expect JSON, not `NOT_FOUND`. +## Frontend project (`aspc-web`) -### 2. Frontend project (`aspc-web`) - -1. Root Directory: **`frontend`** -2. Framework: **Next.js** -3. Env (must match the **working** API URL): +Root Directory: **`frontend`**. Env: ```text -NEXT_PUBLIC_API_URL=https://YOUR-API.vercel.app -NEXT_PUBLIC_WS_URL=wss://YOUR-API.vercel.app +NEXT_PUBLIC_API_URL=https://aspc-plum.vercel.app +NEXT_PUBLIC_WS_URL=wss://aspc-plum.vercel.app ``` -4. Redeploy frontend after changing these (baked at build time). - -### 3. Login - -Use `ASPC_ADMIN_USERNAME` / `ASPC_ADMIN_PASSWORD` from the API env (e.g. `admin` / `admin` if set as above). - -## Local slim image +## Local slim image (optional) ```bash -docker build -f Dockerfile.vercel -t aspc-api:vercel . -# or: docker build -f deploy/vercel/Dockerfile.api -t aspc-api:slim . +docker build -f deploy/vercel/Dockerfile.api -t aspc-api:slim . ``` - -## Brother demo choice - -| Goal | Use | -|------|-----| -| Analyze in the cloud | This slim Vercel path | -| Live + manufacturing sim | Compose on your PC + ngrok | diff --git a/deploy/vercel/vercel.json b/deploy/vercel/vercel.json index 20fc77e..c9987b7 100644 --- a/deploy/vercel/vercel.json +++ b/deploy/vercel/vercel.json @@ -1,3 +1,5 @@ { - "$schema": "https://openapi.vercel.sh/vercel.json" + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "fastapi", + "installCommand": "pip install -e \".[apps]\"" } diff --git a/vercel.json b/vercel.json index 20fc77e..c9987b7 100644 --- a/vercel.json +++ b/vercel.json @@ -1,3 +1,5 @@ { - "$schema": "https://openapi.vercel.sh/vercel.json" + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "fastapi", + "installCommand": "pip install -e \".[apps]\"" } From 84b6a314cbc2944213ef4b76fcf55c32dac89aee Mon Sep 17 00:00:00 2001 From: M1ndsmith Date: Mon, 27 Jul 2026 23:48:15 +0100 Subject: [PATCH 17/23] Fix Vercel analyze: write uploads/reports under /tmp. Serverless FS is read-only outside /tmp, so analyze crashed and the browser showed a CORS "Cannot reach API" error. Co-authored-by: Cursor --- apps/api/main.py | 59 +++++++++++++++++++++++++++++++++----- apps/config.py | 13 +++++++++ deploy/vercel/.env.example | 7 +++-- deploy/vercel/README.md | 6 +++- deploy/vercel/vercel.json | 2 +- frontend/src/lib/api.ts | 24 ++++++++++++---- vercel.json | 2 +- 7 files changed, 95 insertions(+), 18 deletions(-) diff --git a/apps/api/main.py b/apps/api/main.py index a3c4642..2b834d5 100644 --- a/apps/api/main.py +++ b/apps/api/main.py @@ -53,6 +53,7 @@ bias_study, capability_analysis, check_normality, + checklist_ready_for_golive, establish, gage_rr_anova, gage_rr_range, @@ -153,6 +154,16 @@ def _startup_security_checks() -> None: "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" and not cfg.dev_insecure: + 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 and not cfg.dev_insecure: + 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." + ) # ---- auth helpers ------------------------------------------------------------- @@ -277,6 +288,15 @@ def _save_file(file: UploadFile) -> Path: 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) @@ -399,6 +419,8 @@ async def analyze_cc( ruleset: str | None = Form(None), user_id: str | 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() @@ -415,6 +437,28 @@ async def analyze_cc( 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 pipeline = establish( @@ -425,6 +469,7 @@ async def analyze_cc( chart_type=ct, ruleset=ruleset or cfg.ruleset, acf_threshold=cfg.acf_threshold, + **msa_kwargs, ) checklist = phase1_checklist( pipeline, @@ -447,7 +492,8 @@ async def analyze_cc( "source_file": str(path), "frozen": pipeline.frozen, "stopped": pipeline.stopped, - "checklist_passed": bool(checklist.get("passed")), + # 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 @@ -466,8 +512,9 @@ async def analyze_cc( html_path = None if cfg.config["reports"]["auto_generate"]: - html = render_control_chart_html(report) - html_path = str(save_html(html, Path(cfg.report_dir) / f"{run_id}_control_chart.html")) + 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) @@ -519,8 +566,7 @@ async def analyze_cap( ) html_path = None if cfg.config["reports"]["auto_generate"]: - html = render_capability_html(report) - html_path = str(save_html(html, Path(cfg.report_dir) / f"{run_id}_capability.html")) + html_path = _maybe_html(render_capability_html, report, f"{run_id}_capability.html") return AnalyzeResponse( run_id=run_id, analysis_type="capability", @@ -597,8 +643,7 @@ async def analyze_msa( ) html_path = None if cfg.config["reports"]["auto_generate"]: - html = render_msa_html(report) - html_path = str(save_html(html, Path(cfg.report_dir) / f"{run_id}_msa.html")) + html_path = _maybe_html(render_msa_html, report, f"{run_id}_msa.html") return AnalyzeResponse( run_id=run_id, analysis_type="msa", diff --git a/apps/config.py b/apps/config.py index 6a6ef69..f843663 100644 --- a/apps/config.py +++ b/apps/config.py @@ -123,6 +123,19 @@ def _apply_env_overrides(self) -> None: 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) diff --git a/deploy/vercel/.env.example b/deploy/vercel/.env.example index b8d985c..4b1a1b5 100644 --- a/deploy/vercel/.env.example +++ b/deploy/vercel/.env.example @@ -8,9 +8,12 @@ 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://YOUR-API.vercel.app -# NEXT_PUBLIC_WS_URL=wss://YOUR-API.vercel.app +# NEXT_PUBLIC_API_URL=https://aspc-plum.vercel.app +# NEXT_PUBLIC_WS_URL=wss://aspc-plum.vercel.app diff --git a/deploy/vercel/README.md b/deploy/vercel/README.md index 9e827b1..3ca833e 100644 --- a/deploy/vercel/README.md +++ b/deploy/vercel/README.md @@ -14,7 +14,7 @@ This folder is **additive** — it does not change Compose or the streaming stac 1. Framework: **FastAPI** 2. Root Directory: **empty / `.`** (never set this to a Dockerfile path) -3. Install Command: `pip install -e ".[apps]"` +3. Install Command: `pip install -e ".[apps,render]"` 4. Env (at least): ```text @@ -25,11 +25,15 @@ 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. diff --git a/deploy/vercel/vercel.json b/deploy/vercel/vercel.json index c9987b7..9addb03 100644 --- a/deploy/vercel/vercel.json +++ b/deploy/vercel/vercel.json @@ -1,5 +1,5 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", "framework": "fastapi", - "installCommand": "pip install -e \".[apps]\"" + "installCommand": "pip install -e \".[apps,render]\"" } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ffcb704..1c79130 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -60,8 +60,12 @@ async function request(path: string, init: RequestInit = {}): Promise { let res: Response; try { res = await fetch(`${API_URL}${path}`, { ...init, headers }); - } catch { - throw new ApiError(`Cannot reach API at ${API_URL}`, 0); + } catch (err) { + const hint = + err instanceof TypeError + ? " (network/CORS — check API is up and ASPC_CORS_ORIGINS includes this origin)" + : ""; + throw new ApiError(`Cannot reach API at ${API_URL}${hint}`, 0); } if (!res.ok) { @@ -86,8 +90,12 @@ async function upload(path: string, form: FormData): Promise { let res: Response; try { res = await fetch(`${API_URL}${path}`, { method: "POST", headers, body: form }); - } catch { - throw new ApiError(`Cannot reach API at ${API_URL}`, 0); + } catch (err) { + const hint = + err instanceof TypeError + ? " (network/CORS — check API is up and ASPC_CORS_ORIGINS includes this origin)" + : ""; + throw new ApiError(`Cannot reach API at ${API_URL}${hint}`, 0); } if (!res.ok) { @@ -113,8 +121,12 @@ export async function login(username: string, password: string): Promise { headers: { "Content-Type": "application/x-www-form-urlencoded" }, body, }); - } catch { - throw new ApiError(`Cannot reach API at ${API_URL}`, 0); + } catch (err) { + const hint = + err instanceof TypeError + ? " (network/CORS — check API is up and ASPC_CORS_ORIGINS includes this origin)" + : ""; + throw new ApiError(`Cannot reach API at ${API_URL}${hint}`, 0); } if (!res.ok) { diff --git a/vercel.json b/vercel.json index c9987b7..9addb03 100644 --- a/vercel.json +++ b/vercel.json @@ -1,5 +1,5 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", "framework": "fastapi", - "installCommand": "pip install -e \".[apps]\"" + "installCommand": "pip install -e \".[apps,render]\"" } From a2542b81735bf005c9d973066e5870690ecd1861 Mon Sep 17 00:00:00 2001 From: M1ndsmith Date: Tue, 28 Jul 2026 00:25:46 +0100 Subject: [PATCH 18/23] Keep Vercel API install on apps-only to stay under 225MB. Co-authored-by: Cursor --- deploy/vercel/README.md | 2 +- deploy/vercel/vercel.json | 2 +- vercel.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/deploy/vercel/README.md b/deploy/vercel/README.md index 3ca833e..6f7d57c 100644 --- a/deploy/vercel/README.md +++ b/deploy/vercel/README.md @@ -14,7 +14,7 @@ This folder is **additive** — it does not change Compose or the streaming stac 1. Framework: **FastAPI** 2. Root Directory: **empty / `.`** (never set this to a Dockerfile path) -3. Install Command: `pip install -e ".[apps,render]"` +3. Install Command: `pip install -e ".[apps]"` (skip `render`/plotly — it blows the 225 MB function limit; Analyze JSON still works) 4. Env (at least): ```text diff --git a/deploy/vercel/vercel.json b/deploy/vercel/vercel.json index 9addb03..c9987b7 100644 --- a/deploy/vercel/vercel.json +++ b/deploy/vercel/vercel.json @@ -1,5 +1,5 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", "framework": "fastapi", - "installCommand": "pip install -e \".[apps,render]\"" + "installCommand": "pip install -e \".[apps]\"" } diff --git a/vercel.json b/vercel.json index 9addb03..c9987b7 100644 --- a/vercel.json +++ b/vercel.json @@ -1,5 +1,5 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", "framework": "fastapi", - "installCommand": "pip install -e \".[apps,render]\"" + "installCommand": "pip install -e \".[apps]\"" } From 08fe09760e424cd8a22fd673a5a2940b17fd6c40 Mon Sep 17 00:00:00 2001 From: M1ndsmith Date: Tue, 28 Jul 2026 00:54:15 +0100 Subject: [PATCH 19/23] Export checklist_ready_for_golive required by the API entrypoint. Co-authored-by: Cursor --- spc_core/__init__.py | 3 ++- spc_core/pipeline.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/spc_core/__init__.py b/spc_core/__init__.py index 8b9ed2f..6f8f165 100644 --- a/spc_core/__init__.py +++ b/spc_core/__init__.py @@ -62,7 +62,7 @@ check_autocorrelation, check_normality, ) -from .pipeline import Gate, PipelineResult, establish, phase1_checklist +from .pipeline import Gate, PipelineResult, checklist_ready_for_golive, establish, phase1_checklist from .report import CapabilityReport, MSAReport, SPCReport from .rules import RuleEngine, evaluate_series @@ -131,6 +131,7 @@ "p_limits", "parametric_capability", "phase1_checklist", + "checklist_ready_for_golive", "range_check", "select_chart_type", "sigma_to_dpmo", diff --git a/spc_core/pipeline.py b/spc_core/pipeline.py index 861a377..0573933 100644 --- a/spc_core/pipeline.py +++ b/spc_core/pipeline.py @@ -484,3 +484,18 @@ def _add(name: str, passed: bool, reason: str) -> None: "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" + ) From a0e0195aa2fdd904c0bad8770b51d3ec44ac0a9c Mon Sep 17 00:00:00 2001 From: M1ndsmith Date: Tue, 28 Jul 2026 00:58:07 +0100 Subject: [PATCH 20/23] Pin aspc-web to Next.js so monorepo deploys do not pick FastAPI. Co-authored-by: Cursor --- frontend/vercel.json | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 frontend/vercel.json 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" +} From 3aa7103b26ddbe39a223ddad8eaaa2438d369e21 Mon Sep 17 00:00:00 2001 From: M1ndsmith Date: Wed, 12 Aug 2026 14:20:07 +0100 Subject: [PATCH 21/23] Enhanced UI --- .gitignore | 4 +- adapters/stream_engine.py | 44 ++- adapters/stream_sources.py | 27 +- apps/config.yaml | 4 +- deploy/compose/docker-compose.yml | 20 +- deploy/docker/Dockerfile.api | 1 + deploy/docker/Dockerfile.mqtt_bridge | 1 + deploy/docker/Dockerfile.stream_engine | 1 + .../provisioning/datasources/datasources.yml | 19 ++ deploy/mosquitto/mosquitto.conf | 4 + docs/api.md | 10 +- docs/configuration.md | 8 +- docs/deployment.md | 20 +- docs/development.md | 13 +- docs/overview/problem-and-solution.md | 2 +- env.example | 12 +- frontend/.gitignore | 2 + frontend/src/app/analyze/page.tsx | 5 +- frontend/src/app/capability/page.tsx | 7 +- frontend/src/app/globals.css | 32 +- frontend/src/app/live/page.tsx | 9 +- frontend/src/app/login/page.tsx | 13 +- frontend/src/app/msa/page.tsx | 11 +- frontend/src/app/page.tsx | 282 +++++++++++++----- frontend/src/app/runs/[run_id]/page.tsx | 11 +- frontend/src/app/runs/page.tsx | 39 ++- frontend/src/components/Checklist.tsx | 2 +- frontend/src/components/ControlChart.tsx | 30 +- frontend/src/components/GateList.tsx | 4 +- frontend/src/components/Layout.tsx | 184 ++++++++++-- frontend/src/components/ui.tsx | 94 ++++-- frontend/tailwind.config.ts | 34 ++- frontend/tsconfig.tsbuildinfo | 1 + scripts/calibrate_resilience.py | 44 +++ scripts/resilience_report.py | 67 +++++ services/stream_engine/main.py | 3 + spc_core/capability.py | 12 +- tests/resilience/__init__.py | 1 + tests/resilience/test_resilience_catalog.py | 82 +++++ tests/unit/test_capability.py | 31 ++ tests/unit/test_establish_guards.py | 69 +++++ tests/unit/test_exclude_incomplete.py | 31 ++ tests/unit/test_multimodal.py | 53 ++++ tests/unit/test_pipeline.py | 21 ++ tests/unit/test_security.py | 35 +++ tests/unit/test_stream_engine.py | 52 ++++ 46 files changed, 1182 insertions(+), 269 deletions(-) create mode 100644 deploy/grafana/provisioning/datasources/datasources.yml create mode 100644 frontend/tsconfig.tsbuildinfo create mode 100644 scripts/calibrate_resilience.py create mode 100644 scripts/resilience_report.py create mode 100644 tests/resilience/__init__.py create mode 100644 tests/resilience/test_resilience_catalog.py create mode 100644 tests/unit/test_establish_guards.py create mode 100644 tests/unit/test_exclude_incomplete.py create mode 100644 tests/unit/test_multimodal.py diff --git a/.gitignore b/.gitignore index 6abf599..df82384 100644 --- a/.gitignore +++ b/.gitignore @@ -189,4 +189,6 @@ deploy/compose/data/ # Local working notes (keep on disk, never commit) refactoring elements/ -benchmarks/ \ No newline at end of file +benchmarks/ +.vercel +.env* diff --git a/adapters/stream_engine.py b/adapters/stream_engine.py index cddc44a..2fedff3 100644 --- a/adapters/stream_engine.py +++ b/adapters/stream_engine.py @@ -167,11 +167,15 @@ def registered_keys(self) -> list[str]: def handle_observation( self, stream_key: str, - value: float, + value: float | list[float], ts: datetime | None = None, **meta: Any, ) -> list[Signal]: - """Evaluate one observation; write raw + any OOC events; publish live; return signals.""" + """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") @@ -183,18 +187,41 @@ def handle_observation( 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] self.repo.save_raw_measurement( stream_key, ts, - float(value), + plotted, limits_version=limits_version, quality_flag=meta.get("quality_flag"), machine_id=meta.get("machine_id"), gage_id=meta.get("gage_id"), ) - signals = ev.observe(float(value)) + 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, @@ -222,7 +249,7 @@ def handle_observation( payload = { "type": "point", "stream_key": stream_key, - "value": float(value), + "value": plotted, "timestamp": ts.isoformat(), "ts": ts.isoformat(), "index": ev.index, @@ -247,7 +274,10 @@ def handle_observation( return signals def handle_message(self, msg: dict[str, Any]) -> list[Signal]: - """Handle a source dict ``{key, value, timestamp}`` (plus optional meta).""" + """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}") @@ -267,6 +297,8 @@ def handle_message(self, msg: dict[str, Any]) -> list[Signal]: 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 _publish(self, stream_key: str, payload: dict[str, Any]) -> None: diff --git a/adapters/stream_sources.py b/adapters/stream_sources.py index 41564e2..610fb2c 100644 --- a/adapters/stream_sources.py +++ b/adapters/stream_sources.py @@ -7,6 +7,7 @@ import asyncio import json +import logging import queue import threading from collections.abc import AsyncIterator, Iterator @@ -16,14 +17,20 @@ from adapters.stream import ObservationSource +logger = logging.getLogger(__name__) + @dataclass class Observation: - """Normalized measurement from a live source.""" + """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 + value: float | list[float] raw: dict[str, Any] = field(default_factory=dict) def as_dict(self) -> dict[str, Any]: @@ -80,7 +87,13 @@ def _parse_payload(payload: bytes | str | dict, *, default_key: str = "default") value = data.get("measurement") or data.get("v") if value is None: raise ValueError(f"Observation payload missing value: {data!r}") - return Observation(key=key, ts=ts, value=float(value), raw=dict(data)) + 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: @@ -266,7 +279,13 @@ def _on_message(_client, _userdata, message) -> None: try: q.put(obs.as_dict(), timeout=5.0) except queue.Full: - pass # drop under backpressure rather than block the network thread + # 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) diff --git a/apps/config.yaml b/apps/config.yaml index e42caf2..8393dce 100644 --- a/apps/config.yaml +++ b/apps/config.yaml @@ -1,9 +1,11 @@ # 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: ["*"] + cors_origins: ["http://localhost:3000", "http://127.0.0.1:3000"] auth: enabled: true diff --git a/deploy/compose/docker-compose.yml b/deploy/compose/docker-compose.yml index 9bec52b..5269dcb 100644 --- a/deploy/compose/docker-compose.yml +++ b/deploy/compose/docker-compose.yml @@ -24,9 +24,10 @@ services: - 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: - - "19092:19092" - - "9644:9644" + - "127.0.0.1:19092:19092" + - "127.0.0.1:9644:9644" healthcheck: test: ["CMD-SHELL", "rpk cluster health | grep -q 'Healthy'"] interval: 10s @@ -37,7 +38,7 @@ services: image: eclipse-mosquitto:2.0 restart: unless-stopped ports: - - "1883:1883" + - "127.0.0.1:1883:1883" volumes: - ../mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro @@ -51,7 +52,7 @@ services: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in deploy/compose/.env} POSTGRES_DB: ${POSTGRES_DB:-aspc} ports: - - "5433:5432" + - "127.0.0.1:5433:5432" volumes: - tsdata:/var/lib/postgresql/data healthcheck: @@ -64,7 +65,7 @@ services: image: redis:7-alpine restart: unless-stopped ports: - - "6379:6379" + - "127.0.0.1:6379:6379" healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s @@ -166,10 +167,11 @@ services: build: context: ../.. dockerfile: deploy/docker/Dockerfile.frontend + # NEXT_PUBLIC_* are baked at build time — runtime env does not update the bundle. + args: + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8000} + NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-ws://localhost:8000} restart: unless-stopped - environment: - NEXT_PUBLIC_API_URL: http://localhost:8000 - NEXT_PUBLIC_WS_URL: ws://localhost:8000 ports: - "3000:3000" depends_on: @@ -181,7 +183,7 @@ services: profiles: ["ops"] restart: unless-stopped ports: - - "3001:3000" + - "127.0.0.1:3001:3000" volumes: - ../grafana/provisioning:/etc/grafana/provisioning:ro depends_on: diff --git a/deploy/docker/Dockerfile.api b/deploy/docker/Dockerfile.api index 9ab33d2..a7349a8 100644 --- a/deploy/docker/Dockerfile.api +++ b/deploy/docker/Dockerfile.api @@ -16,6 +16,7 @@ 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 ./ diff --git a/deploy/docker/Dockerfile.mqtt_bridge b/deploy/docker/Dockerfile.mqtt_bridge index b089b7c..94034c3 100644 --- a/deploy/docker/Dockerfile.mqtt_bridge +++ b/deploy/docker/Dockerfile.mqtt_bridge @@ -15,6 +15,7 @@ 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) diff --git a/deploy/docker/Dockerfile.stream_engine b/deploy/docker/Dockerfile.stream_engine index 9fad6ec..20a2003 100644 --- a/deploy/docker/Dockerfile.stream_engine +++ b/deploy/docker/Dockerfile.stream_engine @@ -16,6 +16,7 @@ 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 ./ 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 index 0e3b61e..6f87ffa 100644 --- a/deploy/mosquitto/mosquitto.conf +++ b/deploy/mosquitto/mosquitto.conf @@ -1,3 +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/docs/api.md b/docs/api.md index 151d25f..44e8dde 100644 --- a/docs/api.md +++ b/docs/api.md @@ -16,11 +16,13 @@ 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`, default `admin`; any username is accepted): +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=operator&password=admin' + -d 'username=admin&password=$ASPC_ADMIN_PASSWORD' # → {"access_token":"…","token_type":"bearer"} ``` @@ -29,10 +31,10 @@ curl -s -X POST http://localhost:8000/auth/token \ 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**, the handler allows the request in dev mode (`"dev"`) +- If **no keys configured**, requests fail closed unless `ASPC_DEV_INSECURE=1` ```bash -curl -H "X-API-Key: demokey" -H "Authorization: Bearer $TOKEN" … +curl -H "X-API-Key: $ASPC_API_KEY" -H "Authorization: Bearer $TOKEN" … ``` ## Common response shape diff --git a/docs/configuration.md b/docs/configuration.md index cae043d..5f696ad 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -16,18 +16,18 @@ Application config is YAML defaults merged with environment overrides. Sources: |-----|---------|---------| | `host` | `0.0.0.0` | Bind address | | `port` | `8000` | HTTP port | -| `cors_origins` | `["*"]` | CORS allow list | +| `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` | HS256 secret | +| `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` | Password accepted by `/auth/token` | -| `api_keys` | `[]` | Allowed `X-API-Key` values | +| `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 | ### `uploads` / `reports` diff --git a/docs/deployment.md b/docs/deployment.md index 1c89299..4283568 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -14,13 +14,15 @@ docker compose -f deploy/compose/docker-compose.yml --env-file deploy/compose/.e | `migrate` | `alembic upgrade head` (runs once before api/stream-engine) | — | | `api` | FastAPI | 8000 | | `frontend` | Next.js dashboard | 3000 | -| `redpanda` | Kafka-compatible broker | 19092 (external), 9644 admin | -| `mosquitto` | MQTT broker | 1883 | -| `timescaledb` | Tier-1/Tier-2 persistence | 5433 (host → container 5432) | -| `redis` | Live alert pub/sub | 6379 | +| `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`) | 3001 | +| `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): @@ -28,14 +30,16 @@ Secrets come from `deploy/compose/.env` (never commit real values): - `ASPC_CORS_ORIGINS=http://localhost:3000` - `ASPC_TSDB_INIT=0` on api/stream-engine so only the migrate service applies schema -Deferred ops (configure in your environment, not shipped as defaults): Mosquitto TLS/auth, Kafka DLQ, continuous aggregates, image digest pins. +**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: Kafka DLQ, continuous aggregates, image digest pins. -Frontend: `NEXT_PUBLIC_API_URL=http://localhost:8000`, `NEXT_PUBLIC_WS_URL=ws://localhost:8000`. +Frontend `NEXT_PUBLIC_API_URL` / `NEXT_PUBLIC_WS_URL` are **Docker build-args** (Next.js bakes them at build time). Defaults are `http://localhost:8000` and `ws://localhost:8000`; override via compose `.env` and rebuild the frontend image. Ops profile: ```bash -docker compose -f deploy/compose/docker-compose.yml --profile ops up -d +docker compose -f deploy/compose/docker-compose.yml --env-file deploy/compose/.env --profile ops up -d ``` ## Docker images diff --git a/docs/development.md b/docs/development.md index 5d332c0..bbad9d8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -33,7 +33,7 @@ uv lock | `resilience_data/` | Standards-mapped sad/happy path corpus | | `benchmarks/` | Performance + accuracy harnesses | | `frontend/` | Next.js operator UI | -| `tests/` | unit + integration | +| `tests/` | unit + integration + resilience | | `docs/` | This documentation | | `deploy/` | Docker + Compose | | `migrations/` | Alembic | @@ -78,10 +78,10 @@ Configured in [`pyproject.toml`](../pyproject.toml): ```bash ruff check spc_core adapters apps services sample_data resilience_data scripts tests benchmarks -mypy spc_core adapters apps # packages listed in tool.mypy +mypy spc_core # tool.mypy.packages = ["spc_core"] only ``` -CI currently runs ruff non-blocking (`|| true`); treat failures as real before merge. +CI runs **ruff as a blocking check** and **mypy on `spc_core` only** (adapters/apps/services are not type-gated yet). ## Benchmarks @@ -110,9 +110,10 @@ Catalog names include `spc_individual_in_control`, `msa_gage_rr_excellent`, `cap [`.github/workflows/ci.yml`](../.github/workflows/ci.yml): -1. **python** — setup-uv, `uv pip install --system -e ".[dev]"`, ruff, pytest, acceptance script (matrix 3.11 / 3.12) -2. **frontend** — npm ci/lint/test/build -3. **docker** — build API + frontend images +1. **python** (3.11 / 3.12) — `uv pip install -e ".[dev]"`, blocking ruff, mypy `spc_core`, pytest (`not integration`, includes `tests/resilience`), `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 diff --git a/docs/overview/problem-and-solution.md b/docs/overview/problem-and-solution.md index 54cc329..29694e9 100644 --- a/docs/overview/problem-and-solution.md +++ b/docs/overview/problem-and-solution.md @@ -39,7 +39,7 @@ That is the gap ASPC is built to fill. [`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 *which* rule fired and *which* gate status was set, not just “some signal count.” +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 diff --git a/env.example b/env.example index 5dd885b..464212c 100644 --- a/env.example +++ b/env.example @@ -1,21 +1,25 @@ # 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. # Auth ASPC_AUTH_ENABLED=true -ASPC_JWT_SECRET=change-me-in-production +ASPC_JWT_SECRET=replace-with-long-random-string ASPC_ADMIN_USERNAME=admin -ASPC_ADMIN_PASSWORD=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=key1,key2 +ASPC_API_KEYS=replace-with-api-key # Local-only escape hatch (never in production): # ASPC_DEV_INSECURE=1 # API # ASPC_API_HOST=0.0.0.0 # ASPC_API_PORT=8000 -# ASPC_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 +ASPC_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 # Persistence (sqlite | timescale) # ASPC_PERSISTENCE_BACKEND=sqlite diff --git a/frontend/.gitignore b/frontend/.gitignore index 7bf1990..c39edc3 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -9,3 +9,5 @@ test-results .env.local *.log .DS_Store +.vercel +.env* diff --git a/frontend/src/app/analyze/page.tsx b/frontend/src/app/analyze/page.tsx index 08847f6..c9927e5 100644 --- a/frontend/src/app/analyze/page.tsx +++ b/frontend/src/app/analyze/page.tsx @@ -66,6 +66,7 @@ export default function AnalyzePage() {
    @@ -110,7 +111,7 @@ export default function AnalyzePage() {
    Run ID - {result.run_id} + {result.run_id}
    Type @@ -147,7 +148,7 @@ export default function AnalyzePage() { {!report && ( -
    +              
                     {JSON.stringify(result.report, null, 2)}
                   
    diff --git a/frontend/src/app/capability/page.tsx b/frontend/src/app/capability/page.tsx index 174fcde..07131cc 100644 --- a/frontend/src/app/capability/page.tsx +++ b/frontend/src/app/capability/page.tsx @@ -54,6 +54,7 @@ export default function CapabilityPage() {
    @@ -103,7 +104,7 @@ export default function CapabilityPage() {
    Run - {result.run_id} + {result.run_id}
    {cap ? (
    @@ -119,7 +120,7 @@ export default function CapabilityPage() { cap[k] !== undefined && cap[k] !== null ? ( {k} - + {typeof cap[k] === "number" ? (cap[k] as number).toFixed(4) : String(cap[k])} @@ -129,7 +130,7 @@ export default function CapabilityPage() {
    ) : ( -
    +            
                   {JSON.stringify(result.report, null, 2)}
                 
    )} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 611df2f..36bc49f 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -1,4 +1,4 @@ -@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap"); +@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; @@ -6,8 +6,8 @@ :root { color-scheme: dark; - --aspc-bg: #080b10; - --aspc-cyan: #00d4ff; + --aspc-bg: #0b0b0f; + --aspc-accent: #e8c547; } html, @@ -16,12 +16,9 @@ body { } body { - background: - radial-gradient(ellipse 80% 50% at 10% -10%, rgba(0, 212, 255, 0.08), transparent), - radial-gradient(ellipse 60% 40% at 100% 0%, rgba(0, 80, 120, 0.12), transparent), - var(--aspc-bg); - color: #e8eef7; - font-family: "IBM Plex Sans", system-ui, sans-serif; + background: var(--aspc-bg); + color: #f2f2f4; + font-family: "Sora", system-ui, sans-serif; -webkit-font-smoothing: antialiased; } @@ -30,22 +27,7 @@ body { } ::selection { - background: rgba(0, 212, 255, 0.25); -} - -/* Subtle grid texture */ -body::before { - content: ""; - pointer-events: none; - position: fixed; - inset: 0; - background-image: - linear-gradient(rgba(26, 35, 50, 0.35) 1px, transparent 1px), - linear-gradient(90deg, rgba(26, 35, 50, 0.35) 1px, transparent 1px); - background-size: 48px 48px; - mask-image: radial-gradient(ellipse at center, black 20%, transparent 75%); - opacity: 0.4; - z-index: 0; + background: rgba(232, 197, 71, 0.28); } #__next, diff --git a/frontend/src/app/live/page.tsx b/frontend/src/app/live/page.tsx index 3c6e7dc..a5662ee 100644 --- a/frontend/src/app/live/page.tsx +++ b/frontend/src/app/live/page.tsx @@ -127,10 +127,11 @@ export default function LivePage() {
    setManualKey(e.target.value)} - className="w-full rounded-lg border border-aspc-border bg-aspc-bg px-3 py-2 text-sm outline-none focus:border-aspc-cyan/50 disabled:opacity-50" + 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" />
    @@ -181,7 +182,7 @@ export default function LivePage() { @@ -214,7 +215,7 @@ export default function LivePage() { {alerts.map((a, i) => (
  • [{a.rule_id}] idx {a.index} diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index a537035..17a553a 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -27,12 +27,17 @@ export default function LoginPage() { } return ( -
    -
    +
    +
    -
    ASPC
    +
    + + + +
    +
    ASPC

    Sign in

    -

    JWT auth against the ASPC API

    +

    Operator console · JWT against the ASPC API

    {error && } diff --git a/frontend/src/app/msa/page.tsx b/frontend/src/app/msa/page.tsx index d281b08..57a8abd 100644 --- a/frontend/src/app/msa/page.tsx +++ b/frontend/src/app/msa/page.tsx @@ -52,6 +52,7 @@ export default function MsaPage() {
    @@ -96,7 +97,7 @@ export default function MsaPage() {
    Run - {result.run_id} + {result.run_id} {msa?.study_type && ( <> · @@ -128,7 +129,7 @@ export default function MsaPage() {
    ) : ( -
    +            
                   {JSON.stringify(result.report, null, 2)}
                 
    )} @@ -136,13 +137,13 @@ export default function MsaPage() { )} -
    -

    Coming online

    +
    +

    Coming online

    Live bias EWMA (α=0.2), rolling R, and calibration alerts from ContinuousMSA will render here once the stream engine publishes drift metrics on the selected stream.

    -
    +
    diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index ed062d1..4a376a7 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -2,9 +2,48 @@ 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 { ErrorBanner, PageHeader, Panel, Spinner, StatCard } from "@/components/ui"; +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: "/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: "/capability", + title: "Capability", + ticker: "Cpk", + blurb: "Process capability", + tone: "ok" as const, + }, + { + href: "/msa", + title: "MSA", + ticker: "GRR", + blurb: "Measurement systems", + tone: "warn" as const, + }, +]; export default function DashboardPage() { const health = useQuery({ queryKey: ["health"], queryFn: api.health, refetchInterval: 30_000 }); @@ -15,17 +54,22 @@ export default function DashboardPage() { retry: false, }); - const healthy = health.data?.status === "healthy" || health.data?.status === "ok"; - const liveCount = streams.data?.streams?.filter((s) => s.active).length ?? null; const recent = runs.data?.runs ?? []; + const latestId = recent[0]?.run_id; + const latest = useQuery({ + queryKey: ["run", latestId], + queryFn: () => api.getRun(latestId!), + enabled: !!latestId, + }); - return ( -
    - + 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) && ( )} -
    - - - - - - Analyze - - · - - Live - - + {/* 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} + /> +
    +
    + + {/* 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]} + + +
    • + ))} +
    + - - {runs.isLoading && } - {!runs.isLoading && recent.length === 0 && ( -

    No runs yet. Upload a file on Analyze.

    - )} - {recent.length > 0 && ( -
    - - - - - - - - - - - {recent.map((r) => ( - - - - - - - ))} - -
    RunTypeCreatedLimits
    - - {shortId(r.run_id, 12)} - - {r.analysis_type}{formatTimestamp(r.created_at)} - {r.limits_version ? shortId(r.limits_version) : "—"} -
    -
    - )} -
    + + 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 index 9316dec..49cdf59 100644 --- a/frontend/src/app/runs/[run_id]/page.tsx +++ b/frontend/src/app/runs/[run_id]/page.tsx @@ -50,11 +50,12 @@ export default function RunDetailPage() {
    ← All runs @@ -70,7 +71,7 @@ export default function RunDetailPage() {
    Run ID
    -
    {data.run_id}
    +
    {data.run_id}
    Type
    @@ -96,7 +97,7 @@ export default function RunDetailPage() { href={reportUrl(runId)} target="_blank" rel="noopener noreferrer" - className="text-sm text-aspc-cyan hover:underline" + className="text-sm text-aspc-accent hover:underline" > Open HTML report ↗ @@ -142,7 +143,7 @@ export default function RunDetailPage() { capResult[k] !== undefined && capResult[k] !== null ? ( {k} - + {typeof capResult[k] === "number" ? (capResult[k] as number).toFixed(4) : String(capResult[k])} @@ -185,7 +186,7 @@ export default function RunDetailPage() { {!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 index d6fdb55..4646317 100644 --- a/frontend/src/app/runs/page.tsx +++ b/frontend/src/app/runs/page.tsx @@ -1,29 +1,44 @@ "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"; -export default function RunsPage() { +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 = data?.runs ?? []; + 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-lg border border-aspc-border px-3 py-1.5 text-xs text-aspc-muted hover:text-aspc-text" + className="rounded-pill border border-aspc-border px-4 py-1.5 text-xs text-aspc-muted hover:text-aspc-text" > {isFetching ? "Refreshing…" : "Refresh"} @@ -35,7 +50,9 @@ export default function RunsPage() { {isLoading && } {!isLoading && runs.length === 0 && ( -

    No analysis runs stored yet.

    +

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

    )} {runs.length > 0 && (
    @@ -51,11 +68,11 @@ export default function RunsPage() { {runs.map((r) => ( - + {shortId(r.run_id, 14)} @@ -78,3 +95,11 @@ export default function RunsPage() {
    ); } + +export default function RunsPage() { + return ( + }> + + + ); +} diff --git a/frontend/src/components/Checklist.tsx b/frontend/src/components/Checklist.tsx index 04c978d..22d8331 100644 --- a/frontend/src/components/Checklist.tsx +++ b/frontend/src/components/Checklist.tsx @@ -4,7 +4,7 @@ import type { ChecklistItem, Phase1Checklist } from "@/lib/types"; function ItemRow({ item }: { item: ChecklistItem }) { return ( -
  • +
  • import("react-plotly.js"), { ssr: false, loading: () => ( -
    +
    Loading chart…
    ), @@ -63,7 +63,7 @@ export function ControlChart({ type: "scatter", mode: "lines", name: "UCL", - line: { color: "#FF4D4F", width: 1.5, dash: "dash" }, + line: { color: "#F97316", width: 1.5, dash: "dash" }, hoverinfo: "y+name", }, { @@ -72,7 +72,7 @@ export function ControlChart({ type: "scatter", mode: "lines", name: "CL", - line: { color: "#00D4FF", width: 1.5 }, + line: { color: "#E8C547", width: 2 }, hoverinfo: "y+name", }, { @@ -81,7 +81,7 @@ export function ControlChart({ type: "scatter", mode: "lines", name: "LCL", - line: { color: "#FF4D4F", width: 1.5, dash: "dash" }, + line: { color: "#F97316", width: 1.5, dash: "dash" }, hoverinfo: "y+name", }, { @@ -90,8 +90,8 @@ export function ControlChart({ type: "scatter", mode: "lines+markers", name: "Value", - line: { color: "#E8EEF7", width: 1.5 }, - marker: { color: "#E8EEF7", size: 6 }, + line: { color: "#F2F2F4", width: 1.5 }, + marker: { color: "#F2F2F4", size: 6 }, }, { x: oocX, @@ -99,28 +99,28 @@ export function ControlChart({ type: "scatter", mode: "markers", name: "OOC", - marker: { color: "#FF4D4F", size: 10, symbol: "x", line: { width: 2, color: "#FF4D4F" } }, + marker: { color: "#2DD4BF", size: 10, symbol: "x", line: { width: 2, color: "#2DD4BF" } }, }, ]; const layout = { - title: { text: title, font: { color: "#E8EEF7", size: 14 }, x: 0, xanchor: "left" }, + title: { text: title, font: { color: "#F2F2F4", size: 14 }, x: 0, xanchor: "left" }, paper_bgcolor: "rgba(0,0,0,0)", - plot_bgcolor: "#0D1219", - font: { color: "#6B7A90", family: "IBM Plex Sans, sans-serif", size: 11 }, + 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: "#1A2332", + gridcolor: "#2A2A32", zeroline: false, - color: "#6B7A90", + color: "#8A8A96", }, yaxis: { title: "Value", - gridcolor: "#1A2332", + gridcolor: "#2A2A32", zeroline: false, - color: "#6B7A90", + color: "#8A8A96", }, legend: { orientation: "h", @@ -135,7 +135,7 @@ export function ControlChart({ const config = { displayModeBar: false, responsive: true }; return ( -
    +
    (
  • -
    {g.step}
    +
    {g.step}

    {g.reason}

  • diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 653f4af..9a130e2 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -2,24 +2,93 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; -import { useEffect, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; import { getToken, logout } from "@/lib/api"; const NAV = [ - { href: "/", label: "Overview" }, - { href: "/live", label: "Live" }, - { href: "/analyze", label: "Analyze" }, - { href: "/capability", label: "Capability" }, - { href: "/msa", label: "MSA" }, - { href: "/runs", label: "Runs" }, + { + href: "/", + label: "Overview", + 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: ( + + + + + ), + }, ]; +function titleForPath(pathname: string): string { + if (pathname === "/") return "Dashboard"; + 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("/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(); @@ -40,12 +109,12 @@ export function Layout({ children }: { children: ReactNode }) { - + ASPC @@ -53,20 +122,27 @@ export function Layout({ children }: { children: ReactNode }) { {/* Sidebar */}
    -
    +
    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)} -
    +
    {formatLimits(primary)}
    @@ -169,7 +290,10 @@ export default function RunDetailPage() { {Object.entries(msaResult.result) - .filter(([, v]) => typeof v === "number" || typeof v === "string" || typeof v === "boolean") + .filter( + ([, v]) => + typeof v === "number" || typeof v === "string" || typeof v === "boolean", + ) .map(([k, v]) => ( {k} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 9a130e2..79c533d 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -18,6 +18,15 @@ const NAV = [ ), }, + { + href: "/onboarding", + label: "Onboarding", + icon: ( + + + + ), + }, { href: "/live", label: "Live", @@ -68,14 +77,26 @@ const NAV = [ ), }, + { + 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"; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1c79130..5e0eb58 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -6,9 +6,18 @@ import type { TokenResponse, } from "./types"; -const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"; +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; @@ -61,11 +70,7 @@ async function request(path: string, init: RequestInit = {}): Promise { try { res = await fetch(`${API_URL}${path}`, { ...init, headers }); } catch (err) { - const hint = - err instanceof TypeError - ? " (network/CORS — check API is up and ASPC_CORS_ORIGINS includes this origin)" - : ""; - throw new ApiError(`Cannot reach API at ${API_URL}${hint}`, 0); + throw new ApiError(`Cannot reach API at ${API_URL}${unreachableApiHint(err)}`, 0); } if (!res.ok) { @@ -91,11 +96,7 @@ async function upload(path: string, form: FormData): Promise { try { res = await fetch(`${API_URL}${path}`, { method: "POST", headers, body: form }); } catch (err) { - const hint = - err instanceof TypeError - ? " (network/CORS — check API is up and ASPC_CORS_ORIGINS includes this origin)" - : ""; - throw new ApiError(`Cannot reach API at ${API_URL}${hint}`, 0); + throw new ApiError(`Cannot reach API at ${API_URL}${unreachableApiHint(err)}`, 0); } if (!res.ok) { @@ -122,11 +123,7 @@ export async function login(username: string, password: string): Promise { body, }); } catch (err) { - const hint = - err instanceof TypeError - ? " (network/CORS — check API is up and ASPC_CORS_ORIGINS includes this origin)" - : ""; - throw new ApiError(`Cannot reach API at ${API_URL}${hint}`, 0); + throw new ApiError(`Cannot reach API at ${API_URL}${unreachableApiHint(err)}`, 0); } if (!res.ok) { @@ -170,4 +167,90 @@ export const api = { 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/types.ts b/frontend/src/lib/types.ts index bd21e45..13a3526 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -64,6 +64,7 @@ export interface AnalyzeResponse { analysis_type: string; report: SPCReport | Record; html_report?: string | null; + checklist?: Phase1Checklist | null; } export interface RunSummary { diff --git a/frontend/src/lib/ws.ts b/frontend/src/lib/ws.ts index 0ada3ab..22c3112 100644 --- a/frontend/src/lib/ws.ts +++ b/frontend/src/lib/ws.ts @@ -22,10 +22,8 @@ export function connectLiveSocket( onMessage: (msg: LivePointMessage) => void, options: LiveSocketOptions = {}, ): LiveSocketHandle { - const params = new URLSearchParams(); - if (options.token) params.set("token", options.token); - const qs = params.toString(); - const url = `${WS_URL}/ws/live/${encodeURIComponent(streamKey)}${qs ? `?${qs}` : ""}`; + // 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; @@ -48,6 +46,9 @@ export function connectLiveSocket( ws.onopen = () => { attempt = 0; + if (options.token) { + ws?.send(JSON.stringify({ type: "auth", token: options.token })); + } options.onOpen?.(); }; diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index 0124ed9..b93bea2 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +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/live/page.tsx","./src/app/login/page.tsx","./src/app/msa/page.tsx","./src/app/runs/page.tsx","./src/app/runs/[run_id]/page.tsx","./.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","./.next/types/app/runs/[run_id]/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,549],[99,145,360,550],[99,145,360,551],[99,145,360,544],[99,145,360,553],[99,145,360,552],[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,562],[99,145,562,566,567],[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,521,523,524,542,543,545,546],[87,99,145,523,524,543],[99,145,408,526,531],[87,99,145,521,523,524,525,530,542,543],[87,99,145,395,524,543],[99,145,389,521,523,524,530,542,543],[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","d40906362755700662df755aa0a22b6b6f46b2fde4d20681f5fe11a7c4455d51","ee015982e28b11cd871c38164c24c654f90224add0d7b362df3e97bd4dd06f36","4fc12e11a3954e6acd3ad828329c8f8b9bd8fa2394ddf8e7b59f21e378dccbc9","5fea6c61a54f805d6ae653000b1be8e1588dcef65a72c7787a367dedeb9f06d9",{"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","981e3648ccd27aedf8f807ccadfdc34ccc3957da4b905822ff6f525208b7b6dc","3a9b5bf5e6418da8c66ffe2776763f7cced6e34118717cec5b89523483cb7f4a","5c93c490af1bc398119776ebe996f2fb6dd17ff13d1139aa8a6fb0bd0ae323d7","8140f7a032c7e0936d2a0a0095f5630d8aec4d0c9123c184bc38db79d76ed03e","5e292acc19b5ca0829539393f0c90bc8f7f856d2fc28f5269a9ad5af3f0784e7","5dcadddbb51d9269ff493a221f77307f83da6bb88be61f4eeae0fca855aaa196","fdc92afcb54a97a15906b8181f22e3cf31860379599149a68551d77770711e42","36e3057b821936dca3b54918d0bbf81912363c231990093e410c099fe4e85710",{"version":"8b92642732596ae211287f8953532ac9e94f69ddadd1802758ef7189f679cd2e","signature":"08cdf4df4c24eb16aac47f6c0415c7c1f8a1e61bafeca1cea82f7516a6791afc"},"2b92e5a6fb9679fdb268d44ed7bff606f813d4e3e18631ca982344f9278f4741","9c086a77151b6a2b5e0281e10de745e5375dab2ed6e295fef9472acd3a231ffb","68973696dac9e11bbeeae256772a2495c6d02f673988a345af8221ec084c0aa9","995476a56b6065f2939f8dc2a731c302a782ee7f6f82d482e9d4e2663d9868b4","642aa16139f9c73cfacae18eb72b0c2741cfe568ebf22136a97228b31d8e88e3","ccd70d5509ee7c00f3db165531df2ff9007d34d100fbb292185148551255607b","527c2bc1a80650cbb096d8e19a6447acb201fdbc5516571a03afe2f4dea4ed3f","4c7a837b5c7213cbfe629caf839843d55d6a97c1e244c81ed84b0a48ddaf4113","12f1bc3596862b993d1a107e4848da5f0fe2140bcd6789f2ca193313aa52926b",{"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,561]],"options":{"allowJs":true,"esModuleInterop":true,"jsx":1,"module":99,"skipLibCheck":true,"strict":true,"target":4},"referencedMap":[[555,1],[556,2],[557,3],[558,4],[559,5],[554,6],[561,7],[560,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],[563,22],[562,11],[564,11],[565,11],[566,11],[568,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],[567,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],[569,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],[532,317],[549,318],[550,319],[551,316],[544,320],[553,321],[552,322],[545,323],[542,324],[546,323],[526,325],[531,326],[543,82],[524,323],[521,11],[523,11],[525,323],[443,327],[510,328]],"affectedFilesPendingEmit":[555,556,557,558,559,554,561,560,418,522,547,548,532,549,550,551,544,553,552,545,542,546,526,531,543,524,521,523,525,443,510],"version":"5.9.3"} \ No newline at end of file +{"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/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/pyproject.toml b/pyproject.toml index 4e07253..1ade9e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,10 +37,12 @@ apps = [ "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", @@ -64,6 +66,7 @@ dev = [ "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", @@ -131,6 +134,7 @@ 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"] 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/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/services/mqtt_bridge/main.py b/services/mqtt_bridge/main.py index 03fc6e6..0ac6326 100644 --- a/services/mqtt_bridge/main.py +++ b/services/mqtt_bridge/main.py @@ -15,6 +15,7 @@ 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] @@ -22,13 +23,22 @@ def _produce_kafka(bootstrap: str, topic: str, messages): 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) - producer.flush() + 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 @@ -46,16 +56,24 @@ def close() -> None: ) from exc loop = asyncio.new_event_loop() - producer = AIOKafkaProducer(bootstrap_servers=bootstrap) + 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_and_wait(topic, value=data, key=key.encode("utf-8")) + 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() @@ -67,13 +85,16 @@ def _normalise(msg: dict[str, Any]) -> dict[str, Any]: 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": float(value), "timestamp": ts} + return {"key": key, "value": value, "timestamp": ts} def main(argv: list[str] | None = None) -> int: diff --git a/services/stream_engine/main.py b/services/stream_engine/main.py index 285f07b..06c551e 100644 --- a/services/stream_engine/main.py +++ b/services/stream_engine/main.py @@ -53,7 +53,15 @@ def main(argv: list[str] | None = None) -> int: logger.error("%s", exc) return 2 - engine = StreamEngine(repo, redis_url=redis_url) + 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: diff --git a/spc_core/evaluator.py b/spc_core/evaluator.py index 4fd9165..e1108ab 100644 --- a/spc_core/evaluator.py +++ b/spc_core/evaluator.py @@ -156,7 +156,11 @@ def observe_subgroup(self, values) -> list[Signal]: return self._engine.add(mean) def _observe_variable(self, value: float) -> list[Signal]: - """Per-point limits (P/U, variable-n Xbar, EWMA list limits).""" + """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): @@ -194,6 +198,53 @@ def _observe_variable(self, value: float) -> list[Signal]: 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]: 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/pipeline.py b/spc_core/pipeline.py index 0573933..6700d9e 100644 --- a/spc_core/pipeline.py +++ b/spc_core/pipeline.py @@ -277,6 +277,14 @@ def establish( 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( 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/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_security.py b/tests/unit/test_security.py index 04231c2..bdb37b1 100644 --- a/tests/unit/test_security.py +++ b/tests/unit/test_security.py @@ -166,3 +166,47 @@ def test_startup_refuses_empty_api_keys(monkeypatch, tmp_path): 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_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 index 3b28271..4cf8d5e 100644 --- a/uv.lock +++ b/uv.lock @@ -106,6 +106,7 @@ 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" }, @@ -119,8 +120,10 @@ all = [ { name = "aiomqtt" }, { name = "alembic" }, { name = "asyncpg" }, + { name = "bcrypt" }, { name = "fastapi" }, { name = "greenlet" }, + { name = "openpyxl" }, { name = "passlib", extra = ["bcrypt"] }, { name = "plotly" }, { name = "polars" }, @@ -138,7 +141,9 @@ all = [ { name = "uvicorn", extra = ["standard"] }, ] apps = [ + { name = "bcrypt" }, { name = "fastapi" }, + { name = "openpyxl" }, { name = "passlib", extra = ["bcrypt"] }, { name = "prometheus-client" }, { name = "python-dotenv" }, @@ -159,6 +164,7 @@ dev = [ { name = "aiomqtt" }, { name = "alembic" }, { name = "asyncpg" }, + { name = "bcrypt" }, { name = "fastapi" }, { name = "greenlet" }, { name = "httpx" }, @@ -209,6 +215,9 @@ requires-dist = [ { 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" }, @@ -217,6 +226,7 @@ requires-dist = [ { 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" }, @@ -693,6 +703,35 @@ 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" @@ -705,6 +744,15 @@ 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" @@ -1339,6 +1387,18 @@ wheels = [ { 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" @@ -1439,6 +1499,34 @@ 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" From 69e1d393ef0459f7fa021408469d48cf5835f270 Mon Sep 17 00:00:00 2001 From: M1ndsmith Date: Thu, 13 Aug 2026 16:30:35 +0100 Subject: [PATCH 23/23] Fit the Vercel FastAPI function under 225 MB. Drop unused uvicorn/xlsx/redis wheels from the serverless install so frefact can replace aspc-plum production. Co-authored-by: Cursor --- deploy/vercel/README.md | 4 +++- deploy/vercel/vercel.json | 2 +- vercel.json | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/deploy/vercel/README.md b/deploy/vercel/README.md index 6f7d57c..4a7ee16 100644 --- a/deploy/vercel/README.md +++ b/deploy/vercel/README.md @@ -1,5 +1,7 @@ # 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. @@ -14,7 +16,7 @@ This folder is **additive** — it does not change Compose or the streaming stac 1. Framework: **FastAPI** 2. Root Directory: **empty / `.`** (never set this to a Dockerfile path) -3. Install Command: `pip install -e ".[apps]"` (skip `render`/plotly — it blows the 225 MB function limit; Analyze JSON still works) +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 diff --git a/deploy/vercel/vercel.json b/deploy/vercel/vercel.json index c9987b7..3cda8cd 100644 --- a/deploy/vercel/vercel.json +++ b/deploy/vercel/vercel.json @@ -1,5 +1,5 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", "framework": "fastapi", - "installCommand": "pip install -e \".[apps]\"" + "installCommand": "pip install -e \".[apps]\" && pip uninstall -y uvloop watchfiles httptools openpyxl et-xmlfile redis prometheus-client" } diff --git a/vercel.json b/vercel.json index c9987b7..3cda8cd 100644 --- a/vercel.json +++ b/vercel.json @@ -1,5 +1,5 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", "framework": "fastapi", - "installCommand": "pip install -e \".[apps]\"" + "installCommand": "pip install -e \".[apps]\" && pip uninstall -y uvloop watchfiles httptools openpyxl et-xmlfile redis prometheus-client" }