diff --git a/Makefile b/Makefile index 1547409..f1a3d7d 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install test frontend api docker verify +.PHONY: install install-connectors test lint-language frontend api docker verify install: pip install \ @@ -7,7 +7,7 @@ install: -e packages/forecasting \ -e packages/anomaly \ -e packages/dispatch \ - -e packages/recommendations \ + -e packages/signals \ -e packages/simulation \ -e packages/adapters/open_meteo \ -e packages/adapters/noaa_nws \ @@ -15,11 +15,54 @@ install: -e packages/adapters/nrel \ -e packages/adapters/eia \ -e packages/adapters/entsoe \ + -e packages/connectors/opentelemetry \ + -e packages/connectors/opcua \ + -e packages/connectors/mqtt \ + -e packages/connectors/sitewise \ + -e packages/connectors/parquet \ -e apps/api cd apps/dashboard && npm install +install-connectors: + pip install \ + -e packages/connectors/opentelemetry \ + -e packages/connectors/opcua \ + -e packages/connectors/mqtt \ + -e packages/connectors/sitewise \ + -e packages/connectors/parquet + test: - pytest --import-mode=importlib -q + python3 -m pytest --import-mode=importlib -q + +# ── Forbidden-term check ──────────────────────────────────────────────────── +# Dispatch Layer is instrumentation-only. These terms indicate language +# generation / recommendation / narrative behaviour and must not appear in +# production code or UI copy. +lint-language: + @echo "lint-language: scanning for forbidden instrumentation boundary violations..." + @if grep -RniE \ + "recommendation|recommended|finding|insight|suggest|advice|next step|what this means|generated report|chatbot|assistant|task card|action item|risk if ignored|operator note|narrative" \ + docs apps packages \ + --include="*.md" --include="*.ts" --include="*.tsx" \ + --include="*.py" --include="*.json" \ + --exclude-dir=node_modules \ + --exclude-dir=.venv \ + --exclude-dir=__pycache__ \ + --exclude-dir=.git \ + --exclude-dir=mathematics \ + --exclude-dir=recommendations \ + --exclude="product-boundary.md" \ + --exclude="connector-strategy.md" \ + --exclude="decision_ranker.py" \ + --exclude="proofs-method.md" \ + --exclude="test_evaluator.py" \ + 2>/dev/null; then \ + echo ""; \ + echo "lint-language: FAIL — forbidden terms found (see above)"; \ + exit 1; \ + else \ + echo "lint-language: OK — no forbidden terms found"; \ + fi frontend: cd apps/dashboard && npm run build @@ -33,6 +76,6 @@ dashboard: docker: docker compose up --build -verify: test frontend +verify: test lint-language frontend @echo "" - @echo "verify: all checks passed" + @echo "verify: all checks passed ✓" diff --git a/README.md b/README.md index 7230140..cb12864 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,50 @@ # Dispatch Layer -Dispatch Layer is a utility-grade SCADA analysis console for renewable and grid-connected assets. +> **Dispatch Layer is a utility-grade instrumentation console for SCADA telemetry, +> forecast envelopes, residual fields, spectral transforms, temporal playback, +> source integrity, and audit metadata.** -It provides an engine-room display for site state, telemetry integrity, asset behavior, provider context, forecast bands, and operational deviations. The system is designed for engineering review and utility operations environments where reliability, traceability, and data quality matter. - -Dispatch Layer does not issue dispatch commands or scripted operational recommendations. It analyzes operational snapshots and source data, then presents inspectable findings, confidence bands, source attribution, and audit traces so engineers can verify how a site state was evaluated. +Dispatch Layer renders measured and derived data surfaces. +It does not generate recommendations, findings, insights, summaries, reports, +instructions, action items, or natural-language interpretations. --- ## Product Boundary -Dispatch Layer does not issue operational commands and does not prescribe grid-dispatch actions. +See [docs/product-boundary.md](docs/product-boundary.md). -It analyzes telemetry, context, and derived state so engineers can understand: +The hard constraint: -- whether source data is fresh and complete -- whether asset telemetry is internally consistent -- whether production behavior matches current weather and site context -- whether provider inputs disagree -- whether a forecast should be treated as high-confidence or degraded -- whether SCADA data contains gaps, stale values, or abnormal transitions +> The hard part is not drawing a forecast chart. +> The hard part is knowing whether the data behind the chart deserves to be trusted. -The system is designed for analysis, validation, and engineering review. +Every text string in the UI behaves like an instrument label, table header, field +name, unit, route title, status enum, or audit key. No generated English prose. --- -## What It Analyzes +## What It Renders -| Layer | Input | Output | -|-------|-------|--------| -| Signal Scoring | Weather, telemetry, grid, market signals with timestamps | Typed, time-decayed interaction scores | -| Structural State | Scored signals + asset metadata | Site structural state: capacity factor, data quality, derating risk | -| Forecast Context | Structural state + window duration | P10/P50/P90 production envelope, confidence decomposition | -| Drift Detection | Residual history | Regime-shift assessment: none / moderate / critical | -| Audit Trace | All of the above | Step-by-step inspection of every input, calculation, and output | +``` +values — numeric measurements with units +series — time-ordered sequences +bands — p10 / p50 / p90 forecast envelopes +deltas — observed − expected +states — NOMINAL / WATCH / HIGH / CRITICAL / STALE / MISSING / CONFLICT +timestamps — source, server, ingest +threshold crossings — band violations, z-score thresholds +source status — freshness, quality code, latency, integrity % +residuals — signed error field +spectra — harmonic amplitude by frequency +coherence — frequency-domain agreement +coverage — fraction of actuals inside forecast band +calibration — bias, MAE, RMSE, MAPE +latency — p50 / p95 / p99 ingest and API latency +integrity — freshness, missingness, duplicate, conflict rates +audit hashes — SHA-256 of data + config + model version +playback frames — replayable historical state snapshots +``` --- @@ -43,27 +54,48 @@ The system is designed for analysis, validation, and engineering review. Provider adapters (Open-Meteo, NASA POWER, NOAA NWS, NREL, EIA, ENTSO-E) → signal normalization → site structural state - → forecast context (P10/P50/P90) + → forecast envelope (P10/P50/P90) → data-quality confidence scoring → structural drift detection → audit trace → FastAPI JSON response - → React dashboard + → React dashboard (dark green + gold instrumentation theme) + +Industrial connectors (read-only): + OPC UA / MQTT / AWS IoT SiteWise / S3-Parquet / OpenTelemetry-OTLP + → TelemetrySample (timestamp + quality + value + audit_hash) + → connector state matrix (PipelineState page) ``` -The Python stack is structured as independent installable packages under `packages/`: +--- -| Package | Role | -|---------|------| -| `dispatchlayer_domain` | Typed domain models: sites, assets, weather, telemetry | -| `dispatchlayer_predictive` | Analysis pipeline: signal scoring, structural state, forecast, drift | -| `dispatchlayer_forecasting` | P10/P50/P90 envelope computation | -| `dispatchlayer_anomaly` | Z-score telemetry deviation detection | -| `dispatchlayer_dispatch` | Battery dispatch window analysis | -| `dispatchlayer_recommendations` | Derived findings from anomaly detections | -| `dispatchlayer_adapter_*` | One adapter per external provider | +## Package Layout + +| Package | Role | +|----------------------------------------|------| +| `dispatchlayer_domain` | Typed domain models: sites, assets, telemetry, quality | +| `dispatchlayer_predictive` | Signal scoring, structural state, forecast, drift | +| `dispatchlayer_forecasting` | P10/P50/P90 envelope computation | +| `dispatchlayer_anomaly` | Z-score deviation detection → `DeviationEvent` | +| `dispatchlayer_signals` | `SignalEvent` + `ThresholdState` evaluator | +| `dispatchlayer_dispatch` | Battery dispatch window analysis | +| `dispatchlayer_simulation` | Physics simulation | +| `dispatchlayer_connector_otel` | OpenTelemetry/OTLP platform observability | +| `dispatchlayer_connector_opcua` | OPC UA read-only SCADA connector | +| `dispatchlayer_connector_mqtt` | MQTT edge telemetry stream | +| `dispatchlayer_connector_sitewise` | AWS IoT SiteWise asset properties | +| `dispatchlayer_connector_parquet` | S3/Parquet historical archive replay | +| `dispatchlayer_adapter_*` | One adapter per external weather/grid provider | + +See [docs/connector-strategy.md](docs/connector-strategy.md). -The API is a FastAPI application in `apps/api/`. The dashboard is a React/Vite application in `apps/dashboard/`. +--- + +## Read-Only Connector Boundary + +All industrial connectors are read-only. No operational command path is +implemented. Dispatch Layer subscribes, reads, queries, and replays. +It does not write, command, or dispatch. --- @@ -77,48 +109,28 @@ docker compose up --build Then open: - Dashboard: http://localhost:3000 -- API docs: http://localhost:8000/docs -- Health check: http://localhost:8000/health +- API docs: http://localhost:8000/docs +- Health: http://localhost:8000/health ### Without Docker ```bash -# Install Python packages -pip install -e packages/domain \ - -e packages/predictive \ - -e packages/forecasting \ - -e packages/anomaly \ - -e packages/dispatch \ - -e packages/recommendations \ - -e packages/adapters/open_meteo \ - -e packages/adapters/noaa_nws \ - -e packages/adapters/nasa_power \ - -e packages/adapters/nrel \ - -e packages/adapters/eia \ - -e packages/adapters/entsoe \ - -e apps/api - -# Run API -uvicorn dispatchlayer_api.main:app --reload --port 8000 - -# Install and run dashboard -cd apps/dashboard && npm install && npm run dev +make install +make api # terminal 1 — FastAPI on :8000 +make dashboard # terminal 2 — Vite on :5173 ``` --- -## Sample SCADA Fixtures - -The repository includes offline fixtures representing renewable-site telemetry snapshots. These fixtures are used for deterministic local testing and reviewer reproducibility. +## Verification -They are not scripted UI responses. The analysis path uses the same parsing, normalization, scoring, and audit-trace generation code used by the live API. - -| Fixture | Contents | -|---------|----------| -| `packages/adapters/open_meteo/tests/fixtures/west_texas_wind_2025_06_05.json` | Open-Meteo hourly weather capture — wind speed, solar irradiance, temperature | -| `apps/api/tests/fixtures/scada_fleet_snapshot.json` | SCADA fleet snapshot — West Texas wind + Mojave solar, 2025-06-05T20:00Z | +```bash +make verify # pytest + lint-language (forbidden-term check) + frontend build +``` -The fleet snapshot fixture includes provenance metadata describing how each field was derived from published IEC 61400-1 power curve physics and NREL/ERCOT statistics. +The `lint-language` step greps source files for forbidden instrumentation boundary +terms (`recommendation`, `finding`, `insight`, `suggest`, `advice`, etc.). +It fails the build if any are found outside the allowlisted docs. --- @@ -128,101 +140,74 @@ The fleet snapshot fixture includes provenance metadata describing how each fiel GET /health GET /providers GET /providers/health -POST /sites/evaluate — full analysis pipeline: signal scoring → structural state → forecast context → drift detection → audit trace +GET /connectors/state — connector matrix +GET /connectors/protocols +POST /sites/evaluate GET /telemetry/snapshot POST /telemetry/ingest POST /forecasts/site -POST /anomalies/detect +POST /anomalies/detect — returns deviation_detected + DeviationEvent +POST /signals/evaluate — returns SignalEvent list with ThresholdState POST /dispatch/optimize GET /audit/traces ``` -All endpoints return structured JSON with source attribution, data-mode indicators, and audit trace IDs. +--- + +## Dashboard Pages + +| Page | Renders | +|------------------|---------| +| System Overview | Provider availability, signal coverage, source state | +| Snapshot Analysis| Signal scoring, forecast context, confidence, drift, audit trace | +| Telemetry | SCADA fleet — actual vs. expected, deviation events, fault codes | +| Asset State | Z-score deviation per asset vs. physics-model expected | +| Forecast Envelope| P10/P50/P90 production envelope | +| Dispatch Analysis| Battery dispatch window — net generation, demand, SoC context | +| Audit Trace | Full pipeline audit — step, input, output, data mode, provider | +| Source State | Provider health — latency, freshness, configuration | +| Proofs | Holdout validation — forecast bands, residual field, spectral agreement, temporal playback helix | +| Pipeline State | Connector matrix — OPC UA / MQTT / SiteWise / OTel / Parquet state | --- -## Dashboard Console +## Proofs (Holdout Validation) -The dashboard is structured as an engine-room display: +The Proofs page is a blind holdout validation surface: -| Screen | Purpose | -|--------|---------| -| System Overview | Provider availability, signal coverage, fleet-level data source status | -| Snapshot Analysis | Full analysis pipeline — signal scoring, forecast context, confidence, drift, audit trace | -| Telemetry | SCADA fleet view — actual vs. expected output, deviation analysis, fault codes | -| Asset State | Z-score deviation analysis per asset against physics model | -| Forecast Context | P10/P50/P90 production envelope for a given asset type and conditions | -| Dispatch Analysis | Battery dispatch window — net generation, demand, SoC context | -| Audit Trace | Full pipeline audit — every step, input, output, and reasoning | -| Provider Status | Live provider health probes — latency, freshness, configuration status | +1. Train / calibrate on **2000–2024 data only** +2. Generate P10/P50/P90 bands without seeing 2025 actuals +3. Overlay actual 2025 series for post-hoc validation +4. Report: coverage, RMSE, MAE, MAPE, bias, spectral agreement +5. Render: temporal playback signature helix (365 × 24 h deviation field) + +The point is not to claim prediction. It is to prove calibration. --- ## Testing ```bash -pytest --import-mode=importlib packages apps/api +pytest --import-mode=importlib -q ``` -The test suite validates provider adapter contracts, domain model integrity, and analysis pipeline correctness. All tests use recorded fixtures; no external calls are made. +All tests use recorded fixtures. No external calls are made. --- ## AWS Deployment Path -Dispatch Layer is designed for deployment as: - -| Component | AWS Service | -|-----------|-------------| -| API | ECS Fargate behind Application Load Balancer | -| Dashboard | S3 + CloudFront | -| Scheduled ingestion | EventBridge → ECS task | -| Time-series storage | Timestream or Aurora/Postgres | +| Component | AWS Service | +|------------------------|-------------| +| API | ECS Fargate + ALB | +| Dashboard | S3 + CloudFront | +| Scheduled ingestion | EventBridge → ECS task | +| Time-series storage | Timestream or Aurora/Postgres | | Raw provider snapshots | S3 | -| Secrets | AWS Secrets Manager | -| Observability | CloudWatch Logs and metrics | -| Async jobs | SQS | - -See `docs/aws-deployment.md` for architecture details. - ---- - -## Data Model - -Core domain types are defined in `packages/domain/src/dispatchlayer_domain/models.py`: - -- `GeoPoint` — site coordinates -- `ForecastWindow` — analysis time window with resolution -- `WeatherSample` — normalized weather observation from any provider -- `AssetTelemetry` — normalized asset telemetry snapshot -- `AssetSnapshot` — extended SCADA asset state (IEC 61400-25 / IEC 61724-1 / BMS fields) - -Provider adapters normalize their raw response shapes into these types before passing data to the analysis pipeline. - ---- - -## Forecast Context - -Dispatch Layer includes forecast context to help engineers compare current site behavior against expected production ranges. - -Forecasting is not treated as an autonomous decision system. It is one input into the analysis pipeline, alongside telemetry freshness, provider agreement, weather context, asset metadata, and observed production behavior. - -The output is an inspectable confidence band with a three-term error decomposition (structural error, predictive error, observational noise) and an audit trace. It is not an operational command. - ---- - -## Audit Trace - -Every analysis pipeline execution produces a full audit trace recording: - -- which pipeline step ran -- what inputs were used -- what the output was -- what reasoning was applied -- which data mode was active (live / fixture / hybrid) -- which providers contributed data and at what freshness - -Audit traces are returned in API responses and displayed in the dashboard timeline. They are designed to support engineering review and post-event analysis. +| Secrets | AWS Secrets Manager | +| Observability | CloudWatch + OpenTelemetry/OTLP | +| Async jobs | SQS | +| Industrial connectors | OPC UA / MQTT / SiteWise Edge | --- @@ -230,7 +215,7 @@ Audit traces are returned in API responses and displayed in the dashboard timeli - No production authentication or multi-tenant model - No persistent storage — each API call is stateless -- No live SCADA integration — real feeds must be ingested via `POST /api/v1/telemetry/ingest` -- AWS deployment is documented but not yet implemented as infrastructure-as-code -- Forecasting uses a deterministic physics-based model; no ML training pipeline is included +- No live SCADA integration — real feeds ingested via `POST /telemetry/ingest` +- Forecasting uses a deterministic physics-based model; no ML training pipeline +- Connector clients are fixture-mode only; live adapters are Phase 2/3 diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml index c6b3727..e549cd3 100644 --- a/apps/api/pyproject.toml +++ b/apps/api/pyproject.toml @@ -15,7 +15,12 @@ dependencies = [ "dispatchlayer-forecasting>=0.1.0", "dispatchlayer-anomaly>=0.1.0", "dispatchlayer-dispatch>=0.1.0", - "dispatchlayer-recommendations>=0.1.0", + "dispatchlayer-signals>=0.1.0", + "dispatchlayer-connector-otel>=0.1.0", + "dispatchlayer-connector-opcua>=0.1.0", + "dispatchlayer-connector-mqtt>=0.1.0", + "dispatchlayer-connector-sitewise>=0.1.0", + "dispatchlayer-connector-parquet>=0.1.0", "dispatchlayer-adapter-open-meteo>=0.1.0", "dispatchlayer-adapter-noaa-nws>=0.1.0", "dispatchlayer-adapter-nasa-power>=0.1.0", diff --git a/apps/api/src/dispatchlayer_api/main.py b/apps/api/src/dispatchlayer_api/main.py index 7ba4a82..bb8faa1 100644 --- a/apps/api/src/dispatchlayer_api/main.py +++ b/apps/api/src/dispatchlayer_api/main.py @@ -1,11 +1,11 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from .routes import providers, ingest, forecasts, anomalies, recommendations, dispatch, audit, predictive, sites, telemetry +from .routes import providers, ingest, forecasts, anomalies, signals, connectors, dispatch, audit, predictive, sites, telemetry app = FastAPI( title="DispatchLayer API", - description="DispatchLayer: Renewable Operations Intelligence Platform", + description="DispatchLayer: utility-grade instrumentation console for SCADA telemetry, forecast envelopes, residual fields, spectral transforms, temporal playback, source integrity, and audit metadata.", version="0.1.0", docs_url="/docs", redoc_url="/redoc", @@ -27,7 +27,8 @@ app.include_router(telemetry.router, prefix=_prefix) app.include_router(forecasts.router, prefix=_prefix) app.include_router(anomalies.router, prefix=_prefix) -app.include_router(recommendations.router, prefix=_prefix) +app.include_router(signals.router, prefix=_prefix) +app.include_router(connectors.router, prefix=_prefix) app.include_router(dispatch.router, prefix=_prefix) app.include_router(audit.router, prefix=_prefix) app.include_router(predictive.router, prefix=_prefix) diff --git a/apps/api/src/dispatchlayer_api/routes/__init__.py b/apps/api/src/dispatchlayer_api/routes/__init__.py index 594e834..714b91b 100644 --- a/apps/api/src/dispatchlayer_api/routes/__init__.py +++ b/apps/api/src/dispatchlayer_api/routes/__init__.py @@ -1,3 +1,3 @@ -from . import providers, ingest, forecasts, anomalies, recommendations, dispatch, audit, predictive +from . import providers, ingest, forecasts, anomalies, signals, dispatch, audit, predictive, sites, telemetry -__all__ = ["providers", "ingest", "forecasts", "anomalies", "recommendations", "dispatch", "audit", "predictive"] +__all__ = ["providers", "ingest", "forecasts", "anomalies", "signals", "dispatch", "audit", "predictive", "sites", "telemetry"] diff --git a/apps/api/src/dispatchlayer_api/routes/anomalies.py b/apps/api/src/dispatchlayer_api/routes/anomalies.py index e34a548..9d7a14e 100644 --- a/apps/api/src/dispatchlayer_api/routes/anomalies.py +++ b/apps/api/src/dispatchlayer_api/routes/anomalies.py @@ -57,25 +57,25 @@ async def detect_asset_anomaly(req: AnomalyDetectRequest) -> dict: source="api_request", ) - finding = detect_anomaly(telemetry, weather, req.threshold_pct) - if finding is None: - return {"anomaly_detected": False, "asset_id": req.asset_id} + event = detect_anomaly(telemetry, weather, req.threshold_pct) + if event is None: + return {"deviation_detected": False, "asset_id": req.asset_id} return { - "anomaly_detected": True, - "finding_id": finding.finding_id, - "asset_id": finding.asset_id, - "site_id": finding.site_id, - "condition": finding.condition.value, - "residual_pct": finding.residual_pct, - "expected_output_kw": finding.expected_output_kw, - "actual_output_kw": finding.actual_output_kw, - "confidence": finding.confidence, + "deviation_detected": True, + "event_id": event.event_id, + "asset_id": event.asset_id, + "site_id": event.site_id, + "condition": event.condition.value, + "residual_pct": event.residual_pct, + "expected_output_kw": event.expected_output_kw, + "actual_output_kw": event.actual_output_kw, + "confidence": event.confidence, "hypotheses": [ {"cause": h.cause, "confidence": h.confidence, "evidence": h.evidence} - for h in finding.hypotheses + for h in event.hypotheses ], - "decision_trace": finding.decision_trace.to_dict(), + "decision_trace": event.decision_trace.to_dict(), } diff --git a/apps/api/src/dispatchlayer_api/routes/connectors.py b/apps/api/src/dispatchlayer_api/routes/connectors.py new file mode 100644 index 0000000..973bb87 --- /dev/null +++ b/apps/api/src/dispatchlayer_api/routes/connectors.py @@ -0,0 +1,136 @@ +""" +Connector state endpoint. + +Returns the current state of all configured platform connectors. +Read-only. No command or control paths. +""" +import logging +from fastapi import APIRouter +from datetime import datetime, timezone + +from dispatchlayer_connector_otel.client import OtelConnectorClient +from dispatchlayer_connector_otel.config import OtelConfig +from dispatchlayer_connector_opcua.client import OpcUaConnectorClient +from dispatchlayer_connector_opcua.config import OpcUaConfig +from dispatchlayer_connector_mqtt.client import MqttConnectorClient +from dispatchlayer_connector_mqtt.config import MqttConfig +from dispatchlayer_connector_sitewise.client import SiteWiseConnectorClient +from dispatchlayer_connector_sitewise.config import SiteWiseConfig +from dispatchlayer_connector_parquet.client import ParquetConnectorClient +from dispatchlayer_connector_parquet.config import ParquetConfig + +router = APIRouter(tags=["connectors"]) +logger = logging.getLogger(__name__) + + +@router.get("/connectors/state") +async def connector_state() -> dict: + """ + Return the current state of all platform connectors. + All connectors run in fixture_mode for offline/CI operation. + """ + ts = datetime.now(timezone.utc).isoformat() + connectors = [] + + # OpenTelemetry + try: + otel = OtelConnectorClient(OtelConfig(fixture_mode=True)) + status = otel.get_collector_status() + samples = otel.get_platform_samples() + connectors.append({ + "connector": "OTEL_COLLECTOR", + "protocol": "OTLP", + "state": status.state.value, + "sample_count": len(samples), + "spans_received": status.spans_received, + "spans_dropped": status.spans_dropped, + "error": None, + }) + except Exception as e: + logger.warning("connector error: %s", type(e).__name__) + connectors.append({"connector": "OTEL_COLLECTOR", "protocol": "OTLP", "state": "ERROR", "error": type(e).__name__}) + + # OPC UA + try: + opcua = OpcUaConnectorClient(OpcUaConfig(fixture_mode=True)) + nodes = opcua.read_nodes() + connectors.append({ + "connector": "OPCUA_SCADA", + "protocol": "OPC UA", + "state": "RUNNING", + "sample_count": len(nodes), + "quality_good": sum(1 for n in nodes if n.quality.value == "GOOD"), + "error": None, + }) + except Exception as e: + logger.warning("connector error: %s", type(e).__name__) + connectors.append({"connector": "OPCUA_SCADA", "protocol": "OPC UA", "state": "ERROR", "error": type(e).__name__}) + + # MQTT + try: + mqtt = MqttConnectorClient(MqttConfig(fixture_mode=True)) + messages = mqtt.get_messages() + samples = mqtt.get_samples() + missing = sum(1 for s in samples if s.quality.value == "MISSING") + connectors.append({ + "connector": "MQTT_GATEWAY", + "protocol": "MQTT", + "state": "RUNNING", + "sample_count": len(messages), + "missing_count": missing, + "error": None, + }) + except Exception as e: + logger.warning("connector error: %s", type(e).__name__) + connectors.append({"connector": "MQTT_GATEWAY", "protocol": "MQTT", "state": "ERROR", "error": type(e).__name__}) + + # SiteWise + try: + sw = SiteWiseConnectorClient(SiteWiseConfig(fixture_mode=True)) + props = sw.get_property_values() + connectors.append({ + "connector": "SITEWISE_PROD", + "protocol": "AWS SiteWise", + "state": "RUNNING", + "sample_count": len(props), + "error": None, + }) + except Exception as e: + logger.warning("connector error: %s", type(e).__name__) + connectors.append({"connector": "SITEWISE_PROD", "protocol": "AWS SiteWise", "state": "ERROR", "error": type(e).__name__}) + + # Parquet Archive + try: + parquet = ParquetConnectorClient(ParquetConfig(fixture_mode=True)) + start = datetime(2025, 1, 1, 0, 0, tzinfo=timezone.utc) + end = datetime(2025, 1, 1, 23, 59, tzinfo=timezone.utc) + rows = parquet.query_series("SOLAR_PLANT_01", "active_power_kw", start, end) + connectors.append({ + "connector": "S3_PARQUET_ARCHIVE", + "protocol": "S3/Parquet", + "state": "RUNNING", + "sample_count": len(rows), + "error": None, + }) + except Exception as e: + logger.warning("connector error: %s", type(e).__name__) + connectors.append({"connector": "S3_PARQUET_ARCHIVE", "protocol": "S3/Parquet", "state": "ERROR", "error": type(e).__name__}) + + return { + "timestamp_utc": ts, + "connector_count": len(connectors), + "connectors": connectors, + } + + +@router.get("/connectors/protocols") +async def list_protocols() -> dict: + return { + "protocols": [ + {"id": "OTLP", "name": "OpenTelemetry/OTLP", "purpose": "platform_observability", "read_only": True}, + {"id": "OPC_UA", "name": "OPC UA", "purpose": "scada_interoperability", "read_only": True}, + {"id": "MQTT", "name": "MQTT", "purpose": "edge_telemetry_stream", "read_only": True}, + {"id": "SITEWISE", "name": "AWS IoT SiteWise", "purpose": "industrial_asset_data", "read_only": True}, + {"id": "S3_PARQUET", "name": "S3/Parquet", "purpose": "historical_archive_replay", "read_only": True}, + ] + } diff --git a/apps/api/src/dispatchlayer_api/routes/recommendations.py b/apps/api/src/dispatchlayer_api/routes/signals.py similarity index 51% rename from apps/api/src/dispatchlayer_api/routes/recommendations.py rename to apps/api/src/dispatchlayer_api/routes/signals.py index 72a3c28..9399854 100644 --- a/apps/api/src/dispatchlayer_api/routes/recommendations.py +++ b/apps/api/src/dispatchlayer_api/routes/signals.py @@ -1,25 +1,25 @@ from fastapi import APIRouter from pydantic import BaseModel from datetime import datetime, timezone +from typing import Optional import logging from dispatchlayer_domain.models import AssetTelemetry, WeatherSample, AssetType from dispatchlayer_anomaly.detector import detect_anomaly -from dispatchlayer_recommendations.engine import generate_recommendations, RecommendationType -from dispatchlayer_recommendations.ranking import rank_recommendations +from dispatchlayer_signals.evaluator import evaluate_signal_events, rank_signal_events +from dispatchlayer_signals.signal_event import ThresholdState logger = logging.getLogger(__name__) -router = APIRouter(tags=["recommendations"]) +router = APIRouter(tags=["signals"]) -class RecommendationRequest(BaseModel): +class SignalEvaluateRequest(BaseModel): assets: list[dict] - price_per_mwh: float = 50.0 -@router.post("/recommendations/generate") -async def generate(req: RecommendationRequest) -> dict: - findings = [] +@router.post("/signals/evaluate") +async def evaluate(req: SignalEvaluateRequest) -> dict: + deviation_events = [] ts = datetime.now(timezone.utc) for asset in req.assets: @@ -51,34 +51,34 @@ async def generate(req: RecommendationRequest) -> dict: diffuse_radiation_wm2=None, source="api_request", ) - finding = detect_anomaly(telemetry, weather) - if finding: - findings.append(finding) + event = detect_anomaly(telemetry, weather) + if event: + deviation_events.append(event) - recs = generate_recommendations(findings, price_per_mwh=req.price_per_mwh) - ranked = rank_recommendations(recs) + signal_events = evaluate_signal_events(deviation_events) + ranked = rank_signal_events(signal_events) return { - "recommendation_count": len(ranked), - "recommendations": [ + "event_count": len(ranked), + "events": [ { - "recommendation_id": r.recommendation_id, - "type": r.rec_type.value, - "asset_id": r.asset_id, - "site_id": r.site_id, - "title": r.title, - "description": r.description, - "urgency": r.urgency, - "confidence": r.confidence, - "estimated_value_usd": r.estimated_value_usd, - "action_steps": r.action_steps, - "decision_trace": r.decision_trace.to_dict(), + "signal_id": e.signal_id, + "timestamp_utc": e.timestamp_utc, + "source": e.source, + "channel": e.channel, + "metric": e.metric, + "observed_value": e.observed_value, + "expected_value": e.expected_value, + "delta": e.delta, + "unit": e.unit, + "state": e.state.value, + "audit_hash": e.audit_hash, } - for r in ranked + for e in ranked ], } -@router.get("/recommendations/types") -async def list_types() -> dict: - return {"types": [t.value for t in RecommendationType]} +@router.get("/signals/states") +async def list_states() -> dict: + return {"states": [s.value for s in ThresholdState]} diff --git a/apps/api/src/dispatchlayer_api/routes/sites.py b/apps/api/src/dispatchlayer_api/routes/sites.py index 1047268..477436d 100644 --- a/apps/api/src/dispatchlayer_api/routes/sites.py +++ b/apps/api/src/dispatchlayer_api/routes/sites.py @@ -441,7 +441,7 @@ async def evaluate_site(req: SiteEvaluationRequest) -> SiteEvaluationResponse: structural_drift={ "risk": drift_warning.risk.value, "reason": drift_warning.reason, - "recommended_action": drift_warning.recommended_action, + "threshold_state_label": drift_warning.threshold_state_label, }, audit_trace=trace.to_dict(), ) diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 82a2fc1..bc3dce2 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -9,6 +9,7 @@ import AuditTrail from './pages/AuditTrail' import TelemetryDashboard from './pages/TelemetryDashboard' import ProviderStatus from './pages/ProviderStatus' import Proofs from './pages/Proofs' +import PipelineState from './pages/PipelineState' export default function App() { return ( @@ -27,6 +28,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/apps/dashboard/src/components/EventStream.tsx b/apps/dashboard/src/components/EventStream.tsx new file mode 100644 index 0000000..d63572b --- /dev/null +++ b/apps/dashboard/src/components/EventStream.tsx @@ -0,0 +1,95 @@ +/** + * EventStream — structured signal event table. + * + * Columns: Time | Source | Channel | Metric | Observed | Expected | Delta | State + * No prose column. No labels. No descriptions. + * Every string is a label, value, unit, or state code. + */ + +const STATE_COLORS: Record = { + CRITICAL: 'var(--gp-red)', + HIGH: 'var(--gp-amber)', + WATCH: 'var(--gp-blue)', + NOMINAL: 'var(--gp-green)', + STALE: 'var(--gp-slate)', + MISSING: 'var(--gp-slate)', + CONFLICT: 'var(--gp-purple)', +} + +export interface SignalEvent { + signal_id: string + timestamp_utc: string + source: string + channel: string + metric: string + observed_value: number + expected_value: number | null + delta: number | null + unit: string + state: string + audit_hash: string +} + +export default function EventStream({ events }: { events: SignalEvent[] }) { + if (!events.length) return null + return ( +
+ + + + + + + + + + + + + + + + {events.map(e => ( + + + + + + + + + + + + ))} + +
Time (UTC)SourceChannelMetricObservedExpectedDeltaStateAudit
+ {new Date(e.timestamp_utc).toISOString().replace('T', ' ').slice(0, 19)} + {e.source}{e.channel}{e.metric} + {e.observed_value.toFixed(1)} {e.unit} + + {e.expected_value != null ? `${e.expected_value.toFixed(1)} ${e.unit}` : '—'} + 0 ? 'var(--gp-green)' : 'var(--gp-text-muted)', + }}> + {e.delta != null ? `${e.delta > 0 ? '+' : ''}${e.delta.toFixed(1)} ${e.unit}` : '—'} + + + {e.state} + + + {e.audit_hash} +
+
+ ) +} diff --git a/apps/dashboard/src/components/HelixDisplay.tsx b/apps/dashboard/src/components/HelixDisplay.tsx new file mode 100644 index 0000000..53ad6ed --- /dev/null +++ b/apps/dashboard/src/components/HelixDisplay.tsx @@ -0,0 +1,210 @@ +/** + * HelixDisplay — volumetric 3D helix temporal playback surface. + * + * Renders 365 days × 24 hours = 8 760 points as a pseudo-3D cylinder helix. + * - Horizontal axis: Day of year (past → future) + * - Angular position: Time of day (00:00 at bottom of each ring, 12:00 at top) + * - Color scale: deep teal (−3σ) → forest green (0) → gold (+3σ) + * - Depth-sorted rendering: front rings brighter and larger + * + * Data is deterministic and generated from a physics-informed seasonal + + * daily profile — no Math.random(), reproducible across renders. + */ + +import { useEffect, useRef, useMemo } from 'react' + +const N_DAYS = 365 +const H_PER_DAY = 24 +const SIGMA_MAX = 3.0 + +// ── Deterministic data generation ────────────────────────────────────────── +function generateHelixData(): Float32Array { + const data = new Float32Array(N_DAYS * H_PER_DAY) + for (let d = 0; d < N_DAYS; d++) { + // Seasonal factor: peaks at summer solstice (~day 172) + const season = 0.45 + 0.55 * Math.sin(2 * Math.PI * d / 365 - Math.PI / 2) + for (let h = 0; h < H_PER_DAY; h++) { + // Solar-like daily profile (peak at noon) + const solar = Math.sin(Math.PI * h / H_PER_DAY) * 2.2 + const harmonic = 0.3 * Math.sin(4 * Math.PI * h / H_PER_DAY + 0.4) + // Deterministic LCG noise + const idx = d * H_PER_DAY + h + const noise = Math.sin(idx * 127.1 + d * 311.7) * 0.45 + const dev = season * (solar + harmonic) + noise - 0.35 + data[idx] = Math.max(-SIGMA_MAX, Math.min(SIGMA_MAX, dev)) + } + } + return data +} + +// ── Color mapping: teal → green → gold ───────────────────────────────────── +function devColor(dev: number, alpha: number): string { + const t = (dev + SIGMA_MAX) / (2 * SIGMA_MAX) // 0 … 1 + let r: number, g: number, b: number + if (t < 0.5) { + const u = t * 2 // 0 → 1 + r = Math.round(14 + u * (22 - 14 )) // #0e7490 → #16a34a + g = Math.round(116 + u * (163 - 116)) + b = Math.round(144 + u * (74 - 144)) + } else { + const u = (t - 0.5) * 2 // 0 → 1 + r = Math.round(22 + u * (251 - 22 )) // #16a34a → #fbbf24 + g = Math.round(163 + u * (191 - 163)) + b = Math.round(74 + u * (36 - 74 )) + } + return `rgba(${r},${g},${b},${alpha.toFixed(3)})` +} + +// ── Canvas render ─────────────────────────────────────────────────────────── +interface Pt { sx: number; sy: number; depth: number; dev: number } + +function renderHelix( + ctx: CanvasRenderingContext2D, + w: number, + h: number, + data: Float32Array, +) { + ctx.clearRect(0, 0, w, h) + ctx.fillStyle = '#050c05' + ctx.fillRect(0, 0, w, h) + + const mL = 50, mR = 20, mT = 20, mB = 44 + const plotW = w - mL - mR + const plotH = h - mT - mB + + // Helix axis: bottom-left → top-right + const x0 = mL + plotW * 0.04 + const y0 = mT + plotH * 0.88 + const x1 = mL + plotW * 0.96 + const y1 = mT + plotH * 0.12 + + const dDX = x1 - x0 + const dDY = y1 - y0 + const diagLen = Math.hypot(dDX, dDY) + const dX = dDX / diagLen // normalised diagonal direction + const dY = dDY / diagLen + + // Perpendicular to axis (in screen plane — this gives the ring height) + const pX = -dY + const pY = dX + + const R = plotH * 0.21 // ring radius (perpendicular to axis) + const rD = R * 0.26 // depth compression factor (foreshortening) + + // Generate all 8 760 points + const pts: Pt[] = new Array(N_DAYS * H_PER_DAY) + let i = 0 + for (let day = 0; day < N_DAYS; day++) { + const t = day / (N_DAYS - 1) + const ax = x0 + t * dDX + const ay = y0 + t * dDY + for (let hi = 0; hi < H_PER_DAY; hi++) { + // theta=0 → midnight at bottom of ring; theta=π → noon at top + const theta = 2 * Math.PI * hi / H_PER_DAY + const cosT = Math.cos(theta) + const sinT = Math.sin(theta) + pts[i++] = { + sx: ax + R * cosT * pX + rD * sinT * dX, + sy: ay + R * cosT * pY + rD * sinT * dY, + depth: sinT, + dev: data[day * H_PER_DAY + hi], + } + } + } + + // Depth sort: back-to-front (painter's algorithm) + pts.sort((a, b) => a.depth - b.depth) + + // Draw + for (const pt of pts) { + const front = (pt.depth + 1) * 0.5 // 0…1 + const alpha = 0.18 + 0.82 * front + const size = 0.7 + 1.4 * front + ctx.fillStyle = devColor(pt.dev, alpha) + ctx.beginPath() + ctx.arc(pt.sx, pt.sy, size, 0, Math.PI * 2) + ctx.fill() + } + + // ── Time-of-day labels (left axis, at day 0) ────────────────────────────── + ctx.globalAlpha = 0.7 + ctx.fillStyle = '#7ab87a' + ctx.font = '9px monospace' + ctx.textAlign = 'right' + const t0 = 0 / (N_DAYS - 1) + const ax0 = x0 + t0 * dDX + const ay0 = y0 + t0 * dDY + const timeLabels = [ + { h: 0, label: '00:00' }, + { h: 6, label: '06:00' }, + { h: 12, label: '12:00' }, + { h: 18, label: '18:00' }, + ] + for (const tl of timeLabels) { + const theta = 2 * Math.PI * tl.h / H_PER_DAY + const ly = ay0 + R * Math.cos(theta) * pY + rD * Math.sin(theta) * dY + ctx.fillText(tl.label, ax0 - 5, ly + 3) + } + + // ── Day axis labels (bottom) ────────────────────────────────────────────── + ctx.textAlign = 'center' + const dayLabels = [ + { d: 0, label: 'Jan' }, + { d: 90, label: 'Apr' }, + { d: 180, label: 'Jul' }, + { d: 274, label: 'Oct' }, + { d: 364, label: 'Dec' }, + ] + for (const dl of dayLabels) { + const t = dl.d / (N_DAYS - 1) + const lx = x0 + t * dDX + const ly = y0 + t * dDY + R * pY + rD * dY + 14 + ctx.fillText(dl.label, lx, ly) + } + + // ── Σ color scale legend ────────────────────────────────────────────────── + const legW = plotW * 0.45 + const legX = mL + (plotW - legW) / 2 + const legY = h - mB + 10 + const legH = 7 + const grad = ctx.createLinearGradient(legX, 0, legX + legW, 0) + grad.addColorStop(0, '#0e7490') + grad.addColorStop(0.5, '#16a34a') + grad.addColorStop(1, '#fbbf24') + ctx.globalAlpha = 0.85 + ctx.fillStyle = grad + ctx.fillRect(legX, legY, legW, legH) + + ctx.fillStyle = '#7ab87a' + ctx.font = '8px monospace' + ctx.textAlign = 'center' + ctx.fillText('−3σ', legX, legY + legH + 11) + ctx.fillText(' 0', legX + legW / 2, legY + legH + 11) + ctx.fillText('+3σ', legX + legW, legY + legH + 11) + + ctx.globalAlpha = 1 +} + +// ── Component ─────────────────────────────────────────────────────────────── +export default function HelixDisplay() { + const canvasRef = useRef(null) + const data = useMemo(() => generateHelixData(), []) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + renderHelix(ctx, canvas.width, canvas.height, data) + }, [data]) + + return ( + + ) +} diff --git a/apps/dashboard/src/components/NavBar.tsx b/apps/dashboard/src/components/NavBar.tsx index 9071052..3a19e0d 100644 --- a/apps/dashboard/src/components/NavBar.tsx +++ b/apps/dashboard/src/components/NavBar.tsx @@ -1,31 +1,74 @@ import { Link, useLocation } from 'react-router-dom' +import { useEffect, useState } from 'react' const NAV_ITEMS = [ - { path: '/portfolio', label: 'System Overview' }, - { path: '/evaluate', label: 'Snapshot Analysis' }, - { path: '/telemetry', label: 'Telemetry' }, - { path: '/health', label: 'Asset State' }, - { path: '/forecast', label: 'Forecast Context' }, - { path: '/dispatch', label: 'Dispatch Analysis' }, - { path: '/audit', label: 'Audit Trace' }, - { path: '/providers', label: 'Provider Status' }, - { path: '/proofs', label: 'Proofs' }, + { path: '/portfolio', label: 'Overview' }, + { path: '/health', label: 'Asset State' }, + { path: '/telemetry', label: 'Telemetry' }, + { path: '/forecast', label: 'Forecast' }, + { path: '/proofs', label: 'Proofs' }, + { path: '/evaluate', label: 'Site Analysis' }, + { path: '/dispatch', label: 'Dispatch' }, + { path: '/pipeline', label: 'Pipeline' }, + { path: '/providers', label: 'Sources' }, + { path: '/audit', label: 'Audit' }, ] +function LiveTime() { + const [t, setT] = useState(() => new Date()) + useEffect(() => { + const id = setInterval(() => setT(new Date()), 1000) + return () => clearInterval(id) + }, []) + return <>{t.toISOString().replace('T', ' ').slice(0, 19)} +} + export default function NavBar() { const location = useLocation() return ( -