Spaces:
Running
v2: physically correct SRF-PLL, realistic attacks, exploit-proof graders
Browse filesPhysics
- Fix Clarke/Park transforms (vq was cos(θ+θ̂), so the PLL never locked);
cosine-referenced amplitude-invariant transforms, all three phases,
5 kHz simulation, ωn=100 rad/s, ζ=0.707, amplitude-normalised PI loop
- Natural grid events (phase jumps, sags, single-phase faults, harmonic
steps, frequency events) as confounders; sensor noise + 12-bit ADC
- Coordinated phase-spoofing attack for the hard task; trusted, delayed
PMU stream; PMU-disciplined holdover and latched disconnect actions
- Desync measured against the true grid angle; shadow PLL gives the
counterfactual time-to-desync used by the hard grader
Environment / graders / reward
- 40 decisions x 50 ms, attack-free episodes, single seeded RNG tree
- Ground truth hidden until done; env detector no longer leaked in info
- Graders penalise false alarms; always/never/random policies score low,
oracle scores >= 0.9 on every seed
- Dense reward with Brier calibration, mitigation costs and harm penalty
OpenEnv / infra
- openenv-core 0.3.0 create_app (WS, schema, metadata, MCP, health) plus
session-aware HTTP reset/step/state; typed EnvClient; landing page demo
- inference.py: LLM every step, fixed seeds, in-process fallback, fast
fallback on permanent API errors, time budget, atomic log blocks
- Python 3.12 non-root Docker image, .dockerignore, CI workflow,
submission validator, 65 tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- .dockerignore +16 -0
- .env.example +10 -7
- .gitattributes +2 -0
- .github/workflows/ci.yml +40 -0
- .github/workflows/deploy.yml +5 -9
- .gitignore +9 -7
- Dockerfile +17 -9
- README.md +237 -134
- inference.py +274 -442
- inference_test.py +0 -501
- openenv.yaml +40 -58
- pll_cyber/__init__.py +7 -0
- pll_cyber/attacks.py +107 -0
- pll_cyber/baselines.py +195 -0
- pll_cyber/client.py +34 -0
- pll_cyber/environment.py +323 -0
- pll_cyber/evaluation.py +48 -0
- pll_cyber/features.py +108 -0
- pll_cyber/graders.py +146 -0
- pll_cyber/grid.py +138 -0
- pll_cyber/models.py +147 -0
- pll_cyber/pll.py +186 -0
- pll_cyber/rewards.py +97 -0
- pll_cyber/scenarios.py +205 -0
- pll_cyber/sensors.py +152 -0
- pyproject.toml +32 -9
- requirements-dev.txt +3 -0
- requirements.txt +9 -8
- scripts/evaluate_baselines.py +66 -0
- scripts/validate_submission.sh +63 -0
- server/__init__.py +1 -0
- server/app.py +215 -12
- server/landing.html +146 -0
- src/__init__.py +0 -1
- src/api.py +0 -111
- src/attacks.py +0 -134
- src/detector.py +0 -139
- src/env.py +0 -370
- src/graders.py +0 -137
- src/models.py +0 -72
- src/pll_sim.py +0 -127
- submission.py +0 -69
- tests/test_environment.py +133 -64
- tests/test_graders.py +87 -0
- tests/test_inference.py +83 -0
- tests/test_physics.py +128 -0
- tests/test_server.py +89 -0
- uv.lock +70 -19
- validate_gains.py +0 -314
- validate_submission.sh +0 -0
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Never bake secrets or local state into the image
|
| 2 |
+
.env
|
| 3 |
+
.env.*
|
| 4 |
+
!.env.example
|
| 5 |
+
.git
|
| 6 |
+
.github
|
| 7 |
+
.venv
|
| 8 |
+
venv
|
| 9 |
+
**/__pycache__
|
| 10 |
+
**/*.pyc
|
| 11 |
+
.pytest_cache
|
| 12 |
+
tests
|
| 13 |
+
scripts
|
| 14 |
+
uv.lock
|
| 15 |
+
*.patch
|
| 16 |
+
*.log
|
|
@@ -1,10 +1,13 @@
|
|
| 1 |
-
# LLM
|
| 2 |
-
USE_LLM=1
|
| 3 |
API_BASE_URL=https://router.huggingface.co/v1
|
| 4 |
MODEL_NAME=Qwen/Qwen2.5-72B-Instruct
|
| 5 |
-
#
|
| 6 |
-
HF_TOKEN=
|
| 7 |
|
| 8 |
-
#
|
| 9 |
-
#
|
| 10 |
-
ENV_URL=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- LLM used by inference.py (OpenAI-compatible endpoint) ---
|
|
|
|
| 2 |
API_BASE_URL=https://router.huggingface.co/v1
|
| 3 |
MODEL_NAME=Qwen/Qwen2.5-72B-Instruct
|
| 4 |
+
# Hugging Face token (https://huggingface.co/settings/tokens) or any key for API_BASE_URL
|
| 5 |
+
HF_TOKEN=your_token_here
|
| 6 |
|
| 7 |
+
# --- Optional ---
|
| 8 |
+
# Environment server; leave empty to run the environment in-process
|
| 9 |
+
ENV_URL=
|
| 10 |
+
# "llm" (default when a token is set) or "heuristic"
|
| 11 |
+
AGENT=llm
|
| 12 |
+
EPISODES_PER_TASK=3
|
| 13 |
+
MAX_WORKERS=3
|
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.sh text eol=lf
|
| 2 |
+
Dockerfile text eol=lf
|
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: CI
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
pull_request:
|
| 6 |
+
|
| 7 |
+
jobs:
|
| 8 |
+
test:
|
| 9 |
+
runs-on: ubuntu-latest
|
| 10 |
+
steps:
|
| 11 |
+
- uses: actions/checkout@v4
|
| 12 |
+
- uses: actions/setup-python@v5
|
| 13 |
+
with:
|
| 14 |
+
python-version: "3.12"
|
| 15 |
+
cache: pip
|
| 16 |
+
- name: Install
|
| 17 |
+
run: pip install -r requirements-dev.txt
|
| 18 |
+
- name: Unit tests
|
| 19 |
+
run: pytest -q
|
| 20 |
+
- name: OpenEnv structure
|
| 21 |
+
run: openenv validate .
|
| 22 |
+
- name: OpenEnv runtime contract
|
| 23 |
+
run: |
|
| 24 |
+
uvicorn server.app:app --port 7860 &
|
| 25 |
+
for i in $(seq 1 30); do curl -sf localhost:7860/health && break; sleep 1; done
|
| 26 |
+
openenv validate --url http://localhost:7860 | tee runtime.json
|
| 27 |
+
python -c "import json,sys; sys.exit(0 if json.load(open('runtime.json'))['passed'] else 1)"
|
| 28 |
+
- name: Baseline (heuristic, through the server)
|
| 29 |
+
run: AGENT=heuristic ENV_URL=http://localhost:7860 python inference.py
|
| 30 |
+
|
| 31 |
+
docker:
|
| 32 |
+
runs-on: ubuntu-latest
|
| 33 |
+
steps:
|
| 34 |
+
- uses: actions/checkout@v4
|
| 35 |
+
- name: Build and smoke-test image
|
| 36 |
+
run: |
|
| 37 |
+
docker build -t pll-cyberguard .
|
| 38 |
+
docker run -d -p 7860:7860 --name env pll-cyberguard
|
| 39 |
+
for i in $(seq 1 30); do curl -sf localhost:7860/health && break; sleep 1; done
|
| 40 |
+
curl -sf -X POST localhost:7860/reset -H 'content-type: application/json' -d '{"task_id":2,"seed":1}' > /dev/null
|
|
@@ -2,22 +2,18 @@ name: Deploy to Hugging Face Space
|
|
| 2 |
|
| 3 |
on:
|
| 4 |
push:
|
| 5 |
-
branches:
|
| 6 |
-
|
| 7 |
|
| 8 |
jobs:
|
| 9 |
deploy:
|
| 10 |
runs-on: ubuntu-latest
|
| 11 |
-
|
| 12 |
steps:
|
| 13 |
-
-
|
| 14 |
-
uses: actions/checkout@v3
|
| 15 |
with:
|
| 16 |
-
lfs: true
|
| 17 |
fetch-depth: 0
|
| 18 |
-
|
| 19 |
- name: Push to Hugging Face Space
|
| 20 |
env:
|
| 21 |
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 22 |
-
run:
|
| 23 |
-
git push https://krishuggingface:${HF_TOKEN}@huggingface.co/spaces/krishuggingface/CyberAttack-PLL HEAD:main
|
|
|
|
| 2 |
|
| 3 |
on:
|
| 4 |
push:
|
| 5 |
+
branches: [master, main]
|
| 6 |
+
workflow_dispatch:
|
| 7 |
|
| 8 |
jobs:
|
| 9 |
deploy:
|
| 10 |
runs-on: ubuntu-latest
|
|
|
|
| 11 |
steps:
|
| 12 |
+
- uses: actions/checkout@v4
|
|
|
|
| 13 |
with:
|
|
|
|
| 14 |
fetch-depth: 0
|
| 15 |
+
lfs: true
|
| 16 |
- name: Push to Hugging Face Space
|
| 17 |
env:
|
| 18 |
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 19 |
+
run: git push https://krishuggingface:${HF_TOKEN}@huggingface.co/spaces/krishuggingface/CyberAttack-PLL HEAD:main
|
|
|
|
@@ -1,24 +1,26 @@
|
|
| 1 |
# Python
|
| 2 |
__pycache__/
|
| 3 |
-
*.
|
| 4 |
-
*.pyo
|
| 5 |
*.egg-info/
|
| 6 |
dist/
|
| 7 |
build/
|
| 8 |
|
| 9 |
-
#
|
| 10 |
.env
|
| 11 |
.venv/
|
| 12 |
venv/
|
| 13 |
|
| 14 |
-
#
|
| 15 |
.pytest_cache/
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
-
#
|
| 18 |
-
|
|
|
|
| 19 |
|
| 20 |
# IDE
|
| 21 |
.vscode/
|
| 22 |
.idea/
|
| 23 |
*.swp
|
| 24 |
-
*.swo
|
|
|
|
| 1 |
# Python
|
| 2 |
__pycache__/
|
| 3 |
+
*.py[cod]
|
|
|
|
| 4 |
*.egg-info/
|
| 5 |
dist/
|
| 6 |
build/
|
| 7 |
|
| 8 |
+
# Environments / secrets
|
| 9 |
.env
|
| 10 |
.venv/
|
| 11 |
venv/
|
| 12 |
|
| 13 |
+
# Tooling
|
| 14 |
.pytest_cache/
|
| 15 |
+
.ruff_cache/
|
| 16 |
+
.coverage
|
| 17 |
+
htmlcov/
|
| 18 |
|
| 19 |
+
# Local outputs
|
| 20 |
+
results/
|
| 21 |
+
*.log
|
| 22 |
|
| 23 |
# IDE
|
| 24 |
.vscode/
|
| 25 |
.idea/
|
| 26 |
*.swp
|
|
|
|
@@ -1,17 +1,25 @@
|
|
| 1 |
-
FROM python:3.
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
-
|
|
|
|
|
|
|
| 6 |
|
| 7 |
COPY requirements.txt .
|
| 8 |
-
RUN pip install --
|
| 9 |
|
| 10 |
-
COPY
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
CMD curl -f http://localhost:7860/health || exit 1
|
| 14 |
|
|
|
|
| 15 |
EXPOSE 7860
|
| 16 |
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
|
| 3 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 4 |
+
PYTHONUNBUFFERED=1 \
|
| 5 |
+
PIP_NO_CACHE_DIR=1 \
|
| 6 |
+
PORT=7860
|
| 7 |
|
| 8 |
+
# Hugging Face Spaces run containers as uid 1000
|
| 9 |
+
RUN useradd --create-home --uid 1000 app
|
| 10 |
+
WORKDIR /home/app/env
|
| 11 |
|
| 12 |
COPY requirements.txt .
|
| 13 |
+
RUN pip install --upgrade pip && pip install -r requirements.txt
|
| 14 |
|
| 15 |
+
COPY --chown=app:app pll_cyber ./pll_cyber
|
| 16 |
+
COPY --chown=app:app server ./server
|
| 17 |
+
COPY --chown=app:app inference.py openenv.yaml pyproject.toml README.md ./
|
|
|
|
| 18 |
|
| 19 |
+
USER app
|
| 20 |
EXPOSE 7860
|
| 21 |
|
| 22 |
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
| 23 |
+
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:7860/health', timeout=4).status == 200 else 1)"
|
| 24 |
+
|
| 25 |
+
CMD ["sh", "-c", "uvicorn server.app:app --host 0.0.0.0 --port ${PORT}"]
|
|
@@ -1,162 +1,265 @@
|
|
| 1 |
---
|
| 2 |
-
title: PLL
|
| 3 |
emoji: ⚡
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: purple
|
| 6 |
sdk: docker
|
| 7 |
-
|
| 8 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
---
|
| 10 |
-
# PLL Cyberattack Detection — OpenEnv
|
| 11 |
-
|
| 12 |
-
[](https://huggingface.co/spaces/krishuggingface/CyberAttack-PLL)
|
| 13 |
-
[](Dockerfile)
|
| 14 |
-
[](openenv.yaml)
|
| 15 |
-
[](https://python.org)
|
| 16 |
-
|
| 17 |
-
> AI-driven cyberattack detection on SRF Phase-Locked Loops (PLLs) in grid-connected inverters.
|
| 18 |
-
|
| 19 |
-
## Overview
|
| 20 |
-
|
| 21 |
-
Phase-Locked Loops (PLLs) are critical components in grid-connected power converters, responsible for synchronizing the inverter's output with the utility grid. The Synchronous Reference Frame PLL (SRF-PLL) estimates grid frequency and phase angle by tracking the q-axis voltage component. Because of its critical role and reliance on sensor data, the SRF-PLL is a high-value target for **False Data Injection (FDI)** cyberattacks.
|
| 22 |
-
|
| 23 |
-
This OpenEnv environment simulates an SRF-PLL subjected to varied FDI attack scenarios. An AI agent acts as a cyber-guard: it monitors arriving time-windowed sensor observations—such as voltages and frequency deviations—and must accurately detect, classify, and mitigate attacks in real-time before grid synchronization is lost.
|
| 24 |
-
|
| 25 |
-
## Architecture
|
| 26 |
-
|
| 27 |
-
The environment relies on a discrete-time SRF-PLL simulation running at a 1 ms step size. A streamlined view of the signal flow is below:
|
| 28 |
-
|
| 29 |
-
```text
|
| 30 |
-
Grid Voltage (50Hz)
|
| 31 |
-
│
|
| 32 |
-
▼
|
| 33 |
-
[FDI Attack Injection] ◄── Attacker injects a malicious signal on phase `va`
|
| 34 |
-
│
|
| 35 |
-
▼
|
| 36 |
-
Clarke Transform (αβ)
|
| 37 |
-
│
|
| 38 |
-
▼
|
| 39 |
-
Park Transform (dq) ◄── Uses the currently estimated angle θ̂
|
| 40 |
-
│
|
| 41 |
-
▼
|
| 42 |
-
PI Controller ──► ω̂, θ̂ are updated continuously
|
| 43 |
-
│
|
| 44 |
-
▼
|
| 45 |
-
Agent Observation ──► Agent receives: `vq_window`, `omega_deviation_window`, `raw_voltages`
|
| 46 |
-
│
|
| 47 |
-
▼
|
| 48 |
-
Agent Action ──► Agent outputs: `attack_detected`, `attack_type`, `confidence`
|
| 49 |
-
```
|
| 50 |
-
|
| 51 |
-
## Inference Flow & Detector Walkthrough
|
| 52 |
-
|
| 53 |
-
To balance speed and accuracy across thousands of steps, the standard inference client (`inference.py`) deploys a **Smart Blending Strategy**:
|
| 54 |
-
|
| 55 |
-
1. **Environment Simulation (`env.py`)**:
|
| 56 |
-
Every step, the PLL updates its internal math based on potential attack injections. It yields a rich observation window of the last 20 frames for variables like $V_q$ and $\omega_{dev}$.
|
| 57 |
-
2. **Adaptive Physics-Informed Detector (`src/detector.py`)**:
|
| 58 |
-
Before returning the observation to the client, the environment evaluates the data using an intrinsic physics-based detector. This detector calibrates anomaly residuals during the first 20 "healthy" warm-up steps. It tracks variances and symmetry to identify stealthy voltage anomalies, providing a baseline `confidence` score.
|
| 59 |
-
3. **Smart Blending Client (`inference.py`)**:
|
| 60 |
-
The client receives the observation and the detector's baseline prediction.
|
| 61 |
-
* If the intrinsic detector has high confidence (> 50%), the client adopts its recommendation.
|
| 62 |
-
* If the anomaly is ambiguous (confidence < 50%), the client queries its own **Rule-Based Heuristic Agent**, which monitors historical $V_q$ growth, monotonicity, and zero-crossing density.
|
| 63 |
-
* *Optional*: If `USE_LLM=1` is set, the client uses an LLM (e.g., `Qwen2.5-72B`) for advanced reasoning. A resilient "circuit breaker" automatically transitions to the heuristic model if network or authentication failures occur.
|
| 64 |
-
|
| 65 |
-
## Tasks
|
| 66 |
-
|
| 67 |
-
The environment supports three sequentially evaluated difficulty levels:
|
| 68 |
|
| 69 |
-
|
| 70 |
-
|------|----|-----------|-------------|-----------|-------|
|
| 71 |
-
| Sinusoidal FDI | 0 | Easy | Sinusoidal Injection | Detect attack within 100 steps of initiation. | Time-decaying detection reward. |
|
| 72 |
-
| Multi-Attack Class. | 1 | Medium | Sinusoidal, Ramp, Pulse | Safely and correctly classify the specific attack type. | Accuracy and speed aggregate. |
|
| 73 |
-
| Stealthy Detection | 2 | Hard | Low-amplitude phase drift | Detect slow deviations before the PLL loses lock (θ_error > 5°). | Preventative lock-loss metric. |
|
| 74 |
|
| 75 |
-
|
|
|
|
|
|
|
| 76 |
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
-
|
| 80 |
-
|-------|-------|-------------|
|
| 81 |
-
| `vq_window` | `[20]` | q-axis voltage error signal (pu). |
|
| 82 |
-
| `vd_window` | `[20]` | d-axis voltage (pu). |
|
| 83 |
-
| `omega_window` | `[20]` | Normalized frequency deviation from nominal. |
|
| 84 |
-
| `omega_deviation_window` | `[20]` | Frequency deviation from nominal (rad/s). |
|
| 85 |
-
| `raw_voltages` | `[3]` | Raw three-phase voltages `[va, vb, vc]` (pu). |
|
| 86 |
-
| `step` | `scalar` | Current simulation time step. |
|
| 87 |
-
| `task_id` | `scalar` | Current task identifier (0, 1, or 2). |
|
| 88 |
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
-
|
|
|
|
|
|
|
| 92 |
|
| 93 |
-
|
| 94 |
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
|
| 104 |
-
|
| 105 |
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
-
**Via Python (Recommended)**:
|
| 109 |
```bash
|
| 110 |
pip install -r requirements.txt
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
```
|
| 113 |
|
| 114 |
-
|
| 115 |
```bash
|
| 116 |
-
docker build -t pll-
|
| 117 |
-
docker run -p 7860:7860 pll-
|
| 118 |
```
|
| 119 |
|
| 120 |
-
###
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
|
| 125 |
-
|
|
| 126 |
-
| `
|
| 127 |
-
| `
|
| 128 |
-
| `
|
| 129 |
-
| `
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
-H "Content-Type: application/json" \
|
| 146 |
-
-d '{"attack_detected": false, "attack_type": 0, "confidence": 0.5, "protective_action": 0}'
|
| 147 |
-
```
|
| 148 |
-
|
| 149 |
-
3. **GET `/health`**
|
| 150 |
-
Returns operational status and step numbers.
|
| 151 |
-
|
| 152 |
-
## Baseline Performance
|
| 153 |
-
|
| 154 |
-
The default hybrid strategy outlined in `inference.py` consistently yields the following evaluation bounds across a full 500-step envelope:
|
| 155 |
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
|
| 161 |
-
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: PLL Cyber-Guard
|
| 3 |
emoji: ⚡
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: purple
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
+
tags:
|
| 10 |
+
- openenv
|
| 11 |
+
- reinforcement-learning
|
| 12 |
+
- power-systems
|
| 13 |
+
- cybersecurity
|
| 14 |
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
+
# PLL Cyber-Guard — an OpenEnv environment for cyber-attacks on grid inverters
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
+
[](https://huggingface.co/spaces/krishuggingface/CyberAttack-PLL)
|
| 19 |
+
[](openenv.yaml)
|
| 20 |
+
[](.github/workflows/ci.yml)
|
| 21 |
|
| 22 |
+
An agent plays the **cyber-security guard of a grid-connected solar/battery inverter**. Every 50 ms it reads
|
| 23 |
+
the signals of the inverter's phase-locked loop (PLL) and a trusted but delayed phasor-measurement (PMU)
|
| 24 |
+
stream. It must **detect, classify and mitigate false-data-injection (FDI) attacks** on the voltage sensors,
|
| 25 |
+
without raising alarms on the natural disturbances a real grid produces all the time.
|
| 26 |
|
| 27 |
+
## Why this matters
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
+
Inverter-based resources (solar, wind, batteries) now supply a large share of many grids. A grid-following inverter
|
| 30 |
+
locks to the grid through a **Synchronous-Reference-Frame PLL** (SRF-PLL). If an attacker can rewrite the voltage
|
| 31 |
+
samples the PLL sees (compromised sensor firmware, a man-in-the-middle on the sampled-values bus, or malware on the
|
| 32 |
+
controller), they can make the inverter inject current out of phase with the real grid. The result is power-quality
|
| 33 |
+
problems, protection trips, and, at scale, a stability risk. PLL-targeted FDI is an active research topic. What makes
|
| 34 |
+
it hard in practice is not detecting anomalies. It is **telling attacks apart from ordinary grid events**, and
|
| 35 |
+
**acting before damage while avoiding false trips**, because every false trip throws away generation.
|
| 36 |
|
| 37 |
+
This environment turns that operator problem into a benchmark. The physics is simulated faithfully: a 5 kHz sampled
|
| 38 |
+
SRF-PLL, a sensor chain, natural grid events and a PMU. The reward is dense and accounts for cost, and the graders are
|
| 39 |
+
deterministic.
|
| 40 |
|
| 41 |
+
## What the agent faces
|
| 42 |
|
| 43 |
+
```
|
| 44 |
+
true grid (50 Hz ± wander, 5th/7th harmonics, natural events)
|
| 45 |
+
│ │
|
| 46 |
+
▼ ▼
|
| 47 |
+
[FDI attacker rewrites samples] PMU at substation (GPS-timed, not spoofable)
|
| 48 |
+
│ 10 reports/s · noisy · 40–100 ms latency
|
| 49 |
+
▼ │
|
| 50 |
+
sensors: noise σ=5 mpu + 12-bit ADC │
|
| 51 |
+
▼ │
|
| 52 |
+
SRF-PLL (Clarke → Park → PI, ωn=100 rad/s, │
|
| 53 |
+
ζ=0.707, amplitude-normalised) │
|
| 54 |
+
│ ▲ holdover: loop opened, │
|
| 55 |
+
│ └ PMU-disciplined oscillator ◄────┤
|
| 56 |
+
▼ ▼
|
| 57 |
+
features + waveforms ─────────► AGENT ◄── PMU frequency / magnitude
|
| 58 |
+
│
|
| 59 |
+
attack_detected · attack_type · confidence · protective_action
|
| 60 |
+
```
|
| 61 |
|
| 62 |
+
**Physics notes.** The Clarke/Park transforms are cosine-referenced and amplitude-invariant, so `vq = V·sin(θ − θ̂)`.
|
| 63 |
+
The PLL is type-2 and has zero steady-state error to frequency steps; the tests verify this. The simulator also tracks
|
| 64 |
+
the **true** grid angle, so it knows when the inverter really drifts out of synchronism. That is defined as
|
| 65 |
+
**|θ̂ − θ| > 10° sustained for 30 ms**, and the agent never sees it. A "shadow" PLL that never mitigates runs alongside
|
| 66 |
+
and gives the counterfactual time at which an unprotected inverter would have lost sync. The hard-task grader uses
|
| 67 |
+
that time as its deadline.
|
| 68 |
+
|
| 69 |
+
### Natural grid events (not attacks)
|
| 70 |
+
| Event | What it looks like to the PLL |
|
| 71 |
+
|---|---|
|
| 72 |
+
| Phase jump (5–25°) | vq spike, frequency transient; recovers in about 30 ms; the PMU frequency also spikes |
|
| 73 |
+
| Balanced sag (0.5–0.9 pu) | vd drops on all phases |
|
| 74 |
+
| Single-phase sag (0.3–0.8 pu) | zero/negative sequence **at 50 Hz**, 1.5 Hz frequency ripple, which looks like single-phase FDI |
|
| 75 |
+
| Harmonic step | 300 Hz ripple in vq grows |
|
| 76 |
+
| Frequency event (RoCoF 0.1–0.6 Hz/s) | local frequency drifts, and the PMU sees the same drift (with a delay) |
|
| 77 |
+
|
| 78 |
+
### Attacks
|
| 79 |
+
| id | Attack | Footprint |
|
| 80 |
+
|---|---|---|
|
| 81 |
+
| 1 | Sinusoidal interharmonic injection, one phase | zero-sequence content **off** 50 Hz, persistent frequency oscillation |
|
| 82 |
+
| 2 | Ramp bias, one phase | growing DC offset (real grids have no DC) |
|
| 83 |
+
| 3 | Pulse bias, one phase (100–300 ms) | constant DC offset, then gone |
|
| 84 |
+
| 4 | **Coordinated phase spoofing** | all three phases rotated by a slowly accelerating angle δ(t). The PLL tracks it perfectly, so vq is unchanged, and the local frequency drifts like a natural frequency event. The only evidence is a mismatch against the *delayed* PMU. Unprotected, the inverter loses sync 0.35–0.7 s after onset. |
|
| 85 |
|
| 86 |
+
## Tasks
|
| 87 |
|
| 88 |
+
| id | name | difficulty | what the agent must do |
|
| 89 |
+
|---|---|---|---|
|
| 90 |
+
| 0 | `sinusoidal_fdi_detection` | easy | Detect a sinusoidal FDI quickly. 20% of episodes are attack-free; occasional mild sags. |
|
| 91 |
+
| 1 | `attack_classification_under_disturbances` | medium | Detect **and classify** sinusoidal / ramp / pulse attacks while 1–3 natural events occur. At least one event always lands before the attack, so false-alarm resistance is always tested. |
|
| 92 |
+
| 2 | `stealthy_phase_spoofing_mitigation` | hard | Detect phase spoofing hidden in natural frequency events using the delayed PMU, and engage **holdover before the inverter desynchronises**. Holding over or tripping on a healthy grid is penalised. |
|
| 93 |
+
|
| 94 |
+
Each episode is 40 decisions × 50 ms (2 s after a 200 ms warm-up). A scenario is fully determined by
|
| 95 |
+
`(task_id, seed)`: attack type, onset, parameters, grid events, harmonics, PMU latency and every noise stream.
|
| 96 |
+
|
| 97 |
+
## Action space (`PLLAction`)
|
| 98 |
+
| field | type | meaning |
|
| 99 |
+
|---|---|---|
|
| 100 |
+
| `attack_detected` | bool | an attack is in progress |
|
| 101 |
+
| `attack_type` | int 0–4 | 0 none, 1 sinusoidal, 2 ramp, 3 pulse, 4 phase spoofing |
|
| 102 |
+
| `confidence` | float 0–1 | probability that `attack_detected` is right (scored with a Brier rule) |
|
| 103 |
+
| `protective_action` | int 0–3 | 0 none, 1 alert operator, **2 PMU-aided holdover** (open the PLL loop; free-run on a PMU-disciplined oscillator), **3 disconnect** (latched) |
|
| 104 |
+
|
| 105 |
+
## Observation space (`PLLObservation`)
|
| 106 |
+
| group | content |
|
| 107 |
+
|---|---|
|
| 108 |
+
| `waveforms` | the last 50 ms window at 2 ms resolution (25 points each): `va, vb, vc, vd, vq, f_hz` |
|
| 109 |
+
| `features` | on-board signal processing: positive-sequence voltage, vq mean/RMS, local frequency, mean/std/RoCoF, zero-sequence RMS, per-phase RMS and DC offset, dominant vq oscillation (FFT over 200 ms), local-minus-PMU frequency |
|
| 110 |
+
| `pmu` | `f_hz`, `v_pos_pu`, `age_ms` of the latest PMU report that has arrived |
|
| 111 |
+
| `trend` | the last 10 windows of local frequency, PMU frequency, vq RMS, zero-sequence RMS and positive-sequence voltage |
|
| 112 |
+
| status | `task_id`, `task_name`, `task_brief` (on reset), `step`, `time_s`, `inverter_mode` (normal / holdover / disconnected) |
|
| 113 |
+
| `reward_breakdown` | the typed reward model with every component of the last step's reward |
|
| 114 |
+
| `episode_result` | sent only when `done`: `grader_score`, score breakdown, total reward, and the revealed ground-truth scenario |
|
| 115 |
+
|
| 116 |
+
`GET /state` returns `PLLState`. The scenario ground truth stays `null` until the episode ends, so it can't be used
|
| 117 |
+
to cheat.
|
| 118 |
+
|
| 119 |
+
## Reward (dense, every step)
|
| 120 |
+
Action *t* is judged against window *t*, the data the agent actually saw. Once an attack starts, the system counts as
|
| 121 |
+
compromised until the episode ends.
|
| 122 |
+
|
| 123 |
+
| component | value |
|
| 124 |
+
|---|---|
|
| 125 |
+
| true positive / true negative | +0.10 / +0.02 |
|
| 126 |
+
| missed detection / **false alarm** | −0.10 / **−0.25** |
|
| 127 |
+
| early-detection bonus (one-time) | up to +0.5, decaying linearly to 0 at 500 ms |
|
| 128 |
+
| calibration (Brier) | 0.04 · (1 − 4·Brier) ∈ [−0.12, +0.04] |
|
| 129 |
+
| classification (tasks 1–2) | +0.05 correct / −0.02 wrong |
|
| 130 |
+
| holdover / disconnected (per window) | −0.02 / −0.04 (degraded operation / lost generation) |
|
| 131 |
+
| trip | −0.3 if under attack, **−1.5 on a healthy grid** (destructive action) |
|
| 132 |
+
| loss of synchronism | −1.0 once, plus −0.3 per window while desynchronised |
|
| 133 |
+
|
| 134 |
+
Per-step range: **[−3.21, +0.69]**. "Always alarm", "always hold over" and "always trip" all give negative returns.
|
| 135 |
+
|
| 136 |
+
## Graders (deterministic, 0–1)
|
| 137 |
+
They share these building blocks: false-alarm rate **FA** over clean windows, specificity **max(0, 1 − 2·FA)**,
|
| 138 |
+
timeliness **T** of the first detection, recall **R**, and classification accuracy **C** over compromised windows.
|
| 139 |
+
|
| 140 |
+
* **easy:** `(0.6·T + 0.4·R) · spec`, where T = 1 up to 100 ms of delay and falls to 0 at 1 s
|
| 141 |
+
* **medium:** `(0.35·T + 0.25·R + 0.40·C) · spec`
|
| 142 |
+
* **hard:** `(0.55·P·Q + 0.30·T + 0.15·R) · spec`. P = 1 if holdover or disconnect was engaged before the
|
| 143 |
+
counterfactual desync time *and* the inverter never lost sync. Q = 1.0 for holdover, 0.6 for disconnect. T is
|
| 144 |
+
measured against the time-to-desync budget.
|
| 145 |
+
* **attack-free episodes:** `spec`, multiplied by 0.5 for a false trip (0.4 in the hard task) and by 0.75 for a false
|
| 146 |
+
holdover.
|
| 147 |
+
|
| 148 |
+
Scores are clamped to [0.01, 0.99] for validators that require strictly open (0, 1) scores.
|
| 149 |
+
|
| 150 |
+
## Baseline scores
|
| 151 |
+
|
| 152 |
+
100 seeds per task (seeds 1000–1099), from `python scripts/evaluate_baselines.py --seeds 100`:
|
| 153 |
+
|
| 154 |
+
| Policy | Task 0 (easy) | Task 1 (medium) | Task 2 (hard) | Mean |
|
| 155 |
+
|---|---|---|---|---|
|
| 156 |
+
| Always alarm | 0.010 ± 0.000 | 0.010 ± 0.000 | 0.010 ± 0.000 | 0.010 |
|
| 157 |
+
| Never alarm | 0.196 ± 0.384 | 0.226 ± 0.406 | 0.167 ± 0.359 | 0.196 |
|
| 158 |
+
| Random (p = 0.5) | 0.078 ± 0.106 | 0.063 ± 0.075 | 0.043 ± 0.056 | 0.061 |
|
| 159 |
+
| **Physics heuristic** (`pll_cyber/baselines.py`) | 0.986 ± 0.003 | 0.920 ± 0.150 | 0.762 ± 0.177 | 0.889 |
|
| 160 |
+
| Oracle (reads ground truth, upper bound) | 0.990 ± 0.000 | 0.990 ± 0.000 | 0.990 ± 0.000 | 0.990 |
|
| 161 |
+
|
| 162 |
+
The physics heuristic is what a protection engineer would write by hand. It fits the zero-sequence voltage with
|
| 163 |
+
DC + 50 Hz terms and checks the residual, and it integrates the latency-compensated local-vs-PMU frequency mismatch
|
| 164 |
+
into an angle estimate. The hard task leaves a lot of headroom: it requires reasoning about latency, separating a
|
| 165 |
+
spoofed frequency drift from a real one, and deciding when to mitigate.
|
| 166 |
+
|
| 167 |
+
**`inference.py`** (fixed seeds, 3 episodes per task) runs the **LLM agent**. The model gets the features, PMU data,
|
| 168 |
+
trends and a few DSP diagnostics, and makes every decision itself. Without an API key the script falls back to the
|
| 169 |
+
physics heuristic, which scores **0.989 / 0.944 / 0.717** on its evaluation seeds. LLM baseline numbers depend on
|
| 170 |
+
the model and endpoint you use; run the command below to reproduce them.
|
| 171 |
+
|
| 172 |
+
## Quick start
|
| 173 |
|
|
|
|
| 174 |
```bash
|
| 175 |
pip install -r requirements.txt
|
| 176 |
+
|
| 177 |
+
# run the server (HTTP + WebSocket) on :7860
|
| 178 |
+
uvicorn server.app:app --host 0.0.0.0 --port 7860 # or: python -m server.app
|
| 179 |
+
|
| 180 |
+
# baseline (LLM via any OpenAI-compatible endpoint; heuristic if no key)
|
| 181 |
+
export HF_TOKEN=... # API_BASE_URL / MODEL_NAME optional
|
| 182 |
+
python inference.py # in-process env; set ENV_URL=http://localhost:7860 to go through the server
|
| 183 |
```
|
| 184 |
|
| 185 |
+
Docker / Hugging Face Space:
|
| 186 |
```bash
|
| 187 |
+
docker build -t pll-cyberguard .
|
| 188 |
+
docker run -p 7860:7860 pll-cyberguard
|
| 189 |
```
|
| 190 |
|
| 191 |
+
### HTTP API
|
| 192 |
+
| endpoint | purpose |
|
| 193 |
+
|---|---|
|
| 194 |
+
| `POST /reset` | `{"task_id": 0-2, "seed": int, "session_id": str}` → `{observation, reward, done, session_id}` |
|
| 195 |
+
| `POST /step` | `{"action": {...}, "session_id": str}` → `{observation, reward, done, session_id}` |
|
| 196 |
+
| `GET /state?session_id=` | `PLLState` (ground truth hidden until done) |
|
| 197 |
+
| `GET /tasks` | task catalogue, reward range, action schema |
|
| 198 |
+
| `GET /schema`, `/metadata`, `/health`, `POST /mcp` | OpenEnv standard endpoints |
|
| 199 |
+
| `WS /ws` | persistent session per connection (used by `EnvClient`) |
|
| 200 |
+
| `GET /demo?task_id=&seed=` | runs the heuristic baseline and returns the trace (used by the landing page) |
|
| 201 |
+
|
| 202 |
+
openenv-core's own HTTP `/reset` and `/step` are stateless. This server replaces them with **session-aware**
|
| 203 |
+
versions that keep the same request and response shapes. Clients that omit `session_id` share a `"default"` session.
|
| 204 |
+
|
| 205 |
+
### Python client (WebSocket)
|
| 206 |
+
```python
|
| 207 |
+
from pll_cyber.client import PLLCyberEnv
|
| 208 |
+
from pll_cyber.models import PLLAction
|
| 209 |
+
|
| 210 |
+
with PLLCyberEnv(base_url="http://localhost:7860").sync() as env:
|
| 211 |
+
r = env.reset(task_id=2, seed=7)
|
| 212 |
+
while not r.done:
|
| 213 |
+
r = env.step(PLLAction(attack_detected=False))
|
| 214 |
+
print(r.observation.episode_result.grader_score)
|
| 215 |
+
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
|
| 217 |
+
## Validation & tests
|
| 218 |
+
```bash
|
| 219 |
+
pip install -r requirements-dev.txt
|
| 220 |
+
pytest -q # 65 tests: physics, determinism, graders, API, inference
|
| 221 |
+
openenv validate . # local structure
|
| 222 |
+
openenv validate --url http://localhost:7860 # runtime contract (all 6 criteria pass)
|
| 223 |
+
```
|
| 224 |
+
The tests cover:
|
| 225 |
+
- transform identities and PLL lock, frequency-step and phase-jump dynamics
|
| 226 |
+
- natural events never desynchronise the inverter, while spoofing always does yet leaves vq unchanged
|
| 227 |
+
- holdover protects
|
| 228 |
+
- identical trajectories for the same seed
|
| 229 |
+
- ground truth stays hidden until done
|
| 230 |
+
- every reward stays inside the declared range
|
| 231 |
+
- degenerate policies (always / never / random / always-mitigate) score low and the oracle scores at least 0.9 on every seed
|
| 232 |
+
- session isolation and the API contract
|
| 233 |
+
- the inference log format and its LLM failure fallback
|
| 234 |
+
|
| 235 |
+
## Repository layout
|
| 236 |
+
```
|
| 237 |
+
pll_cyber/
|
| 238 |
+
pll.py SRF-PLL (Clarke/Park, PI loop, holdover mode)
|
| 239 |
+
grid.py true grid + natural events
|
| 240 |
+
attacks.py FDI attack models
|
| 241 |
+
sensors.py sensor noise/ADC, PMU stream, PMU-disciplined holdover
|
| 242 |
+
scenarios.py tasks, timing constants, seeded scenario sampling
|
| 243 |
+
features.py on-board signal processing for observations
|
| 244 |
+
rewards.py dense reward
|
| 245 |
+
graders.py per-task graders
|
| 246 |
+
environment.py OpenEnv Environment (reset/step/state)
|
| 247 |
+
models.py typed Action / Observation / Reward / State
|
| 248 |
+
baselines.py physics heuristic + trivial policies
|
| 249 |
+
client.py typed EnvClient (WebSocket)
|
| 250 |
+
server/app.py FastAPI app (openenv create_app + session-aware HTTP)
|
| 251 |
+
inference.py LLM baseline
|
| 252 |
+
scripts/ benchmark + submission validator
|
| 253 |
+
tests/ pytest suite
|
| 254 |
+
```
|
| 255 |
|
| 256 |
+
## Design choices & limitations
|
| 257 |
+
* **The agent gets the PMU's frequency and magnitude channels, not its synchrophasor angle.** The angle is used
|
| 258 |
+
only by the inverter's holdover controller. Exposing it would make spoofing trivially visible; withholding it
|
| 259 |
+
models a bandwidth-limited SCADA status feed.
|
| 260 |
+
* Injection attacks hit one phase, so they have a zero-sequence footprint. A balanced attacker is modelled by
|
| 261 |
+
phase spoofing.
|
| 262 |
+
* The grid is a stiff source behind the point of common coupling. The inverter's current controller and its
|
| 263 |
+
feedback on the grid voltage are not modelled; the harm is measured as angle desynchronisation.
|
| 264 |
+
* **Possible extensions:** grid-impedance and weak-grid dynamics, attacks on the PMU channel, replay attacks, and
|
| 265 |
+
multi-inverter coordination.
|
|
@@ -1,495 +1,327 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
-
=============================================
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
[START] task=<task_name> env=<benchmark> model=<model_name>
|
| 12 |
-
[STEP] step=<n> action=<
|
| 13 |
[END] success=<true|false> steps=<n> score=<score> rewards=<r1,r2,...,rn>
|
| 14 |
"""
|
| 15 |
|
|
|
|
|
|
|
|
|
|
| 16 |
import os
|
|
|
|
| 17 |
import sys
|
| 18 |
-
import
|
| 19 |
import time
|
| 20 |
-
import
|
| 21 |
-
from typing import List, Optional
|
| 22 |
-
from openai import OpenAI
|
| 23 |
|
| 24 |
try:
|
| 25 |
from dotenv import load_dotenv
|
| 26 |
-
load_dotenv()
|
| 27 |
except ImportError:
|
| 28 |
pass
|
| 29 |
|
| 30 |
-
|
| 31 |
-
# The judging sandbox injects API_BASE_URL and API_KEY via their LiteLLM proxy.
|
| 32 |
-
# All LLM calls MUST go through these values or the submission will be rejected.
|
| 33 |
-
API_BASE_URL = os.environ.get("API_BASE_URL", "https://router.huggingface.co/v1")
|
| 34 |
-
MODEL_NAME = os.environ.get("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
|
| 35 |
-
API_KEY = os.environ.get("API_KEY") or os.environ.get("HF_TOKEN", "dummy")
|
| 36 |
-
ENV_URL = os.environ.get("ENV_URL", "http://localhost:7860")
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
1: "Multi-Attack Classification (Medium)",
|
| 48 |
-
2: "Stealthy Attack Detection (Hard)",
|
| 49 |
-
}
|
| 50 |
|
| 51 |
BENCHMARK = "pll-cyberattack-detection"
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
| 58 |
}
|
| 59 |
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
You receive time-windowed sensor readings each step and must detect cyberattacks.
|
| 63 |
-
|
| 64 |
-
vq_window: q-axis voltage error (should be ~0 when healthy)
|
| 65 |
-
vd_window: d-axis voltage
|
| 66 |
-
omega_window: estimated frequency (normalized, nominal=0)
|
| 67 |
-
omega_deviation_window: frequency deviation from nominal in rad/s (useful for detecting slow phase drift)
|
| 68 |
-
raw_voltages: [va, vb, vc] at current step
|
| 69 |
-
task_id: 0=detect only, 1=classify type, 2=detect stealthy attack
|
| 70 |
-
|
| 71 |
-
For task_id=0: Focus on detecting any attack (attack_detected=True/False).
|
| 72 |
-
For task_id=1: Also classify the attack type (1=sinusoidal, 2=ramp, 3=pulse).
|
| 73 |
-
For task_id=2: Detect very subtle attacks before the PLL loses lock. Look for slow drifts in omega_deviation and vq.
|
| 74 |
-
|
| 75 |
-
Analysis tips:
|
| 76 |
-
- In healthy state, vq values should be near 0 and stable.
|
| 77 |
-
- Sinusoidal attacks cause oscillating patterns in vq.
|
| 78 |
-
- Ramp attacks cause steadily increasing vq magnitude.
|
| 79 |
-
- Pulse attacks cause sudden step changes in vq.
|
| 80 |
-
- Stealthy attacks cause very slow, gradual drift in omega_deviation_window.
|
| 81 |
-
- Look at trends across the full window, not just the latest value.
|
| 82 |
-
|
| 83 |
-
Respond ONLY with valid JSON, no explanation:
|
| 84 |
-
{
|
| 85 |
-
"attack_detected": <bool>,
|
| 86 |
-
"attack_type": <int 0-4>,
|
| 87 |
-
"confidence": <float 0.0-1.0>,
|
| 88 |
-
"protective_action": <int 0-3>
|
| 89 |
-
}"""
|
| 90 |
-
|
| 91 |
-
# ── Logging helpers ───────────────────────────────────────────────────────────
|
| 92 |
-
|
| 93 |
-
def log_start(task: str, env: str, model: str) -> None:
|
| 94 |
-
print(f"[START] task={task} env={env} model={model}", flush=True)
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
def log_step(step: int, action: dict, reward: float, done: bool, error) -> None:
|
| 98 |
-
action_str = json.dumps(action, separators=(',', ':'))
|
| 99 |
-
error_val = error if error else "null"
|
| 100 |
-
print(
|
| 101 |
-
f"[STEP] step={step} action={action_str} "
|
| 102 |
-
f"reward={reward:.2f} done={str(done).lower()} error={error_val}",
|
| 103 |
-
flush=True,
|
| 104 |
-
)
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
def log_end(success: bool, steps: int, score: float, rewards: List[float]) -> None:
|
| 108 |
-
rewards_str = ",".join(f"{r:.2f}" for r in rewards)
|
| 109 |
-
print(
|
| 110 |
-
f"[END] success={str(success).lower()} steps={steps} "
|
| 111 |
-
f"score={score:.3f} rewards={rewards_str}",
|
| 112 |
-
flush=True,
|
| 113 |
-
)
|
| 114 |
-
|
| 115 |
-
# ── Detector Agent & Smart Blending ───────────────────────────────────────────
|
| 116 |
-
|
| 117 |
-
def detector_agent(prev_info: dict) -> Optional[dict]:
|
| 118 |
-
"""Reads the environment's intrinsic physics-based detector output."""
|
| 119 |
-
det = prev_info.get("detector", {})
|
| 120 |
-
if not det or "attack_detected" not in det:
|
| 121 |
-
return None
|
| 122 |
-
|
| 123 |
-
return {
|
| 124 |
-
"attack_detected": det.get("attack_detected", False),
|
| 125 |
-
"attack_type": det.get("attack_type", 0),
|
| 126 |
-
"confidence": det.get("confidence", 0.5),
|
| 127 |
-
"protective_action": det.get("protective_action", 0),
|
| 128 |
-
}
|
| 129 |
-
|
| 130 |
-
def smart_blend_agent(obs: dict, prev_info: dict) -> dict:
|
| 131 |
-
"""Uses detector if confident, else falls back to robust heuristic."""
|
| 132 |
-
heur_action = heuristic_agent(obs)
|
| 133 |
-
det_action = detector_agent(prev_info)
|
| 134 |
-
|
| 135 |
-
if not det_action:
|
| 136 |
-
return heur_action
|
| 137 |
-
if det_action["confidence"] < 0.5:
|
| 138 |
-
return heur_action
|
| 139 |
-
|
| 140 |
-
return det_action
|
| 141 |
|
| 142 |
|
| 143 |
-
|
|
|
|
| 144 |
|
| 145 |
-
class HeuristicState:
|
| 146 |
-
"""Tracks running state for the heuristic agent across steps."""
|
| 147 |
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
-
def reset(self):
|
| 152 |
-
self.
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
self.predicted_type = 0
|
| 156 |
-
self.settled_baseline = None
|
| 157 |
-
self.peak_vq = 0.0
|
| 158 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
|
| 160 |
-
_hstate = HeuristicState()
|
| 161 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
|
| 163 |
-
def
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
vq = obs["vq_window"]
|
| 168 |
-
omega_dev = obs["omega_deviation_window"]
|
| 169 |
-
task_id = obs["task_id"]
|
| 170 |
-
step = obs["step"]
|
| 171 |
-
|
| 172 |
-
if step == 0:
|
| 173 |
-
_hstate.reset()
|
| 174 |
-
|
| 175 |
-
vq_abs = [abs(v) for v in vq]
|
| 176 |
-
vq_mean = sum(vq_abs) / len(vq_abs)
|
| 177 |
-
vq_max = max(vq_abs)
|
| 178 |
-
omega_dev_abs = [abs(v) for v in omega_dev]
|
| 179 |
-
omega_dev_mean = sum(omega_dev_abs) / len(omega_dev_abs)
|
| 180 |
-
|
| 181 |
-
_hstate.vq_history.append(vq_mean)
|
| 182 |
-
_hstate.omega_dev_history.append(omega_dev_mean)
|
| 183 |
-
_hstate.peak_vq = max(_hstate.peak_vq, vq_mean)
|
| 184 |
|
| 185 |
-
|
| 186 |
-
|
|
|
|
|
|
|
|
|
|
| 187 |
|
| 188 |
-
detected = False if step < 25 else (vq_mean > 0.01 or vq_max > 0.025)
|
| 189 |
-
if detected:
|
| 190 |
-
_hstate.attack_detected = True
|
| 191 |
|
| 192 |
-
|
| 193 |
-
if
|
| 194 |
-
return
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
|
| 201 |
-
# ── Task 1: classification ────────────────────────────────────────────────
|
| 202 |
-
if task_id == 1:
|
| 203 |
-
if not _hstate.attack_detected:
|
| 204 |
-
return {
|
| 205 |
-
"attack_detected": False,
|
| 206 |
-
"attack_type": 0,
|
| 207 |
-
"confidence": 0.7,
|
| 208 |
-
"protective_action": 0,
|
| 209 |
-
}
|
| 210 |
|
| 211 |
-
|
| 212 |
|
| 213 |
-
if n_elevated < 5:
|
| 214 |
-
attack_type = 1
|
| 215 |
-
else:
|
| 216 |
-
elevated = [v for v in _hstate.vq_history if v > 0.005]
|
| 217 |
-
recent = elevated[-min(20, len(elevated)):]
|
| 218 |
-
|
| 219 |
-
current_vs_peak = vq_mean / _hstate.peak_vq if _hstate.peak_vq > 0 else 0
|
| 220 |
-
zero_crossings = sum(1 for i in range(1, len(vq)) if vq[i] * vq[i - 1] < 0)
|
| 221 |
-
|
| 222 |
-
if len(recent) >= 6:
|
| 223 |
-
first_third = sum(recent[: len(recent) // 3]) / (len(recent) // 3)
|
| 224 |
-
last_third = sum(recent[-len(recent) // 3 :]) / (len(recent) // 3)
|
| 225 |
-
growth = last_third / first_third if first_third > 0.001 else 1.0
|
| 226 |
-
else:
|
| 227 |
-
growth = 1.0
|
| 228 |
-
|
| 229 |
-
if current_vs_peak < 0.15 and _hstate.peak_vq > 0.05:
|
| 230 |
-
attack_type = 3
|
| 231 |
-
elif current_vs_peak < 0.4 and n_elevated > 30:
|
| 232 |
-
attack_type = 3
|
| 233 |
-
elif zero_crossings >= 2 and growth < 1.5:
|
| 234 |
-
attack_type = 1
|
| 235 |
-
elif growth > 1.3:
|
| 236 |
-
attack_type = 2
|
| 237 |
-
elif zero_crossings >= 1:
|
| 238 |
-
attack_type = 1
|
| 239 |
-
else:
|
| 240 |
-
vq_diffs = [vq[i] - vq[i - 1] for i in range(1, len(vq))]
|
| 241 |
-
neg = sum(1 for d in vq_diffs if d < 0)
|
| 242 |
-
attack_type = 3 if neg > 14 else 1
|
| 243 |
-
|
| 244 |
-
_hstate.predicted_type = attack_type
|
| 245 |
-
|
| 246 |
-
return {
|
| 247 |
-
"attack_detected": True,
|
| 248 |
-
"attack_type": _hstate.predicted_type,
|
| 249 |
-
"confidence": 0.8,
|
| 250 |
-
"protective_action": 1,
|
| 251 |
-
}
|
| 252 |
-
|
| 253 |
-
# ── Task 2: stealthy attack ───────────────────────────────────────────────
|
| 254 |
-
if task_id == 2:
|
| 255 |
-
drift_detected = False
|
| 256 |
-
confidence = 0.3
|
| 257 |
-
|
| 258 |
-
if step > 50 and _hstate.settled_baseline is not None:
|
| 259 |
-
baseline = _hstate.settled_baseline
|
| 260 |
-
ratio = omega_dev_mean / baseline if baseline > 0.01 else omega_dev_mean * 100
|
| 261 |
-
|
| 262 |
-
if len(_hstate.omega_dev_history) > 10:
|
| 263 |
-
recent_10 = _hstate.omega_dev_history[-10:]
|
| 264 |
-
old_10 = (_hstate.omega_dev_history[-20:-10]
|
| 265 |
-
if len(_hstate.omega_dev_history) > 20
|
| 266 |
-
else _hstate.omega_dev_history[:10])
|
| 267 |
-
recent_avg = sum(recent_10) / len(recent_10)
|
| 268 |
-
old_avg = sum(old_10) / len(old_10)
|
| 269 |
-
rising = recent_avg > old_avg * 1.1
|
| 270 |
-
else:
|
| 271 |
-
rising = False
|
| 272 |
-
|
| 273 |
-
if ratio > 2.0:
|
| 274 |
-
drift_detected, confidence = True, 0.9
|
| 275 |
-
elif ratio > 1.3 and rising:
|
| 276 |
-
drift_detected, confidence = True, 0.8
|
| 277 |
-
elif rising and vq_mean > 0.1:
|
| 278 |
-
drift_detected, confidence = True, 0.6
|
| 279 |
-
elif vq_mean > 0.2:
|
| 280 |
-
drift_detected, confidence = True, 0.5
|
| 281 |
-
|
| 282 |
-
if drift_detected:
|
| 283 |
-
_hstate.attack_detected = True
|
| 284 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
return {
|
| 286 |
-
"attack_detected":
|
| 287 |
-
"attack_type": 4
|
| 288 |
-
"confidence": confidence,
|
| 289 |
-
"protective_action":
|
| 290 |
}
|
|
|
|
|
|
|
| 291 |
|
| 292 |
-
return DEFAULT_ACTION.copy()
|
| 293 |
-
|
| 294 |
-
# ── LLM agent (PRIMARY — always called first) ─────────────────────────────────
|
| 295 |
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
break
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
def llm_agent(obs: dict, prev_info: dict) -> dict:
|
| 339 |
-
"""Primary agent — calls the LLM through the injected proxy.
|
| 340 |
-
Falls back to smart blending if the API call itself raises an exception.
|
| 341 |
-
Uses a circuit breaker: after the first failure, all future calls skip the
|
| 342 |
-
network request and go straight to blending (restoring ~10s runtime).
|
| 343 |
-
"""
|
| 344 |
-
global _llm_disabled
|
| 345 |
-
if _llm_disabled:
|
| 346 |
-
return smart_blend_agent(obs, prev_info)
|
| 347 |
-
|
| 348 |
-
try:
|
| 349 |
-
completion = client.chat.completions.create(
|
| 350 |
-
model=MODEL_NAME,
|
| 351 |
-
messages=[
|
| 352 |
-
{"role": "system", "content": SYSTEM_PROMPT},
|
| 353 |
-
{"role": "user", "content": format_observation(obs)},
|
| 354 |
-
],
|
| 355 |
-
temperature=0.1,
|
| 356 |
-
max_tokens=200,
|
| 357 |
-
timeout=10.0,
|
| 358 |
-
)
|
| 359 |
-
return parse_llm_response(completion.choices[0].message.content or "")
|
| 360 |
-
except Exception as e:
|
| 361 |
-
print(f"[WARN] LLM error ({type(e).__name__}: {e}), disabling LLM for remaining steps", file=sys.stderr, flush=True)
|
| 362 |
-
_llm_disabled = True
|
| 363 |
-
return smart_blend_agent(obs, prev_info)
|
| 364 |
|
| 365 |
-
# ── Episode runner ────────────────────────────────────────────────────────────
|
| 366 |
|
| 367 |
-
|
| 368 |
-
|
|
|
|
|
|
|
| 369 |
|
| 370 |
-
|
|
|
|
| 371 |
|
| 372 |
-
# Reset heuristic state before every episode so stale data from a previous
|
| 373 |
-
# task never bleeds into the next one (also covers the LLM fallback path).
|
| 374 |
-
_hstate.reset()
|
| 375 |
|
| 376 |
-
|
| 377 |
-
|
|
|
|
|
|
|
|
|
|
| 378 |
rewards: List[float] = []
|
| 379 |
-
success
|
| 380 |
-
|
| 381 |
try:
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
timeout=60,
|
| 386 |
-
)
|
| 387 |
-
reset_resp.raise_for_status()
|
| 388 |
-
obs = reset_resp.json()
|
| 389 |
-
|
| 390 |
-
done = False
|
| 391 |
-
total_reward = 0.0
|
| 392 |
-
info = {}
|
| 393 |
-
|
| 394 |
while not done:
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
obs = result["observation"]
|
| 412 |
-
reward = result["reward"]
|
| 413 |
-
done = result["done"]
|
| 414 |
-
info = result.get("info", {})
|
| 415 |
-
error = result.get("error", None)
|
| 416 |
-
|
| 417 |
-
step_reward = reward["total"] if isinstance(reward, dict) else float(reward)
|
| 418 |
-
total_reward += step_reward
|
| 419 |
-
rewards.append(step_reward)
|
| 420 |
-
|
| 421 |
-
step_count += 1
|
| 422 |
-
log_step(step=step_count, action=action, reward=step_reward, done=done, error=error)
|
| 423 |
-
|
| 424 |
-
if step_count % 50 == 0:
|
| 425 |
-
print(
|
| 426 |
-
f"[DEBUG] step={step_count} cumulative_reward={total_reward:+.4f} "
|
| 427 |
-
f"detected={action['attack_detected']} type={action['attack_type']}",
|
| 428 |
-
file=sys.stderr, flush=True,
|
| 429 |
-
)
|
| 430 |
-
|
| 431 |
-
grader_score = info.get("grader_score", 0.01)
|
| 432 |
-
grader_score = max(0.01, min(0.99, grader_score)) # strict (0, 1)
|
| 433 |
-
success = grader_score > 0.01
|
| 434 |
-
|
| 435 |
-
except Exception as exc:
|
| 436 |
-
print(f"[DEBUG] Episode error: {type(exc).__name__}: {exc}", file=sys.stderr, flush=True)
|
| 437 |
-
success = False
|
| 438 |
-
except BaseException as exc:
|
| 439 |
-
print(f"[DEBUG] Critical interruption: {type(exc).__name__}: {exc}", file=sys.stderr, flush=True)
|
| 440 |
-
success = False
|
| 441 |
-
raise
|
| 442 |
-
|
| 443 |
-
finally:
|
| 444 |
-
log_end(success=success, steps=step_count, score=grader_score, rewards=rewards)
|
| 445 |
-
|
| 446 |
-
return grader_score
|
| 447 |
-
|
| 448 |
-
# ── Server Check ──────────────────────────────────────────────────────────────
|
| 449 |
-
|
| 450 |
-
def wait_for_server(env_url: str, timeout: int = 60) -> bool:
|
| 451 |
-
print(f"[DEBUG] Waiting for environment server at {env_url} to start...", file=sys.stderr, flush=True)
|
| 452 |
-
start_t = time.time()
|
| 453 |
-
while time.time() - start_t < timeout:
|
| 454 |
-
try:
|
| 455 |
-
resp = _session.get(f"{env_url}/health", timeout=2)
|
| 456 |
-
if resp.status_code == 200:
|
| 457 |
-
print("[DEBUG] Environment server is up!", file=sys.stderr, flush=True)
|
| 458 |
-
return True
|
| 459 |
-
except Exception:
|
| 460 |
-
pass
|
| 461 |
-
time.sleep(1)
|
| 462 |
-
print(f"[DEBUG] Environment server failed to start within {timeout}s.", file=sys.stderr, flush=True)
|
| 463 |
-
return False
|
| 464 |
-
|
| 465 |
-
# ── Entry point ───────────────────────────────────────────────────────────────
|
| 466 |
-
|
| 467 |
-
def main() -> None:
|
| 468 |
-
print(f"[DEBUG] PLL Cyberattack Detection — model={MODEL_NAME} env={ENV_URL}", file=sys.stderr, flush=True)
|
| 469 |
-
|
| 470 |
-
if not wait_for_server(ENV_URL):
|
| 471 |
-
print("[DEBUG] Exiting due to server unavailable.", file=sys.stderr, flush=True)
|
| 472 |
-
return
|
| 473 |
|
| 474 |
-
start_time = time.time()
|
| 475 |
-
scores: List[float] = []
|
| 476 |
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 492 |
|
| 493 |
|
| 494 |
if __name__ == "__main__":
|
| 495 |
-
main()
|
|
|
|
| 1 |
"""
|
| 2 |
+
Baseline inference — PLL Cyber-Guard OpenEnv
|
| 3 |
+
=============================================
|
| 4 |
+
|
| 5 |
+
Runs an LLM agent (OpenAI-compatible client) on all three tasks with fixed
|
| 6 |
+
seeds and prints OpenEnv-style logs plus a final score table.
|
| 7 |
+
|
| 8 |
+
Environment variables
|
| 9 |
+
API_BASE_URL OpenAI-compatible endpoint (default: https://router.huggingface.co/v1)
|
| 10 |
+
MODEL_NAME model identifier (default: Qwen/Qwen2.5-72B-Instruct)
|
| 11 |
+
HF_TOKEN / API_KEY API key for the endpoint
|
| 12 |
+
ENV_URL environment server URL; if unset or unreachable the environment runs in-process
|
| 13 |
+
AGENT "llm" (default when a key is set) or "heuristic"
|
| 14 |
+
EPISODES_PER_TASK default 3
|
| 15 |
+
MAX_WORKERS concurrent episodes, default 3
|
| 16 |
+
TIME_BUDGET_S wall-clock budget, default 1050 (17.5 min) — steps beyond it fall back to the heuristic
|
| 17 |
+
|
| 18 |
+
STDOUT (one block per episode, never interleaved)
|
| 19 |
[START] task=<task_name> env=<benchmark> model=<model_name>
|
| 20 |
+
[STEP] step=<n> action=<action_json> reward=<0.00> done=<true|false> error=<msg|null>
|
| 21 |
[END] success=<true|false> steps=<n> score=<score> rewards=<r1,r2,...,rn>
|
| 22 |
"""
|
| 23 |
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import json
|
| 27 |
import os
|
| 28 |
+
import re
|
| 29 |
import sys
|
| 30 |
+
import threading
|
| 31 |
import time
|
| 32 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 33 |
+
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
| 34 |
|
| 35 |
try:
|
| 36 |
from dotenv import load_dotenv
|
| 37 |
+
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
|
| 38 |
except ImportError:
|
| 39 |
pass
|
| 40 |
|
| 41 |
+
from pll_cyber.baselines import PhysicsHeuristicAgent, _v0_decompose
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
+
API_BASE_URL = os.environ.get("API_BASE_URL", "https://router.huggingface.co/v1")
|
| 44 |
+
MODEL_NAME = os.environ.get("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
|
| 45 |
+
API_KEY = os.environ.get("HF_TOKEN") or os.environ.get("API_KEY") or os.environ.get("OPENAI_API_KEY")
|
| 46 |
+
ENV_URL = os.environ.get("ENV_URL", "").rstrip("/")
|
| 47 |
+
AGENT = os.environ.get("AGENT", "llm" if API_KEY else "heuristic").lower()
|
| 48 |
+
EPISODES_PER_TASK = int(os.environ.get("EPISODES_PER_TASK", "3"))
|
| 49 |
+
MAX_WORKERS = int(os.environ.get("MAX_WORKERS", "3"))
|
| 50 |
+
TIME_BUDGET_S = float(os.environ.get("TIME_BUDGET_S", "1050"))
|
| 51 |
+
LLM_TIMEOUT_S = float(os.environ.get("LLM_TIMEOUT_S", "25"))
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
BENCHMARK = "pll-cyberattack-detection"
|
| 54 |
+
TASK_NAMES = {0: "sinusoidal_fdi_detection", 1: "attack_classification_under_disturbances",
|
| 55 |
+
2: "stealthy_phase_spoofing_mitigation"}
|
| 56 |
+
# Fixed evaluation seeds → reproducible scenarios. Chosen so the first three per task already cover
|
| 57 |
+
# the attack types and an attack-free episode (see scripts/evaluate_baselines.py for 100-seed results).
|
| 58 |
+
EVAL_SEEDS = {
|
| 59 |
+
0: [1, 8, 2, 3, 9, 4], # sinusoidal, none, sinusoidal, ...
|
| 60 |
+
1: [1, 3, 4, 7, 2, 5], # sinusoidal, pulse, ramp, none, ...
|
| 61 |
+
2: [1, 4, 2, 3, 5, 6], # spoofing, none, spoofing, ...
|
| 62 |
}
|
| 63 |
|
| 64 |
+
_t0 = time.monotonic()
|
| 65 |
+
_print_lock = threading.Lock()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
|
| 68 |
+
def _err(msg: str) -> None:
|
| 69 |
+
print(msg, file=sys.stderr, flush=True)
|
| 70 |
|
|
|
|
|
|
|
| 71 |
|
| 72 |
+
# ---------------------------------------------------------------------------
|
| 73 |
+
# Environment adapters (HTTP session or in-process)
|
| 74 |
+
# ---------------------------------------------------------------------------
|
| 75 |
+
class HTTPEnv:
|
| 76 |
+
def __init__(self, base_url: str, session_id: str):
|
| 77 |
+
import requests
|
| 78 |
+
self.s = requests.Session()
|
| 79 |
+
self.url = base_url
|
| 80 |
+
self.sid = session_id
|
| 81 |
|
| 82 |
+
def reset(self, task_id: int, seed: int) -> Dict[str, Any]:
|
| 83 |
+
r = self.s.post(f"{self.url}/reset", json={"task_id": task_id, "seed": seed, "session_id": self.sid}, timeout=60)
|
| 84 |
+
r.raise_for_status()
|
| 85 |
+
return r.json()["observation"]
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
+
def step(self, action: Dict[str, Any]) -> Tuple[Dict[str, Any], float, bool]:
|
| 88 |
+
r = self.s.post(f"{self.url}/step", json={"action": action, "session_id": self.sid}, timeout=60)
|
| 89 |
+
r.raise_for_status()
|
| 90 |
+
j = r.json()
|
| 91 |
+
return j["observation"], float(j["reward"] or 0.0), bool(j["done"])
|
| 92 |
|
|
|
|
| 93 |
|
| 94 |
+
class LocalEnv:
|
| 95 |
+
def __init__(self):
|
| 96 |
+
from pll_cyber.environment import PLLCyberEnvironment
|
| 97 |
+
self.env = PLLCyberEnvironment()
|
| 98 |
|
| 99 |
+
def reset(self, task_id: int, seed: int) -> Dict[str, Any]:
|
| 100 |
+
from pll_cyber.evaluation import obs_to_dict
|
| 101 |
+
return obs_to_dict(self.env.reset(task_id=task_id, seed=seed))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
+
def step(self, action: Dict[str, Any]) -> Tuple[Dict[str, Any], float, bool]:
|
| 104 |
+
from pll_cyber.evaluation import obs_to_dict
|
| 105 |
+
from pll_cyber.models import PLLAction
|
| 106 |
+
o = self.env.step(PLLAction(**action))
|
| 107 |
+
return obs_to_dict(o), float(o.reward or 0.0), bool(o.done)
|
| 108 |
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
+
def _server_up(url: str) -> bool:
|
| 111 |
+
if not url:
|
| 112 |
+
return False
|
| 113 |
+
try:
|
| 114 |
+
import requests
|
| 115 |
+
return requests.get(f"{url}/health", timeout=5).status_code == 200
|
| 116 |
+
except Exception:
|
| 117 |
+
return False
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# ---------------------------------------------------------------------------
|
| 121 |
+
# LLM agent
|
| 122 |
+
# ---------------------------------------------------------------------------
|
| 123 |
+
SYSTEM_PROMPT = """You are the cyber-security guard of a grid-connected solar inverter. Every 50 ms you receive
|
| 124 |
+
measurements from its Synchronous-Reference-Frame Phase-Locked Loop (SRF-PLL) and must decide whether a
|
| 125 |
+
False-Data-Injection (FDI) attack on the voltage sensors is in progress.
|
| 126 |
+
|
| 127 |
+
PHYSICS CHEAT-SHEET
|
| 128 |
+
- Healthy grid: vd ≈ 1 pu, vq ≈ 0 (rms < 0.012), frequency ≈ 50 Hz (natural wander ±0.03 Hz), small 5th/7th harmonics.
|
| 129 |
+
- Natural (NOT attacks): phase jumps (short vq spike), balanced sags (all phases dip), single-phase sags
|
| 130 |
+
(one phase dips; zero-sequence appears AT 50 Hz), harmonic steps, frequency events (RoCoF up to 0.6 Hz/s that the
|
| 131 |
+
trusted PMU also sees). These last ≤ 0.25 s or are mirrored by the PMU.
|
| 132 |
+
- Attack 1 sinusoidal: interharmonic injection on one phase → zero-sequence content NOT at 50 Hz
|
| 133 |
+
(diagnostics.v0_offfund_rms high), persistent oscillation of local frequency.
|
| 134 |
+
- Attack 2 ramp: growing DC offset on one phase (phase_dc_offset magnitude keeps increasing).
|
| 135 |
+
- Attack 3 pulse: constant DC offset on one phase for 0.1-0.3 s, then gone. Real grids never have DC offsets.
|
| 136 |
+
- Attack 4 phase spoofing: all three phases rotated together; vq stays clean, but the LOCAL frequency drifts away
|
| 137 |
+
from the PMU frequency. The PMU is trustworthy but delayed (see pmu.age_ms; it reports the average of the
|
| 138 |
+
100 ms ending age_ms ago), so compare the PMU with your local frequency from the same moment in trend.f_local_hz.
|
| 139 |
+
diagnostics.spoof_angle_est_deg integrates that mismatch: |angle| growing past ~3 deg is an attack.
|
| 140 |
+
The inverter desynchronises (damage) at 10 deg, typically 0.35-0.7 s after the attack starts.
|
| 141 |
+
|
| 142 |
+
TASKS
|
| 143 |
+
- task 0: detect a sinusoidal FDI (attack_type 1). task 1: detect AND classify (1/2/3) amid natural events.
|
| 144 |
+
- task 2: detect phase spoofing (attack_type 4) and engage protective_action=2 (PMU-aided holdover) in time.
|
| 145 |
+
Once an attack has started the system stays compromised: keep attack_detected=true for the rest of the episode.
|
| 146 |
+
False alarms are heavily penalised; protective_action 2/3 on a healthy grid is costly; 3 (disconnect) is latched.
|
| 147 |
+
|
| 148 |
+
Reply with ONLY a JSON object:
|
| 149 |
+
{"attack_detected": bool, "attack_type": 0-4, "confidence": 0.0-1.0, "protective_action": 0-3, "reason": "<10 words"}"""
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _diagnostics(obs: Dict[str, Any], tracker: PhysicsHeuristicAgent) -> Dict[str, float]:
|
| 153 |
+
"""DSP helpers an engineer would compute on the raw observation (no ground truth involved)."""
|
| 154 |
+
d = _v0_decompose(obs)
|
| 155 |
+
tracker.act(obs) # updates the latency-compensated spoofing angle estimate
|
| 156 |
+
return {"v0_dc_pu": round(d["dc"], 5), "v0_fundamental_pu": round(d["amp50"], 5),
|
| 157 |
+
"v0_offfund_rms_pu": round(d["resid_rms"], 5),
|
| 158 |
+
"spoof_angle_est_deg": round(tracker.angle_est_deg, 2)}
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _user_message(obs: Dict[str, Any], diag: Dict[str, float], memory: List[str]) -> str:
|
| 162 |
+
payload = {
|
| 163 |
+
"task_id": obs["task_id"], "step": obs["step"], "time_s": obs["time_s"],
|
| 164 |
+
"inverter_mode": obs["inverter_mode"],
|
| 165 |
+
"features": obs["features"], "pmu": obs["pmu"], "trend": obs["trend"],
|
| 166 |
+
"diagnostics": diag, "your_recent_decisions": memory[-6:],
|
| 167 |
+
}
|
| 168 |
+
return json.dumps(payload, separators=(",", ":"))
|
| 169 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
|
| 171 |
+
_JSON_RE = re.compile(r"\{.*\}", re.S)
|
| 172 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
|
| 174 |
+
def parse_action(text: str) -> Optional[Dict[str, Any]]:
|
| 175 |
+
m = _JSON_RE.search(text or "")
|
| 176 |
+
if not m:
|
| 177 |
+
return None
|
| 178 |
+
try:
|
| 179 |
+
p = json.loads(m.group(0))
|
| 180 |
+
det = p.get("attack_detected", False)
|
| 181 |
+
det = det if isinstance(det, bool) else str(det).lower() == "true"
|
| 182 |
return {
|
| 183 |
+
"attack_detected": det,
|
| 184 |
+
"attack_type": max(0, min(4, int(p.get("attack_type", 0)))),
|
| 185 |
+
"confidence": max(0.0, min(1.0, float(p.get("confidence", 0.5)))),
|
| 186 |
+
"protective_action": max(0, min(3, int(p.get("protective_action", 0)))),
|
| 187 |
}
|
| 188 |
+
except (ValueError, TypeError, json.JSONDecodeError):
|
| 189 |
+
return None
|
| 190 |
|
|
|
|
|
|
|
|
|
|
| 191 |
|
| 192 |
+
class LLMAgent:
|
| 193 |
+
_consecutive_failures = 0
|
| 194 |
+
_disabled = False
|
| 195 |
+
_lock = threading.Lock()
|
| 196 |
+
|
| 197 |
+
def __init__(self, client):
|
| 198 |
+
self.client = client
|
| 199 |
+
self.tracker = PhysicsHeuristicAgent()
|
| 200 |
+
self.fallback = PhysicsHeuristicAgent()
|
| 201 |
+
self.memory: List[str] = []
|
| 202 |
+
self.stats = {"llm": 0, "fallback": 0}
|
| 203 |
+
|
| 204 |
+
def act(self, obs: Dict[str, Any]) -> Tuple[Dict[str, Any], Optional[str]]:
|
| 205 |
+
diag = _diagnostics(obs, self.tracker)
|
| 206 |
+
fb = self.fallback.act(obs)
|
| 207 |
+
action, error = None, None
|
| 208 |
+
if not LLMAgent._disabled and time.monotonic() - _t0 < TIME_BUDGET_S:
|
| 209 |
+
for attempt in range(2):
|
| 210 |
+
try:
|
| 211 |
+
resp = self.client.chat.completions.create(
|
| 212 |
+
model=MODEL_NAME, temperature=0.0, max_tokens=120, seed=0, timeout=LLM_TIMEOUT_S,
|
| 213 |
+
messages=[{"role": "system", "content": SYSTEM_PROMPT},
|
| 214 |
+
{"role": "user", "content": _user_message(obs, diag, self.memory)}],
|
| 215 |
+
)
|
| 216 |
+
action = parse_action(resp.choices[0].message.content)
|
| 217 |
+
if action is None:
|
| 218 |
+
error = "unparseable_llm_output"
|
| 219 |
+
with LLMAgent._lock:
|
| 220 |
+
LLMAgent._consecutive_failures = 0
|
| 221 |
break
|
| 222 |
+
except Exception as e: # network / auth / rate limit
|
| 223 |
+
error = f"{type(e).__name__}"
|
| 224 |
+
if getattr(e, "status_code", None) in (401, 402, 403): # permanent: bad key / no credits
|
| 225 |
+
with LLMAgent._lock:
|
| 226 |
+
if not LLMAgent._disabled:
|
| 227 |
+
LLMAgent._disabled = True
|
| 228 |
+
_err(f"[WARN] LLM unavailable (HTTP {e.status_code}); using heuristic fallback")
|
| 229 |
+
break
|
| 230 |
+
time.sleep(1.5 * (attempt + 1))
|
| 231 |
+
if action is None and error and error != "unparseable_llm_output":
|
| 232 |
+
with LLMAgent._lock:
|
| 233 |
+
LLMAgent._consecutive_failures += 1
|
| 234 |
+
if LLMAgent._consecutive_failures >= 5 and not LLMAgent._disabled:
|
| 235 |
+
LLMAgent._disabled = True
|
| 236 |
+
_err(f"[WARN] LLM disabled after repeated failures ({error}); using heuristic fallback")
|
| 237 |
+
elif LLMAgent._disabled:
|
| 238 |
+
error = "llm_disabled_fallback"
|
| 239 |
+
else:
|
| 240 |
+
error = "time_budget_fallback"
|
| 241 |
+
if action is None:
|
| 242 |
+
action = fb
|
| 243 |
+
self.stats["fallback"] += 1
|
| 244 |
+
else:
|
| 245 |
+
self.stats["llm"] += 1
|
| 246 |
+
self.memory.append(f"t={obs['time_s']:.2f}:det={int(action['attack_detected'])},type={action['attack_type']},"
|
| 247 |
+
f"prot={action['protective_action']}")
|
| 248 |
+
return action, error
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
|
|
|
|
| 250 |
|
| 251 |
+
class HeuristicAgentAdapter:
|
| 252 |
+
def __init__(self):
|
| 253 |
+
self.agent = PhysicsHeuristicAgent()
|
| 254 |
+
self.stats = {"llm": 0, "fallback": 0}
|
| 255 |
|
| 256 |
+
def act(self, obs: Dict[str, Any]) -> Tuple[Dict[str, Any], Optional[str]]:
|
| 257 |
+
return self.agent.act(obs), None
|
| 258 |
|
|
|
|
|
|
|
|
|
|
| 259 |
|
| 260 |
+
# ---------------------------------------------------------------------------
|
| 261 |
+
# Episode runner
|
| 262 |
+
# ---------------------------------------------------------------------------
|
| 263 |
+
def run_episode(task_id: int, seed: int, make_env, make_agent, label: str) -> Dict[str, Any]:
|
| 264 |
+
lines = [f"[START] task={TASK_NAMES[task_id]} env={BENCHMARK} model={label}"]
|
| 265 |
rewards: List[float] = []
|
| 266 |
+
score, success, steps = 0.0, False, 0
|
| 267 |
+
agent = make_agent()
|
| 268 |
try:
|
| 269 |
+
env = make_env(f"t{task_id}-s{seed}")
|
| 270 |
+
obs = env.reset(task_id, seed)
|
| 271 |
+
done = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
while not done:
|
| 273 |
+
action, error = agent.act(obs)
|
| 274 |
+
obs, reward, done = env.step(action)
|
| 275 |
+
steps += 1
|
| 276 |
+
rewards.append(reward)
|
| 277 |
+
lines.append(f"[STEP] step={steps} action={json.dumps(action, separators=(',', ':'))} "
|
| 278 |
+
f"reward={reward:.2f} done={str(done).lower()} error={error or 'null'}")
|
| 279 |
+
res = obs.get("episode_result") or {}
|
| 280 |
+
score = float(res.get("grader_score", 0.0))
|
| 281 |
+
success = True
|
| 282 |
+
except Exception as e:
|
| 283 |
+
_err(f"[DEBUG] episode task={task_id} seed={seed} failed: {type(e).__name__}: {e}")
|
| 284 |
+
lines.append(f"[END] success={str(success).lower()} steps={steps} score={score:.3f} "
|
| 285 |
+
f"rewards={','.join(f'{r:.2f}' for r in rewards)}")
|
| 286 |
+
with _print_lock:
|
| 287 |
+
print("\n".join(lines), flush=True)
|
| 288 |
+
return {"task_id": task_id, "seed": seed, "score": score, "success": success, "stats": agent.stats}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
|
|
|
|
|
|
|
| 290 |
|
| 291 |
+
def main() -> None:
|
| 292 |
+
use_http = _server_up(ENV_URL)
|
| 293 |
+
if ENV_URL and not use_http:
|
| 294 |
+
_err(f"[WARN] ENV_URL={ENV_URL} unreachable — running the environment in-process")
|
| 295 |
+
make_env = (lambda sid: HTTPEnv(ENV_URL, sid)) if use_http else (lambda sid: LocalEnv())
|
| 296 |
+
|
| 297 |
+
if AGENT == "llm" and API_KEY:
|
| 298 |
+
from openai import OpenAI
|
| 299 |
+
client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
|
| 300 |
+
make_agent = lambda: LLMAgent(client) # noqa: E731
|
| 301 |
+
label = MODEL_NAME
|
| 302 |
+
else:
|
| 303 |
+
if AGENT == "llm":
|
| 304 |
+
_err("[WARN] No HF_TOKEN/API_KEY set — running the physics-heuristic baseline instead of the LLM")
|
| 305 |
+
make_agent = HeuristicAgentAdapter
|
| 306 |
+
label = "physics-heuristic"
|
| 307 |
+
|
| 308 |
+
jobs = [(t, s) for t in range(3) for s in EVAL_SEEDS[t][:EPISODES_PER_TASK]]
|
| 309 |
+
_err(f"[DEBUG] agent={label} env={'http ' + ENV_URL if use_http else 'in-process'} episodes={len(jobs)}")
|
| 310 |
+
with ThreadPoolExecutor(max_workers=max(1, MAX_WORKERS)) as pool:
|
| 311 |
+
results = list(pool.map(lambda j: run_episode(j[0], j[1], make_env, make_agent, label), jobs))
|
| 312 |
+
|
| 313 |
+
print("\n=== Baseline scores ===", flush=True)
|
| 314 |
+
overall = []
|
| 315 |
+
for t in range(3):
|
| 316 |
+
sc = [r["score"] for r in results if r["task_id"] == t]
|
| 317 |
+
mean = sum(sc) / len(sc)
|
| 318 |
+
overall.append(mean)
|
| 319 |
+
print(f"task {t} ({TASK_NAMES[t]}): mean={mean:.3f} scores={[round(x, 3) for x in sc]}", flush=True)
|
| 320 |
+
llm_steps = sum(r["stats"]["llm"] for r in results)
|
| 321 |
+
fb_steps = sum(r["stats"]["fallback"] for r in results)
|
| 322 |
+
print(f"overall mean={sum(overall) / 3:.3f} agent={label} llm_steps={llm_steps} fallback_steps={fb_steps} "
|
| 323 |
+
f"elapsed={time.monotonic() - _t0:.1f}s", flush=True)
|
| 324 |
|
| 325 |
|
| 326 |
if __name__ == "__main__":
|
| 327 |
+
main()
|
|
@@ -1,501 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Inference Script — PLL Cyberattack Detection OpenEnv
|
| 3 |
-
=====================================================
|
| 4 |
-
MANDATORY environment variables:
|
| 5 |
-
API_BASE_URL The API endpoint for the LLM
|
| 6 |
-
MODEL_NAME The model identifier to use
|
| 7 |
-
HF_TOKEN Your Hugging Face / API key
|
| 8 |
-
|
| 9 |
-
Uses a HYBRID approach:
|
| 10 |
-
- A fast rule-based heuristic agent runs by default (no LLM needed)
|
| 11 |
-
- The heuristic analyzes vq/omega_deviation windows to detect attacks
|
| 12 |
-
- Set USE_LLM=1 env var to use the LLM instead (slower, may fail)
|
| 13 |
-
|
| 14 |
-
Must be named inference.py and placed at the project root.
|
| 15 |
-
Uses OpenAI client for LLM calls when enabled.
|
| 16 |
-
"""
|
| 17 |
-
|
| 18 |
-
import os
|
| 19 |
-
import json
|
| 20 |
-
from typing import List, Optional
|
| 21 |
-
import time
|
| 22 |
-
import math
|
| 23 |
-
import requests
|
| 24 |
-
from openai import OpenAI
|
| 25 |
-
|
| 26 |
-
API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
|
| 27 |
-
MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
|
| 28 |
-
HF_TOKEN = os.getenv("HF_TOKEN")
|
| 29 |
-
ENV_URL = os.getenv("ENV_URL", "https://krishuggingface-cyberattack-pll.hf.space")
|
| 30 |
-
USE_LLM = os.environ.get("USE_LLM", "0") == "1"
|
| 31 |
-
|
| 32 |
-
client = OpenAI(base_url=API_BASE_URL, api_key=HF_TOKEN)
|
| 33 |
-
|
| 34 |
-
SYSTEM_PROMPT = """You are an AI agent monitoring a power grid inverter's Phase-Locked Loop (PLL).
|
| 35 |
-
You receive time-windowed sensor readings each step and must detect cyberattacks.
|
| 36 |
-
|
| 37 |
-
vq_window: q-axis voltage error (should be ~0 when healthy)
|
| 38 |
-
vd_window: d-axis voltage
|
| 39 |
-
omega_window: estimated frequency (normalized, nominal=0)
|
| 40 |
-
omega_deviation_window: frequency deviation from nominal in rad/s (useful for detecting slow phase drift)
|
| 41 |
-
raw_voltages: [va, vb, vc] at current step
|
| 42 |
-
task_id: 0=detect only, 1=classify type, 2=detect stealthy attack
|
| 43 |
-
|
| 44 |
-
For task_id=0: Focus on detecting any attack (attack_detected=True/False).
|
| 45 |
-
For task_id=1: Also classify the attack type (1=sinusoidal, 2=ramp, 3=pulse).
|
| 46 |
-
For task_id=2: Detect very subtle attacks before the PLL loses lock. Look for slow drifts in omega_deviation and vq.
|
| 47 |
-
|
| 48 |
-
Analysis tips:
|
| 49 |
-
- In healthy state, vq values should be near 0 and stable.
|
| 50 |
-
- Sinusoidal attacks cause oscillating patterns in vq.
|
| 51 |
-
- Ramp attacks cause steadily increasing vq magnitude.
|
| 52 |
-
- Pulse attacks cause sudden step changes in vq.
|
| 53 |
-
- Stealthy attacks cause very slow, gradual drift in omega_deviation_window.
|
| 54 |
-
- Look at trends across the full window, not just the latest value.
|
| 55 |
-
|
| 56 |
-
Respond ONLY with valid JSON, no explanation:
|
| 57 |
-
{
|
| 58 |
-
"attack_detected": <bool>,
|
| 59 |
-
"attack_type": <int 0-4>,
|
| 60 |
-
"confidence": <float 0.0-1.0>,
|
| 61 |
-
"protective_action": <int 0-3>
|
| 62 |
-
}"""
|
| 63 |
-
|
| 64 |
-
TASK_NAMES = {
|
| 65 |
-
0: "Sinusoidal FDI Detection (Easy)",
|
| 66 |
-
1: "Multi-Attack Classification (Medium)",
|
| 67 |
-
2: "Stealthy Attack Detection (Hard)",
|
| 68 |
-
}
|
| 69 |
-
|
| 70 |
-
DEFAULT_ACTION = {
|
| 71 |
-
"attack_detected": False,
|
| 72 |
-
"attack_type": 0,
|
| 73 |
-
"confidence": 0.5,
|
| 74 |
-
"protective_action": 0,
|
| 75 |
-
}
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
# =====================================================================
|
| 79 |
-
# Logging Helpers (OpenEnv compliance)
|
| 80 |
-
# =====================================================================
|
| 81 |
-
|
| 82 |
-
def log_start(task: str, env: str, model: str) -> None:
|
| 83 |
-
print(f"[START] task={task} env={env} model={model}", flush=True)
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
def log_step(step: int, action: dict, reward: float, done: bool, error) -> None:
|
| 87 |
-
action_str = json.dumps(action, separators=(',', ':'))
|
| 88 |
-
error_val = error if error else "null"
|
| 89 |
-
print(f"[STEP] step={step} action={action_str} reward={reward:.2f} done={str(done).lower()} error={error_val}", flush=True)
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
def log_end(success: bool, steps: int, score: float, rewards: list) -> None:
|
| 93 |
-
rewards_str = ",".join(f"{r:.2f}" for r in rewards)
|
| 94 |
-
print(f"[END] success={str(success).lower()} steps={steps} score={score:.3f} rewards={rewards_str}", flush=True)
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
# =====================================================================
|
| 98 |
-
# Detector-Based Agent
|
| 99 |
-
# =====================================================================
|
| 100 |
-
|
| 101 |
-
def detector_agent(prev_info: dict) -> Optional[dict]:
|
| 102 |
-
"""Reads the environment's adaptive detector output from the previous step."""
|
| 103 |
-
det = prev_info.get("detector", {})
|
| 104 |
-
if not det or "attack_detected" not in det:
|
| 105 |
-
return None
|
| 106 |
-
|
| 107 |
-
return {
|
| 108 |
-
"attack_detected": det.get("attack_detected", False),
|
| 109 |
-
"attack_type": det.get("attack_type", 0),
|
| 110 |
-
"confidence": det.get("confidence", 0.5),
|
| 111 |
-
"protective_action": det.get("protective_action", 0),
|
| 112 |
-
}
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
# =====================================================================
|
| 116 |
-
# Rule-Based Heuristic Agent
|
| 117 |
-
# =====================================================================
|
| 118 |
-
|
| 119 |
-
class HeuristicState:
|
| 120 |
-
"""Tracks running state for the heuristic agent across steps."""
|
| 121 |
-
def __init__(self):
|
| 122 |
-
self.reset()
|
| 123 |
-
|
| 124 |
-
def reset(self):
|
| 125 |
-
self.vq_history = [] # all vq_mean(abs) values
|
| 126 |
-
self.omega_dev_history = [] # all omega_dev_mean(abs) values
|
| 127 |
-
self.attack_detected = False # latched detection flag
|
| 128 |
-
self.predicted_type = 0 # latched classification
|
| 129 |
-
self.settled_baseline = None # omega_dev baseline when PLL settles
|
| 130 |
-
self.peak_vq = 0.0 # highest vq_mean seen
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
_hstate = HeuristicState()
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
def heuristic_agent(obs: dict) -> dict:
|
| 137 |
-
"""
|
| 138 |
-
Rule-based attack detector using cumulative state tracking.
|
| 139 |
-
No LLM needed — runs instantly.
|
| 140 |
-
|
| 141 |
-
The key insight is that the PLL's closed-loop response transforms
|
| 142 |
-
attack signals, so we track statistics over time rather than
|
| 143 |
-
trying to classify from a single 20-step vq window shape.
|
| 144 |
-
"""
|
| 145 |
-
global _hstate
|
| 146 |
-
vq = obs["vq_window"]
|
| 147 |
-
omega_dev = obs["omega_deviation_window"]
|
| 148 |
-
task_id = obs["task_id"]
|
| 149 |
-
step = obs["step"]
|
| 150 |
-
|
| 151 |
-
if step == 0:
|
| 152 |
-
_hstate.reset()
|
| 153 |
-
|
| 154 |
-
# --- Compute per-step features ---
|
| 155 |
-
vq_abs = [abs(v) for v in vq]
|
| 156 |
-
vq_mean = sum(vq_abs) / len(vq_abs)
|
| 157 |
-
vq_max = max(vq_abs)
|
| 158 |
-
vq_latest = abs(vq[-1])
|
| 159 |
-
|
| 160 |
-
omega_dev_abs = [abs(v) for v in omega_dev]
|
| 161 |
-
omega_dev_mean = sum(omega_dev_abs) / len(omega_dev_abs)
|
| 162 |
-
|
| 163 |
-
# Track history
|
| 164 |
-
_hstate.vq_history.append(vq_mean)
|
| 165 |
-
_hstate.omega_dev_history.append(omega_dev_mean)
|
| 166 |
-
_hstate.peak_vq = max(_hstate.peak_vq, vq_mean)
|
| 167 |
-
|
| 168 |
-
# Record baseline around step 45-50 (PLL settled)
|
| 169 |
-
if step == 50:
|
| 170 |
-
_hstate.settled_baseline = omega_dev_mean
|
| 171 |
-
|
| 172 |
-
# -----------------------------------------------------------------
|
| 173 |
-
# Detection: is vq significantly elevated?
|
| 174 |
-
# After PLL warm-start settles (~step 20-30), healthy vq < 0.005
|
| 175 |
-
# -----------------------------------------------------------------
|
| 176 |
-
if step < 25:
|
| 177 |
-
# PLL still settling, don't detect
|
| 178 |
-
detected = False
|
| 179 |
-
else:
|
| 180 |
-
detected = vq_mean > 0.01 or vq_max > 0.025
|
| 181 |
-
|
| 182 |
-
# Latch detection on
|
| 183 |
-
if detected:
|
| 184 |
-
_hstate.attack_detected = True
|
| 185 |
-
|
| 186 |
-
# -----------------------------------------------------------------
|
| 187 |
-
# Task 0: Binary detection only
|
| 188 |
-
# -----------------------------------------------------------------
|
| 189 |
-
if task_id == 0:
|
| 190 |
-
return {
|
| 191 |
-
"attack_detected": _hstate.attack_detected,
|
| 192 |
-
"attack_type": 1 if _hstate.attack_detected else 0,
|
| 193 |
-
"confidence": min(1.0, vq_mean * 50) if _hstate.attack_detected else 0.8,
|
| 194 |
-
"protective_action": 1 if _hstate.attack_detected else 0,
|
| 195 |
-
}
|
| 196 |
-
|
| 197 |
-
# -----------------------------------------------------------------
|
| 198 |
-
# Task 1: Classification using cumulative patterns
|
| 199 |
-
# -----------------------------------------------------------------
|
| 200 |
-
if task_id == 1:
|
| 201 |
-
if not _hstate.attack_detected:
|
| 202 |
-
return {
|
| 203 |
-
"attack_detected": False,
|
| 204 |
-
"attack_type": 0,
|
| 205 |
-
"confidence": 0.7,
|
| 206 |
-
"protective_action": 0,
|
| 207 |
-
}
|
| 208 |
-
|
| 209 |
-
# Classify using cumulative vq_history
|
| 210 |
-
# Only classify after enough attack data (10+ steps of elevated vq)
|
| 211 |
-
n_elevated = sum(1 for v in _hstate.vq_history if v > 0.01)
|
| 212 |
-
|
| 213 |
-
if n_elevated < 5:
|
| 214 |
-
# Not enough data yet, use simple guess
|
| 215 |
-
attack_type = 1
|
| 216 |
-
else:
|
| 217 |
-
# Get recent vq trend (last 10 elevated values)
|
| 218 |
-
elevated = [v for v in _hstate.vq_history if v > 0.005]
|
| 219 |
-
recent = elevated[-min(20, len(elevated)):]
|
| 220 |
-
|
| 221 |
-
# Feature 1: Is vq currently high or has it decayed?
|
| 222 |
-
current_vs_peak = vq_mean / _hstate.peak_vq if _hstate.peak_vq > 0 else 0
|
| 223 |
-
|
| 224 |
-
# Feature 2: How many zero crossings in current window
|
| 225 |
-
zero_crossings = sum(1 for i in range(1, len(vq)) if vq[i] * vq[i-1] < 0)
|
| 226 |
-
|
| 227 |
-
# Feature 3: Is vq growing or shrinking over recent history
|
| 228 |
-
if len(recent) >= 6:
|
| 229 |
-
first_third = sum(recent[:len(recent)//3]) / (len(recent)//3)
|
| 230 |
-
last_third = sum(recent[-len(recent)//3:]) / (len(recent)//3)
|
| 231 |
-
growth = last_third / first_third if first_third > 0.001 else 1.0
|
| 232 |
-
else:
|
| 233 |
-
growth = 1.0
|
| 234 |
-
|
| 235 |
-
# Classification logic:
|
| 236 |
-
# Sinusoidal: persistent oscillation, zero crossings, stable amplitude
|
| 237 |
-
# Ramp: growing vq over time (growth > 1)
|
| 238 |
-
# Pulse: high initial vq that decays to near zero (current_vs_peak < 0.3)
|
| 239 |
-
|
| 240 |
-
if current_vs_peak < 0.15 and _hstate.peak_vq > 0.05:
|
| 241 |
-
# vq has decayed significantly from peak → pulse (ended)
|
| 242 |
-
attack_type = 3
|
| 243 |
-
elif current_vs_peak < 0.4 and n_elevated > 30:
|
| 244 |
-
# vq decayed after a long time → pulse
|
| 245 |
-
attack_type = 3
|
| 246 |
-
elif zero_crossings >= 2 and growth < 1.5:
|
| 247 |
-
# Active oscillation without growing → sinusoidal
|
| 248 |
-
attack_type = 1
|
| 249 |
-
elif growth > 1.3:
|
| 250 |
-
# Growing signal → ramp
|
| 251 |
-
attack_type = 2
|
| 252 |
-
elif zero_crossings >= 1:
|
| 253 |
-
# Some oscillation → sinusoidal
|
| 254 |
-
attack_type = 1
|
| 255 |
-
else:
|
| 256 |
-
# Default: if mono-decrease, pulse; else sinusoidal
|
| 257 |
-
vq_diffs = [vq[i] - vq[i-1] for i in range(1, len(vq))]
|
| 258 |
-
neg = sum(1 for d in vq_diffs if d < 0)
|
| 259 |
-
if neg > 14: # 14/19 = 73% decreasing
|
| 260 |
-
attack_type = 3
|
| 261 |
-
else:
|
| 262 |
-
attack_type = 1
|
| 263 |
-
|
| 264 |
-
_hstate.predicted_type = attack_type
|
| 265 |
-
|
| 266 |
-
return {
|
| 267 |
-
"attack_detected": True,
|
| 268 |
-
"attack_type": _hstate.predicted_type,
|
| 269 |
-
"confidence": 0.8,
|
| 270 |
-
"protective_action": 1,
|
| 271 |
-
}
|
| 272 |
-
|
| 273 |
-
# -----------------------------------------------------------------
|
| 274 |
-
# Task 2: Stealthy attack — detect omega_dev rising above baseline
|
| 275 |
-
# -----------------------------------------------------------------
|
| 276 |
-
if task_id == 2:
|
| 277 |
-
drift_detected = False
|
| 278 |
-
confidence = 0.3
|
| 279 |
-
|
| 280 |
-
if step > 50 and _hstate.settled_baseline is not None:
|
| 281 |
-
baseline = _hstate.settled_baseline
|
| 282 |
-
|
| 283 |
-
# Compare current to baseline
|
| 284 |
-
ratio = omega_dev_mean / baseline if baseline > 0.01 else omega_dev_mean * 100
|
| 285 |
-
|
| 286 |
-
# Check if omega_dev is rising relative to recent history
|
| 287 |
-
if len(_hstate.omega_dev_history) > 10:
|
| 288 |
-
recent_10 = _hstate.omega_dev_history[-10:]
|
| 289 |
-
old_10 = _hstate.omega_dev_history[-20:-10] if len(_hstate.omega_dev_history) > 20 else _hstate.omega_dev_history[:10]
|
| 290 |
-
recent_avg = sum(recent_10) / len(recent_10)
|
| 291 |
-
old_avg = sum(old_10) / len(old_10)
|
| 292 |
-
rising = recent_avg > old_avg * 1.1
|
| 293 |
-
else:
|
| 294 |
-
rising = False
|
| 295 |
-
|
| 296 |
-
if ratio > 2.0:
|
| 297 |
-
drift_detected = True
|
| 298 |
-
confidence = 0.9
|
| 299 |
-
elif ratio > 1.3 and rising:
|
| 300 |
-
drift_detected = True
|
| 301 |
-
confidence = 0.8
|
| 302 |
-
elif rising and vq_mean > 0.1:
|
| 303 |
-
drift_detected = True
|
| 304 |
-
confidence = 0.6
|
| 305 |
-
elif vq_mean > 0.2:
|
| 306 |
-
drift_detected = True
|
| 307 |
-
confidence = 0.5
|
| 308 |
-
|
| 309 |
-
if drift_detected:
|
| 310 |
-
_hstate.attack_detected = True
|
| 311 |
-
|
| 312 |
-
return {
|
| 313 |
-
"attack_detected": drift_detected,
|
| 314 |
-
"attack_type": 4 if drift_detected else 0,
|
| 315 |
-
"confidence": confidence,
|
| 316 |
-
"protective_action": 2 if drift_detected else 0,
|
| 317 |
-
}
|
| 318 |
-
|
| 319 |
-
return DEFAULT_ACTION.copy()
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
# =====================================================================
|
| 323 |
-
# LLM Agent (optional, set USE_LLM=1)
|
| 324 |
-
# =====================================================================
|
| 325 |
-
|
| 326 |
-
def parse_llm_response(response_text: str) -> dict:
|
| 327 |
-
"""Parse LLM response JSON, returning default action on failure."""
|
| 328 |
-
try:
|
| 329 |
-
text = response_text.strip()
|
| 330 |
-
if text.startswith("```"):
|
| 331 |
-
lines = text.split("\n")
|
| 332 |
-
json_lines = []
|
| 333 |
-
in_block = False
|
| 334 |
-
for line in lines:
|
| 335 |
-
if line.strip().startswith("```") and not in_block:
|
| 336 |
-
in_block = True
|
| 337 |
-
continue
|
| 338 |
-
elif line.strip().startswith("```") and in_block:
|
| 339 |
-
break
|
| 340 |
-
elif in_block:
|
| 341 |
-
json_lines.append(line)
|
| 342 |
-
text = "\n".join(json_lines)
|
| 343 |
-
|
| 344 |
-
parsed = json.loads(text)
|
| 345 |
-
action = {
|
| 346 |
-
"attack_detected": bool(parsed.get("attack_detected", False)),
|
| 347 |
-
"attack_type": max(0, min(4, int(parsed.get("attack_type", 0)))),
|
| 348 |
-
"confidence": max(0.0, min(1.0, float(parsed.get("confidence", 0.5)))),
|
| 349 |
-
"protective_action": max(0, min(3, int(parsed.get("protective_action", 0)))),
|
| 350 |
-
}
|
| 351 |
-
return action
|
| 352 |
-
except (json.JSONDecodeError, KeyError, TypeError, ValueError):
|
| 353 |
-
return DEFAULT_ACTION.copy()
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
def format_observation(obs: dict) -> str:
|
| 357 |
-
"""Format observation dict into a concise string for the LLM."""
|
| 358 |
-
parts = [
|
| 359 |
-
f"Step: {obs['step']}",
|
| 360 |
-
f"Task: {obs['task_id']}",
|
| 361 |
-
f"vq_window (last 20): {[round(v, 6) for v in obs['vq_window']]}",
|
| 362 |
-
f"vd_window (last 20): {[round(v, 6) for v in obs['vd_window']]}",
|
| 363 |
-
f"omega_window (last 20): {[round(v, 6) for v in obs['omega_window']]}",
|
| 364 |
-
f"omega_deviation_window (last 20): {[round(v, 6) for v in obs['omega_deviation_window']]}",
|
| 365 |
-
f"raw_voltages: {[round(v, 6) for v in obs['raw_voltages']]}",
|
| 366 |
-
]
|
| 367 |
-
return "\n".join(parts)
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
def llm_agent(obs: dict) -> dict:
|
| 371 |
-
"""Call the LLM to decide an action. Falls back to heuristic on error."""
|
| 372 |
-
try:
|
| 373 |
-
obs_text = format_observation(obs)
|
| 374 |
-
completion = client.chat.completions.create(
|
| 375 |
-
model=MODEL_NAME,
|
| 376 |
-
messages=[
|
| 377 |
-
{"role": "system", "content": SYSTEM_PROMPT},
|
| 378 |
-
{"role": "user", "content": obs_text},
|
| 379 |
-
],
|
| 380 |
-
temperature=0.1,
|
| 381 |
-
max_tokens=200,
|
| 382 |
-
)
|
| 383 |
-
llm_response = completion.choices[0].message.content
|
| 384 |
-
return parse_llm_response(llm_response)
|
| 385 |
-
except Exception as e:
|
| 386 |
-
print(f" LLM error ({type(e).__name__}: {e}), falling back to heuristic")
|
| 387 |
-
return heuristic_agent(obs)
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
# =====================================================================
|
| 391 |
-
# Episode Runner
|
| 392 |
-
# =====================================================================
|
| 393 |
-
|
| 394 |
-
def run_episode(task_id: int) -> float:
|
| 395 |
-
log_start(task=TASK_NAMES[task_id], env="pll-cyberattack-detection", model=MODEL_NAME if USE_LLM else "rule-based-heuristic")
|
| 396 |
-
|
| 397 |
-
print(f"\n{'='*60}")
|
| 398 |
-
print(f"Task {task_id}: {TASK_NAMES[task_id]}")
|
| 399 |
-
print(f"Agent: {'LLM (' + MODEL_NAME + ')' if USE_LLM else 'Rule-Based Heuristic'}")
|
| 400 |
-
print(f"{'='*60}")
|
| 401 |
-
|
| 402 |
-
step_count = 0
|
| 403 |
-
grader_score = 0.0
|
| 404 |
-
rewards = []
|
| 405 |
-
|
| 406 |
-
try:
|
| 407 |
-
# Reset environment
|
| 408 |
-
reset_response = requests.post(
|
| 409 |
-
f"{ENV_URL}/reset",
|
| 410 |
-
json={"task_id": task_id},
|
| 411 |
-
timeout=30,
|
| 412 |
-
)
|
| 413 |
-
reset_response.raise_for_status()
|
| 414 |
-
obs = reset_response.json()
|
| 415 |
-
|
| 416 |
-
done = False
|
| 417 |
-
total_reward = 0.0
|
| 418 |
-
prev_info = {}
|
| 419 |
-
|
| 420 |
-
while not done:
|
| 421 |
-
# Choose agent
|
| 422 |
-
if USE_LLM:
|
| 423 |
-
action = llm_agent(obs)
|
| 424 |
-
else:
|
| 425 |
-
if step_count == 0:
|
| 426 |
-
action = DEFAULT_ACTION.copy()
|
| 427 |
-
else:
|
| 428 |
-
det_action = detector_agent(prev_info) if "detector" in prev_info else None
|
| 429 |
-
heur_action = heuristic_agent(obs)
|
| 430 |
-
|
| 431 |
-
if not det_action:
|
| 432 |
-
action = heur_action
|
| 433 |
-
elif det_action["confidence"] < 0.5:
|
| 434 |
-
action = heur_action
|
| 435 |
-
else:
|
| 436 |
-
action = heur_action
|
| 437 |
-
|
| 438 |
-
# Step environment
|
| 439 |
-
step_response = requests.post(
|
| 440 |
-
f"{ENV_URL}/step",
|
| 441 |
-
json=action,
|
| 442 |
-
timeout=30,
|
| 443 |
-
)
|
| 444 |
-
step_response.raise_for_status()
|
| 445 |
-
result = step_response.json()
|
| 446 |
-
|
| 447 |
-
obs = result["observation"]
|
| 448 |
-
reward = result["reward"]
|
| 449 |
-
done = result["done"]
|
| 450 |
-
info = result["info"]
|
| 451 |
-
total_reward += reward["total"]
|
| 452 |
-
rewards.append(reward["total"])
|
| 453 |
-
log_step(step=step_count, action=action, reward=reward["total"], done=done, error=None)
|
| 454 |
-
|
| 455 |
-
prev_info = info
|
| 456 |
-
step_count += 1
|
| 457 |
-
|
| 458 |
-
# Print progress every 50 steps
|
| 459 |
-
if step_count % 50 == 0:
|
| 460 |
-
print(f" Step {step_count:3d} | Reward: {reward['total']:+.4f} | "
|
| 461 |
-
f"Cumulative: {total_reward:+.4f} | "
|
| 462 |
-
f"Detected: {action['attack_detected']} | "
|
| 463 |
-
f"Type: {action['attack_type']}")
|
| 464 |
-
|
| 465 |
-
# Extract grader score
|
| 466 |
-
grader_score = info.get("grader_score", 0.0)
|
| 467 |
-
print(f"\n Episode complete: {step_count} steps")
|
| 468 |
-
print(f" Total reward: {total_reward:+.4f}")
|
| 469 |
-
print(f" Grader score: {grader_score:.4f}")
|
| 470 |
-
finally:
|
| 471 |
-
log_end(success=grader_score > 0.0, steps=step_count, score=grader_score, rewards=rewards)
|
| 472 |
-
|
| 473 |
-
return grader_score
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
if __name__ == "__main__":
|
| 477 |
-
agent_name = f"LLM ({MODEL_NAME})" if USE_LLM else "Rule-Based Heuristic"
|
| 478 |
-
print("PLL Cyberattack Detection — Agentic Inference")
|
| 479 |
-
print(f"Agent: {agent_name}")
|
| 480 |
-
print(f"Environment: {ENV_URL}")
|
| 481 |
-
if not USE_LLM:
|
| 482 |
-
print("(Set USE_LLM=1 to use LLM agent instead of heuristic)")
|
| 483 |
-
|
| 484 |
-
start_time = time.time()
|
| 485 |
-
scores = []
|
| 486 |
-
|
| 487 |
-
for task_id in range(3):
|
| 488 |
-
score = run_episode(task_id)
|
| 489 |
-
print(f"Task {task_id} score: {score:.4f}")
|
| 490 |
-
scores.append(score)
|
| 491 |
-
|
| 492 |
-
elapsed = time.time() - start_time
|
| 493 |
-
|
| 494 |
-
print(f"\n{'='*60}")
|
| 495 |
-
print("FINAL RESULTS")
|
| 496 |
-
print(f"{'='*60}")
|
| 497 |
-
for i, score in enumerate(scores):
|
| 498 |
-
print(f" Task {i} ({TASK_NAMES[i]}): {score:.4f}")
|
| 499 |
-
print(f"\n Average score: {sum(scores)/len(scores):.4f}")
|
| 500 |
-
print(f" Total time: {elapsed:.1f}s ({elapsed/60:.1f} min)")
|
| 501 |
-
print(f"{'='*60}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,65 +1,47 @@
|
|
|
|
|
| 1 |
name: pll-cyberattack-detection
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
description: >
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
fields:
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
shape: [20]
|
| 22 |
-
description: normalized frequency deviation from nominal
|
| 23 |
-
- name: omega_deviation_window
|
| 24 |
-
shape: [20]
|
| 25 |
-
description: frequency deviation from nominal (rad/s)
|
| 26 |
-
- name: raw_voltages
|
| 27 |
-
shape: [3]
|
| 28 |
-
description: raw three-phase voltages [va, vb, vc] (pu)
|
| 29 |
-
action_space:
|
| 30 |
-
type: mixed
|
| 31 |
-
fields:
|
| 32 |
-
- name: attack_detected
|
| 33 |
-
type: bool
|
| 34 |
-
- name: attack_type
|
| 35 |
-
type: int
|
| 36 |
-
range: [0, 4]
|
| 37 |
-
- name: confidence
|
| 38 |
-
type: float
|
| 39 |
-
range: [0.0, 1.0]
|
| 40 |
-
- name: protective_action
|
| 41 |
-
type: int
|
| 42 |
-
range: [0, 3]
|
| 43 |
tasks:
|
| 44 |
-
- id:
|
|
|
|
| 45 |
difficulty: easy
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
description: Detect sinusoidal FDI attack within 100 steps of attack start
|
| 51 |
-
- id: multi_attack_classification
|
| 52 |
difficulty: medium
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
description: Classify attack type from observation window
|
| 58 |
-
- id: stealthy_attack_detection
|
| 59 |
difficulty: hard
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
max_score: 1.0
|
| 63 |
-
episode_length: 500
|
| 64 |
-
description: Detect stealthy low-amplitude attack before PLL loss-of-lock
|
| 65 |
-
episode_length: 500
|
|
|
|
| 1 |
+
spec_version: 1
|
| 2 |
name: pll-cyberattack-detection
|
| 3 |
+
type: space
|
| 4 |
+
runtime: fastapi
|
| 5 |
+
app: server.app:app
|
| 6 |
+
port: 7860
|
| 7 |
+
|
| 8 |
+
# ---- Environment card ---------------------------------------------------
|
| 9 |
+
version: 2.0.0
|
| 10 |
description: >
|
| 11 |
+
Cyber-guard for a grid-connected inverter. An agent watches the signals of a
|
| 12 |
+
5 kHz-simulated SRF phase-locked loop and a delayed, trusted PMU stream, and must
|
| 13 |
+
detect, classify and mitigate false-data-injection attacks on the voltage sensors
|
| 14 |
+
without alarming on natural grid disturbances (phase jumps, sags, single-phase
|
| 15 |
+
faults, harmonic steps, frequency events).
|
| 16 |
+
tags: [openenv, power-systems, cybersecurity, smart-grid, control-systems, pll, fdi, intrusion-detection]
|
| 17 |
+
episode:
|
| 18 |
+
steps: 40
|
| 19 |
+
decision_interval_ms: 50
|
| 20 |
+
simulation_rate_hz: 5000
|
| 21 |
+
reward_range: [-3.21, 0.69]
|
| 22 |
+
action:
|
| 23 |
+
model: pll_cyber.models.PLLAction
|
| 24 |
fields:
|
| 25 |
+
attack_detected: {type: bool}
|
| 26 |
+
attack_type: {type: int, range: [0, 4], meaning: "0 none, 1 sinusoidal, 2 ramp, 3 pulse, 4 phase spoofing"}
|
| 27 |
+
confidence: {type: float, range: [0.0, 1.0], scored_with: brier}
|
| 28 |
+
protective_action: {type: int, range: [0, 3], meaning: "0 none, 1 alert, 2 PMU-aided holdover, 3 disconnect (latched)"}
|
| 29 |
+
observation:
|
| 30 |
+
model: pll_cyber.models.PLLObservation
|
| 31 |
+
groups: [waveforms, features, pmu, trend, reward_breakdown, episode_result]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
tasks:
|
| 33 |
+
- id: 0
|
| 34 |
+
name: sinusoidal_fdi_detection
|
| 35 |
difficulty: easy
|
| 36 |
+
grader: pll_cyber.graders.grade_easy
|
| 37 |
+
score_range: [0.0, 1.0]
|
| 38 |
+
- id: 1
|
| 39 |
+
name: attack_classification_under_disturbances
|
|
|
|
|
|
|
| 40 |
difficulty: medium
|
| 41 |
+
grader: pll_cyber.graders.grade_medium
|
| 42 |
+
score_range: [0.0, 1.0]
|
| 43 |
+
- id: 2
|
| 44 |
+
name: stealthy_phase_spoofing_mitigation
|
|
|
|
|
|
|
| 45 |
difficulty: hard
|
| 46 |
+
grader: pll_cyber.graders.grade_hard
|
| 47 |
+
score_range: [0.0, 1.0]
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PLL Cyber-Guard: an OpenEnv environment for cyber-attack detection on grid-inverter PLLs."""
|
| 2 |
+
|
| 3 |
+
__version__ = "2.0.0"
|
| 4 |
+
|
| 5 |
+
from .models import PLLAction, PLLObservation, PLLState, RewardBreakdown # noqa: E402
|
| 6 |
+
|
| 7 |
+
__all__ = ["PLLAction", "PLLObservation", "PLLState", "RewardBreakdown", "__version__"]
|
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
False Data Injection (FDI) attacks on the PLL's voltage-measurement channel.
|
| 3 |
+
|
| 4 |
+
The attacker sits between the voltage transducers / ADC and the controller
|
| 5 |
+
(compromised sensor firmware, a man-in-the-middle on the sampled-values bus,
|
| 6 |
+
or malware on the control board) and rewrites the samples the PLL sees.
|
| 7 |
+
|
| 8 |
+
1 sinusoidal — interharmonic injection on one phase: excites a
|
| 9 |
+
persistent oscillation in the PLL frequency estimate
|
| 10 |
+
2 ramp — slowly growing DC bias on one phase (sensor-offset
|
| 11 |
+
style): a 50 Hz ripple in vq that grows over time
|
| 12 |
+
3 pulse — step bias on one phase for a short burst
|
| 13 |
+
4 phase_spoofing — coordinated, balanced manipulation of all three
|
| 14 |
+
phases that rotates the measured voltage vector by a
|
| 15 |
+
slowly accelerating angle δ(t). The PLL tracks the
|
| 16 |
+
spoofed angle perfectly (vq stays ≈ 0) so classic
|
| 17 |
+
residual detectors are blind, while the inverter
|
| 18 |
+
drifts out of synchronism with the real grid. Its
|
| 19 |
+
only local footprint is a small frequency deviation
|
| 20 |
+
that is indistinguishable from a natural grid
|
| 21 |
+
frequency event — unless it is cross-checked against
|
| 22 |
+
a trusted reference (the PMU stream).
|
| 23 |
+
|
| 24 |
+
Types 1–3 are "loud but harmless-ish"; type 4 is quiet and destructive.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
import math
|
| 30 |
+
from dataclasses import asdict, dataclass, field
|
| 31 |
+
from typing import Any, Dict, Optional
|
| 32 |
+
|
| 33 |
+
import numpy as np
|
| 34 |
+
|
| 35 |
+
from .pll import TWO_PI
|
| 36 |
+
|
| 37 |
+
ATTACK_TYPES: Dict[int, str] = {
|
| 38 |
+
0: "none",
|
| 39 |
+
1: "sinusoidal",
|
| 40 |
+
2: "ramp",
|
| 41 |
+
3: "pulse",
|
| 42 |
+
4: "phase_spoofing",
|
| 43 |
+
}
|
| 44 |
+
ATTACK_IDS: Dict[str, int] = {v: k for k, v in ATTACK_TYPES.items()}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@dataclass
|
| 48 |
+
class AttackSpec:
|
| 49 |
+
kind: str = "none"
|
| 50 |
+
t_start: float = math.inf
|
| 51 |
+
params: Dict[str, float] = field(default_factory=dict)
|
| 52 |
+
|
| 53 |
+
@property
|
| 54 |
+
def type_id(self) -> int:
|
| 55 |
+
return ATTACK_IDS[self.kind]
|
| 56 |
+
|
| 57 |
+
@property
|
| 58 |
+
def present(self) -> bool:
|
| 59 |
+
return self.kind != "none"
|
| 60 |
+
|
| 61 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 62 |
+
d = asdict(self)
|
| 63 |
+
d["type_id"] = self.type_id
|
| 64 |
+
if not math.isfinite(d["t_start"]):
|
| 65 |
+
d["t_start"] = None
|
| 66 |
+
return d
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class Attack:
|
| 70 |
+
"""Evaluates an AttackSpec on a block of sample times."""
|
| 71 |
+
|
| 72 |
+
def __init__(self, spec: AttackSpec):
|
| 73 |
+
self.spec = spec
|
| 74 |
+
|
| 75 |
+
def angle_offset(self, t: np.ndarray) -> Optional[np.ndarray]:
|
| 76 |
+
"""Spoofed angle δ(t) in rad added to all three measured phases, or None."""
|
| 77 |
+
s = self.spec
|
| 78 |
+
if s.kind != "phase_spoofing":
|
| 79 |
+
return None
|
| 80 |
+
tau = np.clip(t - s.t_start, 0.0, None)
|
| 81 |
+
r, df_max, sign = s.params["rocof_hz_s"], s.params["max_offset_hz"], s.params["direction"]
|
| 82 |
+
t_sat = df_max / r
|
| 83 |
+
# Frequency offset ramps at r until df_max, then holds; δ = 2π ∫ Δf dτ
|
| 84 |
+
ramp_part = 0.5 * r * np.minimum(tau, t_sat) ** 2
|
| 85 |
+
hold_part = df_max * np.clip(tau - t_sat, 0.0, None)
|
| 86 |
+
return sign * TWO_PI * (ramp_part + hold_part)
|
| 87 |
+
|
| 88 |
+
def additive(self, t: np.ndarray) -> Optional[np.ndarray]:
|
| 89 |
+
"""Additive injection, shape (3, n), or None."""
|
| 90 |
+
s = self.spec
|
| 91 |
+
if s.kind in ("none", "phase_spoofing"):
|
| 92 |
+
return None
|
| 93 |
+
tau = t - s.t_start
|
| 94 |
+
active = tau >= 0.0
|
| 95 |
+
if not active.any():
|
| 96 |
+
return None
|
| 97 |
+
sig = np.zeros_like(t)
|
| 98 |
+
p = s.params
|
| 99 |
+
if s.kind == "sinusoidal":
|
| 100 |
+
sig = np.where(active, p["amplitude_pu"] * np.sin(TWO_PI * p["freq_hz"] * tau + p["phase"]), 0.0)
|
| 101 |
+
elif s.kind == "ramp":
|
| 102 |
+
sig = np.where(active, np.clip(p["rate_pu_s"] * tau, -p["cap_pu"], p["cap_pu"]), 0.0)
|
| 103 |
+
elif s.kind == "pulse":
|
| 104 |
+
sig = np.where(active & (tau < p["duration_s"]), p["magnitude_pu"], 0.0)
|
| 105 |
+
out = np.zeros((3, t.size))
|
| 106 |
+
out[int(p["phase_index"])] = sig
|
| 107 |
+
return out
|
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Reference policies.
|
| 3 |
+
|
| 4 |
+
`PhysicsHeuristicAgent` is a transparent, physics-informed baseline that a
|
| 5 |
+
protection engineer could write by hand. It works on plain observation
|
| 6 |
+
dicts, so the same code drives in-process, HTTP and WebSocket rollouts.
|
| 7 |
+
|
| 8 |
+
* Single-phase injection (tasks 0/1). A genuine grid has no DC component
|
| 9 |
+
and its only zero-sequence content comes from single-phase faults, which
|
| 10 |
+
sit at the 50 Hz fundamental. The agent fits v0 = (va+vb+vc)/3 with
|
| 11 |
+
DC + 50 Hz terms:
|
| 12 |
+
DC → bias attack (ramp if growing, pulse if not)
|
| 13 |
+
residual off 50 Hz → sinusoidal interharmonic injection
|
| 14 |
+
50 Hz only → single-phase sag (benign)
|
| 15 |
+
* Phase spoofing (task 2). The local frequency is compared with the PMU
|
| 16 |
+
frequency at the *same* instant (latency-compensated using the agent's
|
| 17 |
+
own history of local frequency). The mismatch is integrated into an
|
| 18 |
+
estimate of the accumulated angle error; holdover is engaged before it
|
| 19 |
+
approaches the 10° limit.
|
| 20 |
+
|
| 21 |
+
Also included: trivial policies used by tests to prove the graders cannot
|
| 22 |
+
be gamed.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import math
|
| 28 |
+
import random
|
| 29 |
+
from typing import Any, Dict, List, Optional
|
| 30 |
+
|
| 31 |
+
import numpy as np
|
| 32 |
+
|
| 33 |
+
WAVE_DT_S = 0.002 # exported waveform resolution
|
| 34 |
+
F0 = 50.0
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _v0_decompose(obs: Dict[str, Any]) -> Dict[str, float]:
|
| 38 |
+
w = obs["waveforms"]
|
| 39 |
+
v0 = (np.asarray(w["va"]) + np.asarray(w["vb"]) + np.asarray(w["vc"])) / 3.0
|
| 40 |
+
t = np.arange(v0.size) * WAVE_DT_S
|
| 41 |
+
X = np.column_stack([np.ones_like(t), np.cos(2 * np.pi * F0 * t), np.sin(2 * np.pi * F0 * t)])
|
| 42 |
+
coef, *_ = np.linalg.lstsq(X, v0, rcond=None)
|
| 43 |
+
resid = v0 - X @ coef
|
| 44 |
+
return {"dc": float(coef[0]), "amp50": float(math.hypot(coef[1], coef[2])),
|
| 45 |
+
"resid_rms": float(np.sqrt(np.mean(resid ** 2)))}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class PhysicsHeuristicAgent:
|
| 49 |
+
# Thresholds (pu unless noted); noise floor of v0 residual is ≈0.003 pu
|
| 50 |
+
RESID_THR = 0.0075
|
| 51 |
+
DC_THR = 0.012
|
| 52 |
+
SPOOF_ANGLE_ALARM_DEG = 2.5
|
| 53 |
+
SPOOF_ANGLE_HOLDOVER_DEG = 3.5
|
| 54 |
+
SPOOF_RESID_HZ = 0.025
|
| 55 |
+
CONFIRM = 2
|
| 56 |
+
|
| 57 |
+
def __init__(self):
|
| 58 |
+
self.reset()
|
| 59 |
+
|
| 60 |
+
def reset(self) -> None:
|
| 61 |
+
self.latched = False
|
| 62 |
+
self.latched_type = 0
|
| 63 |
+
self.streak = 0
|
| 64 |
+
self.dc_hist: List[float] = []
|
| 65 |
+
self.resid_hist: List[float] = []
|
| 66 |
+
self.pulse_ended = False
|
| 67 |
+
self.f_hist: List[tuple] = [] # (t_center_s, f_local)
|
| 68 |
+
self.last_pmu: Optional[tuple] = None
|
| 69 |
+
self.angle_est_deg = 0.0
|
| 70 |
+
self.spoof_streak = 0
|
| 71 |
+
|
| 72 |
+
# ------------------------------------------------------------------
|
| 73 |
+
def act(self, obs: Dict[str, Any]) -> Dict[str, Any]:
|
| 74 |
+
if obs.get("step", 0) == 0:
|
| 75 |
+
self.reset()
|
| 76 |
+
task = obs["task_id"]
|
| 77 |
+
if task == 2:
|
| 78 |
+
return self._act_spoofing(obs)
|
| 79 |
+
return self._act_injection(obs, task)
|
| 80 |
+
|
| 81 |
+
# ------------------------------------------------------------------
|
| 82 |
+
def _act_injection(self, obs: Dict[str, Any], task: int) -> Dict[str, Any]:
|
| 83 |
+
d = _v0_decompose(obs)
|
| 84 |
+
thr_dc = self.DC_THR / 3.0 # v0 carries one third of a single-phase offset
|
| 85 |
+
bias_now = abs(d["dc"]) > thr_dc
|
| 86 |
+
resid_now = d["resid_rms"] > self.RESID_THR
|
| 87 |
+
hit = bias_now or resid_now
|
| 88 |
+
|
| 89 |
+
self.streak = self.streak + 1 if hit else 0
|
| 90 |
+
if hit or self.latched:
|
| 91 |
+
self.dc_hist.append(d["dc"])
|
| 92 |
+
self.resid_hist.append(d["resid_rms"])
|
| 93 |
+
if not self.latched and self.streak >= self.CONFIRM:
|
| 94 |
+
self.latched = True
|
| 95 |
+
if self.latched and task == 1:
|
| 96 |
+
self.latched_type = self._classify(bias_now)
|
| 97 |
+
|
| 98 |
+
if not self.latched:
|
| 99 |
+
conf = 0.9 if self.streak == 0 else 0.6
|
| 100 |
+
return {"attack_detected": False, "attack_type": 0, "confidence": conf, "protective_action": 0}
|
| 101 |
+
atype = 1 if task == 0 else self.latched_type
|
| 102 |
+
return {"attack_detected": True, "attack_type": atype, "confidence": 0.9, "protective_action": 1}
|
| 103 |
+
|
| 104 |
+
def _classify(self, bias_now: bool) -> int:
|
| 105 |
+
"""sinusoidal ↔ bias (ramp/pulse) from the v0 history since the alarm started."""
|
| 106 |
+
if self.latched_type == 3 and self.pulse_ended:
|
| 107 |
+
return 3
|
| 108 |
+
thr_dc = self.DC_THR / 3.0
|
| 109 |
+
active = [x for x in self.dc_hist if abs(x) > thr_dc]
|
| 110 |
+
signs = {np.sign(x) for x in active}
|
| 111 |
+
if len(signs) > 1:
|
| 112 |
+
return 1 # offset changes sign → oscillation, not a bias
|
| 113 |
+
if np.median(self.resid_hist) > self.RESID_THR:
|
| 114 |
+
return 1
|
| 115 |
+
if not bias_now and len(active) >= 1:
|
| 116 |
+
self.pulse_ended = True # a bias that disappears was a pulse
|
| 117 |
+
return 3
|
| 118 |
+
mags = [abs(x) for x in active]
|
| 119 |
+
if len(mags) >= 2:
|
| 120 |
+
growth = mags[-1] - mags[0]
|
| 121 |
+
if growth > 0.25 * max(mags):
|
| 122 |
+
return 2
|
| 123 |
+
if self.latched_type == 2 and len(mags) > 4:
|
| 124 |
+
return 2 # ramp that has reached its saturation cap
|
| 125 |
+
return 3
|
| 126 |
+
return self.latched_type or 2
|
| 127 |
+
|
| 128 |
+
# ------------------------------------------------------------------
|
| 129 |
+
def _act_spoofing(self, obs: Dict[str, Any]) -> Dict[str, Any]:
|
| 130 |
+
f = obs["features"]
|
| 131 |
+
pmu = obs["pmu"]
|
| 132 |
+
t_now = obs["time_s"]
|
| 133 |
+
interval = obs.get("decision_interval_ms", 50.0) / 1000.0
|
| 134 |
+
self.f_hist.append((t_now - interval / 2.0, f["f_local_hz"]))
|
| 135 |
+
self.f_hist = self.f_hist[-20:]
|
| 136 |
+
|
| 137 |
+
# Only learn from a *new* PMU report
|
| 138 |
+
t_rep = t_now - pmu["age_ms"] / 1000.0 - 0.05 # report = average over its 100 ms interval
|
| 139 |
+
new_report = self.last_pmu is None or abs(t_rep - self.last_pmu[0]) > 1e-6
|
| 140 |
+
if new_report and len(self.f_hist) >= 3 and obs["inverter_mode"] == "normal":
|
| 141 |
+
ts = np.array([x[0] for x in self.f_hist])
|
| 142 |
+
fs = np.array([x[1] for x in self.f_hist])
|
| 143 |
+
if ts[0] <= t_rep:
|
| 144 |
+
# average local frequency over the same 100 ms interval the PMU measured
|
| 145 |
+
grid = np.linspace(t_rep - 0.05, t_rep + 0.05, 5)
|
| 146 |
+
f_loc = float(np.interp(grid, ts, fs).mean())
|
| 147 |
+
resid = f_loc - pmu["f_hz"]
|
| 148 |
+
dt = 0.1 if self.last_pmu is None else max(0.05, t_rep - self.last_pmu[0])
|
| 149 |
+
self.angle_est_deg = 0.9 * self.angle_est_deg + 360.0 * resid * dt
|
| 150 |
+
same_sign = np.sign(resid) == np.sign(self.angle_est_deg)
|
| 151 |
+
if abs(resid) > self.SPOOF_RESID_HZ and same_sign:
|
| 152 |
+
self.spoof_streak += 1
|
| 153 |
+
else:
|
| 154 |
+
self.spoof_streak = 0
|
| 155 |
+
if new_report:
|
| 156 |
+
self.last_pmu = (t_rep, pmu["f_hz"])
|
| 157 |
+
|
| 158 |
+
angle = abs(self.angle_est_deg)
|
| 159 |
+
if not self.latched and (angle > self.SPOOF_ANGLE_ALARM_DEG or self.spoof_streak >= 2):
|
| 160 |
+
self.latched = True
|
| 161 |
+
if not self.latched:
|
| 162 |
+
return {"attack_detected": False, "attack_type": 0, "confidence": 0.8, "protective_action": 0}
|
| 163 |
+
holdover = angle > self.SPOOF_ANGLE_HOLDOVER_DEG or self.spoof_streak >= 2 or obs["inverter_mode"] == "holdover"
|
| 164 |
+
return {"attack_detected": True, "attack_type": 4, "confidence": 0.85,
|
| 165 |
+
"protective_action": 2 if holdover else 1}
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
# ---------------------------------------------------------------------------
|
| 169 |
+
# Trivial policies (exploit checks)
|
| 170 |
+
# ---------------------------------------------------------------------------
|
| 171 |
+
|
| 172 |
+
class ConstantAgent:
|
| 173 |
+
def __init__(self, detected: bool, attack_type: int = 0, protective_action: int = 0):
|
| 174 |
+
self.a = {"attack_detected": detected, "attack_type": attack_type,
|
| 175 |
+
"confidence": 1.0, "protective_action": protective_action}
|
| 176 |
+
|
| 177 |
+
def reset(self) -> None:
|
| 178 |
+
pass
|
| 179 |
+
|
| 180 |
+
def act(self, obs: Dict[str, Any]) -> Dict[str, Any]:
|
| 181 |
+
return dict(self.a)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
class RandomAgent:
|
| 185 |
+
def __init__(self, seed: int = 0, p_detect: float = 0.5):
|
| 186 |
+
self.rng = random.Random(seed)
|
| 187 |
+
self.p = p_detect
|
| 188 |
+
|
| 189 |
+
def reset(self) -> None:
|
| 190 |
+
pass
|
| 191 |
+
|
| 192 |
+
def act(self, obs: Dict[str, Any]) -> Dict[str, Any]:
|
| 193 |
+
d = self.rng.random() < self.p
|
| 194 |
+
return {"attack_detected": d, "attack_type": self.rng.randint(0, 4) if d else 0,
|
| 195 |
+
"confidence": self.rng.random(), "protective_action": self.rng.choice([0, 0, 1, 2])}
|
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Typed OpenEnv client (persistent WebSocket session).
|
| 3 |
+
|
| 4 |
+
from pll_cyber.client import PLLCyberEnv
|
| 5 |
+
from pll_cyber.models import PLLAction
|
| 6 |
+
|
| 7 |
+
with PLLCyberEnv(base_url="http://localhost:7860").sync() as env:
|
| 8 |
+
result = env.reset(task_id=2, seed=7)
|
| 9 |
+
while not result.done:
|
| 10 |
+
result = env.step(PLLAction(attack_detected=False))
|
| 11 |
+
print(result.observation.episode_result.grader_score)
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from typing import Any, Dict
|
| 17 |
+
|
| 18 |
+
from openenv.core import EnvClient
|
| 19 |
+
from openenv.core.client_types import StepResult
|
| 20 |
+
|
| 21 |
+
from .models import PLLAction, PLLObservation, PLLState
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class PLLCyberEnv(EnvClient[PLLAction, PLLObservation, PLLState]):
|
| 25 |
+
def _step_payload(self, action: PLLAction) -> Dict[str, Any]:
|
| 26 |
+
return action.model_dump(exclude={"metadata"})
|
| 27 |
+
|
| 28 |
+
def _parse_result(self, payload: Dict[str, Any]) -> StepResult[PLLObservation]:
|
| 29 |
+
obs = PLLObservation(**payload.get("observation", {}),
|
| 30 |
+
done=payload.get("done", False), reward=payload.get("reward"))
|
| 31 |
+
return StepResult(observation=obs, reward=payload.get("reward"), done=payload.get("done", False))
|
| 32 |
+
|
| 33 |
+
def _parse_state(self, payload: Dict[str, Any]) -> PLLState:
|
| 34 |
+
return PLLState(**payload)
|
|
@@ -0,0 +1,323 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
PLL Cyber-Guard environment (OpenEnv `Environment` implementation).
|
| 3 |
+
|
| 4 |
+
One episode = 40 decisions × 50 ms of a grid-connected inverter. Each step:
|
| 5 |
+
|
| 6 |
+
1. the agent's action a_t is scored against window t (what it observed)
|
| 7 |
+
2. the protective action is applied (normal / holdover / disconnect)
|
| 8 |
+
3. the next 50 ms window is simulated at 5 kHz:
|
| 9 |
+
grid → FDI attack → sensors+ADC → SRF-PLL → features
|
| 10 |
+
alongside a "shadow" PLL that never mitigates, which gives the
|
| 11 |
+
counterfactual time at which the unprotected inverter would have
|
| 12 |
+
lost synchronism with the real grid
|
| 13 |
+
4. physical harm (sustained >10° desynchronisation) is charged
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import math
|
| 19 |
+
import secrets
|
| 20 |
+
import uuid
|
| 21 |
+
from collections import deque
|
| 22 |
+
from typing import Any, Dict, List, Optional
|
| 23 |
+
|
| 24 |
+
import numpy as np
|
| 25 |
+
from openenv.core.env_server.interfaces import Environment
|
| 26 |
+
from openenv.core.env_server.types import EnvironmentMetadata
|
| 27 |
+
|
| 28 |
+
from . import __version__
|
| 29 |
+
from .attacks import Attack, ATTACK_TYPES
|
| 30 |
+
from .features import FeatureExtractor
|
| 31 |
+
from .graders import grade_episode
|
| 32 |
+
from .grid import GridModel
|
| 33 |
+
from .models import (EpisodeResult, PLLAction, PLLObservation, PLLState, PMUReading,
|
| 34 |
+
RewardBreakdown, SignalFeatures, Trend, Waveforms)
|
| 35 |
+
from .pll import F_NOMINAL, SRFPLL
|
| 36 |
+
from .rewards import StepOutcome, compute_reward
|
| 37 |
+
from .scenarios import (ADC_LSB_PU, DECISION_INTERVAL_S, DT, MAX_STEPS, PMU_F_NOISE_HZ,
|
| 38 |
+
PMU_REPORT_INTERVAL_S, PMU_V_NOISE_PU, SAMPLE_RATE_HZ,
|
| 39 |
+
SAMPLES_PER_DECISION, SENSOR_NOISE_PU, TASKS, WARMUP_S,
|
| 40 |
+
Scenario, sample_scenario)
|
| 41 |
+
from .sensors import DisciplinedHoldover, PMUStream, measure
|
| 42 |
+
|
| 43 |
+
DESYNC_THRESHOLD_DEG = 10.0
|
| 44 |
+
DESYNC_SUSTAIN_S = 0.030
|
| 45 |
+
_DESYNC_RAD = math.radians(DESYNC_THRESHOLD_DEG)
|
| 46 |
+
_DESYNC_SAMPLES = int(round(DESYNC_SUSTAIN_S * SAMPLE_RATE_HZ))
|
| 47 |
+
_INTERVAL_MS = DECISION_INTERVAL_S * 1000.0
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class _DesyncTracker:
|
| 51 |
+
"""Detects sustained loss of synchronism: |θ̂ − θ| > 10° for ≥ 30 ms."""
|
| 52 |
+
|
| 53 |
+
def __init__(self):
|
| 54 |
+
self.run = 0
|
| 55 |
+
self.first_step: Optional[int] = None
|
| 56 |
+
self.max_err_deg = 0.0
|
| 57 |
+
|
| 58 |
+
def update(self, theta_hat: List[float], theta_true: np.ndarray, step: int, active: bool = True) -> bool:
|
| 59 |
+
err = np.abs(np.angle(np.exp(1j * (np.asarray(theta_hat) - theta_true))))
|
| 60 |
+
if not active:
|
| 61 |
+
self.run = 0
|
| 62 |
+
return False
|
| 63 |
+
self.max_err_deg = max(self.max_err_deg, float(np.degrees(err.max())))
|
| 64 |
+
hit = False
|
| 65 |
+
run = self.run
|
| 66 |
+
for above in (err > _DESYNC_RAD):
|
| 67 |
+
run = run + 1 if above else 0
|
| 68 |
+
if run >= _DESYNC_SAMPLES:
|
| 69 |
+
hit = True
|
| 70 |
+
self.run = run
|
| 71 |
+
if hit and self.first_step is None:
|
| 72 |
+
self.first_step = step
|
| 73 |
+
return hit
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class PLLCyberEnvironment(Environment[PLLAction, PLLObservation, PLLState]):
|
| 77 |
+
SUPPORTS_CONCURRENT_SESSIONS = True
|
| 78 |
+
|
| 79 |
+
def __init__(self):
|
| 80 |
+
super().__init__()
|
| 81 |
+
self._sc: Optional[Scenario] = None
|
| 82 |
+
self._state = PLLState(episode_id=None, step_count=0)
|
| 83 |
+
|
| 84 |
+
# ------------------------------------------------------------------
|
| 85 |
+
# OpenEnv API
|
| 86 |
+
# ------------------------------------------------------------------
|
| 87 |
+
def reset(self, seed: Optional[int] = None, episode_id: Optional[str] = None,
|
| 88 |
+
task_id: Optional[int] = None, task: Optional[str] = None, **kwargs: Any) -> PLLObservation:
|
| 89 |
+
tid = self._resolve_task(task_id, task)
|
| 90 |
+
if seed is None:
|
| 91 |
+
seed = secrets.randbelow(2 ** 31)
|
| 92 |
+
seed = int(seed)
|
| 93 |
+
|
| 94 |
+
sc = sample_scenario(tid, seed)
|
| 95 |
+
self._sc = sc
|
| 96 |
+
ss = np.random.SeedSequence([seed, 2000 + tid])
|
| 97 |
+
rng_sensor, rng_pmu = (np.random.default_rng(s) for s in ss.spawn(2))
|
| 98 |
+
self._rng_sensor = rng_sensor
|
| 99 |
+
|
| 100 |
+
self._grid = GridModel(sc.grid, theta0=sc.theta0)
|
| 101 |
+
self._attack = Attack(sc.attack)
|
| 102 |
+
self._pll = SRFPLL(DT, theta0=sc.theta0)
|
| 103 |
+
self._shadow = SRFPLL(DT, theta0=sc.theta0)
|
| 104 |
+
self._pmu = PMUStream(PMU_REPORT_INTERVAL_S, sc.pmu_latency_s, PMU_F_NOISE_HZ,
|
| 105 |
+
PMU_V_NOISE_PU, rng_pmu)
|
| 106 |
+
self._fx = FeatureExtractor(SAMPLE_RATE_HZ, SAMPLES_PER_DECISION)
|
| 107 |
+
self._desync = _DesyncTracker()
|
| 108 |
+
self._desync_shadow = _DesyncTracker()
|
| 109 |
+
self._holdover = DisciplinedHoldover()
|
| 110 |
+
self._theta_hist: deque = deque(maxlen=6) # (t0, θ̂ samples) of recent windows
|
| 111 |
+
|
| 112 |
+
self._mode = "normal"
|
| 113 |
+
self._step = 0
|
| 114 |
+
self._done = False
|
| 115 |
+
self._had_tp = False
|
| 116 |
+
self._log: List[Dict[str, Any]] = []
|
| 117 |
+
self._cum_reward = 0.0
|
| 118 |
+
self._result: Optional[EpisodeResult] = None
|
| 119 |
+
|
| 120 |
+
# Warm-up: let the PLL settle; the last warm-up window is observation 0.
|
| 121 |
+
n_warm = int(round(WARMUP_S / DECISION_INTERVAL_S))
|
| 122 |
+
for k in range(n_warm):
|
| 123 |
+
window = self._simulate_window(step=k - n_warm + 1, warmup=True)
|
| 124 |
+
|
| 125 |
+
self._state = PLLState(
|
| 126 |
+
episode_id=episode_id or str(uuid.uuid4()), step_count=0, task_id=tid,
|
| 127 |
+
task_name=TASKS[tid].name, seed=seed, done=False, time_s=0.0,
|
| 128 |
+
inverter_mode="normal", cumulative_reward=0.0,
|
| 129 |
+
)
|
| 130 |
+
obs = self._observation(window, reward=None, breakdown=None)
|
| 131 |
+
obs.task_brief = TASKS[tid].brief
|
| 132 |
+
obs.message = f"Episode started: task {tid} ({TASKS[tid].difficulty}), seed {seed}."
|
| 133 |
+
return obs
|
| 134 |
+
|
| 135 |
+
def step(self, action: PLLAction, timeout_s: Optional[float] = None, **kwargs: Any) -> PLLObservation:
|
| 136 |
+
if self._sc is None:
|
| 137 |
+
raise RuntimeError("Call reset() before step().")
|
| 138 |
+
if self._done:
|
| 139 |
+
obs = self._observation(self._last_window, reward=0.0, breakdown=RewardBreakdown())
|
| 140 |
+
obs.message = "Episode already finished — call reset() to start a new one."
|
| 141 |
+
return obs
|
| 142 |
+
|
| 143 |
+
sc = self._sc
|
| 144 |
+
t = self._step
|
| 145 |
+
onset = sc.onset_step
|
| 146 |
+
label = onset is not None and t >= onset
|
| 147 |
+
detected = bool(action.attack_detected)
|
| 148 |
+
first_tp = label and detected and not self._had_tp
|
| 149 |
+
self._had_tp = self._had_tp or (label and detected)
|
| 150 |
+
|
| 151 |
+
tripped_now = action.protective_action == 3 and self._mode != "disconnected"
|
| 152 |
+
if self._mode != "disconnected":
|
| 153 |
+
self._mode = {3: "disconnected", 2: "holdover"}.get(action.protective_action, "normal")
|
| 154 |
+
|
| 155 |
+
window = self._simulate_window(step=t + 1)
|
| 156 |
+
|
| 157 |
+
outcome = StepOutcome(
|
| 158 |
+
label_attack=label, true_type=sc.attack.type_id,
|
| 159 |
+
steps_since_onset=(t - onset) if label else 0,
|
| 160 |
+
detected=detected, reported_type=int(action.attack_type),
|
| 161 |
+
confidence=float(action.confidence), first_true_positive=first_tp,
|
| 162 |
+
classify=sc.task_id >= 1, tripped_now=tripped_now, mode_next=self._mode,
|
| 163 |
+
desync_in_next_window=window["desync"], first_desync=window["first_desync"],
|
| 164 |
+
decision_interval_ms=_INTERVAL_MS,
|
| 165 |
+
)
|
| 166 |
+
breakdown = compute_reward(outcome)
|
| 167 |
+
self._cum_reward += breakdown.total
|
| 168 |
+
|
| 169 |
+
self._log.append({
|
| 170 |
+
"step": t, "label": label, "detected": detected,
|
| 171 |
+
"attack_type": int(action.attack_type), "confidence": float(action.confidence),
|
| 172 |
+
"protective_action": int(action.protective_action), "reward": breakdown.total,
|
| 173 |
+
})
|
| 174 |
+
self._step += 1
|
| 175 |
+
self._done = self._step >= MAX_STEPS
|
| 176 |
+
if self._done:
|
| 177 |
+
self._finish()
|
| 178 |
+
|
| 179 |
+
self._state.step_count = self._step
|
| 180 |
+
self._state.done = self._done
|
| 181 |
+
self._state.time_s = round(self._step * DECISION_INTERVAL_S, 3)
|
| 182 |
+
self._state.inverter_mode = self._mode
|
| 183 |
+
self._state.cumulative_reward = round(self._cum_reward, 6)
|
| 184 |
+
|
| 185 |
+
obs = self._observation(window, reward=breakdown.total, breakdown=breakdown)
|
| 186 |
+
if self._done:
|
| 187 |
+
obs.message = f"Episode finished. grader_score={self._result.grader_score:.3f}"
|
| 188 |
+
return obs
|
| 189 |
+
|
| 190 |
+
@property
|
| 191 |
+
def state(self) -> PLLState:
|
| 192 |
+
return self._state
|
| 193 |
+
|
| 194 |
+
def get_metadata(self) -> EnvironmentMetadata:
|
| 195 |
+
return EnvironmentMetadata(
|
| 196 |
+
name="pll-cyberattack-detection",
|
| 197 |
+
description=("Cyber-guard for a grid-connected inverter: detect, classify and mitigate false-data-"
|
| 198 |
+
"injection attacks on an SRF-PLL, without alarming on natural grid disturbances."),
|
| 199 |
+
version=__version__,
|
| 200 |
+
author="Kris Keshav",
|
| 201 |
+
documentation_url="https://github.com/KrisKeshav/PLL-Cyberattack-Detection-OpenEnv",
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
# ------------------------------------------------------------------
|
| 205 |
+
# Internals
|
| 206 |
+
# ------------------------------------------------------------------
|
| 207 |
+
@staticmethod
|
| 208 |
+
def _resolve_task(task_id: Optional[int], task: Optional[str]) -> int:
|
| 209 |
+
if task is not None:
|
| 210 |
+
for tid, spec in TASKS.items():
|
| 211 |
+
if task in (spec.name, spec.difficulty, str(tid)):
|
| 212 |
+
return tid
|
| 213 |
+
raise ValueError(f"Unknown task '{task}'. Options: {[s.name for s in TASKS.values()]}")
|
| 214 |
+
tid = 0 if task_id is None else int(task_id)
|
| 215 |
+
if tid not in TASKS:
|
| 216 |
+
raise ValueError(f"task_id must be one of {sorted(TASKS)}")
|
| 217 |
+
return tid
|
| 218 |
+
|
| 219 |
+
def _simulate_window(self, step: int, warmup: bool = False) -> Dict[str, Any]:
|
| 220 |
+
n = SAMPLES_PER_DECISION
|
| 221 |
+
grid, attack = self._grid, self._attack
|
| 222 |
+
t_next = grid.t + DT * np.arange(1, n + 1)
|
| 223 |
+
out = grid.generate(n, DT, attack.angle_offset(t_next))
|
| 224 |
+
v = out["v"]
|
| 225 |
+
add = attack.additive(out["t"])
|
| 226 |
+
if add is not None:
|
| 227 |
+
v = v + add
|
| 228 |
+
vm = measure(v, self._rng_sensor, SENSOR_NOISE_PU, ADC_LSB_PU)
|
| 229 |
+
|
| 230 |
+
self._pmu.ingest(out["t"], out["theta"], out["v_pos"])
|
| 231 |
+
now = float(out["t"][-1])
|
| 232 |
+
rep = self._pmu.latest(now)
|
| 233 |
+
|
| 234 |
+
holdover = self._mode == "holdover"
|
| 235 |
+
if holdover:
|
| 236 |
+
# θ̂ held by the PLL is the angle for the next sample instant, out["t"][0]
|
| 237 |
+
self._update_holdover(t_next=float(out["t"][0]))
|
| 238 |
+
elif self._holdover.active:
|
| 239 |
+
self._holdover.release()
|
| 240 |
+
r = self._pll.run(vm[0], vm[1], vm[2], holdover=holdover,
|
| 241 |
+
holdover_freq_hz=self._holdover.f_hold,
|
| 242 |
+
holdover_rocof_hz_s=self._holdover.rocof)
|
| 243 |
+
if holdover: # carry the extrapolated frequency ramp into the next window
|
| 244 |
+
self._holdover.f_hold += self._holdover.rocof * n * DT
|
| 245 |
+
rs = self._shadow.run(vm[0], vm[1], vm[2])
|
| 246 |
+
self._theta_hist.append((float(out["t"][0]), r.theta_hat))
|
| 247 |
+
|
| 248 |
+
connected = self._mode != "disconnected"
|
| 249 |
+
had_desync = self._desync.first_step is not None
|
| 250 |
+
desync = False
|
| 251 |
+
if not warmup:
|
| 252 |
+
desync = self._desync.update(r.theta_hat, out["theta"], step, active=connected)
|
| 253 |
+
self._desync_shadow.update(rs.theta_hat, out["theta"], step)
|
| 254 |
+
|
| 255 |
+
pmu_f = rep.f_hz if rep is not None else F_NOMINAL
|
| 256 |
+
fx = self._fx.update(vm, r.vd, r.vq, r.f_hat, pmu_f)
|
| 257 |
+
window = {
|
| 258 |
+
**fx,
|
| 259 |
+
"pmu": {
|
| 260 |
+
"f_hz": round(pmu_f, 4),
|
| 261 |
+
"v_pos_pu": round(rep.v_pos_pu if rep is not None else 1.0, 4),
|
| 262 |
+
"age_ms": round((now - rep.t_end) * 1000.0, 1) if rep is not None else 999.0,
|
| 263 |
+
},
|
| 264 |
+
"desync": desync,
|
| 265 |
+
"first_desync": desync and not had_desync,
|
| 266 |
+
}
|
| 267 |
+
self._last_window = window
|
| 268 |
+
return window
|
| 269 |
+
|
| 270 |
+
def _theta_hat_at(self, t: float) -> Optional[float]:
|
| 271 |
+
for t0, th in reversed(self._theta_hist):
|
| 272 |
+
i = int(round((t - t0) / DT))
|
| 273 |
+
if 0 <= i < len(th):
|
| 274 |
+
return th[i]
|
| 275 |
+
return None
|
| 276 |
+
|
| 277 |
+
def _update_holdover(self, t_next: float) -> None:
|
| 278 |
+
"""Engage / steer the PMU-disciplined oscillator before simulating a window."""
|
| 279 |
+
if not self._holdover.active:
|
| 280 |
+
self._holdover.engage(t_next)
|
| 281 |
+
self._holdover.update(self._pll, self._pmu, t_next)
|
| 282 |
+
|
| 283 |
+
def _finish(self) -> None:
|
| 284 |
+
sc = self._sc
|
| 285 |
+
graded = grade_episode(
|
| 286 |
+
sc.task_id, self._log, sc.onset_step, _INTERVAL_MS,
|
| 287 |
+
true_type=sc.attack.type_id,
|
| 288 |
+
unmitigated_desync_step=self._desync_shadow.first_step,
|
| 289 |
+
actual_desync_step=self._desync.first_step,
|
| 290 |
+
)
|
| 291 |
+
scenario = sc.to_dict()
|
| 292 |
+
scenario["unmitigated_desync_step"] = self._desync_shadow.first_step
|
| 293 |
+
scenario["actual_desync_step"] = self._desync.first_step
|
| 294 |
+
scenario["max_sync_error_deg"] = round(self._desync.max_err_deg, 3)
|
| 295 |
+
scenario["unmitigated_max_sync_error_deg"] = round(self._desync_shadow.max_err_deg, 3)
|
| 296 |
+
scenario["attack_type_name"] = ATTACK_TYPES[sc.attack.type_id]
|
| 297 |
+
self._result = EpisodeResult(
|
| 298 |
+
grader_score=graded["score"], score_breakdown=graded,
|
| 299 |
+
total_reward=round(self._cum_reward, 6), scenario=scenario,
|
| 300 |
+
)
|
| 301 |
+
self._state.grader_score = graded["score"]
|
| 302 |
+
self._state.scenario = scenario
|
| 303 |
+
|
| 304 |
+
def _observation(self, window: Dict[str, Any], reward: Optional[float],
|
| 305 |
+
breakdown: Optional[RewardBreakdown]) -> PLLObservation:
|
| 306 |
+
sc = self._sc
|
| 307 |
+
return PLLObservation(
|
| 308 |
+
done=self._done,
|
| 309 |
+
reward=None if reward is None else round(reward, 6),
|
| 310 |
+
task_id=sc.task_id,
|
| 311 |
+
task_name=TASKS[sc.task_id].name,
|
| 312 |
+
step=self._step,
|
| 313 |
+
max_steps=MAX_STEPS,
|
| 314 |
+
time_s=round(self._step * DECISION_INTERVAL_S, 3),
|
| 315 |
+
decision_interval_ms=_INTERVAL_MS,
|
| 316 |
+
inverter_mode=self._mode,
|
| 317 |
+
waveforms=Waveforms(**window["waveforms"]),
|
| 318 |
+
features=SignalFeatures(**window["features"]),
|
| 319 |
+
pmu=PMUReading(**window["pmu"]),
|
| 320 |
+
trend=Trend(**window["trend"]),
|
| 321 |
+
reward_breakdown=breakdown,
|
| 322 |
+
episode_result=self._result if self._done else None,
|
| 323 |
+
)
|
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""In-process rollout helpers (used by tests, the benchmark script and inference.py)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Any, Callable, Dict, Iterable, List
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
|
| 9 |
+
from .environment import PLLCyberEnvironment
|
| 10 |
+
from .models import PLLAction
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def obs_to_dict(obs) -> Dict[str, Any]:
|
| 14 |
+
return obs.model_dump(exclude={"metadata"})
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def run_episode(agent, task_id: int, seed: int, env: PLLCyberEnvironment | None = None,
|
| 18 |
+
on_step: Callable[[int, Dict[str, Any], Any], None] | None = None) -> Dict[str, Any]:
|
| 19 |
+
env = env or PLLCyberEnvironment()
|
| 20 |
+
obs = env.reset(seed=seed, task_id=task_id)
|
| 21 |
+
if hasattr(agent, "reset"):
|
| 22 |
+
agent.reset()
|
| 23 |
+
rewards: List[float] = []
|
| 24 |
+
while not obs.done:
|
| 25 |
+
action = agent.act(obs_to_dict(obs))
|
| 26 |
+
obs = env.step(PLLAction(**action))
|
| 27 |
+
rewards.append(float(obs.reward or 0.0))
|
| 28 |
+
if on_step:
|
| 29 |
+
on_step(len(rewards), action, obs)
|
| 30 |
+
res = obs.episode_result
|
| 31 |
+
return {"task_id": task_id, "seed": seed, "score": res.grader_score,
|
| 32 |
+
"total_reward": res.total_reward, "rewards": rewards,
|
| 33 |
+
"breakdown": res.score_breakdown, "scenario": res.scenario}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def evaluate(make_agent: Callable[[], Any], task_ids: Iterable[int], seeds: Iterable[int]) -> Dict[int, Dict[str, float]]:
|
| 37 |
+
env = PLLCyberEnvironment()
|
| 38 |
+
out: Dict[int, Dict[str, float]] = {}
|
| 39 |
+
seeds = list(seeds)
|
| 40 |
+
for tid in task_ids:
|
| 41 |
+
scores, rets = [], []
|
| 42 |
+
for s in seeds:
|
| 43 |
+
r = run_episode(make_agent(), tid, s, env)
|
| 44 |
+
scores.append(r["score"])
|
| 45 |
+
rets.append(r["total_reward"])
|
| 46 |
+
out[tid] = {"mean": float(np.mean(scores)), "std": float(np.std(scores)),
|
| 47 |
+
"return": float(np.mean(rets)), "n": len(seeds)}
|
| 48 |
+
return out
|
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
On-board signal processing: turns raw per-sample signals of a decision window
|
| 3 |
+
into the compact, physically meaningful features exposed to the agent.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from collections import deque
|
| 9 |
+
from typing import Deque, Dict, List
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
from .pll import F_NOMINAL
|
| 14 |
+
|
| 15 |
+
TREND_LEN = 10
|
| 16 |
+
FFT_BUFFER_S = 0.200
|
| 17 |
+
DECIMATE = 10 # 5 kHz → 500 Hz for the exported waveforms (25 points / window)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _r(x: float, nd: int = 5) -> float:
|
| 21 |
+
return float(round(float(x), nd))
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _rl(xs, nd: int = 4) -> List[float]:
|
| 25 |
+
return [float(round(float(x), nd)) for x in xs]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class FeatureExtractor:
|
| 29 |
+
def __init__(self, sample_rate_hz: float, samples_per_window: int):
|
| 30 |
+
self.fs = sample_rate_hz
|
| 31 |
+
self.n = samples_per_window
|
| 32 |
+
fft_len = int(FFT_BUFFER_S * sample_rate_hz) // 5 # vq decimated ×5 → 1 kHz
|
| 33 |
+
self.vq_buf: Deque[float] = deque(maxlen=fft_len)
|
| 34 |
+
self.f_means: Deque[float] = deque(maxlen=TREND_LEN)
|
| 35 |
+
self.pmu_f: Deque[float] = deque(maxlen=TREND_LEN)
|
| 36 |
+
self.vq_rms: Deque[float] = deque(maxlen=TREND_LEN)
|
| 37 |
+
self.v0_rms: Deque[float] = deque(maxlen=TREND_LEN)
|
| 38 |
+
self.vpos: Deque[float] = deque(maxlen=TREND_LEN)
|
| 39 |
+
|
| 40 |
+
def update(self, v_meas: np.ndarray, vd: List[float], vq: List[float], f_hat: List[float],
|
| 41 |
+
pmu_f: float) -> Dict[str, object]:
|
| 42 |
+
vd_a, vq_a, f_a = np.asarray(vd), np.asarray(vq), np.asarray(f_hat)
|
| 43 |
+
|
| 44 |
+
self.vq_buf.extend(vq_a[::5].tolist())
|
| 45 |
+
vq_rms = float(np.sqrt(np.mean(vq_a ** 2)))
|
| 46 |
+
v0 = v_meas.sum(axis=0) / 3.0
|
| 47 |
+
v0_rms = float(np.sqrt(np.mean(v0 ** 2)))
|
| 48 |
+
f_mean = float(f_a.mean())
|
| 49 |
+
vpos = float(vd_a.mean())
|
| 50 |
+
|
| 51 |
+
self.f_means.append(f_mean)
|
| 52 |
+
self.pmu_f.append(pmu_f)
|
| 53 |
+
self.vq_rms.append(vq_rms)
|
| 54 |
+
self.v0_rms.append(v0_rms)
|
| 55 |
+
self.vpos.append(vpos)
|
| 56 |
+
|
| 57 |
+
# RoCoF from the slope of window-mean frequency over the last 4 windows
|
| 58 |
+
fm = np.asarray(self.f_means)[-4:]
|
| 59 |
+
if fm.size >= 2:
|
| 60 |
+
rocof = float(np.polyfit(np.arange(fm.size) * (self.n / self.fs), fm, 1)[0])
|
| 61 |
+
else:
|
| 62 |
+
rocof = 0.0
|
| 63 |
+
|
| 64 |
+
# Dominant vq oscillation (Hann-windowed FFT over the last 200 ms at 1 kHz)
|
| 65 |
+
buf = np.asarray(self.vq_buf)
|
| 66 |
+
osc_f, osc_a = 0.0, 0.0
|
| 67 |
+
if buf.size >= 32:
|
| 68 |
+
x = (buf - buf.mean()) * np.hanning(buf.size)
|
| 69 |
+
spec = np.abs(np.fft.rfft(x)) * 2.0 / (np.hanning(buf.size).sum())
|
| 70 |
+
freqs = np.fft.rfftfreq(buf.size, d=5.0 / self.fs)
|
| 71 |
+
band = (freqs >= 3.0) & (freqs <= 400.0)
|
| 72 |
+
if band.any():
|
| 73 |
+
i = int(np.argmax(np.where(band, spec, 0.0)))
|
| 74 |
+
osc_f, osc_a = float(freqs[i]), float(spec[i])
|
| 75 |
+
|
| 76 |
+
two_cycles = int(round(2.0 * self.fs / F_NOMINAL))
|
| 77 |
+
dc = v_meas[:, -two_cycles:].mean(axis=1)
|
| 78 |
+
|
| 79 |
+
features = {
|
| 80 |
+
"v_pos_seq_pu": _r(vpos),
|
| 81 |
+
"vq_mean_pu": _r(vq_a.mean()),
|
| 82 |
+
"vq_rms_pu": _r(vq_rms),
|
| 83 |
+
"f_local_hz": _r(f_mean),
|
| 84 |
+
"f_local_std_hz": _r(f_a.std()),
|
| 85 |
+
"rocof_local_hz_s": _r(rocof, 4),
|
| 86 |
+
"v_zero_seq_rms_pu": _r(v0_rms),
|
| 87 |
+
"phase_rms_pu": _rl(np.sqrt(np.mean(v_meas ** 2, axis=1)), 5),
|
| 88 |
+
"phase_dc_offset_pu": _rl(dc, 5),
|
| 89 |
+
"vq_osc_freq_hz": _r(osc_f, 1),
|
| 90 |
+
"vq_osc_amp_pu": _r(osc_a),
|
| 91 |
+
"f_local_minus_pmu_hz": _r(f_mean - pmu_f),
|
| 92 |
+
}
|
| 93 |
+
waveforms = {
|
| 94 |
+
"va": _rl(v_meas[0, DECIMATE - 1::DECIMATE]),
|
| 95 |
+
"vb": _rl(v_meas[1, DECIMATE - 1::DECIMATE]),
|
| 96 |
+
"vc": _rl(v_meas[2, DECIMATE - 1::DECIMATE]),
|
| 97 |
+
"vd": _rl(vd_a[DECIMATE - 1::DECIMATE]),
|
| 98 |
+
"vq": _rl(vq_a[DECIMATE - 1::DECIMATE]),
|
| 99 |
+
"f_hz": _rl(f_a[DECIMATE - 1::DECIMATE], 4),
|
| 100 |
+
}
|
| 101 |
+
trend = {
|
| 102 |
+
"f_local_hz": _rl(self.f_means, 4),
|
| 103 |
+
"pmu_f_hz": _rl(self.pmu_f, 4),
|
| 104 |
+
"vq_rms_pu": _rl(self.vq_rms, 5),
|
| 105 |
+
"v_zero_seq_rms_pu": _rl(self.v0_rms, 5),
|
| 106 |
+
"v_pos_seq_pu": _rl(self.vpos, 4),
|
| 107 |
+
}
|
| 108 |
+
return {"features": features, "waveforms": waveforms, "trend": trend}
|
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Deterministic per-episode graders, one per task, scores in [0, 1].
|
| 3 |
+
|
| 4 |
+
All graders share the same building blocks, computed from the decision log:
|
| 5 |
+
|
| 6 |
+
FA false-alarm rate: fraction of clean windows (before onset) flagged
|
| 7 |
+
spec specificity factor max(0, 1 − 2·FA) — every false-alarm window costs
|
| 8 |
+
twice its share, so random / chattering policies are punished, not
|
| 9 |
+
just "always on" ones (an operator stops trusting a noisy IDS)
|
| 10 |
+
T timeliness of the first detection after onset
|
| 11 |
+
R recall: fraction of compromised windows flagged
|
| 12 |
+
C classification: fraction of compromised windows flagged with the
|
| 13 |
+
correct attack_type
|
| 14 |
+
|
| 15 |
+
Attack-free episodes are scored on specificity alone (and on avoiding
|
| 16 |
+
destructive protective actions), so "always alarm" scores ≈ 0 everywhere and
|
| 17 |
+
"never alarm" only collects the attack-free share of episodes.
|
| 18 |
+
|
| 19 |
+
Scores are clamped to [0.01, 0.99] for compatibility with validators that
|
| 20 |
+
require strictly-open (0, 1) scores.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
from typing import Any, Dict, List, Optional
|
| 26 |
+
|
| 27 |
+
SCORE_MIN, SCORE_MAX = 0.01, 0.99
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _clamp(x: float) -> float:
|
| 31 |
+
return max(SCORE_MIN, min(SCORE_MAX, x))
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _linear_timeliness(delay_ms: Optional[float], full_ms: float, zero_ms: float) -> float:
|
| 35 |
+
if delay_ms is None:
|
| 36 |
+
return 0.0
|
| 37 |
+
if delay_ms <= full_ms:
|
| 38 |
+
return 1.0
|
| 39 |
+
return max(0.0, 1.0 - (delay_ms - full_ms) / (zero_ms - full_ms))
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _common(log: List[Dict[str, Any]], onset: Optional[int], interval_ms: float) -> Dict[str, Any]:
|
| 43 |
+
clean = [e for e in log if onset is None or e["step"] < onset]
|
| 44 |
+
post = [e for e in log if onset is not None and e["step"] >= onset]
|
| 45 |
+
fa = sum(e["detected"] for e in clean) / len(clean) if clean else 0.0
|
| 46 |
+
first = next((e["step"] for e in post if e["detected"]), None)
|
| 47 |
+
delay_ms = (first - onset) * interval_ms if first is not None else None
|
| 48 |
+
recall = sum(e["detected"] for e in post) / len(post) if post else 0.0
|
| 49 |
+
false_trip = any(e["protective_action"] == 3 for e in clean)
|
| 50 |
+
false_holdover = any(e["protective_action"] == 2 for e in clean)
|
| 51 |
+
return {
|
| 52 |
+
"false_alarm_rate": fa,
|
| 53 |
+
"specificity": max(0.0, 1.0 - 2.0 * fa),
|
| 54 |
+
"first_detection_step": first,
|
| 55 |
+
"detection_delay_ms": delay_ms,
|
| 56 |
+
"recall": recall,
|
| 57 |
+
"false_trip": false_trip,
|
| 58 |
+
"false_holdover": false_holdover,
|
| 59 |
+
"_post": post,
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _finish(score: float, parts: Dict[str, Any]) -> Dict[str, Any]:
|
| 64 |
+
parts.pop("_post", None)
|
| 65 |
+
parts["raw_score"] = round(score, 6)
|
| 66 |
+
parts["score"] = round(_clamp(score), 6)
|
| 67 |
+
return parts
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def grade_easy(log, onset, interval_ms, **_) -> Dict[str, Any]:
|
| 71 |
+
p = _common(log, onset, interval_ms)
|
| 72 |
+
if onset is None:
|
| 73 |
+
score = p["specificity"]
|
| 74 |
+
else:
|
| 75 |
+
p["timeliness"] = _linear_timeliness(p["detection_delay_ms"], 100.0, 1000.0)
|
| 76 |
+
score = (0.6 * p["timeliness"] + 0.4 * p["recall"]) * p["specificity"]
|
| 77 |
+
if p["false_trip"]:
|
| 78 |
+
score *= 0.5
|
| 79 |
+
return _finish(score, p)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def grade_medium(log, onset, interval_ms, true_type: int = 0, **_) -> Dict[str, Any]:
|
| 83 |
+
p = _common(log, onset, interval_ms)
|
| 84 |
+
if onset is None:
|
| 85 |
+
score = p["specificity"]
|
| 86 |
+
else:
|
| 87 |
+
post = p["_post"]
|
| 88 |
+
p["timeliness"] = _linear_timeliness(p["detection_delay_ms"], 100.0, 1000.0)
|
| 89 |
+
p["classification"] = sum(e["detected"] and e["attack_type"] == true_type for e in post) / len(post)
|
| 90 |
+
score = (0.35 * p["timeliness"] + 0.25 * p["recall"] + 0.40 * p["classification"]) * p["specificity"]
|
| 91 |
+
if p["false_trip"]:
|
| 92 |
+
score *= 0.5
|
| 93 |
+
return _finish(score, p)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def grade_hard(log, onset, interval_ms, unmitigated_desync_step: Optional[int] = None,
|
| 97 |
+
actual_desync_step: Optional[int] = None, **_) -> Dict[str, Any]:
|
| 98 |
+
"""Stealthy phase spoofing: detection *and* timely mitigation.
|
| 99 |
+
|
| 100 |
+
protection P = 1 if the inverter never lost synchronism and a protective
|
| 101 |
+
action (holdover/disconnect) was engaged by the time the unprotected
|
| 102 |
+
inverter would have desynchronised. Holdover keeps generating, so it is
|
| 103 |
+
worth more than a disconnect (quality Q = 1.0 vs 0.6).
|
| 104 |
+
"""
|
| 105 |
+
p = _common(log, onset, interval_ms)
|
| 106 |
+
p["unmitigated_desync_step"] = unmitigated_desync_step
|
| 107 |
+
p["actual_desync_step"] = actual_desync_step
|
| 108 |
+
|
| 109 |
+
if onset is None:
|
| 110 |
+
score = p["specificity"]
|
| 111 |
+
if p["false_trip"]:
|
| 112 |
+
score *= 0.4
|
| 113 |
+
elif p["false_holdover"]:
|
| 114 |
+
score *= 0.75
|
| 115 |
+
if actual_desync_step is not None:
|
| 116 |
+
score *= 0.5
|
| 117 |
+
return _finish(score, p)
|
| 118 |
+
|
| 119 |
+
post = p["_post"]
|
| 120 |
+
deadline = unmitigated_desync_step if unmitigated_desync_step is not None else log[-1]["step"] + 1
|
| 121 |
+
mitig = next((e for e in post if e["protective_action"] in (2, 3)), None)
|
| 122 |
+
engaged_in_time = mitig is not None and mitig["step"] < deadline
|
| 123 |
+
protected = engaged_in_time and actual_desync_step is None
|
| 124 |
+
q = 0.6 if any(e["protective_action"] == 3 for e in post) else 1.0
|
| 125 |
+
p["mitigation_step"] = mitig["step"] if mitig else None
|
| 126 |
+
p["protected"] = protected
|
| 127 |
+
p["mitigation_quality"] = q if protected else 0.0
|
| 128 |
+
|
| 129 |
+
budget_ms = max(interval_ms, (deadline - onset) * interval_ms)
|
| 130 |
+
delay = p["detection_delay_ms"]
|
| 131 |
+
p["timeliness"] = 0.0 if delay is None else max(0.0, 1.0 - delay / budget_ms)
|
| 132 |
+
|
| 133 |
+
score = (0.55 * (q if protected else 0.0) + 0.30 * p["timeliness"] + 0.15 * p["recall"]) * p["specificity"]
|
| 134 |
+
if p["false_trip"]:
|
| 135 |
+
score *= 0.4
|
| 136 |
+
elif p["false_holdover"]:
|
| 137 |
+
score *= 0.75
|
| 138 |
+
return _finish(score, p)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
GRADERS = {0: grade_easy, 1: grade_medium, 2: grade_hard}
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def grade_episode(task_id: int, log: List[Dict[str, Any]], onset: Optional[int],
|
| 145 |
+
interval_ms: float, **kwargs) -> Dict[str, Any]:
|
| 146 |
+
return GRADERS[task_id](log, onset, interval_ms, **kwargs)
|
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
True (physical) grid voltage at the inverter's point of common coupling.
|
| 3 |
+
|
| 4 |
+
The grid is not a perfect 50 Hz source. Besides slow frequency wander and
|
| 5 |
+
background 5th/7th harmonics, an episode may contain *benign* power-system
|
| 6 |
+
events that disturb the PLL exactly like an attack would at first glance:
|
| 7 |
+
|
| 8 |
+
frequency_event — generation/load imbalance: RoCoF ramp to a new frequency
|
| 9 |
+
phase_jump — fault clearing / line switching: step in voltage angle
|
| 10 |
+
voltage_sag — balanced sag (e.g. remote three-phase fault, motor start)
|
| 11 |
+
single_phase_sag — single-line-to-ground fault: one phase dips (creates
|
| 12 |
+
zero/negative sequence, like a single-phase FDI attack)
|
| 13 |
+
harmonic_step — non-linear load switching: harmonic distortion rises
|
| 14 |
+
|
| 15 |
+
These are what make cyber-attack detection on PLLs hard in practice: an alarm
|
| 16 |
+
on every grid event is useless to an operator.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import math
|
| 22 |
+
from dataclasses import asdict, dataclass, field
|
| 23 |
+
from typing import Any, Dict, List, Optional
|
| 24 |
+
|
| 25 |
+
import numpy as np
|
| 26 |
+
|
| 27 |
+
from .pll import F_NOMINAL, TWO_PI
|
| 28 |
+
|
| 29 |
+
PHASE_SHIFTS = np.array([0.0, -TWO_PI / 3.0, TWO_PI / 3.0]) # a, b, c
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@dataclass
|
| 33 |
+
class GridEvent:
|
| 34 |
+
kind: str
|
| 35 |
+
t_start: float
|
| 36 |
+
params: Dict[str, float] = field(default_factory=dict)
|
| 37 |
+
|
| 38 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 39 |
+
return asdict(self)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@dataclass
|
| 43 |
+
class GridConfig:
|
| 44 |
+
v_nominal: float = 1.0
|
| 45 |
+
f_drift_amp_hz: float = 0.01 # slow frequency wander amplitude
|
| 46 |
+
f_drift_period_s: float = 6.0
|
| 47 |
+
f_drift_phase: float = 0.0
|
| 48 |
+
h5_pu: float = 0.02 # 5th harmonic (negative sequence)
|
| 49 |
+
h7_pu: float = 0.01 # 7th harmonic (positive sequence)
|
| 50 |
+
h5_phase: float = 0.0
|
| 51 |
+
h7_phase: float = 0.0
|
| 52 |
+
phase_unbalance: List[float] = field(default_factory=lambda: [1.0, 1.0, 1.0])
|
| 53 |
+
events: List[GridEvent] = field(default_factory=list)
|
| 54 |
+
|
| 55 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 56 |
+
d = asdict(self)
|
| 57 |
+
d["events"] = [e.to_dict() for e in self.events]
|
| 58 |
+
return d
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class GridModel:
|
| 62 |
+
"""Generates true three-phase voltages block-by-block."""
|
| 63 |
+
|
| 64 |
+
def __init__(self, cfg: GridConfig, theta0: float = 0.0):
|
| 65 |
+
self.cfg = cfg
|
| 66 |
+
self.theta = theta0 # unwrapped true angle at the end of the last block
|
| 67 |
+
self.t = 0.0 # time at the end of the last block
|
| 68 |
+
|
| 69 |
+
# ------------------------------------------------------------------
|
| 70 |
+
def frequency(self, t: np.ndarray) -> np.ndarray:
|
| 71 |
+
cfg = self.cfg
|
| 72 |
+
f = F_NOMINAL + cfg.f_drift_amp_hz * np.sin(TWO_PI * t / cfg.f_drift_period_s + cfg.f_drift_phase)
|
| 73 |
+
for ev in cfg.events:
|
| 74 |
+
if ev.kind != "frequency_event":
|
| 75 |
+
continue
|
| 76 |
+
tau = np.clip(t - ev.t_start, 0.0, None)
|
| 77 |
+
rocof, target = ev.params["rocof_hz_s"], ev.params["delta_f_hz"]
|
| 78 |
+
ramp = np.minimum(np.abs(rocof) * tau, abs(target)) * math.copysign(1.0, target)
|
| 79 |
+
f = f + ramp
|
| 80 |
+
return f
|
| 81 |
+
|
| 82 |
+
def _phase_jumps(self, t: np.ndarray) -> np.ndarray:
|
| 83 |
+
off = np.zeros_like(t)
|
| 84 |
+
for ev in self.cfg.events:
|
| 85 |
+
if ev.kind == "phase_jump":
|
| 86 |
+
off += np.where(t >= ev.t_start, math.radians(ev.params["angle_deg"]), 0.0)
|
| 87 |
+
return off
|
| 88 |
+
|
| 89 |
+
def _amplitudes(self, t: np.ndarray) -> np.ndarray:
|
| 90 |
+
"""Per-phase fundamental amplitude, shape (3, n)."""
|
| 91 |
+
cfg = self.cfg
|
| 92 |
+
amp = np.tile(np.asarray(cfg.phase_unbalance, dtype=float)[:, None] * cfg.v_nominal, (1, t.size))
|
| 93 |
+
for ev in cfg.events:
|
| 94 |
+
if ev.kind == "voltage_sag":
|
| 95 |
+
active = (t >= ev.t_start) & (t < ev.t_start + ev.params["duration_s"])
|
| 96 |
+
amp[:, active] *= ev.params["depth_pu"]
|
| 97 |
+
elif ev.kind == "single_phase_sag":
|
| 98 |
+
active = (t >= ev.t_start) & (t < ev.t_start + ev.params["duration_s"])
|
| 99 |
+
amp[int(ev.params["phase"]), active] *= ev.params["depth_pu"]
|
| 100 |
+
return amp
|
| 101 |
+
|
| 102 |
+
def _harmonic_gain(self, t: np.ndarray) -> np.ndarray:
|
| 103 |
+
g = np.ones_like(t)
|
| 104 |
+
for ev in self.cfg.events:
|
| 105 |
+
if ev.kind == "harmonic_step":
|
| 106 |
+
g = np.where(t >= ev.t_start, g * ev.params["gain"], g)
|
| 107 |
+
return g
|
| 108 |
+
|
| 109 |
+
# ------------------------------------------------------------------
|
| 110 |
+
def generate(self, n: int, dt: float, angle_offset: Optional[np.ndarray] = None) -> Dict[str, np.ndarray]:
|
| 111 |
+
"""Advance the grid by `n` samples.
|
| 112 |
+
|
| 113 |
+
`angle_offset` (rad, shape (n,)) is added to the *reported* angle only —
|
| 114 |
+
it is how a coordinated phase-spoofing FDI attack is synthesised. The
|
| 115 |
+
returned `theta` is always the true, physical grid angle.
|
| 116 |
+
"""
|
| 117 |
+
t = self.t + dt * np.arange(1, n + 1)
|
| 118 |
+
f = self.frequency(t)
|
| 119 |
+
theta_smooth = self.theta + TWO_PI * np.cumsum(f) * dt
|
| 120 |
+
theta = theta_smooth + self._phase_jumps(t)
|
| 121 |
+
theta_rep = theta if angle_offset is None else theta + angle_offset
|
| 122 |
+
|
| 123 |
+
amp = self._amplitudes(t)
|
| 124 |
+
hg = self._harmonic_gain(t)
|
| 125 |
+
cfg = self.cfg
|
| 126 |
+
ph = PHASE_SHIFTS[:, None]
|
| 127 |
+
th = theta_rep[None, :]
|
| 128 |
+
v = (
|
| 129 |
+
amp * np.cos(th + ph)
|
| 130 |
+
+ (cfg.h5_pu * hg)[None, :] * np.cos(5.0 * (th + ph) + cfg.h5_phase)
|
| 131 |
+
+ (cfg.h7_pu * hg)[None, :] * np.cos(7.0 * (th + ph) + cfg.h7_phase)
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
self.theta = float(theta_smooth[-1])
|
| 135 |
+
self.t = float(t[-1])
|
| 136 |
+
# True positive-sequence magnitude (what a PMU would report)
|
| 137 |
+
v_pos = amp.mean(axis=0)
|
| 138 |
+
return {"t": t, "v": v, "theta": theta, "f": f, "v_pos": v_pos}
|
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Typed OpenEnv models: Action, Observation, Reward breakdown and State.
|
| 3 |
+
|
| 4 |
+
All models subclass the openenv-core base types so they plug straight into
|
| 5 |
+
`create_app` (HTTP + WebSocket) and `EnvClient`.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from typing import Any, Dict, List, Literal, Optional
|
| 11 |
+
|
| 12 |
+
from openenv.core.env_server.types import Action, Observation, State
|
| 13 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 14 |
+
|
| 15 |
+
InverterMode = Literal["normal", "holdover", "disconnected"]
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class _Strict(BaseModel):
|
| 19 |
+
model_config = ConfigDict(extra="forbid")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# ---------------------------------------------------------------------------
|
| 23 |
+
# Action
|
| 24 |
+
# ---------------------------------------------------------------------------
|
| 25 |
+
|
| 26 |
+
class PLLAction(Action):
|
| 27 |
+
"""The cyber-guard's decision for the window it has just observed."""
|
| 28 |
+
|
| 29 |
+
attack_detected: bool = Field(..., description="True if a cyber-attack is believed to be in progress.")
|
| 30 |
+
attack_type: int = Field(
|
| 31 |
+
0, ge=0, le=4,
|
| 32 |
+
description="0=none, 1=sinusoidal injection, 2=ramp bias, 3=pulse bias, 4=phase spoofing.",
|
| 33 |
+
)
|
| 34 |
+
confidence: float = Field(
|
| 35 |
+
0.5, ge=0.0, le=1.0,
|
| 36 |
+
description="Probability that the reported attack_detected value is correct (scored with a Brier rule).",
|
| 37 |
+
)
|
| 38 |
+
protective_action: int = Field(
|
| 39 |
+
0, ge=0, le=3,
|
| 40 |
+
description=(
|
| 41 |
+
"0=none, 1=alert operator (no physical effect), "
|
| 42 |
+
"2=PMU-aided holdover: open the PLL loop and free-run on the trusted PMU frequency, "
|
| 43 |
+
"3=disconnect inverter (latched for the rest of the episode)."
|
| 44 |
+
),
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# ---------------------------------------------------------------------------
|
| 49 |
+
# Observation pieces
|
| 50 |
+
# ---------------------------------------------------------------------------
|
| 51 |
+
|
| 52 |
+
class Waveforms(_Strict):
|
| 53 |
+
"""Last decision window (50 ms) decimated to 2 ms resolution (25 points each)."""
|
| 54 |
+
va: List[float]
|
| 55 |
+
vb: List[float]
|
| 56 |
+
vc: List[float]
|
| 57 |
+
vd: List[float]
|
| 58 |
+
vq: List[float]
|
| 59 |
+
f_hz: List[float] = Field(description="PLL frequency estimate (low-pass filtered), Hz.")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class SignalFeatures(_Strict):
|
| 63 |
+
"""Standard signal-processing features a protection relay would compute on-board."""
|
| 64 |
+
v_pos_seq_pu: float = Field(description="Mean d-axis voltage ≈ positive-sequence magnitude.")
|
| 65 |
+
vq_mean_pu: float
|
| 66 |
+
vq_rms_pu: float = Field(description="RMS of the q-axis (phase-detector) error.")
|
| 67 |
+
f_local_hz: float = Field(description="Mean local PLL frequency over the window.")
|
| 68 |
+
f_local_std_hz: float
|
| 69 |
+
rocof_local_hz_s: float = Field(description="Rate of change of local frequency (from the last 4 windows).")
|
| 70 |
+
v_zero_seq_rms_pu: float = Field(description="RMS of (va+vb+vc)/3; non-zero for unbalanced faults or single-phase injection.")
|
| 71 |
+
phase_rms_pu: List[float] = Field(description="Per-phase RMS [a, b, c].")
|
| 72 |
+
phase_dc_offset_pu: List[float] = Field(description="Per-phase mean over the last two fundamental cycles.")
|
| 73 |
+
vq_osc_freq_hz: float = Field(description="Dominant oscillation frequency in vq over the last 200 ms.")
|
| 74 |
+
vq_osc_amp_pu: float
|
| 75 |
+
f_local_minus_pmu_hz: float = Field(description="Local frequency minus the latest PMU frequency (note PMU latency).")
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class PMUReading(_Strict):
|
| 79 |
+
"""Trusted, GPS-synchronised phasor-measurement stream from the substation.
|
| 80 |
+
|
| 81 |
+
It is out-of-band (cannot be spoofed by the attacker), but low-rate
|
| 82 |
+
(10 reports/s), noisy and delayed — so it cannot be used naively.
|
| 83 |
+
"""
|
| 84 |
+
f_hz: float
|
| 85 |
+
v_pos_pu: float
|
| 86 |
+
age_ms: float = Field(description="Age of the measurement interval end-time at the moment of observation.")
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class Trend(_Strict):
|
| 90 |
+
"""History of the last 10 decision windows (oldest first)."""
|
| 91 |
+
f_local_hz: List[float]
|
| 92 |
+
pmu_f_hz: List[float]
|
| 93 |
+
vq_rms_pu: List[float]
|
| 94 |
+
v_zero_seq_rms_pu: List[float]
|
| 95 |
+
v_pos_seq_pu: List[float]
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class RewardBreakdown(_Strict):
|
| 99 |
+
"""Typed Reward model: every component of the dense per-step reward."""
|
| 100 |
+
detection: float = 0.0
|
| 101 |
+
early_detection_bonus: float = 0.0
|
| 102 |
+
calibration: float = 0.0
|
| 103 |
+
classification: float = 0.0
|
| 104 |
+
mitigation_cost: float = 0.0
|
| 105 |
+
harm_penalty: float = 0.0
|
| 106 |
+
total: float = 0.0
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
class EpisodeResult(_Strict):
|
| 110 |
+
"""Revealed only once the episode is done."""
|
| 111 |
+
grader_score: float
|
| 112 |
+
score_breakdown: Dict[str, Any]
|
| 113 |
+
total_reward: float
|
| 114 |
+
scenario: Dict[str, Any]
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
class PLLObservation(Observation):
|
| 118 |
+
task_id: int = Field(0, ge=0, le=2)
|
| 119 |
+
task_name: str = ""
|
| 120 |
+
task_brief: str = Field("", description="Task instructions (sent on reset only).")
|
| 121 |
+
step: int = 0
|
| 122 |
+
max_steps: int = 0
|
| 123 |
+
time_s: float = Field(0.0, description="Time since the start of the monitored episode, s.")
|
| 124 |
+
decision_interval_ms: float = 50.0
|
| 125 |
+
inverter_mode: InverterMode = "normal"
|
| 126 |
+
waveforms: Optional[Waveforms] = None
|
| 127 |
+
features: Optional[SignalFeatures] = None
|
| 128 |
+
pmu: Optional[PMUReading] = None
|
| 129 |
+
trend: Optional[Trend] = None
|
| 130 |
+
reward_breakdown: Optional[RewardBreakdown] = None
|
| 131 |
+
episode_result: Optional[EpisodeResult] = None
|
| 132 |
+
message: str = ""
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
class PLLState(State):
|
| 136 |
+
"""Server-side episode state. Ground truth stays hidden until `done`."""
|
| 137 |
+
task_id: int = 0
|
| 138 |
+
task_name: str = ""
|
| 139 |
+
seed: Optional[int] = None
|
| 140 |
+
done: bool = False
|
| 141 |
+
time_s: float = 0.0
|
| 142 |
+
inverter_mode: InverterMode = "normal"
|
| 143 |
+
cumulative_reward: float = 0.0
|
| 144 |
+
grader_score: Optional[float] = None
|
| 145 |
+
scenario: Optional[Dict[str, Any]] = Field(
|
| 146 |
+
None, description="Attack / disturbance ground truth — null while the episode is running."
|
| 147 |
+
)
|
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Synchronous Reference Frame Phase-Locked Loop (SRF-PLL).
|
| 3 |
+
|
| 4 |
+
Conventions (cosine-referenced, amplitude-invariant):
|
| 5 |
+
|
| 6 |
+
va = V cos(θ), vb = V cos(θ − 2π/3), vc = V cos(θ + 2π/3)
|
| 7 |
+
|
| 8 |
+
Clarke: vα = (2va − vb − vc) / 3 vβ = (vb − vc) / √3
|
| 9 |
+
→ vα = V cos θ, vβ = V sin θ
|
| 10 |
+
|
| 11 |
+
Park: vd = vα cos θ̂ + vβ sin θ̂ = V cos(θ − θ̂)
|
| 12 |
+
vq = −vα sin θ̂ + vβ cos θ̂ = V sin(θ − θ̂)
|
| 13 |
+
|
| 14 |
+
So when locked (θ̂ ≈ θ): vd → V and vq → 0, and for small errors vq ≈ V·(θ − θ̂),
|
| 15 |
+
which is the phase-detector characteristic the PI loop filter drives to zero.
|
| 16 |
+
|
| 17 |
+
Loop design (amplitude-normalised, so dynamics do not change during voltage sags):
|
| 18 |
+
|
| 19 |
+
θ̂/θ = (Kp s + Ki) / (s² + Kp s + Ki), ωn = √Ki, ζ = Kp / (2√Ki)
|
| 20 |
+
|
| 21 |
+
Defaults give ωn = 100 rad/s, ζ = 0.707 (≈ 25 Hz closed-loop bandwidth), a
|
| 22 |
+
typical industrial tuning trade-off between tracking speed and harmonic rejection.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import math
|
| 28 |
+
from dataclasses import dataclass, field
|
| 29 |
+
from typing import Dict, List, Sequence
|
| 30 |
+
|
| 31 |
+
TWO_PI = 2.0 * math.pi
|
| 32 |
+
SQRT3 = math.sqrt(3.0)
|
| 33 |
+
|
| 34 |
+
F_NOMINAL = 50.0
|
| 35 |
+
OMEGA_NOMINAL = TWO_PI * F_NOMINAL
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def wrap_angle(angle: float) -> float:
|
| 39 |
+
"""Wrap an angle to [−π, π)."""
|
| 40 |
+
return (angle + math.pi) % TWO_PI - math.pi
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def clarke(va: float, vb: float, vc: float) -> tuple[float, float]:
|
| 44 |
+
"""Amplitude-invariant Clarke transform (abc → αβ), using all three phases."""
|
| 45 |
+
return (2.0 * va - vb - vc) / 3.0, (vb - vc) / SQRT3
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def park(v_alpha: float, v_beta: float, theta: float) -> tuple[float, float]:
|
| 49 |
+
"""Park transform (αβ → dq) on the estimated angle θ̂."""
|
| 50 |
+
c, s = math.cos(theta), math.sin(theta)
|
| 51 |
+
return v_alpha * c + v_beta * s, -v_alpha * s + v_beta * c
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@dataclass
|
| 55 |
+
class PLLGains:
|
| 56 |
+
omega_n: float = 100.0 # natural frequency (rad/s)
|
| 57 |
+
zeta: float = 0.7071 # damping ratio
|
| 58 |
+
freq_filter_hz: float = 10.0 # LPF on the reported frequency
|
| 59 |
+
max_dev_hz: float = 5.0 # anti-windup / frequency clamp
|
| 60 |
+
|
| 61 |
+
@property
|
| 62 |
+
def kp(self) -> float:
|
| 63 |
+
return 2.0 * self.zeta * self.omega_n
|
| 64 |
+
|
| 65 |
+
@property
|
| 66 |
+
def ki(self) -> float:
|
| 67 |
+
return self.omega_n ** 2
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@dataclass
|
| 71 |
+
class PLLWindowOutput:
|
| 72 |
+
"""Per-sample PLL signals for one simulation window."""
|
| 73 |
+
vd: List[float] = field(default_factory=list)
|
| 74 |
+
vq: List[float] = field(default_factory=list)
|
| 75 |
+
f_hat: List[float] = field(default_factory=list) # filtered frequency estimate (Hz)
|
| 76 |
+
theta_hat: List[float] = field(default_factory=list)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class SRFPLL:
|
| 80 |
+
"""Discrete-time SRF-PLL with an optional PMU-aided holdover mode.
|
| 81 |
+
|
| 82 |
+
Modes:
|
| 83 |
+
normal — closed-loop tracking of the measured voltages
|
| 84 |
+
holdover — loop opened; θ̂ free-runs at an externally supplied trusted
|
| 85 |
+
frequency (e.g. a PMU stream). Measurements are still
|
| 86 |
+
transformed so vd/vq remain observable.
|
| 87 |
+
"""
|
| 88 |
+
|
| 89 |
+
def __init__(self, dt: float, gains: PLLGains | None = None, theta0: float = 0.0):
|
| 90 |
+
self.dt = dt
|
| 91 |
+
self.gains = gains or PLLGains()
|
| 92 |
+
self.reset(theta0)
|
| 93 |
+
|
| 94 |
+
def reset(self, theta0: float = 0.0) -> None:
|
| 95 |
+
self.theta_hat = wrap_angle(theta0)
|
| 96 |
+
self.integrator = 0.0 # ∫ normalised vq dt (rad/s units after Ki)
|
| 97 |
+
self.omega_hat = OMEGA_NOMINAL
|
| 98 |
+
self.f_filtered = F_NOMINAL
|
| 99 |
+
self.vd_filtered = 1.0
|
| 100 |
+
self.vd = 1.0
|
| 101 |
+
self.vq = 0.0
|
| 102 |
+
|
| 103 |
+
def snapshot(self) -> Dict[str, float]:
|
| 104 |
+
return {
|
| 105 |
+
"theta_hat": self.theta_hat,
|
| 106 |
+
"integrator": self.integrator,
|
| 107 |
+
"omega_hat": self.omega_hat,
|
| 108 |
+
"f_filtered": self.f_filtered,
|
| 109 |
+
"vd_filtered": self.vd_filtered,
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
def run(
|
| 113 |
+
self,
|
| 114 |
+
va: Sequence[float],
|
| 115 |
+
vb: Sequence[float],
|
| 116 |
+
vc: Sequence[float],
|
| 117 |
+
holdover: bool = False,
|
| 118 |
+
holdover_freq_hz: float = F_NOMINAL,
|
| 119 |
+
holdover_rocof_hz_s: float = 0.0,
|
| 120 |
+
record: bool = True,
|
| 121 |
+
) -> PLLWindowOutput:
|
| 122 |
+
"""Process a block of samples. Returns per-sample signals if `record`."""
|
| 123 |
+
g = self.gains
|
| 124 |
+
dt = self.dt
|
| 125 |
+
kp, ki = g.kp, g.ki
|
| 126 |
+
max_dev = TWO_PI * g.max_dev_hz
|
| 127 |
+
a_f = dt / (dt + 1.0 / (TWO_PI * g.freq_filter_hz)) # 1st-order LPF coefficient
|
| 128 |
+
a_v = dt / (dt + 1.0 / (TWO_PI * 20.0)) # amplitude estimator LPF
|
| 129 |
+
omega_hold = TWO_PI * holdover_freq_hz
|
| 130 |
+
d_omega_hold = TWO_PI * holdover_rocof_hz_s * dt
|
| 131 |
+
|
| 132 |
+
th = self.theta_hat
|
| 133 |
+
integ = self.integrator
|
| 134 |
+
f_filt = self.f_filtered
|
| 135 |
+
vd_filt = self.vd_filtered
|
| 136 |
+
omega = self.omega_hat
|
| 137 |
+
vd = self.vd
|
| 138 |
+
vq = self.vq
|
| 139 |
+
|
| 140 |
+
out = PLLWindowOutput()
|
| 141 |
+
cos, sin = math.cos, math.sin
|
| 142 |
+
two_pi, pi = TWO_PI, math.pi
|
| 143 |
+
|
| 144 |
+
for a, b, c in zip(va, vb, vc):
|
| 145 |
+
v_alpha = (2.0 * a - b - c) / 3.0
|
| 146 |
+
v_beta = (b - c) / SQRT3
|
| 147 |
+
ct, st = cos(th), sin(th)
|
| 148 |
+
vd = v_alpha * ct + v_beta * st
|
| 149 |
+
vq = -v_alpha * st + v_beta * ct
|
| 150 |
+
|
| 151 |
+
vd_filt += a_v * (vd - vd_filt)
|
| 152 |
+
if holdover:
|
| 153 |
+
omega = omega_hold
|
| 154 |
+
omega_hold += d_omega_hold
|
| 155 |
+
else:
|
| 156 |
+
vq_n = vq / max(vd_filt, 0.2) # amplitude normalisation
|
| 157 |
+
integ += ki * vq_n * dt
|
| 158 |
+
if integ > max_dev:
|
| 159 |
+
integ = max_dev
|
| 160 |
+
elif integ < -max_dev:
|
| 161 |
+
integ = -max_dev
|
| 162 |
+
dev = kp * vq_n + integ
|
| 163 |
+
if dev > max_dev:
|
| 164 |
+
dev = max_dev
|
| 165 |
+
elif dev < -max_dev:
|
| 166 |
+
dev = -max_dev
|
| 167 |
+
omega = OMEGA_NOMINAL + dev
|
| 168 |
+
|
| 169 |
+
f_filt += a_f * (omega / two_pi - f_filt)
|
| 170 |
+
if record:
|
| 171 |
+
# θ̂ recorded is the angle used for this sample's Park transform,
|
| 172 |
+
# i.e. the controller's estimate of θ at this sampling instant.
|
| 173 |
+
out.vd.append(vd)
|
| 174 |
+
out.vq.append(vq)
|
| 175 |
+
out.f_hat.append(f_filt)
|
| 176 |
+
out.theta_hat.append(th)
|
| 177 |
+
th = (th + omega * dt + pi) % two_pi - pi
|
| 178 |
+
|
| 179 |
+
self.theta_hat = th
|
| 180 |
+
self.integrator = integ
|
| 181 |
+
self.f_filtered = f_filt
|
| 182 |
+
self.vd_filtered = vd_filt
|
| 183 |
+
self.omega_hat = omega
|
| 184 |
+
self.vd = vd
|
| 185 |
+
self.vq = vq
|
| 186 |
+
return out
|
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Dense per-step reward.
|
| 3 |
+
|
| 4 |
+
Action a_t is judged against the window t the agent actually observed
|
| 5 |
+
(label y_t = 1 once the attack has started — the system is then considered
|
| 6 |
+
compromised until the end of the episode). Physical consequences of the
|
| 7 |
+
chosen protective action (costs, desynchronisation harm) are charged on the
|
| 8 |
+
window simulated right after the action.
|
| 9 |
+
|
| 10 |
+
The costs are asymmetric on purpose: false alarms and false trips are what
|
| 11 |
+
make real intrusion-detection systems unusable, so "always alarm" and
|
| 12 |
+
"always trip" are strongly negative-return strategies.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from dataclasses import dataclass
|
| 18 |
+
|
| 19 |
+
from .models import RewardBreakdown
|
| 20 |
+
|
| 21 |
+
TRUE_POSITIVE = 0.10
|
| 22 |
+
TRUE_NEGATIVE = 0.02
|
| 23 |
+
MISSED_DETECTION = -0.10
|
| 24 |
+
FALSE_ALARM = -0.25
|
| 25 |
+
|
| 26 |
+
EARLY_BONUS_MAX = 0.50
|
| 27 |
+
EARLY_BONUS_HORIZON_MS = 500.0
|
| 28 |
+
|
| 29 |
+
CALIBRATION_SCALE = 0.04 # 0.04 * (1 - 4*Brier) ∈ [-0.12, +0.04]
|
| 30 |
+
|
| 31 |
+
CLASS_CORRECT = 0.05
|
| 32 |
+
CLASS_WRONG = -0.02
|
| 33 |
+
|
| 34 |
+
HOLDOVER_COST = -0.02 # per window spent in holdover (degraded mode)
|
| 35 |
+
DISCONNECTED_COST = -0.04 # per window of lost generation
|
| 36 |
+
TRIP_COST_JUSTIFIED = -0.30 # one-off, attack in progress
|
| 37 |
+
TRIP_COST_FALSE = -1.50 # one-off, healthy grid ("destructive action")
|
| 38 |
+
|
| 39 |
+
DESYNC_WINDOW_PENALTY = -0.30 # each window with sustained >10° error while connected
|
| 40 |
+
DESYNC_EVENT_PENALTY = -1.00 # one-off, first loss of synchronism
|
| 41 |
+
|
| 42 |
+
REWARD_MAX = TRUE_POSITIVE + EARLY_BONUS_MAX + CALIBRATION_SCALE + CLASS_CORRECT
|
| 43 |
+
REWARD_MIN = (FALSE_ALARM - 3 * CALIBRATION_SCALE + TRIP_COST_FALSE + DISCONNECTED_COST
|
| 44 |
+
+ DESYNC_WINDOW_PENALTY + DESYNC_EVENT_PENALTY)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@dataclass
|
| 48 |
+
class StepOutcome:
|
| 49 |
+
"""Everything the reward needs to know about one transition."""
|
| 50 |
+
label_attack: bool
|
| 51 |
+
true_type: int
|
| 52 |
+
steps_since_onset: int # valid when label_attack
|
| 53 |
+
detected: bool
|
| 54 |
+
reported_type: int
|
| 55 |
+
confidence: float
|
| 56 |
+
first_true_positive: bool
|
| 57 |
+
classify: bool # task scores the attack type
|
| 58 |
+
tripped_now: bool
|
| 59 |
+
mode_next: str # inverter mode during the next window
|
| 60 |
+
desync_in_next_window: bool
|
| 61 |
+
first_desync: bool
|
| 62 |
+
decision_interval_ms: float
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def compute_reward(o: StepOutcome) -> RewardBreakdown:
|
| 66 |
+
r = RewardBreakdown()
|
| 67 |
+
|
| 68 |
+
if o.label_attack:
|
| 69 |
+
r.detection = TRUE_POSITIVE if o.detected else MISSED_DETECTION
|
| 70 |
+
if o.first_true_positive:
|
| 71 |
+
delay_ms = o.steps_since_onset * o.decision_interval_ms
|
| 72 |
+
r.early_detection_bonus = EARLY_BONUS_MAX * max(0.0, 1.0 - delay_ms / EARLY_BONUS_HORIZON_MS)
|
| 73 |
+
if o.classify and o.detected:
|
| 74 |
+
r.classification = CLASS_CORRECT if o.reported_type == o.true_type else CLASS_WRONG
|
| 75 |
+
else:
|
| 76 |
+
r.detection = FALSE_ALARM if o.detected else TRUE_NEGATIVE
|
| 77 |
+
|
| 78 |
+
# Brier score on the implied probability of "attack in progress"
|
| 79 |
+
p_attack = o.confidence if o.detected else 1.0 - o.confidence
|
| 80 |
+
brier = (p_attack - (1.0 if o.label_attack else 0.0)) ** 2
|
| 81 |
+
r.calibration = CALIBRATION_SCALE * (1.0 - 4.0 * brier)
|
| 82 |
+
|
| 83 |
+
if o.tripped_now:
|
| 84 |
+
r.mitigation_cost += TRIP_COST_JUSTIFIED if o.label_attack else TRIP_COST_FALSE
|
| 85 |
+
if o.mode_next == "holdover":
|
| 86 |
+
r.mitigation_cost += HOLDOVER_COST
|
| 87 |
+
elif o.mode_next == "disconnected":
|
| 88 |
+
r.mitigation_cost += DISCONNECTED_COST
|
| 89 |
+
|
| 90 |
+
if o.desync_in_next_window:
|
| 91 |
+
r.harm_penalty += DESYNC_WINDOW_PENALTY
|
| 92 |
+
if o.first_desync:
|
| 93 |
+
r.harm_penalty += DESYNC_EVENT_PENALTY
|
| 94 |
+
|
| 95 |
+
r.total = (r.detection + r.early_detection_bonus + r.calibration + r.classification
|
| 96 |
+
+ r.mitigation_cost + r.harm_penalty)
|
| 97 |
+
return r
|
|
@@ -0,0 +1,205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Task definitions and seeded scenario sampling.
|
| 3 |
+
|
| 4 |
+
Every episode is fully determined by (task_id, seed): attack type/onset and
|
| 5 |
+
parameters, benign grid events, harmonics, PMU latency and all noise streams.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import math
|
| 11 |
+
from dataclasses import dataclass, field
|
| 12 |
+
from typing import Any, Dict, List, Optional
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
|
| 16 |
+
from .attacks import AttackSpec
|
| 17 |
+
from .grid import GridConfig, GridEvent
|
| 18 |
+
|
| 19 |
+
# ---------------------------------------------------------------------------
|
| 20 |
+
# Timing (shared by all tasks)
|
| 21 |
+
# ---------------------------------------------------------------------------
|
| 22 |
+
SAMPLE_RATE_HZ = 5000.0
|
| 23 |
+
DT = 1.0 / SAMPLE_RATE_HZ
|
| 24 |
+
DECISION_INTERVAL_S = 0.050 # 2.5 fundamental cycles per agent decision
|
| 25 |
+
SAMPLES_PER_DECISION = int(round(DECISION_INTERVAL_S * SAMPLE_RATE_HZ))
|
| 26 |
+
WARMUP_S = 0.200 # PLL settles before the agent's first observation
|
| 27 |
+
MAX_STEPS = 40 # 2.0 s monitored horizon
|
| 28 |
+
|
| 29 |
+
PMU_REPORT_INTERVAL_S = 0.100
|
| 30 |
+
PMU_F_NOISE_HZ = 0.01
|
| 31 |
+
PMU_V_NOISE_PU = 0.003
|
| 32 |
+
SENSOR_NOISE_PU = 0.005
|
| 33 |
+
ADC_LSB_PU = 4.0 / 4096.0 # 12-bit ADC over ±2 pu
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@dataclass(frozen=True)
|
| 37 |
+
class TaskSpec:
|
| 38 |
+
task_id: int
|
| 39 |
+
name: str
|
| 40 |
+
difficulty: str
|
| 41 |
+
attack_probability: float
|
| 42 |
+
brief: str
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
TASKS: Dict[int, TaskSpec] = {
|
| 46 |
+
0: TaskSpec(
|
| 47 |
+
0, "sinusoidal_fdi_detection", "easy", 0.80,
|
| 48 |
+
"Monitor the inverter's PLL. A sinusoidal false-data-injection attack may start on one phase "
|
| 49 |
+
"(some episodes are attack-free). Raise attack_detected=True as soon as the attack is present "
|
| 50 |
+
"and keep it raised; never raise it before. Grid background harmonics and occasional mild sags "
|
| 51 |
+
"are normal operation.",
|
| 52 |
+
),
|
| 53 |
+
1: TaskSpec(
|
| 54 |
+
1, "attack_classification_under_disturbances", "medium", 0.80,
|
| 55 |
+
"A sinusoidal (1), ramp-bias (2) or pulse-bias (3) injection may start on one phase, while genuine "
|
| 56 |
+
"grid events (phase jumps, voltage sags, single-phase faults, harmonic steps, frequency events) also "
|
| 57 |
+
"occur. Detect the attack quickly, report its attack_type, and do NOT alarm on natural grid events. "
|
| 58 |
+
"Once an attack has started the system counts as compromised for the rest of the episode.",
|
| 59 |
+
),
|
| 60 |
+
2: TaskSpec(
|
| 61 |
+
2, "stealthy_phase_spoofing_mitigation", "hard", 0.80,
|
| 62 |
+
"A coordinated phase-spoofing attack (4) may slowly rotate the measured voltage angle. The PLL "
|
| 63 |
+
"follows it with almost no residual, and the local frequency drifts like a natural grid frequency "
|
| 64 |
+
"event. Use the trusted but delayed PMU stream to tell them apart. Detect it and engage "
|
| 65 |
+
"protective_action=2 (PMU-aided holdover) before the inverter drifts more than 10 deg out of sync "
|
| 66 |
+
"with the real grid. Holdover or disconnect (3) on a healthy grid is costly and can itself cause "
|
| 67 |
+
"desynchronisation.",
|
| 68 |
+
),
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@dataclass
|
| 73 |
+
class Scenario:
|
| 74 |
+
task_id: int
|
| 75 |
+
seed: int
|
| 76 |
+
grid: GridConfig
|
| 77 |
+
attack: AttackSpec
|
| 78 |
+
onset_step: Optional[int] # decision index at which the attack begins, None if attack-free
|
| 79 |
+
pmu_latency_s: float
|
| 80 |
+
theta0: float = 0.0
|
| 81 |
+
|
| 82 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 83 |
+
return {
|
| 84 |
+
"task_id": self.task_id,
|
| 85 |
+
"task_name": TASKS[self.task_id].name,
|
| 86 |
+
"seed": self.seed,
|
| 87 |
+
"attack": self.attack.to_dict(),
|
| 88 |
+
"onset_step": self.onset_step,
|
| 89 |
+
"pmu_latency_ms": round(self.pmu_latency_s * 1000.0, 1),
|
| 90 |
+
"grid": self.grid.to_dict(),
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def step_start_time(step: int) -> float:
|
| 95 |
+
"""Absolute sim time at which decision window `step` begins (window 0 = last warm-up window)."""
|
| 96 |
+
return WARMUP_S + (step - 1) * DECISION_INTERVAL_S
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# ---------------------------------------------------------------------------
|
| 100 |
+
# Sampling helpers
|
| 101 |
+
# ---------------------------------------------------------------------------
|
| 102 |
+
|
| 103 |
+
def _u(rng: np.random.Generator, lo: float, hi: float) -> float:
|
| 104 |
+
return float(rng.uniform(lo, hi))
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _sign(rng: np.random.Generator) -> float:
|
| 108 |
+
return 1.0 if rng.random() < 0.5 else -1.0
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _benign_event(rng: np.random.Generator, kind: str, t: float, strong: bool) -> GridEvent:
|
| 112 |
+
if kind == "phase_jump":
|
| 113 |
+
return GridEvent(kind, t, {"angle_deg": _sign(rng) * _u(rng, 5.0, 25.0 if strong else 12.0)})
|
| 114 |
+
if kind == "voltage_sag":
|
| 115 |
+
return GridEvent(kind, t, {"depth_pu": _u(rng, 0.5 if strong else 0.8, 0.9), "duration_s": _u(rng, 0.06, 0.25)})
|
| 116 |
+
if kind == "single_phase_sag":
|
| 117 |
+
return GridEvent(kind, t, {"phase": int(rng.integers(0, 3)), "depth_pu": _u(rng, 0.3, 0.8), "duration_s": _u(rng, 0.06, 0.20)})
|
| 118 |
+
if kind == "harmonic_step":
|
| 119 |
+
return GridEvent(kind, t, {"gain": _u(rng, 1.5, 2.5)})
|
| 120 |
+
if kind == "frequency_event":
|
| 121 |
+
return GridEvent(kind, t, {"rocof_hz_s": _u(rng, 0.1, 0.6), "delta_f_hz": _sign(rng) * _u(rng, 0.05, 0.25)})
|
| 122 |
+
raise ValueError(kind)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _injection_attack(rng: np.random.Generator, kind: str, t: float, easy: bool) -> AttackSpec:
|
| 126 |
+
phase = int(rng.integers(0, 3))
|
| 127 |
+
if kind == "sinusoidal":
|
| 128 |
+
lo, hi = (0.05, 0.15) if easy else (0.03, 0.10)
|
| 129 |
+
p = {"amplitude_pu": _u(rng, lo, hi), "freq_hz": _u(rng, 5.0, 25.0),
|
| 130 |
+
"phase": _u(rng, 0.0, 2 * math.pi), "phase_index": phase}
|
| 131 |
+
elif kind == "ramp":
|
| 132 |
+
p = {"rate_pu_s": _sign(rng) * _u(rng, 0.08, 0.30), "cap_pu": 0.30, "phase_index": phase}
|
| 133 |
+
elif kind == "pulse":
|
| 134 |
+
p = {"magnitude_pu": _sign(rng) * _u(rng, 0.08, 0.25), "duration_s": _u(rng, 0.10, 0.30), "phase_index": phase}
|
| 135 |
+
else:
|
| 136 |
+
raise ValueError(kind)
|
| 137 |
+
return AttackSpec(kind, t, p)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def sample_scenario(task_id: int, seed: int) -> Scenario:
|
| 141 |
+
if task_id not in TASKS:
|
| 142 |
+
raise ValueError(f"task_id must be one of {sorted(TASKS)}")
|
| 143 |
+
task = TASKS[task_id]
|
| 144 |
+
rng = np.random.default_rng(np.random.SeedSequence([seed, 1000 + task_id]))
|
| 145 |
+
|
| 146 |
+
grid = GridConfig(
|
| 147 |
+
f_drift_amp_hz=_u(rng, 0.005, 0.02 if task_id < 2 else 0.03),
|
| 148 |
+
f_drift_period_s=_u(rng, 4.0, 10.0),
|
| 149 |
+
f_drift_phase=_u(rng, 0.0, 2 * math.pi),
|
| 150 |
+
h5_pu=_u(rng, 0.01, 0.04),
|
| 151 |
+
h7_pu=_u(rng, 0.005, 0.025),
|
| 152 |
+
h5_phase=_u(rng, 0.0, 2 * math.pi),
|
| 153 |
+
h7_phase=_u(rng, 0.0, 2 * math.pi),
|
| 154 |
+
phase_unbalance=[_u(rng, 0.99, 1.01) for _ in range(3)],
|
| 155 |
+
)
|
| 156 |
+
attacked = rng.random() < task.attack_probability
|
| 157 |
+
pmu_latency = _u(rng, 0.04, 0.10)
|
| 158 |
+
t_end = step_start_time(MAX_STEPS)
|
| 159 |
+
|
| 160 |
+
if task_id == 0:
|
| 161 |
+
onset = int(rng.integers(6, 21)) if attacked else None
|
| 162 |
+
if rng.random() < 0.3:
|
| 163 |
+
grid.events.append(_benign_event(rng, "voltage_sag", step_start_time(int(rng.integers(2, MAX_STEPS - 4))), strong=False))
|
| 164 |
+
attack = _injection_attack(rng, "sinusoidal", step_start_time(onset), easy=True) if attacked else AttackSpec()
|
| 165 |
+
|
| 166 |
+
elif task_id == 1:
|
| 167 |
+
onset = int(rng.integers(10, 25)) if attacked else None
|
| 168 |
+
kinds = ["phase_jump", "voltage_sag", "single_phase_sag", "harmonic_step", "frequency_event"]
|
| 169 |
+
n_events = int(rng.integers(1, 3)) + (0 if attacked else 1)
|
| 170 |
+
# Guarantee a benign event in the clean part of the episode, so false-alarm
|
| 171 |
+
# resistance is always tested.
|
| 172 |
+
last_clean = (onset - 1) if attacked else MAX_STEPS - 4
|
| 173 |
+
times = [step_start_time(int(rng.integers(2, max(3, last_clean))))]
|
| 174 |
+
times += [step_start_time(int(rng.integers(2, MAX_STEPS - 4))) for _ in range(n_events - 1)]
|
| 175 |
+
for t in times:
|
| 176 |
+
grid.events.append(_benign_event(rng, str(rng.choice(kinds)), t + _u(rng, 0.0, DECISION_INTERVAL_S), strong=True))
|
| 177 |
+
if attacked:
|
| 178 |
+
kind = ["sinusoidal", "ramp", "pulse"][int(rng.integers(0, 3))]
|
| 179 |
+
attack = _injection_attack(rng, kind, step_start_time(onset), easy=False)
|
| 180 |
+
else:
|
| 181 |
+
attack = AttackSpec()
|
| 182 |
+
|
| 183 |
+
else:
|
| 184 |
+
onset = int(rng.integers(6, 19)) if attacked else None
|
| 185 |
+
# Natural frequency events are the main confounder for phase spoofing.
|
| 186 |
+
n_freq = 1 if attacked else int(rng.integers(1, 3))
|
| 187 |
+
for _ in range(n_freq):
|
| 188 |
+
k = int(rng.integers(2, (onset + 4) if attacked else MAX_STEPS - 8))
|
| 189 |
+
grid.events.append(_benign_event(rng, "frequency_event", step_start_time(k) + _u(rng, 0.0, DECISION_INTERVAL_S), strong=True))
|
| 190 |
+
if rng.random() < 0.6:
|
| 191 |
+
kind = ["voltage_sag", "single_phase_sag", "harmonic_step"][int(rng.integers(0, 3))]
|
| 192 |
+
grid.events.append(_benign_event(rng, kind, step_start_time(int(rng.integers(2, MAX_STEPS - 4))), strong=False))
|
| 193 |
+
if attacked:
|
| 194 |
+
attack = AttackSpec("phase_spoofing", step_start_time(onset), {
|
| 195 |
+
"rocof_hz_s": _u(rng, 0.12, 0.45),
|
| 196 |
+
"max_offset_hz": _u(rng, 0.20, 0.40),
|
| 197 |
+
"direction": _sign(rng),
|
| 198 |
+
})
|
| 199 |
+
else:
|
| 200 |
+
attack = AttackSpec()
|
| 201 |
+
|
| 202 |
+
grid.events.sort(key=lambda e: e.t_start)
|
| 203 |
+
grid.events = [e for e in grid.events if e.t_start < t_end]
|
| 204 |
+
return Scenario(task_id=task_id, seed=seed, grid=grid, attack=attack,
|
| 205 |
+
onset_step=onset, pmu_latency_s=pmu_latency)
|
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Measurement chain: local voltage sensors (noise + ADC quantisation) and the
|
| 3 |
+
trusted, out-of-band PMU reference stream.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from collections import deque
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
from typing import Deque, List, Optional, Tuple
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
|
| 14 |
+
from .pll import F_NOMINAL, TWO_PI
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def measure(v: np.ndarray, rng: np.random.Generator, noise_pu: float, lsb_pu: float) -> np.ndarray:
|
| 18 |
+
"""Additive Gaussian sensor noise followed by uniform ADC quantisation."""
|
| 19 |
+
noisy = v + rng.normal(0.0, noise_pu, v.shape)
|
| 20 |
+
return np.round(noisy / lsb_pu) * lsb_pu
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class PMUReport:
|
| 25 |
+
t_end: float # end of the measurement interval (s)
|
| 26 |
+
f_hz: float
|
| 27 |
+
v_pos_pu: float
|
| 28 |
+
theta: float # synchrophasor angle at t_end (rad, GPS-referenced)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class PMUStream:
|
| 32 |
+
"""IEEE C37.118-style µPMU at the substation (GPS time-stamped, not spoofable).
|
| 33 |
+
|
| 34 |
+
Each report gives the average frequency over the last reporting interval,
|
| 35 |
+
computed from the true phase-angle advance — so it also shows the
|
| 36 |
+
frequency spike that accompanies a real phase jump — plus Gaussian noise.
|
| 37 |
+
Reports reach the inverter after a fixed communication latency.
|
| 38 |
+
|
| 39 |
+
The synchrophasor angle is only consumed by the inverter's holdover
|
| 40 |
+
controller; the monitoring agent subscribes to the frequency and magnitude
|
| 41 |
+
channels (a low-bandwidth SCADA status feed).
|
| 42 |
+
"""
|
| 43 |
+
|
| 44 |
+
def __init__(self, interval_s: float, latency_s: float, f_noise_hz: float,
|
| 45 |
+
v_noise_pu: float, rng: np.random.Generator, angle_noise_rad: float = 0.0035):
|
| 46 |
+
self.interval = interval_s
|
| 47 |
+
self.latency = latency_s
|
| 48 |
+
self.f_noise = f_noise_hz
|
| 49 |
+
self.v_noise = v_noise_pu
|
| 50 |
+
self.angle_noise = angle_noise_rad
|
| 51 |
+
self.rng = rng
|
| 52 |
+
self.reports: Deque[PMUReport] = deque(maxlen=64)
|
| 53 |
+
self._last_theta: Optional[Tuple[float, float]] = None # (t, θ) at last report boundary
|
| 54 |
+
self._v_acc = 0.0
|
| 55 |
+
self._v_n = 0
|
| 56 |
+
self._next_t = None
|
| 57 |
+
|
| 58 |
+
def ingest(self, t: np.ndarray, theta: np.ndarray, v_pos: np.ndarray) -> None:
|
| 59 |
+
"""Feed a block of true grid samples; emits reports at interval boundaries."""
|
| 60 |
+
if self._next_t is None:
|
| 61 |
+
self._next_t = (np.floor(t[0] / self.interval) + 1) * self.interval
|
| 62 |
+
self._last_theta = (float(t[0]), float(theta[0]))
|
| 63 |
+
start = 0
|
| 64 |
+
while True:
|
| 65 |
+
idx = int(np.searchsorted(t, self._next_t - 1e-9))
|
| 66 |
+
if idx >= t.size:
|
| 67 |
+
break
|
| 68 |
+
seg = v_pos[start:idx + 1]
|
| 69 |
+
self._v_acc += float(seg.sum())
|
| 70 |
+
self._v_n += seg.size
|
| 71 |
+
t0, th0 = self._last_theta
|
| 72 |
+
t1, th1 = float(t[idx]), float(theta[idx])
|
| 73 |
+
f = (th1 - th0) / (TWO_PI * (t1 - t0)) if t1 > t0 else F_NOMINAL
|
| 74 |
+
v = self._v_acc / max(self._v_n, 1)
|
| 75 |
+
self.reports.append(PMUReport(
|
| 76 |
+
t_end=t1,
|
| 77 |
+
f_hz=float(f + self.rng.normal(0.0, self.f_noise)),
|
| 78 |
+
v_pos_pu=float(v + self.rng.normal(0.0, self.v_noise)),
|
| 79 |
+
theta=float(th1 + self.rng.normal(0.0, self.angle_noise)),
|
| 80 |
+
))
|
| 81 |
+
self._last_theta = (t1, th1)
|
| 82 |
+
self._v_acc, self._v_n = 0.0, 0
|
| 83 |
+
self._next_t += self.interval
|
| 84 |
+
start = idx + 1
|
| 85 |
+
seg = v_pos[start:]
|
| 86 |
+
self._v_acc += float(seg.sum())
|
| 87 |
+
self._v_n += seg.size
|
| 88 |
+
|
| 89 |
+
def arrived(self, since: float, now: float) -> List[PMUReport]:
|
| 90 |
+
"""Reports that arrived in (since, now]."""
|
| 91 |
+
return [r for r in self.reports if since < r.t_end + self.latency <= now + 1e-9]
|
| 92 |
+
|
| 93 |
+
def latest(self, now: float) -> Optional[PMUReport]:
|
| 94 |
+
"""Most recent report that has arrived by time `now`."""
|
| 95 |
+
for rep in reversed(self.reports):
|
| 96 |
+
if rep.t_end + self.latency <= now + 1e-9:
|
| 97 |
+
return rep
|
| 98 |
+
return None
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
class DisciplinedHoldover:
|
| 102 |
+
"""PMU/GPS-disciplined oscillator used while the PLL loop is open.
|
| 103 |
+
|
| 104 |
+
On every PMU report that has arrived, it re-anchors θ̂ towards the angle
|
| 105 |
+
*predicted for the present instant* from the synchrophasor angle, the
|
| 106 |
+
reported frequency and a RoCoF estimated by a linear fit over the last
|
| 107 |
+
few reports (latency compensation). Between reports θ̂ free-runs with the
|
| 108 |
+
extrapolated frequency ramp.
|
| 109 |
+
"""
|
| 110 |
+
|
| 111 |
+
PHASE_GAIN = 0.7
|
| 112 |
+
FIT_REPORTS = 4
|
| 113 |
+
MAX_ROCOF = 1.0 # Hz/s, beyond natural grid behaviour
|
| 114 |
+
|
| 115 |
+
def __init__(self):
|
| 116 |
+
self.active = False
|
| 117 |
+
self.f_hold = 50.0
|
| 118 |
+
self.rocof = 0.0
|
| 119 |
+
self.last_arrival_check = -1.0
|
| 120 |
+
|
| 121 |
+
def engage(self, now: float) -> None:
|
| 122 |
+
self.active = True
|
| 123 |
+
self.last_arrival_check = -1.0
|
| 124 |
+
|
| 125 |
+
def release(self) -> None:
|
| 126 |
+
self.active = False
|
| 127 |
+
|
| 128 |
+
def update(self, pll, pmu: "PMUStream", now: float) -> None:
|
| 129 |
+
"""Steer the oscillator using reports that arrived since the last call."""
|
| 130 |
+
arrived = [r for r in pmu.reports if r.t_end + pmu.latency <= now + 1e-9]
|
| 131 |
+
if not arrived:
|
| 132 |
+
return
|
| 133 |
+
newest = arrived[-1]
|
| 134 |
+
if newest.t_end + pmu.latency <= self.last_arrival_check:
|
| 135 |
+
return # nothing new
|
| 136 |
+
T = pmu.interval
|
| 137 |
+
fit = arrived[-self.FIT_REPORTS:]
|
| 138 |
+
tm = np.array([r.t_end - T / 2 for r in fit]) # report freq = interval average
|
| 139 |
+
fv = np.array([r.f_hz for r in fit])
|
| 140 |
+
if len(fit) >= 3:
|
| 141 |
+
coef = np.polyfit(tm, fv, 1)
|
| 142 |
+
self.rocof = float(np.clip(coef[0], -self.MAX_ROCOF, self.MAX_ROCOF))
|
| 143 |
+
f_at_t = float(np.polyval(coef, newest.t_end))
|
| 144 |
+
else:
|
| 145 |
+
self.rocof = 0.0
|
| 146 |
+
f_at_t = float(np.median(fv))
|
| 147 |
+
dt = now - newest.t_end
|
| 148 |
+
theta_pred = newest.theta + TWO_PI * (f_at_t * dt + 0.5 * self.rocof * dt * dt)
|
| 149 |
+
err = (theta_pred - pll.theta_hat + np.pi) % TWO_PI - np.pi
|
| 150 |
+
pll.theta_hat = float((pll.theta_hat + self.PHASE_GAIN * err + np.pi) % TWO_PI - np.pi)
|
| 151 |
+
self.f_hold = f_at_t + self.rocof * dt
|
| 152 |
+
self.last_arrival_check = now
|
|
@@ -1,19 +1,42 @@
|
|
| 1 |
[build-system]
|
| 2 |
-
requires = ["setuptools>=
|
| 3 |
-
build-backend = "setuptools.
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "pll-cyberattack-detection"
|
| 7 |
-
version = "
|
| 8 |
-
description = "OpenEnv
|
|
|
|
| 9 |
requires-python = ">=3.10"
|
|
|
|
|
|
|
| 10 |
dependencies = [
|
| 11 |
-
"
|
| 12 |
-
"
|
| 13 |
-
"
|
| 14 |
-
"
|
| 15 |
-
"
|
|
|
|
|
|
|
|
|
|
| 16 |
]
|
| 17 |
|
|
|
|
|
|
|
|
|
|
| 18 |
[project.scripts]
|
| 19 |
server = "server.app:main"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
[build-system]
|
| 2 |
+
requires = ["setuptools>=68", "wheel"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "pll-cyberattack-detection"
|
| 7 |
+
version = "2.0.0"
|
| 8 |
+
description = "OpenEnv environment: detect, classify and mitigate false-data-injection attacks on the SRF-PLL of a grid-connected inverter"
|
| 9 |
+
readme = "README.md"
|
| 10 |
requires-python = ">=3.10"
|
| 11 |
+
authors = [{ name = "Kris Keshav" }]
|
| 12 |
+
keywords = ["openenv", "reinforcement-learning", "power-systems", "cybersecurity", "pll", "smart-grid"]
|
| 13 |
dependencies = [
|
| 14 |
+
"openenv-core>=0.3.0,<0.4",
|
| 15 |
+
"numpy>=1.26",
|
| 16 |
+
"fastapi>=0.110",
|
| 17 |
+
"uvicorn>=0.29",
|
| 18 |
+
"pydantic>=2.6",
|
| 19 |
+
"openai>=1.40",
|
| 20 |
+
"requests>=2.31",
|
| 21 |
+
"python-dotenv>=1.0",
|
| 22 |
]
|
| 23 |
|
| 24 |
+
[project.optional-dependencies]
|
| 25 |
+
dev = ["pytest>=8.0", "httpx>=0.27"]
|
| 26 |
+
|
| 27 |
[project.scripts]
|
| 28 |
server = "server.app:main"
|
| 29 |
+
|
| 30 |
+
[project.urls]
|
| 31 |
+
Repository = "https://github.com/KrisKeshav/PLL-Cyberattack-Detection-OpenEnv"
|
| 32 |
+
Space = "https://huggingface.co/spaces/krishuggingface/CyberAttack-PLL"
|
| 33 |
+
|
| 34 |
+
[tool.setuptools]
|
| 35 |
+
packages = ["pll_cyber", "server"]
|
| 36 |
+
py-modules = ["inference"]
|
| 37 |
+
|
| 38 |
+
[tool.setuptools.package-data]
|
| 39 |
+
server = ["*.html"]
|
| 40 |
+
|
| 41 |
+
[tool.pytest.ini_options]
|
| 42 |
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-r requirements.txt
|
| 2 |
+
pytest==9.1.1
|
| 3 |
+
httpx==0.28.1
|
|
@@ -1,8 +1,9 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
| 1 |
+
# Runtime dependencies (pinned for a reproducible Docker image / HF Space)
|
| 2 |
+
openenv-core==0.3.0
|
| 3 |
+
numpy==2.5.4
|
| 4 |
+
fastapi==0.146.0
|
| 5 |
+
uvicorn==0.54.0
|
| 6 |
+
pydantic==2.14.1
|
| 7 |
+
openai==3.35.1
|
| 8 |
+
requests==2.34.2
|
| 9 |
+
python-dotenv==1.2.4
|
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Benchmark reference policies over many seeds (in-process, no network).
|
| 3 |
+
|
| 4 |
+
python scripts/evaluate_baselines.py --seeds 100
|
| 5 |
+
|
| 6 |
+
Prints a Markdown table (mean ± std of grader scores per task) used in the README.
|
| 7 |
+
The oracle reads ground truth and is an upper bound, not a real agent.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import argparse
|
| 13 |
+
import itertools
|
| 14 |
+
import os
|
| 15 |
+
import sys
|
| 16 |
+
import time
|
| 17 |
+
|
| 18 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 19 |
+
|
| 20 |
+
import numpy as np # noqa: E402
|
| 21 |
+
|
| 22 |
+
from pll_cyber.baselines import ConstantAgent, PhysicsHeuristicAgent, RandomAgent # noqa: E402
|
| 23 |
+
from pll_cyber.environment import PLLCyberEnvironment # noqa: E402
|
| 24 |
+
from pll_cyber.evaluation import run_episode # noqa: E402
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class Oracle:
|
| 28 |
+
def __init__(self, env):
|
| 29 |
+
self.env = env
|
| 30 |
+
|
| 31 |
+
def act(self, obs):
|
| 32 |
+
sc = self.env._sc
|
| 33 |
+
lab = sc.onset_step is not None and obs["step"] >= sc.onset_step
|
| 34 |
+
return {"attack_detected": lab, "attack_type": sc.attack.type_id if lab else 0, "confidence": 1.0,
|
| 35 |
+
"protective_action": 2 if (lab and sc.task_id == 2) else 0}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def main() -> None:
|
| 39 |
+
ap = argparse.ArgumentParser()
|
| 40 |
+
ap.add_argument("--seeds", type=int, default=100)
|
| 41 |
+
args = ap.parse_args()
|
| 42 |
+
seeds = range(1000, 1000 + args.seeds) # disjoint from inference.py's evaluation seeds
|
| 43 |
+
rnd = itertools.count()
|
| 44 |
+
policies = {
|
| 45 |
+
"Always alarm": lambda env, t: ConstantAgent(True, [1, 1, 4][t]),
|
| 46 |
+
"Never alarm": lambda env, t: ConstantAgent(False),
|
| 47 |
+
"Random (p=0.5)": lambda env, t: RandomAgent(seed=next(rnd)),
|
| 48 |
+
"Physics heuristic": lambda env, t: PhysicsHeuristicAgent(),
|
| 49 |
+
"Oracle (ground truth)": lambda env, t: Oracle(env),
|
| 50 |
+
}
|
| 51 |
+
env = PLLCyberEnvironment()
|
| 52 |
+
t0 = time.time()
|
| 53 |
+
print("| Policy | Task 0 (easy) | Task 1 (medium) | Task 2 (hard) | Mean |")
|
| 54 |
+
print("|---|---|---|---|---|")
|
| 55 |
+
for name, make in policies.items():
|
| 56 |
+
cells, means = [], []
|
| 57 |
+
for t in range(3):
|
| 58 |
+
s = np.array([run_episode(make(env, t), t, seed, env)["score"] for seed in seeds])
|
| 59 |
+
cells.append(f"{s.mean():.3f} ± {s.std():.3f}")
|
| 60 |
+
means.append(s.mean())
|
| 61 |
+
print(f"| {name} | " + " | ".join(cells) + f" | {np.mean(means):.3f} |", flush=True)
|
| 62 |
+
print(f"\n{args.seeds} seeds per task, {time.time() - t0:.0f}s", file=sys.stderr)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
if __name__ == "__main__":
|
| 66 |
+
main()
|
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Pre-submission checks for the PLL Cyber-Guard OpenEnv environment.
|
| 3 |
+
#
|
| 4 |
+
# ./scripts/validate_submission.sh [SPACE_URL]
|
| 5 |
+
#
|
| 6 |
+
# 1. unit tests 2. openenv validate (structure + runtime)
|
| 7 |
+
# 3. docker build + run 4. inference.py completes and prints valid logs
|
| 8 |
+
# 5. (optional) the live HF Space answers /health and POST /reset
|
| 9 |
+
set -uo pipefail
|
| 10 |
+
cd "$(dirname "$0")/.."
|
| 11 |
+
SPACE_URL="${1:-}"
|
| 12 |
+
PORT="${PORT:-7861}"
|
| 13 |
+
fail=0
|
| 14 |
+
ok() { printf ' \033[32mPASS\033[0m %s\n' "$1"; }
|
| 15 |
+
bad() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; fail=1; }
|
| 16 |
+
|
| 17 |
+
echo "== 1. unit tests"
|
| 18 |
+
python -m pytest -q >/tmp/pll_tests.log 2>&1 && ok "pytest" || { bad "pytest (see /tmp/pll_tests.log)"; tail -5 /tmp/pll_tests.log; }
|
| 19 |
+
|
| 20 |
+
echo "== 2. openenv validate"
|
| 21 |
+
openenv validate . >/dev/null 2>&1 && ok "structure" || bad "structure"
|
| 22 |
+
python -m uvicorn server.app:app --port "$PORT" >/tmp/pll_server.log 2>&1 &
|
| 23 |
+
SRV=$!
|
| 24 |
+
for _ in $(seq 1 30); do curl -sf "localhost:$PORT/health" >/dev/null && break; sleep 1; done
|
| 25 |
+
openenv validate --url "http://localhost:$PORT" >/tmp/pll_runtime.json 2>/dev/null
|
| 26 |
+
python -c "import json,sys; sys.exit(0 if json.load(sys.stdin)['passed'] else 1)" </tmp/pll_runtime.json \
|
| 27 |
+
&& ok "runtime contract" || bad "runtime contract (see /tmp/pll_runtime.json)"
|
| 28 |
+
|
| 29 |
+
echo "== 3. inference.py"
|
| 30 |
+
ENV_URL="http://localhost:$PORT" EPISODES_PER_TASK="${EPISODES_PER_TASK:-3}" python inference.py >/tmp/pll_inf.log 2>/tmp/pll_inf.err
|
| 31 |
+
rc=$?
|
| 32 |
+
kill $SRV 2>/dev/null
|
| 33 |
+
starts=$(grep -c '^\[START\] ' /tmp/pll_inf.log); ends=$(grep -c '^\[END\] success=true ' /tmp/pll_inf.log)
|
| 34 |
+
bad_lines=$(grep -E '^\[' /tmp/pll_inf.log | grep -vcE '^\[(START|STEP|END)\] ')
|
| 35 |
+
if [ $rc -eq 0 ] && [ "$starts" -ge 3 ] && [ "$starts" -eq "$ends" ] && [ "$bad_lines" -eq 0 ]; then
|
| 36 |
+
ok "inference ($starts episodes) — $(grep '^overall' /tmp/pll_inf.log)"
|
| 37 |
+
else
|
| 38 |
+
bad "inference rc=$rc starts=$starts successful_ends=$ends (see /tmp/pll_inf.log /tmp/pll_inf.err)"
|
| 39 |
+
fi
|
| 40 |
+
|
| 41 |
+
echo "== 4. docker"
|
| 42 |
+
if command -v docker >/dev/null && docker info >/dev/null 2>&1; then
|
| 43 |
+
if docker build -q -t pll-cyberguard:validate . >/dev/null; then
|
| 44 |
+
ok "docker build"
|
| 45 |
+
cid=$(docker run -d -p 17860:7860 pll-cyberguard:validate)
|
| 46 |
+
for _ in $(seq 1 40); do curl -sf localhost:17860/health >/dev/null && break; sleep 1; done
|
| 47 |
+
curl -sf -X POST localhost:17860/reset -H 'content-type: application/json' -d '{"task_id":0,"seed":1}' >/dev/null \
|
| 48 |
+
&& ok "docker run + POST /reset" || bad "docker run + POST /reset"
|
| 49 |
+
docker rm -f "$cid" >/dev/null
|
| 50 |
+
else bad "docker build"; fi
|
| 51 |
+
else
|
| 52 |
+
echo " SKIP docker (daemon not available)"
|
| 53 |
+
fi
|
| 54 |
+
|
| 55 |
+
if [ -n "$SPACE_URL" ]; then
|
| 56 |
+
echo "== 5. HF Space $SPACE_URL"
|
| 57 |
+
curl -sf "$SPACE_URL/health" >/dev/null && ok "/health" || bad "/health"
|
| 58 |
+
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$SPACE_URL/reset" -H 'content-type: application/json' -d '{}')
|
| 59 |
+
[ "$code" = "200" ] && ok "POST /reset → 200" || bad "POST /reset → $code"
|
| 60 |
+
fi
|
| 61 |
+
|
| 62 |
+
[ $fail -eq 0 ] && echo "ALL CHECKS PASSED" || echo "SOME CHECKS FAILED"
|
| 63 |
+
exit $fail
|
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Server package for the PLL Cyber-Guard OpenEnv environment."""
|
|
@@ -1,19 +1,222 @@
|
|
| 1 |
"""
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
-
|
| 5 |
-
from
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
|
| 8 |
-
def
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
|
| 18 |
if __name__ == "__main__":
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
FastAPI server for the PLL Cyber-Guard OpenEnv environment.
|
| 3 |
+
|
| 4 |
+
Built on openenv-core's `create_app`, which provides the standard surface:
|
| 5 |
+
WS /ws persistent sessions (one environment per connection) — used by EnvClient
|
| 6 |
+
GET /schema action / observation / state JSON schemas
|
| 7 |
+
GET /metadata environment metadata
|
| 8 |
+
GET /health {"status": "healthy"}
|
| 9 |
+
POST /mcp MCP JSON-RPC endpoint
|
| 10 |
+
|
| 11 |
+
openenv's HTTP /reset and /step are stateless (a fresh environment per request),
|
| 12 |
+
which cannot run a multi-step episode. They are replaced here by session-aware
|
| 13 |
+
versions with the same request/response shapes:
|
| 14 |
+
POST /reset body: {"task_id": 0-2, "seed": int, "session_id": str?}
|
| 15 |
+
POST /step body: {"action": {...}, "session_id": str?} (a bare action dict is also accepted)
|
| 16 |
+
GET /state ?session_id=...
|
| 17 |
+
Requests without a session_id share the "default" session (handy for curl).
|
| 18 |
+
|
| 19 |
+
Extras:
|
| 20 |
+
GET /tasks task catalogue
|
| 21 |
+
GET /demo run the physics-heuristic baseline on one episode and return the trace
|
| 22 |
+
GET / landing page with a live demo plot
|
| 23 |
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import asyncio
|
| 28 |
+
import os
|
| 29 |
+
import time
|
| 30 |
+
from collections import OrderedDict
|
| 31 |
+
from pathlib import Path
|
| 32 |
+
from typing import Any, Dict, Optional
|
| 33 |
+
|
| 34 |
+
from fastapi import Body, HTTPException, Query
|
| 35 |
+
from fastapi.concurrency import run_in_threadpool
|
| 36 |
+
from fastapi.responses import HTMLResponse
|
| 37 |
+
from fastapi.routing import APIRoute
|
| 38 |
+
from openenv.core.env_server.http_server import create_app
|
| 39 |
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
| 40 |
+
|
| 41 |
+
from pll_cyber import __version__
|
| 42 |
+
from pll_cyber.baselines import PhysicsHeuristicAgent
|
| 43 |
+
from pll_cyber.environment import PLLCyberEnvironment
|
| 44 |
+
from pll_cyber.evaluation import obs_to_dict, run_episode
|
| 45 |
+
from pll_cyber.graders import GRADERS
|
| 46 |
+
from pll_cyber.models import PLLAction, PLLObservation, PLLState
|
| 47 |
+
from pll_cyber.rewards import REWARD_MAX, REWARD_MIN
|
| 48 |
+
from pll_cyber.scenarios import MAX_STEPS, TASKS
|
| 49 |
+
|
| 50 |
+
MAX_HTTP_SESSIONS = int(os.environ.get("MAX_HTTP_SESSIONS", "256"))
|
| 51 |
+
SESSION_TTL_S = float(os.environ.get("SESSION_TTL_S", "1800"))
|
| 52 |
+
MAX_WS_SESSIONS = int(os.environ.get("MAX_WS_SESSIONS", "64"))
|
| 53 |
+
|
| 54 |
+
app = create_app(
|
| 55 |
+
PLLCyberEnvironment,
|
| 56 |
+
PLLAction,
|
| 57 |
+
PLLObservation,
|
| 58 |
+
env_name="pll-cyberattack-detection",
|
| 59 |
+
max_concurrent_envs=MAX_WS_SESSIONS,
|
| 60 |
+
)
|
| 61 |
+
app.title = "PLL Cyber-Guard — OpenEnv"
|
| 62 |
+
app.description = (
|
| 63 |
+
"Detect, classify and mitigate false-data-injection attacks on the SRF-PLL of a grid-connected "
|
| 64 |
+
"inverter, without alarming on natural grid disturbances. See /tasks and /docs."
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
# Drop openenv's stateless HTTP simulation routes; session-aware versions follow.
|
| 68 |
+
app.router.routes = [
|
| 69 |
+
r for r in app.router.routes
|
| 70 |
+
if not (isinstance(r, APIRoute) and r.path in {"/reset", "/step", "/state"})
|
| 71 |
+
]
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# ---------------------------------------------------------------------------
|
| 75 |
+
# HTTP session store
|
| 76 |
+
# ---------------------------------------------------------------------------
|
| 77 |
+
class _Session:
|
| 78 |
+
def __init__(self):
|
| 79 |
+
self.env = PLLCyberEnvironment()
|
| 80 |
+
self.lock = asyncio.Lock()
|
| 81 |
+
self.touched = time.monotonic()
|
| 82 |
+
self.started = False
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
_sessions: "OrderedDict[str, _Session]" = OrderedDict()
|
| 86 |
+
_sessions_lock = asyncio.Lock()
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
async def _get_session(session_id: str, create: bool) -> _Session:
|
| 90 |
+
async with _sessions_lock:
|
| 91 |
+
now = time.monotonic()
|
| 92 |
+
for sid in [k for k, s in _sessions.items() if now - s.touched > SESSION_TTL_S]:
|
| 93 |
+
_sessions.pop(sid, None)
|
| 94 |
+
sess = _sessions.get(session_id)
|
| 95 |
+
if sess is None:
|
| 96 |
+
if not create:
|
| 97 |
+
raise HTTPException(404, f"Unknown session_id '{session_id}'. Call POST /reset first.")
|
| 98 |
+
while len(_sessions) >= MAX_HTTP_SESSIONS:
|
| 99 |
+
_sessions.popitem(last=False) # evict least-recently used
|
| 100 |
+
sess = _Session()
|
| 101 |
+
_sessions[session_id] = sess
|
| 102 |
+
_sessions.move_to_end(session_id)
|
| 103 |
+
sess.touched = now
|
| 104 |
+
return sess
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
class ResetBody(BaseModel):
|
| 108 |
+
model_config = ConfigDict(extra="allow")
|
| 109 |
+
task_id: Optional[int] = Field(None, ge=0, le=2, description="0=easy, 1=medium, 2=hard")
|
| 110 |
+
task: Optional[str] = Field(None, description="Alternatively the task name or difficulty")
|
| 111 |
+
seed: Optional[int] = Field(None, ge=0, description="Seed for a reproducible episode")
|
| 112 |
+
episode_id: Optional[str] = None
|
| 113 |
+
session_id: Optional[str] = Field(None, description="Isolates concurrent clients; default 'default'")
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
class EnvResponse(BaseModel):
|
| 117 |
+
observation: Dict[str, Any]
|
| 118 |
+
reward: Optional[float] = None
|
| 119 |
+
done: bool = False
|
| 120 |
+
session_id: str
|
| 121 |
|
| 122 |
|
| 123 |
+
def _response(obs: PLLObservation, session_id: str) -> EnvResponse:
|
| 124 |
+
return EnvResponse(observation=obs_to_dict(obs), reward=obs.reward, done=obs.done, session_id=session_id)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
@app.post("/reset", response_model=EnvResponse, tags=["Environment Control"],
|
| 128 |
+
summary="Start a new episode (session-aware)")
|
| 129 |
+
async def reset(body: Optional[ResetBody] = Body(default=None)) -> EnvResponse:
|
| 130 |
+
body = body or ResetBody()
|
| 131 |
+
sid = body.session_id or "default"
|
| 132 |
+
sess = await _get_session(sid, create=True)
|
| 133 |
+
async with sess.lock:
|
| 134 |
+
try:
|
| 135 |
+
obs = await run_in_threadpool(sess.env.reset, seed=body.seed, episode_id=body.episode_id,
|
| 136 |
+
task_id=body.task_id, task=body.task)
|
| 137 |
+
except ValueError as e:
|
| 138 |
+
raise HTTPException(422, str(e))
|
| 139 |
+
sess.started = True
|
| 140 |
+
return _response(obs, sid)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
@app.post("/step", response_model=EnvResponse, tags=["Environment Control"],
|
| 144 |
+
summary="Apply an action to the session's episode")
|
| 145 |
+
async def step(body: Dict[str, Any] = Body(..., examples=[{
|
| 146 |
+
"action": {"attack_detected": False, "attack_type": 0, "confidence": 0.9, "protective_action": 0},
|
| 147 |
+
"session_id": "default"}])) -> EnvResponse:
|
| 148 |
+
sid = body.get("session_id") or "default"
|
| 149 |
+
raw = body.get("action", {k: v for k, v in body.items() if k not in ("session_id", "timeout_s", "request_id")})
|
| 150 |
+
try:
|
| 151 |
+
action = PLLAction.model_validate(raw)
|
| 152 |
+
except ValidationError as e:
|
| 153 |
+
raise HTTPException(422, e.errors(include_url=False))
|
| 154 |
+
sess = await _get_session(sid, create=False)
|
| 155 |
+
async with sess.lock:
|
| 156 |
+
if not sess.started:
|
| 157 |
+
raise HTTPException(400, "Call POST /reset before /step.")
|
| 158 |
+
obs = await run_in_threadpool(sess.env.step, action)
|
| 159 |
+
return _response(obs, sid)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
@app.get("/state", response_model=PLLState, tags=["State Management"],
|
| 163 |
+
summary="Episode state (ground truth is revealed only after done)")
|
| 164 |
+
async def state(session_id: str = Query("default")) -> PLLState:
|
| 165 |
+
sess = await _get_session(session_id, create=True)
|
| 166 |
+
return sess.env.state
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
@app.get("/tasks", tags=["Environment Info"], summary="Task catalogue")
|
| 170 |
+
async def tasks() -> Dict[str, Any]:
|
| 171 |
+
return {
|
| 172 |
+
"episode_steps": MAX_STEPS,
|
| 173 |
+
"decision_interval_ms": 50,
|
| 174 |
+
"reward_range": [round(REWARD_MIN, 4), round(REWARD_MAX, 4)],
|
| 175 |
+
"tasks": [
|
| 176 |
+
{"id": t.task_id, "name": t.name, "difficulty": t.difficulty,
|
| 177 |
+
"attack_probability": t.attack_probability, "grader": GRADERS[t.task_id].__name__,
|
| 178 |
+
"score_range": [0.0, 1.0], "description": t.brief}
|
| 179 |
+
for t in TASKS.values()
|
| 180 |
+
],
|
| 181 |
+
"action_schema": PLLAction.model_json_schema(),
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
@app.get("/demo", tags=["Environment Info"], summary="Run the heuristic baseline on one episode")
|
| 186 |
+
async def demo(task_id: int = Query(2, ge=0, le=2), seed: int = Query(7, ge=0)) -> Dict[str, Any]:
|
| 187 |
+
trace = []
|
| 188 |
+
|
| 189 |
+
def record(i, action, obs):
|
| 190 |
+
f = obs.features
|
| 191 |
+
trace.append({"step": i, "time_s": obs.time_s, "f_local_hz": f.f_local_hz, "pmu_f_hz": obs.pmu.f_hz,
|
| 192 |
+
"vq_rms_pu": f.vq_rms_pu, "v_zero_seq_rms_pu": f.v_zero_seq_rms_pu,
|
| 193 |
+
"mode": obs.inverter_mode, "reward": obs.reward, "action": action})
|
| 194 |
+
|
| 195 |
+
res = await run_in_threadpool(run_episode, PhysicsHeuristicAgent(), task_id, seed, None, record)
|
| 196 |
+
return {"task_id": task_id, "seed": seed, "score": res["score"], "total_reward": res["total_reward"],
|
| 197 |
+
"breakdown": res["breakdown"], "scenario": res["scenario"], "trace": trace}
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
_LANDING = Path(__file__).with_name("landing.html")
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
|
| 204 |
+
async def landing() -> HTMLResponse:
|
| 205 |
+
html = _LANDING.read_text(encoding="utf-8") if _LANDING.exists() else "<h1>PLL Cyber-Guard</h1>"
|
| 206 |
+
return HTMLResponse(html.replace("{{VERSION}}", __version__))
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def main(host: str = "0.0.0.0", port: int = int(os.environ.get("PORT", "7860"))) -> None:
|
| 210 |
+
import uvicorn
|
| 211 |
+
|
| 212 |
+
uvicorn.run(app, host=host, port=port)
|
| 213 |
|
| 214 |
|
| 215 |
if __name__ == "__main__":
|
| 216 |
+
import argparse
|
| 217 |
+
|
| 218 |
+
parser = argparse.ArgumentParser()
|
| 219 |
+
parser.add_argument("--host", default="0.0.0.0")
|
| 220 |
+
parser.add_argument("--port", type=int, default=int(os.environ.get("PORT", "7860")))
|
| 221 |
+
args = parser.parse_args()
|
| 222 |
+
main(args.host, args.port)
|
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>PLL Cyber-Guard</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root {
|
| 9 |
+
--bg: #f7f8fa; --panel: #ffffff; --text: #1b1f24; --muted: #5b6573; --border: #dde1e6;
|
| 10 |
+
--accent: #2f6fde; --local: #2f6fde; --pmu: #d9822b; --attack: #c93c3c; --ok: #2e8b57; --code: #eef1f5;
|
| 11 |
+
}
|
| 12 |
+
@media (prefers-color-scheme: dark) {
|
| 13 |
+
:root { --bg: #0f1216; --panel: #171b21; --text: #e6e9ee; --muted: #9aa4b2; --border: #2a313a;
|
| 14 |
+
--accent: #6ea0ff; --local: #6ea0ff; --pmu: #f0a35a; --attack: #ff6b6b; --ok: #4fc58a; --code: #1f252d; }
|
| 15 |
+
}
|
| 16 |
+
* { box-sizing: border-box; }
|
| 17 |
+
body { margin: 0; background: var(--bg); color: var(--text);
|
| 18 |
+
font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
| 19 |
+
main { max-width: 980px; margin: 0 auto; padding: 32px 16px 56px; }
|
| 20 |
+
h1 { font-size: 28px; margin: 0 0 4px; letter-spacing: -0.01em; }
|
| 21 |
+
h2 { font-size: 18px; margin: 32px 0 10px; }
|
| 22 |
+
p { margin: 8px 0; }
|
| 23 |
+
.sub { color: var(--muted); margin-bottom: 20px; }
|
| 24 |
+
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 18px; }
|
| 25 |
+
.grid3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
|
| 26 |
+
@media (max-width: 720px) { .grid3 { grid-template-columns: 1fr; } }
|
| 27 |
+
.tag { display: inline-block; font-size: 12px; padding: 1px 8px; border-radius: 99px; border: 1px solid var(--border); color: var(--muted); }
|
| 28 |
+
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; }
|
| 29 |
+
pre { background: var(--code); padding: 12px; border-radius: 8px; overflow-x: auto; margin: 8px 0; }
|
| 30 |
+
code { background: var(--code); padding: 1px 5px; border-radius: 4px; }
|
| 31 |
+
.controls { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 12px; }
|
| 32 |
+
select, input, button { font: inherit; padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border);
|
| 33 |
+
background: var(--panel); color: var(--text); }
|
| 34 |
+
button { background: var(--accent); border-color: var(--accent); color: #fff; cursor: pointer; }
|
| 35 |
+
button:disabled { opacity: .6; cursor: wait; }
|
| 36 |
+
svg { width: 100%; height: auto; display: block; }
|
| 37 |
+
.legend { display: flex; flex-wrap: wrap; gap: 16px; font-size: 13px; color: var(--muted); margin-top: 6px; }
|
| 38 |
+
.sw { display: inline-block; width: 14px; height: 3px; vertical-align: middle; margin-right: 6px; }
|
| 39 |
+
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 2px 14px; font-size: 14px; margin-top: 10px; }
|
| 40 |
+
.kv div:nth-child(odd) { color: var(--muted); }
|
| 41 |
+
a { color: var(--accent); }
|
| 42 |
+
</style>
|
| 43 |
+
</head>
|
| 44 |
+
<body>
|
| 45 |
+
<main>
|
| 46 |
+
<h1>PLL Cyber-Guard</h1>
|
| 47 |
+
<p class="sub">An OpenEnv environment for cyber-attack detection on grid-connected inverters · v{{VERSION}}</p>
|
| 48 |
+
|
| 49 |
+
<div class="panel">
|
| 50 |
+
<p>Grid-following inverters synchronise to the grid with a <b>phase-locked loop</b>. An attacker who can
|
| 51 |
+
rewrite its voltage measurements (false-data injection) can desynchronise the inverter from the real grid.
|
| 52 |
+
The agent watches PLL signals simulated at 5 kHz plus a trusted but delayed PMU stream. Every 50 ms it must decide
|
| 53 |
+
whether an attack is under way, classify it, and engage a protective action in time. It must also avoid
|
| 54 |
+
alarming on real grid events: phase jumps, sags, single-phase faults, harmonic steps and frequency events.</p>
|
| 55 |
+
</div>
|
| 56 |
+
|
| 57 |
+
<h2>Tasks</h2>
|
| 58 |
+
<div class="grid3">
|
| 59 |
+
<div class="panel"><span class="tag">easy · task 0</span><p><b>Sinusoidal FDI detection.</b> Spot an interharmonic injection on one phase quickly. Some episodes have no attack.</p></div>
|
| 60 |
+
<div class="panel"><span class="tag">medium · task 1</span><p><b>Classify under disturbances.</b> Tell sinusoidal, ramp and pulse injections apart while natural grid events happen.</p></div>
|
| 61 |
+
<div class="panel"><span class="tag">hard · task 2</span><p><b>Stealthy phase spoofing.</b> A balanced angle drift the PLL tracks perfectly. Cross-check against the PMU and hold over before 10° of desync.</p></div>
|
| 62 |
+
</div>
|
| 63 |
+
|
| 64 |
+
<h2>Live demo · physics-heuristic baseline</h2>
|
| 65 |
+
<div class="panel">
|
| 66 |
+
<div class="controls">
|
| 67 |
+
<label>Task <select id="task"><option value="0">0 · easy</option><option value="1">1 · medium</option><option value="2" selected>2 · hard</option></select></label>
|
| 68 |
+
<label>Seed <input id="seed" type="number" min="0" value="1" style="width:90px"></label>
|
| 69 |
+
<button id="run">Run episode</button>
|
| 70 |
+
<span id="status" class="tag">idle</span>
|
| 71 |
+
</div>
|
| 72 |
+
<svg id="chart" viewBox="0 0 900 300" role="img" aria-label="Local PLL frequency versus PMU frequency"></svg>
|
| 73 |
+
<div class="legend">
|
| 74 |
+
<span><span class="sw" style="background:var(--local)"></span>local PLL frequency</span>
|
| 75 |
+
<span><span class="sw" style="background:var(--pmu)"></span>PMU frequency (delayed, trusted)</span>
|
| 76 |
+
<span><span class="sw" style="background:var(--attack)"></span>attack onset</span>
|
| 77 |
+
<span><span class="sw" style="background:var(--ok);height:8px;opacity:.35"></span>agent alarm raised</span>
|
| 78 |
+
</div>
|
| 79 |
+
<div class="kv" id="summary"></div>
|
| 80 |
+
</div>
|
| 81 |
+
|
| 82 |
+
<h2>Quick start</h2>
|
| 83 |
+
<pre>curl -X POST $URL/reset -H 'content-type: application/json' -d '{"task_id": 2, "seed": 7, "session_id": "me"}'
|
| 84 |
+
curl -X POST $URL/step -H 'content-type: application/json' \
|
| 85 |
+
-d '{"session_id": "me", "action": {"attack_detected": false, "attack_type": 0, "confidence": 0.9, "protective_action": 0}}'</pre>
|
| 86 |
+
<p>Endpoints: <code>POST /reset</code> <code>POST /step</code> <code>GET /state</code> <code>GET /tasks</code>
|
| 87 |
+
<code>GET /schema</code> <code>GET /metadata</code> <code>GET /health</code> <code>WS /ws</code> ·
|
| 88 |
+
<a href="/docs">OpenAPI docs</a> ·
|
| 89 |
+
<a href="https://github.com/KrisKeshav/PLL-Cyberattack-Detection-OpenEnv">source</a></p>
|
| 90 |
+
</main>
|
| 91 |
+
<script>
|
| 92 |
+
const $ = (id) => document.getElementById(id);
|
| 93 |
+
const css = (v) => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
|
| 94 |
+
|
| 95 |
+
function draw(data) {
|
| 96 |
+
const svg = $("chart"), W = 900, H = 300, L = 60, R = 16, T = 14, B = 34;
|
| 97 |
+
const tr = data.trace, n = tr.length;
|
| 98 |
+
const ys = tr.flatMap(p => [p.f_local_hz, p.pmu_f_hz]);
|
| 99 |
+
let lo = Math.min(...ys), hi = Math.max(...ys);
|
| 100 |
+
const pad = Math.max(0.02, (hi - lo) * 0.12); lo -= pad; hi += pad;
|
| 101 |
+
const x = (i) => L + (W - L - R) * i / Math.max(1, n - 1);
|
| 102 |
+
const y = (v) => T + (H - T - B) * (hi - v) / (hi - lo);
|
| 103 |
+
const path = (k) => tr.map((p, i) => `${i ? "L" : "M"}${x(i).toFixed(1)},${y(p[k]).toFixed(1)}`).join("");
|
| 104 |
+
let s = "";
|
| 105 |
+
for (let k = 0; k <= 4; k++) {
|
| 106 |
+
const v = lo + (hi - lo) * k / 4, yy = y(v);
|
| 107 |
+
s += `<line x1="${L}" x2="${W - R}" y1="${yy}" y2="${yy}" stroke="${css('--border')}"/>`;
|
| 108 |
+
s += `<text x="${L - 8}" y="${yy + 4}" text-anchor="end" font-size="11" fill="${css('--muted')}">${v.toFixed(2)}</text>`;
|
| 109 |
+
}
|
| 110 |
+
tr.forEach((p, i) => { if (p.action.attack_detected) s += `<rect x="${x(i) - 5}" y="${T}" width="10" height="${H - T - B}" fill="${css('--ok')}" opacity=".16"/>`; });
|
| 111 |
+
const onset = data.scenario.onset_step;
|
| 112 |
+
if (onset !== null && onset !== undefined) {
|
| 113 |
+
s += `<line x1="${x(onset)}" x2="${x(onset)}" y1="${T}" y2="${H - B}" stroke="${css('--attack')}" stroke-width="2" stroke-dasharray="5 4"/>`;
|
| 114 |
+
}
|
| 115 |
+
s += `<path d="${path('pmu_f_hz')}" fill="none" stroke="${css('--pmu')}" stroke-width="2"/>`;
|
| 116 |
+
s += `<path d="${path('f_local_hz')}" fill="none" stroke="${css('--local')}" stroke-width="2"/>`;
|
| 117 |
+
s += `<text x="${(L + W - R) / 2}" y="${H - 6}" text-anchor="middle" font-size="12" fill="${css('--muted')}">decision step (50 ms each)</text>`;
|
| 118 |
+
s += `<text x="14" y="${(T + H - B) / 2}" font-size="12" fill="${css('--muted')}" transform="rotate(-90 14 ${(T + H - B) / 2})" text-anchor="middle">Hz</text>`;
|
| 119 |
+
svg.innerHTML = s;
|
| 120 |
+
const sc = data.scenario, b = data.breakdown;
|
| 121 |
+
const rows = [
|
| 122 |
+
["grader score", data.score.toFixed(3)],
|
| 123 |
+
["attack", sc.attack_type_name + (onset != null ? ` (onset step ${onset})` : "")],
|
| 124 |
+
["grid events", sc.grid.events.map(e => e.kind).join(", ") || "none"],
|
| 125 |
+
["detection delay", b.detection_delay_ms == null ? "n/a" : b.detection_delay_ms + " ms"],
|
| 126 |
+
["false-alarm rate", (100 * b.false_alarm_rate).toFixed(1) + " %"],
|
| 127 |
+
["unprotected desync at", sc.unmitigated_desync_step == null ? "never" : "step " + sc.unmitigated_desync_step],
|
| 128 |
+
["actual max sync error", sc.max_sync_error_deg.toFixed(1) + "°"],
|
| 129 |
+
];
|
| 130 |
+
$("summary").innerHTML = rows.map(([k, v]) => `<div>${k}</div><div>${v}</div>`).join("");
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
async function run() {
|
| 134 |
+
$("run").disabled = true; $("status").textContent = "running…";
|
| 135 |
+
try {
|
| 136 |
+
const r = await fetch(`/demo?task_id=${$("task").value}&seed=${Math.max(0, +$("seed").value || 0)}`);
|
| 137 |
+
if (!r.ok) throw new Error(r.status);
|
| 138 |
+
draw(await r.json()); $("status").textContent = "done";
|
| 139 |
+
} catch (e) { $("status").textContent = "error " + e.message; }
|
| 140 |
+
$("run").disabled = false;
|
| 141 |
+
}
|
| 142 |
+
$("run").addEventListener("click", run);
|
| 143 |
+
run();
|
| 144 |
+
</script>
|
| 145 |
+
</body>
|
| 146 |
+
</html>
|
|
@@ -1 +0,0 @@
|
|
| 1 |
-
# PLL Cyberattack Detection OpenEnv
|
|
|
|
|
|
|
@@ -1,111 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
FastAPI application for the PLL Cyberattack Detection OpenEnv.
|
| 3 |
-
|
| 4 |
-
Exposes HTTP endpoints for environment interaction:
|
| 5 |
-
POST /reset — Reset environment with task_id
|
| 6 |
-
POST /step — Submit an action and advance one step
|
| 7 |
-
GET /state — Get current internal state
|
| 8 |
-
GET /health — Health check (returns 200)
|
| 9 |
-
GET /tasks — List available tasks
|
| 10 |
-
"""
|
| 11 |
-
|
| 12 |
-
import asyncio
|
| 13 |
-
from typing import Any, Dict, Optional
|
| 14 |
-
|
| 15 |
-
from fastapi import FastAPI, HTTPException, Request
|
| 16 |
-
from pydantic import BaseModel
|
| 17 |
-
|
| 18 |
-
from src.models import Observation, Action, Reward, State
|
| 19 |
-
from src.env import PLLAttackEnv
|
| 20 |
-
|
| 21 |
-
app = FastAPI(
|
| 22 |
-
title="PLL Cyberattack Detection OpenEnv",
|
| 23 |
-
description="OpenEnv for AI-driven cyberattack detection on SRF-PLLs",
|
| 24 |
-
version="1.0.0",
|
| 25 |
-
)
|
| 26 |
-
|
| 27 |
-
env = PLLAttackEnv()
|
| 28 |
-
env_lock = asyncio.Lock()
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
class ResetRequest(BaseModel):
|
| 32 |
-
task_id: int = 0
|
| 33 |
-
seed: Optional[int] = None
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
class StepResponse(BaseModel):
|
| 37 |
-
observation: Observation
|
| 38 |
-
reward: Reward
|
| 39 |
-
done: bool
|
| 40 |
-
info: Dict[str, Any]
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
@app.post("/reset", response_model=Observation)
|
| 44 |
-
async def reset(req: Request):
|
| 45 |
-
"""Reset the environment and return initial observation."""
|
| 46 |
-
async with env_lock:
|
| 47 |
-
try:
|
| 48 |
-
body = await req.body()
|
| 49 |
-
if body:
|
| 50 |
-
data = await req.json()
|
| 51 |
-
request = ResetRequest(**data)
|
| 52 |
-
else:
|
| 53 |
-
request = ResetRequest()
|
| 54 |
-
except Exception:
|
| 55 |
-
request = ResetRequest()
|
| 56 |
-
return env.reset(task_id=request.task_id, seed=request.seed)
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
@app.post("/step", response_model=StepResponse)
|
| 60 |
-
async def step(action: Action):
|
| 61 |
-
async with env_lock:
|
| 62 |
-
if env.attack_generator is None:
|
| 63 |
-
raise HTTPException(status_code=400, detail="Call /reset before /step")
|
| 64 |
-
obs, reward, done, info = env.step(action)
|
| 65 |
-
return StepResponse(observation=obs, reward=reward, done=done, info=info)
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
@app.get("/state", response_model=State)
|
| 69 |
-
async def get_state():
|
| 70 |
-
async with env_lock:
|
| 71 |
-
return env.get_state()
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
@app.get("/health")
|
| 75 |
-
async def health():
|
| 76 |
-
"""Health check endpoint."""
|
| 77 |
-
return {
|
| 78 |
-
"status": "ok",
|
| 79 |
-
"version": "1.0.0",
|
| 80 |
-
"environment": "pll-cyberattack-detection",
|
| 81 |
-
"tasks_available": 3,
|
| 82 |
-
"episode_active": env.attack_generator is not None,
|
| 83 |
-
"current_step": env.step_count,
|
| 84 |
-
}
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
@app.get("/tasks")
|
| 88 |
-
async def list_tasks():
|
| 89 |
-
"""List all available tasks."""
|
| 90 |
-
return {
|
| 91 |
-
"tasks": [
|
| 92 |
-
{
|
| 93 |
-
"id": 0,
|
| 94 |
-
"name": "sinusoidal_fdi",
|
| 95 |
-
"difficulty": "easy",
|
| 96 |
-
"description": "Detect sinusoidal FDI attack"
|
| 97 |
-
},
|
| 98 |
-
{
|
| 99 |
-
"id": 1,
|
| 100 |
-
"name": "multi_attack_classification",
|
| 101 |
-
"difficulty": "medium",
|
| 102 |
-
"description": "Classify attack type from observations"
|
| 103 |
-
},
|
| 104 |
-
{
|
| 105 |
-
"id": 2,
|
| 106 |
-
"name": "stealthy_attack_detection",
|
| 107 |
-
"difficulty": "hard",
|
| 108 |
-
"description": "Detect stealthy attack before PLL lock loss"
|
| 109 |
-
},
|
| 110 |
-
]
|
| 111 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,134 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Attack injection logic for the PLL Cyberattack Detection OpenEnv.
|
| 3 |
-
|
| 4 |
-
Implements four attack types:
|
| 5 |
-
1. Sinusoidal FDI (Easy)
|
| 6 |
-
2. Ramp injection (Medium)
|
| 7 |
-
3. Pulse/step bias (Medium)
|
| 8 |
-
4. Stealthy low-and-slow phase drift (Hard)
|
| 9 |
-
"""
|
| 10 |
-
|
| 11 |
-
import math
|
| 12 |
-
import numpy as np
|
| 13 |
-
from typing import Dict, Any
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
def sample_sinusoidal_params(rng: np.random.Generator) -> Dict[str, Any]:
|
| 17 |
-
"""Sample parameters for a sinusoidal FDI attack."""
|
| 18 |
-
return {
|
| 19 |
-
"type": "sinusoidal",
|
| 20 |
-
"amplitude": float(rng.uniform(0.05, 0.20)),
|
| 21 |
-
"freq": float(rng.uniform(5.0, 20.0)),
|
| 22 |
-
"phase": float(rng.uniform(0.0, 2.0 * math.pi)),
|
| 23 |
-
}
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
def sample_ramp_params(rng: np.random.Generator) -> Dict[str, Any]:
|
| 27 |
-
"""Sample parameters for a ramp injection attack."""
|
| 28 |
-
return {
|
| 29 |
-
"type": "ramp",
|
| 30 |
-
"rate": float(rng.uniform(0.0002, 0.001)),
|
| 31 |
-
}
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
def sample_pulse_params(rng: np.random.Generator) -> Dict[str, Any]:
|
| 35 |
-
"""Sample parameters for a pulse/step bias attack."""
|
| 36 |
-
return {
|
| 37 |
-
"type": "pulse",
|
| 38 |
-
"magnitude": float(rng.uniform(0.1, 0.3)),
|
| 39 |
-
"duration": int(rng.integers(20, 81)), # 20 to 80 steps inclusive
|
| 40 |
-
}
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
def sample_stealthy_params(rng: np.random.Generator) -> Dict[str, Any]:
|
| 44 |
-
"""Sample parameters for a stealthy low-and-slow attack."""
|
| 45 |
-
return {
|
| 46 |
-
"type": "stealthy",
|
| 47 |
-
"amplitude": 0.03,
|
| 48 |
-
"drift_rate": float(rng.uniform(0.05, 0.2)),
|
| 49 |
-
}
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
def sample_attack_start(rng: np.random.Generator) -> int:
|
| 53 |
-
"""Sample a random attack start step between 30 and 80 inclusive."""
|
| 54 |
-
return int(rng.integers(30, 81))
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
class AttackGenerator:
|
| 58 |
-
"""Generates attack signals given parameters and current simulation state."""
|
| 59 |
-
|
| 60 |
-
def __init__(self, attack_params: Dict[str, Any], attack_start_step: int):
|
| 61 |
-
self.params = attack_params
|
| 62 |
-
self.attack_start_step = attack_start_step
|
| 63 |
-
self.attack_type_str = attack_params.get("type", "none")
|
| 64 |
-
|
| 65 |
-
# For stealthy attack: track cumulative phase drift
|
| 66 |
-
self.delta = 0.0
|
| 67 |
-
|
| 68 |
-
def get_signal(self, current_step: int, sim_time: float) -> float:
|
| 69 |
-
"""
|
| 70 |
-
Compute the attack signal value at the given step.
|
| 71 |
-
Args:
|
| 72 |
-
current_step: Current environment step (0-indexed).
|
| 73 |
-
sim_time: Current simulation time in seconds.
|
| 74 |
-
Returns:
|
| 75 |
-
Attack signal value (pu). Returns 0.0 if attack not yet started.
|
| 76 |
-
"""
|
| 77 |
-
if current_step < self.attack_start_step:
|
| 78 |
-
return 0.0
|
| 79 |
-
|
| 80 |
-
steps_since_start = current_step - self.attack_start_step
|
| 81 |
-
dt = 1e-3 # time step
|
| 82 |
-
|
| 83 |
-
if self.attack_type_str == "sinusoidal":
|
| 84 |
-
A = self.params["amplitude"]
|
| 85 |
-
fa = self.params["freq"]
|
| 86 |
-
phi = self.params["phase"]
|
| 87 |
-
return A * math.sin(2.0 * math.pi * fa * sim_time + phi)
|
| 88 |
-
|
| 89 |
-
elif self.attack_type_str == "ramp":
|
| 90 |
-
rate = self.params["rate"]
|
| 91 |
-
return rate * steps_since_start
|
| 92 |
-
|
| 93 |
-
elif self.attack_type_str == "pulse":
|
| 94 |
-
mag = self.params["magnitude"]
|
| 95 |
-
dur = self.params["duration"]
|
| 96 |
-
if steps_since_start < dur:
|
| 97 |
-
return mag
|
| 98 |
-
else:
|
| 99 |
-
return 0.0
|
| 100 |
-
|
| 101 |
-
elif self.attack_type_str == "stealthy":
|
| 102 |
-
A_s = self.params["amplitude"]
|
| 103 |
-
drift_rate = self.params["drift_rate"]
|
| 104 |
-
# δ(t) = δ(t-1) + drift_rate * Δt — accumulated each call
|
| 105 |
-
self.delta += drift_rate * dt
|
| 106 |
-
f0 = 50.0
|
| 107 |
-
return A_s * math.sin(2.0 * math.pi * f0 * sim_time + self.delta)
|
| 108 |
-
|
| 109 |
-
return 0.0
|
| 110 |
-
|
| 111 |
-
def is_active(self, current_step: int) -> bool:
|
| 112 |
-
"""Check whether the attack is currently active at this specific step."""
|
| 113 |
-
if current_step < self.attack_start_step:
|
| 114 |
-
return False
|
| 115 |
-
|
| 116 |
-
# Pulse attacks end after duration
|
| 117 |
-
if self.attack_type_str == "pulse":
|
| 118 |
-
steps_since_start = current_step - self.attack_start_step
|
| 119 |
-
dur = self.params["duration"]
|
| 120 |
-
return steps_since_start < dur
|
| 121 |
-
|
| 122 |
-
return True
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
def get_attack_type_id(attack_type_str: str) -> int:
|
| 126 |
-
"""Map an attack type string to its corresponding integer ID."""
|
| 127 |
-
mapping = {
|
| 128 |
-
"none": 0,
|
| 129 |
-
"sinusoidal": 1,
|
| 130 |
-
"ramp": 2,
|
| 131 |
-
"pulse": 3,
|
| 132 |
-
"stealthy": 4,
|
| 133 |
-
}
|
| 134 |
-
return mapping.get(attack_type_str, 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,139 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Adaptive Physics-informed cyberattack detector for the PLL OpenEnv.
|
| 3 |
-
|
| 4 |
-
Uses residual-based and pattern-based features derived from the
|
| 5 |
-
observation windows to detect, classify, and recommend protective
|
| 6 |
-
actions. The detector builds a baseline from the first 20 observations
|
| 7 |
-
(warmup window) of the episode to normalize the features individually.
|
| 8 |
-
"""
|
| 9 |
-
|
| 10 |
-
import numpy as np
|
| 11 |
-
from typing import Dict, Any
|
| 12 |
-
|
| 13 |
-
from src.models import Observation
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
class AdaptiveDetector:
|
| 17 |
-
def __init__(self):
|
| 18 |
-
# Baseline collections
|
| 19 |
-
self.r1_history = []
|
| 20 |
-
self.r3_history = []
|
| 21 |
-
self.r4_history = []
|
| 22 |
-
self.r5_history = []
|
| 23 |
-
|
| 24 |
-
# Calibrated statistics
|
| 25 |
-
self.mean_R1 = 0.0
|
| 26 |
-
self.std_R1 = 1e-6
|
| 27 |
-
self.mean_R3 = 0.0
|
| 28 |
-
self.std_R3 = 1e-6
|
| 29 |
-
self.mean_R4 = 0.0
|
| 30 |
-
self.std_R4 = 1e-6
|
| 31 |
-
self.mean_R5 = 0.0
|
| 32 |
-
self.std_R5 = 1e-6
|
| 33 |
-
|
| 34 |
-
self.is_calibrated = False
|
| 35 |
-
|
| 36 |
-
def detect(self, observation) -> Dict[str, Any]:
|
| 37 |
-
"""
|
| 38 |
-
Run physics-informed anomaly detection on the current observation.
|
| 39 |
-
"""
|
| 40 |
-
vq = np.array(observation.vq_window, dtype=np.float64)
|
| 41 |
-
vd = np.array(observation.vd_window, dtype=np.float64)
|
| 42 |
-
omega = np.array(observation.omega_window, dtype=np.float64)
|
| 43 |
-
omega_dev = np.array(observation.omega_deviation_window, dtype=np.float64)
|
| 44 |
-
va, vb, vc = observation.raw_voltages
|
| 45 |
-
|
| 46 |
-
# ---- Step 1: Feature Extraction --------------------------------
|
| 47 |
-
vq_mean = float(np.mean(np.abs(vq)))
|
| 48 |
-
vd_mean = float(np.mean(np.abs(vd)))
|
| 49 |
-
vq_ratio = vq_mean / (vd_mean + 1e-6)
|
| 50 |
-
|
| 51 |
-
omega_var = float(np.var(omega))
|
| 52 |
-
omega_dev_var = float(np.var(omega_dev))
|
| 53 |
-
vd_var = float(np.var(vd))
|
| 54 |
-
|
| 55 |
-
abs_v_sum = abs(va) + abs(vb) + abs(vc) + 1e-6
|
| 56 |
-
symmetry_ratio = float(abs(va + vb + vc) / abs_v_sum)
|
| 57 |
-
|
| 58 |
-
vq_diff = np.diff(vq) if len(vq) > 1 else np.array([0.0])
|
| 59 |
-
vq_trend = float(np.mean(vq_diff))
|
| 60 |
-
vq_spike = float(np.max(np.abs(vq)))
|
| 61 |
-
vq_drift = float(np.sum(vq))
|
| 62 |
-
|
| 63 |
-
step = observation.step
|
| 64 |
-
|
| 65 |
-
# ---- Step 2: Baseline Calibration ------------------------------
|
| 66 |
-
if step < 20:
|
| 67 |
-
self.r1_history.append(vq_ratio)
|
| 68 |
-
self.r3_history.append(omega_var)
|
| 69 |
-
self.r4_history.append(vd_var)
|
| 70 |
-
self.r5_history.append(symmetry_ratio)
|
| 71 |
-
|
| 72 |
-
return {
|
| 73 |
-
"attack_detected": False,
|
| 74 |
-
"attack_type": 0,
|
| 75 |
-
"confidence": 0.0,
|
| 76 |
-
"protective_action": 0,
|
| 77 |
-
"score": 0.0,
|
| 78 |
-
"baseline_score": 0.0
|
| 79 |
-
}
|
| 80 |
-
|
| 81 |
-
if not self.is_calibrated:
|
| 82 |
-
self.mean_R1 = float(np.mean(self.r1_history))
|
| 83 |
-
self.std_R1 = max(float(np.std(self.r1_history)), 1e-6)
|
| 84 |
-
|
| 85 |
-
self.mean_R3 = float(np.mean(self.r3_history))
|
| 86 |
-
self.std_R3 = max(float(np.std(self.r3_history)), 1e-6)
|
| 87 |
-
|
| 88 |
-
self.mean_R4 = float(np.mean(self.r4_history))
|
| 89 |
-
self.std_R4 = max(float(np.std(self.r4_history)), 1e-6)
|
| 90 |
-
|
| 91 |
-
self.mean_R5 = float(np.mean(self.r5_history))
|
| 92 |
-
self.std_R5 = max(float(np.std(self.r5_history)), 1e-6)
|
| 93 |
-
|
| 94 |
-
self.is_calibrated = True
|
| 95 |
-
|
| 96 |
-
# ---- Step 3: Normalized Features ------------------------------
|
| 97 |
-
R1 = (vq_ratio - self.mean_R1) / self.std_R1
|
| 98 |
-
R3 = (omega_var - self.mean_R3) / self.std_R3
|
| 99 |
-
R4 = (vd_var - self.mean_R4) / self.std_R4
|
| 100 |
-
R5 = (symmetry_ratio - self.mean_R5) / self.std_R5
|
| 101 |
-
|
| 102 |
-
# ---- Step 4: Score --------------------------------------------
|
| 103 |
-
score = 0.4 * R1 + 0.2 * R3 + 0.2 * R5 + 0.2 * R4
|
| 104 |
-
|
| 105 |
-
# ---- Step 5: Detection ----------------------------------------
|
| 106 |
-
attack_detected = score > 5.0
|
| 107 |
-
confidence = min(1.0, score / 5.0) if attack_detected else 0.0
|
| 108 |
-
|
| 109 |
-
# ---- Step 6: Classification -----------------------------------
|
| 110 |
-
if not attack_detected:
|
| 111 |
-
attack_type = 0
|
| 112 |
-
else:
|
| 113 |
-
if R3 > 2:
|
| 114 |
-
attack_type = 1 # sinusoidal
|
| 115 |
-
elif abs(vq_trend) > 0.01:
|
| 116 |
-
attack_type = 2 # ramp
|
| 117 |
-
elif vq_spike > 0.1:
|
| 118 |
-
attack_type = 3 # pulse
|
| 119 |
-
else:
|
| 120 |
-
attack_type = 4 # stealthy
|
| 121 |
-
|
| 122 |
-
# ---- Step 7: Protective Action --------------------------------
|
| 123 |
-
if score > 6:
|
| 124 |
-
protective_action = 3
|
| 125 |
-
elif score > 3:
|
| 126 |
-
protective_action = 2
|
| 127 |
-
else:
|
| 128 |
-
protective_action = 1
|
| 129 |
-
if not attack_detected:
|
| 130 |
-
protective_action = 0
|
| 131 |
-
|
| 132 |
-
return {
|
| 133 |
-
"attack_detected": bool(attack_detected),
|
| 134 |
-
"attack_type": int(attack_type),
|
| 135 |
-
"confidence": float(confidence),
|
| 136 |
-
"protective_action": int(protective_action),
|
| 137 |
-
"score": float(score),
|
| 138 |
-
"baseline_score": 0.0
|
| 139 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,370 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Main environment class for the PLL Cyberattack Detection OpenEnv.
|
| 3 |
-
|
| 4 |
-
Implements step(), reset(), get_state(), and compute_reward().
|
| 5 |
-
Manages the PLL simulation, attack injection, observation windowing,
|
| 6 |
-
episode history, and grading.
|
| 7 |
-
"""
|
| 8 |
-
|
| 9 |
-
import uuid
|
| 10 |
-
import numpy as np
|
| 11 |
-
from typing import Tuple, Dict, Any, List, Optional
|
| 12 |
-
from collections import deque
|
| 13 |
-
|
| 14 |
-
from src.models import Observation, Action, Reward, State
|
| 15 |
-
from src.pll_sim import SRFPLLSimulator, OMEGA0
|
| 16 |
-
from src.attacks import (
|
| 17 |
-
AttackGenerator,
|
| 18 |
-
sample_sinusoidal_params,
|
| 19 |
-
sample_ramp_params,
|
| 20 |
-
sample_pulse_params,
|
| 21 |
-
sample_stealthy_params,
|
| 22 |
-
sample_attack_start,
|
| 23 |
-
get_attack_type_id,
|
| 24 |
-
)
|
| 25 |
-
from src.graders import grade_task_easy, grade_task_medium, grade_task_hard
|
| 26 |
-
from src.detector import AdaptiveDetector
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
WINDOW_SIZE = 20
|
| 30 |
-
MAX_STEPS = 500
|
| 31 |
-
LOCK_LOSS_THRESHOLD = 0.0873 # 5 degrees in radians
|
| 32 |
-
|
| 33 |
-
DETECTION_THRESHOLD = 2.0
|
| 34 |
-
EARLY_DETECTION_WINDOW = 100
|
| 35 |
-
FALSE_ALARM_PENALTY = -0.2
|
| 36 |
-
TRUE_POSITIVE_REWARD = 0.1
|
| 37 |
-
TRUE_NEGATIVE_REWARD = 0.05
|
| 38 |
-
MISSED_DETECTION_PENALTY = -0.05
|
| 39 |
-
CLASSIFICATION_BONUS = 0.05
|
| 40 |
-
LOCK_LOSS_PENALTY = -2.0
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
class PLLAttackEnv:
|
| 44 |
-
"""OpenEnv-compliant PLL cyberattack detection environment."""
|
| 45 |
-
|
| 46 |
-
def __init__(self):
|
| 47 |
-
self.pll = SRFPLLSimulator()
|
| 48 |
-
self.rng: Optional[np.random.Generator] = None
|
| 49 |
-
self.task_id = 0
|
| 50 |
-
self.step_count = 0
|
| 51 |
-
self.episode_id = ""
|
| 52 |
-
self.done = False
|
| 53 |
-
|
| 54 |
-
# Attack state
|
| 55 |
-
self.attack_generator: Optional[AttackGenerator] = None
|
| 56 |
-
self.attack_active = False
|
| 57 |
-
self.attack_type = 0
|
| 58 |
-
self.attack_params: Dict[str, Any] = {}
|
| 59 |
-
self.attack_start_step = 0
|
| 60 |
-
self.true_attack_type = 0
|
| 61 |
-
|
| 62 |
-
# Detection tracking
|
| 63 |
-
self.first_detection_recorded = False
|
| 64 |
-
self.first_detection_step = 0
|
| 65 |
-
|
| 66 |
-
# Lock loss tracking (Task 2 / hard)
|
| 67 |
-
self.lock_lost = False
|
| 68 |
-
self.lock_loss_step: Optional[int] = None
|
| 69 |
-
self.lock_loss_penalized = False
|
| 70 |
-
|
| 71 |
-
# Observation windows
|
| 72 |
-
self.vq_window: deque = deque(maxlen=WINDOW_SIZE)
|
| 73 |
-
self.vd_window: deque = deque(maxlen=WINDOW_SIZE)
|
| 74 |
-
self.omega_window: deque = deque(maxlen=WINDOW_SIZE)
|
| 75 |
-
self.omega_deviation_window: deque = deque(maxlen=WINDOW_SIZE)
|
| 76 |
-
|
| 77 |
-
# Detector
|
| 78 |
-
self.detector = AdaptiveDetector()
|
| 79 |
-
|
| 80 |
-
# Episode history for grading
|
| 81 |
-
self.history: List[Dict[str, Any]] = []
|
| 82 |
-
|
| 83 |
-
# ------------------------------------------------------------------
|
| 84 |
-
# Public API
|
| 85 |
-
# ------------------------------------------------------------------
|
| 86 |
-
|
| 87 |
-
def reset(self, task_id: int = 0, seed: Optional[int] = None) -> Observation:
|
| 88 |
-
"""
|
| 89 |
-
Reset the environment for a new episode.
|
| 90 |
-
Args:
|
| 91 |
-
task_id: 0=easy (sinusoidal), 1=medium (multi-type),
|
| 92 |
-
2=hard (stealthy).
|
| 93 |
-
seed: Optional RNG seed for reproducibility.
|
| 94 |
-
Returns:
|
| 95 |
-
Initial Observation with non-zero raw_voltages.
|
| 96 |
-
"""
|
| 97 |
-
self.rng = np.random.default_rng(seed) # seed=None → random
|
| 98 |
-
|
| 99 |
-
self.task_id = task_id
|
| 100 |
-
self.step_count = 0
|
| 101 |
-
self.episode_id = str(uuid.uuid4())
|
| 102 |
-
self.done = False
|
| 103 |
-
|
| 104 |
-
# Reset PLL simulator
|
| 105 |
-
self.pll.reset()
|
| 106 |
-
|
| 107 |
-
# Reset detection tracking
|
| 108 |
-
self.first_detection_recorded = False
|
| 109 |
-
self.first_detection_step = 0
|
| 110 |
-
|
| 111 |
-
# Reset lock-loss tracking
|
| 112 |
-
self.lock_lost = False
|
| 113 |
-
self.lock_loss_step = None
|
| 114 |
-
self.lock_loss_penalized = False
|
| 115 |
-
|
| 116 |
-
# Reset history
|
| 117 |
-
self.history = []
|
| 118 |
-
|
| 119 |
-
# Reset observation windows
|
| 120 |
-
self.vq_window = deque(maxlen=WINDOW_SIZE)
|
| 121 |
-
self.vd_window = deque(maxlen=WINDOW_SIZE)
|
| 122 |
-
self.omega_window = deque(maxlen=WINDOW_SIZE)
|
| 123 |
-
self.omega_deviation_window = deque(maxlen=WINDOW_SIZE)
|
| 124 |
-
|
| 125 |
-
# Reset detector
|
| 126 |
-
self.detector = AdaptiveDetector()
|
| 127 |
-
|
| 128 |
-
# Sample attack for this episode
|
| 129 |
-
self._setup_attack()
|
| 130 |
-
|
| 131 |
-
for _ in range(WINDOW_SIZE):
|
| 132 |
-
pll_out = self.pll.step(0.0) # no attack during warm-up
|
| 133 |
-
omega_norm = (pll_out["omega_hat"] - OMEGA0) / OMEGA0
|
| 134 |
-
omega_dev = pll_out["omega_hat"] - OMEGA0
|
| 135 |
-
self.vq_window.append(pll_out["vq"])
|
| 136 |
-
self.vd_window.append(pll_out["vd"])
|
| 137 |
-
self.omega_window.append(omega_norm)
|
| 138 |
-
self.omega_deviation_window.append(omega_dev)
|
| 139 |
-
# step_count stays at 0 — warm-up steps are invisible to the agent
|
| 140 |
-
|
| 141 |
-
return self._get_observation()
|
| 142 |
-
|
| 143 |
-
def step(self, action: Action) -> Tuple[Observation, Reward, bool, Dict[str, Any]]:
|
| 144 |
-
"""
|
| 145 |
-
Advance the environment by one step.
|
| 146 |
-
Args:
|
| 147 |
-
action: Agent's Action for this step.
|
| 148 |
-
Returns:
|
| 149 |
-
(observation, reward, done, info)
|
| 150 |
-
"""
|
| 151 |
-
if self.done:
|
| 152 |
-
return (
|
| 153 |
-
self._get_observation(),
|
| 154 |
-
Reward(
|
| 155 |
-
total=0.0, detection_reward=0.0, classification_bonus=0.0,
|
| 156 |
-
early_detection_bonus=0.0, false_alarm_penalty=0.0,
|
| 157 |
-
lock_loss_penalty=0.0,
|
| 158 |
-
),
|
| 159 |
-
True,
|
| 160 |
-
{"message": "Episode already done. Call /reset to start a new episode."},
|
| 161 |
-
)
|
| 162 |
-
|
| 163 |
-
# --- Attack signal ------------------------------------------------
|
| 164 |
-
# attack_active uses is_active() (step-based). It does NOT depend on the instantaneous
|
| 165 |
-
# signal value, because the attack signal can cross zero even while the attack is active.
|
| 166 |
-
attack_signal = self.attack_generator.get_signal(self.step_count, self.pll.t)
|
| 167 |
-
self.attack_active = self.attack_generator.is_active(self.step_count)
|
| 168 |
-
|
| 169 |
-
# --- Advance PLL --------------------------------------------------
|
| 170 |
-
pll_out = self.pll.step(attack_signal)
|
| 171 |
-
|
| 172 |
-
# --- Updating observation windows -----------------------------------
|
| 173 |
-
omega_norm = (pll_out["omega_hat"] - OMEGA0) / OMEGA0
|
| 174 |
-
omega_dev = pll_out["omega_hat"] - OMEGA0 # raw deviation (rad/s)
|
| 175 |
-
self.vq_window.append(pll_out["vq"])
|
| 176 |
-
self.vd_window.append(pll_out["vd"])
|
| 177 |
-
self.omega_window.append(omega_norm)
|
| 178 |
-
self.omega_deviation_window.append(omega_dev)
|
| 179 |
-
|
| 180 |
-
# --- Lock-loss check (Task 2) -------------------------
|
| 181 |
-
PLL_CONVERGENCE_STEPS = 60 # PLL transient settles by ~step 50, using 60 for margin
|
| 182 |
-
if (
|
| 183 |
-
self.task_id == 2
|
| 184 |
-
and not self.lock_lost
|
| 185 |
-
and self.step_count > self.attack_start_step
|
| 186 |
-
and self.step_count > PLL_CONVERGENCE_STEPS # guard against startup transient
|
| 187 |
-
):
|
| 188 |
-
if abs(pll_out["theta_err"]) > LOCK_LOSS_THRESHOLD:
|
| 189 |
-
self.lock_lost = True
|
| 190 |
-
self.lock_loss_step = self.step_count
|
| 191 |
-
|
| 192 |
-
# --- Reward -------------------------------------------------------
|
| 193 |
-
reward = self.compute_reward(action)
|
| 194 |
-
|
| 195 |
-
# --- Record history entry for graders ----------------------------
|
| 196 |
-
self.history.append({
|
| 197 |
-
"step": self.step_count,
|
| 198 |
-
"attack_active": self.attack_active,
|
| 199 |
-
"attack_detected": action.attack_detected,
|
| 200 |
-
"true_attack_type": self.true_attack_type,
|
| 201 |
-
"agent_attack_type": action.attack_type,
|
| 202 |
-
"theta_err": pll_out["theta_err"],
|
| 203 |
-
})
|
| 204 |
-
|
| 205 |
-
# --- Advance step counter ----------------------------------------
|
| 206 |
-
self.step_count += 1
|
| 207 |
-
|
| 208 |
-
# Terminate Task 2 early upon losing lock to save computational steps
|
| 209 |
-
if self.step_count >= MAX_STEPS:
|
| 210 |
-
self.done = True
|
| 211 |
-
elif self.task_id == 2 and self.lock_lost:
|
| 212 |
-
self.done = True
|
| 213 |
-
|
| 214 |
-
# --- Physics-informed detector (evaluation/debug only) ------------
|
| 215 |
-
detector_output = self.detector.detect(self._get_observation())
|
| 216 |
-
|
| 217 |
-
# --- Build info --------------------------------------------------
|
| 218 |
-
info: Dict[str, Any] = {
|
| 219 |
-
"detector": detector_output,
|
| 220 |
-
"detector_features": {"step": self.step_count, "raw_score": detector_output.get("score")}
|
| 221 |
-
}
|
| 222 |
-
if self.done:
|
| 223 |
-
info["grader_score"] = self._compute_grader_score()
|
| 224 |
-
info["episode_id"] = self.episode_id
|
| 225 |
-
info["total_steps"] = self.step_count
|
| 226 |
-
info["lock_lost"] = self.lock_lost
|
| 227 |
-
|
| 228 |
-
return self._get_observation(), reward, self.done, info
|
| 229 |
-
|
| 230 |
-
def compute_reward(self, action: Action) -> Reward:
|
| 231 |
-
"""
|
| 232 |
-
Computes the dense reward signal for the current step.
|
| 233 |
-
Reward components:
|
| 234 |
-
detection_reward: +0.10 true positive (per step)
|
| 235 |
-
+0.05 true negative (per step)
|
| 236 |
-
-0.05 missed detection (per step)
|
| 237 |
-
false_alarm_penalty: -0.20 per false-positive step
|
| 238 |
-
classification_bonus: +0.05 per step correct type (task 1 only)
|
| 239 |
-
early_detection_bonus: one-time sparse, scaled by detection speed
|
| 240 |
-
lock_loss_penalty: -2.00 one-time on lock loss (task 2 only)
|
| 241 |
-
"""
|
| 242 |
-
detection_reward = 0.0
|
| 243 |
-
false_alarm_penalty = 0.0
|
| 244 |
-
classification_bonus = 0.0
|
| 245 |
-
early_detection_bonus = 0.0
|
| 246 |
-
lock_loss_penalty = 0.0
|
| 247 |
-
|
| 248 |
-
if self.attack_active:
|
| 249 |
-
if action.attack_detected:
|
| 250 |
-
detection_reward = TRUE_POSITIVE_REWARD
|
| 251 |
-
# One-time early detection bonus on first correct detection
|
| 252 |
-
if not self.first_detection_recorded:
|
| 253 |
-
self.first_detection_step = self.step_count
|
| 254 |
-
self.first_detection_recorded = True
|
| 255 |
-
# Relative steps since attack started
|
| 256 |
-
t = self.first_detection_step - self.attack_start_step
|
| 257 |
-
early_detection_bonus = max(0.0, 1.0 - t / EARLY_DETECTION_WINDOW)
|
| 258 |
-
else:
|
| 259 |
-
detection_reward = MISSED_DETECTION_PENALTY
|
| 260 |
-
else:
|
| 261 |
-
if action.attack_detected:
|
| 262 |
-
false_alarm_penalty = FALSE_ALARM_PENALTY
|
| 263 |
-
else:
|
| 264 |
-
detection_reward = TRUE_NEGATIVE_REWARD
|
| 265 |
-
|
| 266 |
-
# Task 1 (medium): per-step classification bonus
|
| 267 |
-
if self.task_id == 1 and self.attack_active:
|
| 268 |
-
if action.attack_type == self.true_attack_type:
|
| 269 |
-
classification_bonus = CLASSIFICATION_BONUS
|
| 270 |
-
|
| 271 |
-
# Task 2 (hard): one-time lock-loss penalty
|
| 272 |
-
if self.task_id == 2 and self.lock_lost and not self.lock_loss_penalized:
|
| 273 |
-
lock_loss_penalty = LOCK_LOSS_PENALTY
|
| 274 |
-
self.lock_loss_penalized = True
|
| 275 |
-
|
| 276 |
-
total = (
|
| 277 |
-
detection_reward
|
| 278 |
-
+ false_alarm_penalty
|
| 279 |
-
+ classification_bonus
|
| 280 |
-
+ early_detection_bonus
|
| 281 |
-
+ lock_loss_penalty
|
| 282 |
-
)
|
| 283 |
-
|
| 284 |
-
return Reward(
|
| 285 |
-
total=total,
|
| 286 |
-
detection_reward=detection_reward,
|
| 287 |
-
classification_bonus=classification_bonus,
|
| 288 |
-
early_detection_bonus=early_detection_bonus,
|
| 289 |
-
false_alarm_penalty=false_alarm_penalty,
|
| 290 |
-
lock_loss_penalty=lock_loss_penalty,
|
| 291 |
-
)
|
| 292 |
-
|
| 293 |
-
def get_state(self) -> State:
|
| 294 |
-
"""Returning full internal state for debugging / GET /state endpoint."""
|
| 295 |
-
return State(
|
| 296 |
-
theta_true=self.pll.theta_true,
|
| 297 |
-
theta_hat=self.pll.theta_hat,
|
| 298 |
-
omega_hat=self.pll.omega_hat,
|
| 299 |
-
vq_integral=self.pll.vq_integral,
|
| 300 |
-
attack_active=self.attack_active,
|
| 301 |
-
attack_type=self.attack_type,
|
| 302 |
-
attack_params=self.attack_params,
|
| 303 |
-
attack_start_step=self.attack_start_step,
|
| 304 |
-
lock_lost=self.lock_lost,
|
| 305 |
-
step=self.step_count,
|
| 306 |
-
episode_id=self.episode_id,
|
| 307 |
-
task_id=self.task_id,
|
| 308 |
-
)
|
| 309 |
-
|
| 310 |
-
# ------------------------------------------------------------------
|
| 311 |
-
# Private helpers
|
| 312 |
-
# ------------------------------------------------------------------
|
| 313 |
-
|
| 314 |
-
def _setup_attack(self) -> None:
|
| 315 |
-
"""Sample attack type and parameters based on current task_id."""
|
| 316 |
-
self.attack_start_step = sample_attack_start(self.rng)
|
| 317 |
-
|
| 318 |
-
if self.task_id == 0:
|
| 319 |
-
# Easy: sinusoidal FDI only
|
| 320 |
-
self.attack_params = sample_sinusoidal_params(self.rng)
|
| 321 |
-
self.true_attack_type = 1
|
| 322 |
-
|
| 323 |
-
elif self.task_id == 1:
|
| 324 |
-
# Medium: random choice of sinusoidal / ramp / pulse
|
| 325 |
-
choice = int(self.rng.integers(0, 3))
|
| 326 |
-
if choice == 0:
|
| 327 |
-
self.attack_params = sample_sinusoidal_params(self.rng)
|
| 328 |
-
self.true_attack_type = 1
|
| 329 |
-
elif choice == 1:
|
| 330 |
-
self.attack_params = sample_ramp_params(self.rng)
|
| 331 |
-
self.true_attack_type = 2
|
| 332 |
-
else:
|
| 333 |
-
self.attack_params = sample_pulse_params(self.rng)
|
| 334 |
-
self.true_attack_type = 3
|
| 335 |
-
|
| 336 |
-
elif self.task_id == 2:
|
| 337 |
-
# Hard: stealthy low-and-slow
|
| 338 |
-
self.attack_params = sample_stealthy_params(self.rng)
|
| 339 |
-
self.true_attack_type = 4
|
| 340 |
-
|
| 341 |
-
self.attack_type = get_attack_type_id(self.attack_params.get("type", "none"))
|
| 342 |
-
self.attack_generator = AttackGenerator(self.attack_params, self.attack_start_step)
|
| 343 |
-
|
| 344 |
-
def _get_observation(self) -> Observation:
|
| 345 |
-
"""
|
| 346 |
-
Building the current Observation from internal windows.
|
| 347 |
-
"""
|
| 348 |
-
return Observation(
|
| 349 |
-
vq_window=list(self.vq_window),
|
| 350 |
-
vd_window=list(self.vd_window),
|
| 351 |
-
omega_window=list(self.omega_window),
|
| 352 |
-
omega_deviation_window=list(self.omega_deviation_window),
|
| 353 |
-
raw_voltages=[self.pll.va_m, self.pll.vb_m, self.pll.vc_m],
|
| 354 |
-
task_id=self.task_id,
|
| 355 |
-
step=self.step_count,
|
| 356 |
-
)
|
| 357 |
-
|
| 358 |
-
def _compute_grader_score(self) -> float:
|
| 359 |
-
"""Running the appropriate grader at episode end."""
|
| 360 |
-
if self.task_id == 0:
|
| 361 |
-
return grade_task_easy(self.history, self.attack_start_step)
|
| 362 |
-
elif self.task_id == 1:
|
| 363 |
-
return grade_task_medium(self.history, self.attack_start_step)
|
| 364 |
-
elif self.task_id == 2:
|
| 365 |
-
return grade_task_hard(
|
| 366 |
-
self.history,
|
| 367 |
-
self.lock_loss_step,
|
| 368 |
-
self.attack_start_step,
|
| 369 |
-
)
|
| 370 |
-
return 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,137 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Per-task deterministic graders for the PLL Cyberattack Detection OpenEnv.
|
| 3 |
-
|
| 4 |
-
Each grader takes an episode history and returns a score in [0.0, 1.0].
|
| 5 |
-
Graders are deterministic given the same episode data.
|
| 6 |
-
"""
|
| 7 |
-
|
| 8 |
-
from typing import List, Dict, Any, Optional
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
def grade_task_easy(history: List[Dict[str, Any]], attack_start_step: int) -> float:
|
| 12 |
-
"""
|
| 13 |
-
Task 1 — Sinusoidal FDI Detection (Easy).
|
| 14 |
-
Grader logic (relative to attack onset):
|
| 15 |
-
delay = first_correct_detection_step - attack_start_step
|
| 16 |
-
if delay <= 20: score = 1.0
|
| 17 |
-
elif delay <= 100: score = linear decay from 1.0 to 0.5
|
| 18 |
-
elif delay <= 420: score = 0.2
|
| 19 |
-
else (never detected): score = 0.0
|
| 20 |
-
"""
|
| 21 |
-
first_correct_detection_step = None
|
| 22 |
-
|
| 23 |
-
for entry in history:
|
| 24 |
-
step = entry["step"]
|
| 25 |
-
attack_active = entry["attack_active"]
|
| 26 |
-
attack_detected = entry["attack_detected"]
|
| 27 |
-
|
| 28 |
-
if attack_active and attack_detected:
|
| 29 |
-
first_correct_detection_step = step
|
| 30 |
-
break
|
| 31 |
-
|
| 32 |
-
if first_correct_detection_step is None:
|
| 33 |
-
return 0.01
|
| 34 |
-
|
| 35 |
-
delay = first_correct_detection_step - attack_start_step
|
| 36 |
-
|
| 37 |
-
if delay <= 20:
|
| 38 |
-
score = 0.99
|
| 39 |
-
elif delay <= 100:
|
| 40 |
-
# Linear decay from 0.99 at delay=20 to 0.5 at delay=100
|
| 41 |
-
score = 0.99 - 0.49 * (delay - 20) / 80.0
|
| 42 |
-
elif delay <= 420:
|
| 43 |
-
score = 0.2
|
| 44 |
-
else:
|
| 45 |
-
score = 0.01
|
| 46 |
-
|
| 47 |
-
return max(0.01, min(0.99, score))
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
def grade_task_medium(history: List[Dict[str, Any]], attack_start_step: int) -> float:
|
| 51 |
-
"""
|
| 52 |
-
Task 2 — Multi-Attack Classification (Medium).
|
| 53 |
-
Grader logic:
|
| 54 |
-
base_score = fraction of steps (after attack_start) where attack_type is correctly classified
|
| 55 |
-
early_bonus = 0.4 * max(0, 1 - first_correct_classification_step / 100)
|
| 56 |
-
score = min(1.0, base_score * 0.6 + early_bonus)
|
| 57 |
-
"""
|
| 58 |
-
steps_after_attack = 0
|
| 59 |
-
correct_classifications = 0
|
| 60 |
-
first_correct_classification_step = None
|
| 61 |
-
|
| 62 |
-
for entry in history:
|
| 63 |
-
step = entry["step"]
|
| 64 |
-
if step < attack_start_step:
|
| 65 |
-
continue
|
| 66 |
-
|
| 67 |
-
steps_after_attack += 1
|
| 68 |
-
true_type = entry["true_attack_type"]
|
| 69 |
-
agent_type = entry["agent_attack_type"]
|
| 70 |
-
|
| 71 |
-
if agent_type == true_type:
|
| 72 |
-
correct_classifications += 1
|
| 73 |
-
if first_correct_classification_step is None:
|
| 74 |
-
first_correct_classification_step = step
|
| 75 |
-
|
| 76 |
-
if steps_after_attack == 0:
|
| 77 |
-
return 0.01
|
| 78 |
-
|
| 79 |
-
base_score = correct_classifications / steps_after_attack
|
| 80 |
-
|
| 81 |
-
if first_correct_classification_step is not None:
|
| 82 |
-
early_bonus = 0.4 * max(0.0, 1.0 - first_correct_classification_step / 100.0)
|
| 83 |
-
else:
|
| 84 |
-
early_bonus = 0.0
|
| 85 |
-
|
| 86 |
-
score = min(0.99, base_score * 0.6 + early_bonus)
|
| 87 |
-
return max(0.01, score)
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
def grade_task_hard(
|
| 91 |
-
history: List[Dict[str, Any]],
|
| 92 |
-
loss_of_lock_step: Optional[int],
|
| 93 |
-
attack_start_step: int,
|
| 94 |
-
) -> float:
|
| 95 |
-
"""
|
| 96 |
-
Task 3 — Stealthy Low-and-Slow Attack (Hard).
|
| 97 |
-
Grader logic:
|
| 98 |
-
if detected before loss_of_lock_step:
|
| 99 |
-
score = 1.0 * (1 - first_detection_step / loss_of_lock_step)
|
| 100 |
-
elif detected after loss_of_lock but before episode end:
|
| 101 |
-
score = 0.3
|
| 102 |
-
else (never detected):
|
| 103 |
-
score = 0.0
|
| 104 |
-
false_alarm_penalty = 0.2 per false alarm before attack starts
|
| 105 |
-
(capped at reducing score to 0.0 minimum)
|
| 106 |
-
"""
|
| 107 |
-
first_detection_step = None
|
| 108 |
-
false_alarm_count = 0
|
| 109 |
-
|
| 110 |
-
for entry in history:
|
| 111 |
-
step = entry["step"]
|
| 112 |
-
attack_active = entry["attack_active"]
|
| 113 |
-
attack_detected = entry["attack_detected"]
|
| 114 |
-
|
| 115 |
-
# Only count false alarms before the attack starts
|
| 116 |
-
if attack_detected and not attack_active and step < attack_start_step:
|
| 117 |
-
false_alarm_count += 1
|
| 118 |
-
|
| 119 |
-
if attack_detected and attack_active and first_detection_step is None:
|
| 120 |
-
first_detection_step = step
|
| 121 |
-
|
| 122 |
-
# Compute base score
|
| 123 |
-
if first_detection_step is None:
|
| 124 |
-
score = 0.0
|
| 125 |
-
elif loss_of_lock_step is not None and first_detection_step < loss_of_lock_step:
|
| 126 |
-
score = 1.0 * (1.0 - first_detection_step / loss_of_lock_step)
|
| 127 |
-
elif loss_of_lock_step is not None and first_detection_step >= loss_of_lock_step:
|
| 128 |
-
score = 0.3
|
| 129 |
-
else:
|
| 130 |
-
# No loss of lock occurred but attack was detected
|
| 131 |
-
score = 0.3
|
| 132 |
-
|
| 133 |
-
# Apply false alarm penalty
|
| 134 |
-
penalty = 0.2 * false_alarm_count
|
| 135 |
-
score = max(0.01, score - penalty)
|
| 136 |
-
|
| 137 |
-
return max(0.01, min(0.99, score))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,72 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Pydantic models for the PLL Cyberattack Detection OpenEnv.
|
| 3 |
-
Defines Observation, Action, Reward, and State schemas.
|
| 4 |
-
"""
|
| 5 |
-
import numpy as np
|
| 6 |
-
from typing import Annotated, Any, Dict, List, Optional
|
| 7 |
-
from pydantic import BaseModel, Field, model_validator
|
| 8 |
-
|
| 9 |
-
# Exactly 20 floats — enforced at validation time, not just documented.
|
| 10 |
-
WindowList = Annotated[List[float], Field(min_length=20, max_length=20)]
|
| 11 |
-
|
| 12 |
-
# Exactly 3 floats for [va, vb, vc].
|
| 13 |
-
VoltageList = Annotated[List[float], Field(min_length=3, max_length=3)]
|
| 14 |
-
|
| 15 |
-
class Observation(BaseModel):
|
| 16 |
-
vq_window: WindowList
|
| 17 |
-
vd_window: WindowList
|
| 18 |
-
omega_window: WindowList
|
| 19 |
-
omega_deviation_window: WindowList
|
| 20 |
-
raw_voltages: VoltageList
|
| 21 |
-
task_id: int = Field(ge=0, le=2)
|
| 22 |
-
step: int = Field(ge=0)
|
| 23 |
-
|
| 24 |
-
class Action(BaseModel):
|
| 25 |
-
attack_detected: bool
|
| 26 |
-
attack_type: int = Field(ge=0, le=4)
|
| 27 |
-
confidence: float = Field(ge=0.0, le=1.0)
|
| 28 |
-
protective_action: int = Field(ge=0, le=3)
|
| 29 |
-
|
| 30 |
-
class Reward(BaseModel):
|
| 31 |
-
total: float
|
| 32 |
-
detection_reward: float
|
| 33 |
-
classification_bonus: float
|
| 34 |
-
early_detection_bonus: float
|
| 35 |
-
false_alarm_penalty: float
|
| 36 |
-
lock_loss_penalty: float
|
| 37 |
-
|
| 38 |
-
class State(BaseModel):
|
| 39 |
-
theta_true: float
|
| 40 |
-
theta_hat: float
|
| 41 |
-
omega_hat: float
|
| 42 |
-
vq_integral: float
|
| 43 |
-
attack_active: bool
|
| 44 |
-
attack_type: int # Integer ID of the current attack: 0=none, 1=sinusoidal, 2=ramp, 3=pulse, 4=stealthy.
|
| 45 |
-
attack_params: Dict[str, Any]
|
| 46 |
-
attack_start_step: int
|
| 47 |
-
lock_lost: bool # Whether the PLL has lost lock (|theta_err| > 5°). Task 2 only.
|
| 48 |
-
step: int = Field(ge=0)
|
| 49 |
-
episode_id: str
|
| 50 |
-
task_id: int = Field(ge=0, le=2)
|
| 51 |
-
|
| 52 |
-
@model_validator(mode="before")
|
| 53 |
-
@classmethod
|
| 54 |
-
def coerce_attack_params(cls, values: Dict[str, Any]) -> Dict[str, Any]:
|
| 55 |
-
"""
|
| 56 |
-
This validator ensures JSON serialization never fails due
|
| 57 |
-
to np.float32 / np.int64 / np.bool_ leaking into the params dict.
|
| 58 |
-
"""
|
| 59 |
-
params = values.get("attack_params", {})
|
| 60 |
-
if isinstance(params, dict):
|
| 61 |
-
coerced = {}
|
| 62 |
-
for k, v in params.items():
|
| 63 |
-
if isinstance(v, np.floating):
|
| 64 |
-
coerced[k] = float(v)
|
| 65 |
-
elif isinstance(v, np.integer):
|
| 66 |
-
coerced[k] = int(v)
|
| 67 |
-
elif isinstance(v, np.bool_):
|
| 68 |
-
coerced[k] = bool(v)
|
| 69 |
-
else:
|
| 70 |
-
coerced[k] = v
|
| 71 |
-
values["attack_params"] = coerced
|
| 72 |
-
return values
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,127 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
SRF-PLL Discrete-Time Simulation.
|
| 3 |
-
|
| 4 |
-
Implements the Synchronous Reference Frame Phase-Locked Loop used in
|
| 5 |
-
grid-connected inverters. Discrete time step Δt = 1 ms.
|
| 6 |
-
|
| 7 |
-
Steps:
|
| 8 |
-
1. Generate true 3-phase grid voltages (50 Hz, 1.0 pu)
|
| 9 |
-
2. Apply attack injection on va
|
| 10 |
-
3. Clarke transform (αβ)
|
| 11 |
-
4. Park transform (dq) using estimated angle θ̂
|
| 12 |
-
5. PI controller to update ω̂ and θ̂
|
| 13 |
-
6. Compute phase error
|
| 14 |
-
"""
|
| 15 |
-
|
| 16 |
-
import numpy as np
|
| 17 |
-
import math
|
| 18 |
-
|
| 19 |
-
# Constants
|
| 20 |
-
V_NOM = 1.0 # Nominal voltage (pu)
|
| 21 |
-
F0 = 50.0 # Grid frequency (Hz)
|
| 22 |
-
OMEGA0 = 2.0 * math.pi * F0 # Nominal angular freq (rad/s)
|
| 23 |
-
DT = 1e-3 # Time step (1 ms)
|
| 24 |
-
KP = 50.0 # PI proportional gain
|
| 25 |
-
KI = 1500.0 # PI integral gain
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
def wrap_angle(angle: float) -> float:
|
| 29 |
-
"""Wrap angle to [-π, π]."""
|
| 30 |
-
return (angle + math.pi) % (2.0 * math.pi) - math.pi
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
class SRFPLLSimulator:
|
| 34 |
-
"""Discrete-time SRF-PLL simulator."""
|
| 35 |
-
|
| 36 |
-
def __init__(self, rng: np.random.Generator = None,
|
| 37 |
-
kp: float = None, ki: float = None):
|
| 38 |
-
self.rng = rng or np.random.default_rng()
|
| 39 |
-
self.kp = kp if kp is not None else KP
|
| 40 |
-
self.ki = ki if ki is not None else KI
|
| 41 |
-
self.reset()
|
| 42 |
-
|
| 43 |
-
def reset(self):
|
| 44 |
-
"""Reset PLL state to initial conditions."""
|
| 45 |
-
self.t = 0.0 # Simulation time (s)
|
| 46 |
-
self.theta_true = 0.0 # True grid angle (rad)
|
| 47 |
-
self.theta_hat = 0.0 # Estimated angle (rad)
|
| 48 |
-
self.omega_hat = OMEGA0 # Estimated angular freq (rad/s)
|
| 49 |
-
self.vq_integral = 0.0 # Integral of vq for PI controller
|
| 50 |
-
|
| 51 |
-
# Current signal values
|
| 52 |
-
self.vd = 0.0
|
| 53 |
-
self.vq = 0.0
|
| 54 |
-
self.va_m = 0.0
|
| 55 |
-
self.vb_m = 0.0
|
| 56 |
-
self.vc_m = 0.0
|
| 57 |
-
self.theta_err = 0.0
|
| 58 |
-
|
| 59 |
-
def step(self, attack_signal: float = 0.0):
|
| 60 |
-
"""
|
| 61 |
-
Advance the PLL by one time step.
|
| 62 |
-
Args:
|
| 63 |
-
attack_signal: Attack injection added to va (pu).
|
| 64 |
-
|
| 65 |
-
Returns:
|
| 66 |
-
dict with vd, vq, omega_hat, theta_err, va_m, vb_m, vc_m, theta_true, theta_hat
|
| 67 |
-
"""
|
| 68 |
-
# Step 1 — True three-phase grid voltages
|
| 69 |
-
va = V_NOM * math.sin(self.theta_true)
|
| 70 |
-
vb = V_NOM * math.sin(self.theta_true - 2.0 * math.pi / 3.0)
|
| 71 |
-
vc = V_NOM * math.sin(self.theta_true + 2.0 * math.pi / 3.0)
|
| 72 |
-
|
| 73 |
-
# Step 2 — Apply attack injection on va
|
| 74 |
-
va_m = va + attack_signal
|
| 75 |
-
vb_m = vb
|
| 76 |
-
vc_m = vc
|
| 77 |
-
|
| 78 |
-
# Measurement noise (σ = 0.01 pu, realistic for grid sensors)
|
| 79 |
-
NOISE_STD = 0.01
|
| 80 |
-
va_m += self.rng.normal(0, NOISE_STD)
|
| 81 |
-
vb_m += self.rng.normal(0, NOISE_STD)
|
| 82 |
-
vc_m += self.rng.normal(0, NOISE_STD)
|
| 83 |
-
|
| 84 |
-
# Step 3 — Clarke Transform (αβ)
|
| 85 |
-
v_alpha = va_m
|
| 86 |
-
v_beta = -(va_m + 2.0 * vb_m) / math.sqrt(3.0)
|
| 87 |
-
|
| 88 |
-
# Step 4 — Park Transform (dq) using estimated angle θ̂
|
| 89 |
-
cos_th = math.cos(self.theta_hat)
|
| 90 |
-
sin_th = math.sin(self.theta_hat)
|
| 91 |
-
vd = v_alpha * cos_th + v_beta * sin_th
|
| 92 |
-
vq = -v_alpha * sin_th + v_beta * cos_th
|
| 93 |
-
|
| 94 |
-
# Step 5 — PI Controller
|
| 95 |
-
self.vq_integral = np.clip(self.vq_integral + vq * DT, -0.3, 0.3)
|
| 96 |
-
omega_hat = OMEGA0 + self.kp * vq + self.ki * self.vq_integral
|
| 97 |
-
self.theta_hat = wrap_angle(self.theta_hat + omega_hat * DT)
|
| 98 |
-
|
| 99 |
-
# Advance true angle
|
| 100 |
-
self.theta_true = wrap_angle(self.theta_true + OMEGA0 * DT)
|
| 101 |
-
|
| 102 |
-
# Step 6 — Phase error wrapped to [-π, π]
|
| 103 |
-
theta_err = wrap_angle(self.theta_hat - self.theta_true)
|
| 104 |
-
|
| 105 |
-
# Update time
|
| 106 |
-
self.t += DT
|
| 107 |
-
|
| 108 |
-
# Storing current values
|
| 109 |
-
self.vd = vd
|
| 110 |
-
self.vq = vq
|
| 111 |
-
self.omega_hat = omega_hat
|
| 112 |
-
self.va_m = va_m
|
| 113 |
-
self.vb_m = vb_m
|
| 114 |
-
self.vc_m = vc_m
|
| 115 |
-
self.theta_err = theta_err
|
| 116 |
-
|
| 117 |
-
return {
|
| 118 |
-
"vd": vd,
|
| 119 |
-
"vq": vq,
|
| 120 |
-
"omega_hat": omega_hat,
|
| 121 |
-
"theta_err": theta_err,
|
| 122 |
-
"va_m": va_m,
|
| 123 |
-
"vb_m": vb_m,
|
| 124 |
-
"vc_m": vc_m,
|
| 125 |
-
"theta_true": self.theta_true,
|
| 126 |
-
"theta_hat": self.theta_hat,
|
| 127 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,69 +0,0 @@
|
|
| 1 |
-
# %% [Cell 1] Install dependencies
|
| 2 |
-
import subprocess
|
| 3 |
-
import sys
|
| 4 |
-
|
| 5 |
-
subprocess.check_call([
|
| 6 |
-
sys.executable, "-m", "pip", "install", "-q",
|
| 7 |
-
"fastapi", "uvicorn", "pydantic", "numpy", "openai", "requests", "pyngrok", "nest_asyncio"
|
| 8 |
-
])
|
| 9 |
-
print("All dependencies installed successfully.")
|
| 10 |
-
|
| 11 |
-
# %% [Cell 2] Write all source files to disk
|
| 12 |
-
import os
|
| 13 |
-
|
| 14 |
-
os.makedirs("src", exist_ok=True)
|
| 15 |
-
|
| 16 |
-
# --- src/__init__.py ---
|
| 17 |
-
with open('src/__init__.py', 'w', encoding='utf-8') as f:
|
| 18 |
-
f.write('# PLL Cyberattack Detection OpenEnv\n')
|
| 19 |
-
|
| 20 |
-
# --- src/models.py ---
|
| 21 |
-
with open('src/models.py', 'w', encoding='utf-8') as f:
|
| 22 |
-
f.write('"""\nPydantic models for the PLL Cyberattack Detection OpenEnv.\nDefines Observation, Action, Reward, and State schemas.\n"""\nimport numpy as np\nfrom typing import Annotated, Any, Dict, List, Optional\nfrom pydantic import BaseModel, Field, model_validator\n \n# Exactly 20 floats — enforced at validation time, not just documented.\nWindowList = Annotated[List[float], Field(min_length=20, max_length=20)]\n \n# Exactly 3 floats for [va, vb, vc].\nVoltageList = Annotated[List[float], Field(min_length=3, max_length=3)]\n \nclass Observation(BaseModel):\n vq_window: WindowList\n vd_window: WindowList\n omega_window: WindowList\n omega_deviation_window: WindowList\n raw_voltages: VoltageList\n task_id: int = Field(ge=0, le=2)\n step: int = Field(ge=0)\n \nclass Action(BaseModel):\n attack_detected: bool\n attack_type: int = Field(ge=0, le=4)\n confidence: float = Field(ge=0.0, le=1.0)\n protective_action: int = Field(ge=0, le=3)\n \nclass Reward(BaseModel):\n total: float\n detection_reward: float\n classification_bonus: float\n early_detection_bonus: float\n false_alarm_penalty: float\n lock_loss_penalty: float\n \nclass State(BaseModel):\n theta_true: float\n theta_hat: float\n omega_hat: float\n vq_integral: float\n attack_active: bool\n attack_type: int # Integer ID of the current attack: 0=none, 1=sinusoidal, 2=ramp, 3=pulse, 4=stealthy.\n attack_params: Dict[str, Any]\n attack_start_step: int\n lock_lost: bool # Whether the PLL has lost lock (|theta_err| > 5°). Task 2 only.\n step: int = Field(ge=0)\n episode_id: str\n task_id: int = Field(ge=0, le=2)\n \n @model_validator(mode="before")\n @classmethod\n def coerce_attack_params(cls, values: Dict[str, Any]) -> Dict[str, Any]:\n """\n Coerce numpy scalar types inside attack_params to native Python types.\n sample_*_params() casts with float()/int() but a future contributor\n may forget. This validator ensures JSON serialization never fails due\n to np.float32 / np.int64 / np.bool_ leaking into the params dict.\n """\n params = values.get("attack_params", {})\n if isinstance(params, dict):\n coerced = {}\n for k, v in params.items():\n if isinstance(v, np.floating):\n coerced[k] = float(v)\n elif isinstance(v, np.integer):\n coerced[k] = int(v)\n elif isinstance(v, np.bool_):\n coerced[k] = bool(v)\n else:\n coerced[k] = v\n values["attack_params"] = coerced\n return values\n')
|
| 23 |
-
|
| 24 |
-
# --- src/pll_sim.py ---
|
| 25 |
-
with open('src/pll_sim.py', 'w', encoding='utf-8') as f:
|
| 26 |
-
f.write('"""\nSRF-PLL Discrete-Time Simulation.\n\nImplements the Synchronous Reference Frame Phase-Locked Loop used in\ngrid-connected inverters. Discrete time step Δt = 1 ms.\n\nSteps:\n 1. Generate true 3-phase grid voltages (50 Hz, 1.0 pu)\n 2. Apply attack injection on va\n 3. Clarke transform (αβ)\n 4. Park transform (dq) using estimated angle θ̂\n 5. PI controller to update ω̂ and θ̂\n 6. Compute phase error\n"""\n\nimport numpy as np\nimport math\n\n\n# Constants\nV_NOM = 1.0 # Nominal voltage (pu)\nF0 = 50.0 # Grid frequency (Hz)\nOMEGA0 = 2.0 * math.pi * F0 # Nominal angular freq (rad/s)\nDT = 1e-3 # Time step (1 ms)\nKP = 50.0 # PI proportional gain\nKI = 1500.0 # PI integral gain\n\n\ndef wrap_angle(angle: float) -> float:\n """Wrap angle to [-π, π]."""\n return (angle + math.pi) % (2.0 * math.pi) - math.pi\n\n\nclass SRFPLLSimulator:\n """Discrete-time SRF-PLL simulator."""\n\n def __init__(self, rng: np.random.Generator = None,\n kp: float = None, ki: float = None):\n self.rng = rng or np.random.default_rng()\n self.kp = kp if kp is not None else KP\n self.ki = ki if ki is not None else KI\n self.reset()\n\n def reset(self):\n """Reset PLL state to initial conditions."""\n self.t = 0.0 # Simulation time (s)\n self.theta_true = 0.0 # True grid angle (rad)\n self.theta_hat = 0.0 # Estimated angle (rad)\n self.omega_hat = OMEGA0 # Estimated angular freq (rad/s)\n self.vq_integral = 0.0 # Integral of vq for PI controller\n\n # Current signal values\n self.vd = 0.0\n self.vq = 0.0\n self.va_m = 0.0\n self.vb_m = 0.0\n self.vc_m = 0.0\n self.theta_err = 0.0\n\n def step(self, attack_signal: float = 0.0):\n """\n Advance the PLL by one time step.\n\n Args:\n attack_signal: Attack injection added to va (pu).\n\n Returns:\n dict with vd, vq, omega_hat, theta_err, va_m, vb_m, vc_m, theta_true, theta_hat\n """\n # Step 1 — True three-phase grid voltages\n va = V_NOM * math.sin(self.theta_true)\n vb = V_NOM * math.sin(self.theta_true - 2.0 * math.pi / 3.0)\n vc = V_NOM * math.sin(self.theta_true + 2.0 * math.pi / 3.0)\n\n # Step 2 — Apply attack injection on va\n va_m = va + attack_signal\n vb_m = vb\n vc_m = vc\n\n # Measurement noise (σ = 0.01 pu, realistic for grid sensors)\n NOISE_STD = 0.01\n va_m += self.rng.normal(0, NOISE_STD)\n vb_m += self.rng.normal(0, NOISE_STD)\n vc_m += self.rng.normal(0, NOISE_STD)\n\n # Step 3 — Clarke Transform (αβ)\n v_alpha = va_m\n v_beta = -(va_m + 2.0 * vb_m) / math.sqrt(3.0)\n\n # Step 4 — Park Transform (dq) using estimated angle θ̂\n cos_th = math.cos(self.theta_hat)\n sin_th = math.sin(self.theta_hat)\n vd = v_alpha * cos_th + v_beta * sin_th\n vq = -v_alpha * sin_th + v_beta * cos_th\n\n # Step 5 — PI Controller\n self.vq_integral = np.clip(self.vq_integral + vq * DT, -0.3, 0.3)\n omega_hat = OMEGA0 + self.kp * vq + self.ki * self.vq_integral\n self.theta_hat = wrap_angle(self.theta_hat + omega_hat * DT)\n\n # Advance true angle\n self.theta_true = wrap_angle(self.theta_true + OMEGA0 * DT)\n\n # Step 6 — Phase error wrapped to [-π, π]\n theta_err = wrap_angle(self.theta_hat - self.theta_true)\n\n # Update time\n self.t += DT\n\n # Store current values\n self.vd = vd\n self.vq = vq\n self.omega_hat = omega_hat\n self.va_m = va_m\n self.vb_m = vb_m\n self.vc_m = vc_m\n self.theta_err = theta_err\n\n return {\n "vd": vd,\n "vq": vq,\n "omega_hat": omega_hat,\n "theta_err": theta_err,\n "va_m": va_m,\n "vb_m": vb_m,\n "vc_m": vc_m,\n "theta_true": self.theta_true,\n "theta_hat": self.theta_hat,\n }\n')
|
| 27 |
-
|
| 28 |
-
# --- src/attacks.py ---
|
| 29 |
-
with open('src/attacks.py', 'w', encoding='utf-8') as f:
|
| 30 |
-
f.write('"""\nAttack injection logic for the PLL Cyberattack Detection OpenEnv.\n\nImplements four attack types:\n 1. Sinusoidal FDI (Easy)\n 2. Ramp injection (Medium)\n 3. Pulse/step bias (Medium)\n 4. Stealthy low-and-slow phase drift (Hard)\n"""\n\nimport math\nimport numpy as np\nfrom typing import Dict, Any\n\n\ndef sample_sinusoidal_params(rng: np.random.Generator) -> Dict[str, Any]:\n """Sample parameters for a sinusoidal FDI attack."""\n return {\n "type": "sinusoidal",\n "amplitude": float(rng.uniform(0.05, 0.20)),\n "freq": float(rng.uniform(5.0, 20.0)),\n "phase": float(rng.uniform(0.0, 2.0 * math.pi)),\n }\n\n\ndef sample_ramp_params(rng: np.random.Generator) -> Dict[str, Any]:\n """Sample parameters for a ramp injection attack."""\n return {\n "type": "ramp",\n "rate": float(rng.uniform(0.0002, 0.001)),\n }\n\n\ndef sample_pulse_params(rng: np.random.Generator) -> Dict[str, Any]:\n """Sample parameters for a pulse/step bias attack."""\n return {\n "type": "pulse",\n "magnitude": float(rng.uniform(0.1, 0.3)),\n "duration": int(rng.integers(20, 81)), # 20 to 80 steps inclusive\n }\n\n\ndef sample_stealthy_params(rng: np.random.Generator) -> Dict[str, Any]:\n """Sample parameters for a stealthy low-and-slow attack."""\n return {\n "type": "stealthy",\n "amplitude": 0.03,\n "drift_rate": float(rng.uniform(0.05, 0.2)),\n }\n\n\ndef sample_attack_start(rng: np.random.Generator) -> int:\n """Sample a random attack start step between 30 and 80 inclusive."""\n return int(rng.integers(30, 81))\n\n\nclass AttackGenerator:\n """Generates attack signals given parameters and current simulation state."""\n\n def __init__(self, attack_params: Dict[str, Any], attack_start_step: int):\n self.params = attack_params\n self.attack_start_step = attack_start_step\n self.attack_type_str = attack_params.get("type", "none")\n\n # For stealthy attack: track cumulative phase drift\n self.delta = 0.0\n\n def get_signal(self, current_step: int, sim_time: float) -> float:\n """\n Compute the attack signal value at the given step.\n\n Args:\n current_step: Current environment step (0-indexed).\n sim_time: Current simulation time in seconds.\n\n Returns:\n Attack signal value (pu). Returns 0.0 if attack not yet started.\n """\n if current_step < self.attack_start_step:\n return 0.0\n\n steps_since_start = current_step - self.attack_start_step\n dt = 1e-3 # time step\n\n if self.attack_type_str == "sinusoidal":\n A = self.params["amplitude"]\n fa = self.params["freq"]\n phi = self.params["phase"]\n return A * math.sin(2.0 * math.pi * fa * sim_time + phi)\n\n elif self.attack_type_str == "ramp":\n rate = self.params["rate"]\n return rate * steps_since_start\n\n elif self.attack_type_str == "pulse":\n mag = self.params["magnitude"]\n dur = self.params["duration"]\n if steps_since_start < dur:\n return mag\n else:\n return 0.0\n\n elif self.attack_type_str == "stealthy":\n A_s = self.params["amplitude"]\n drift_rate = self.params["drift_rate"]\n # δ(t) = δ(t-1) + drift_rate * Δt — accumulated each call\n self.delta += drift_rate * dt\n f0 = 50.0\n return A_s * math.sin(2.0 * math.pi * f0 * sim_time + self.delta)\n\n return 0.0\n\n def is_active(self, current_step: int) -> bool:\n """Check if the attack is currently active at this step."""\n if current_step < self.attack_start_step:\n return False\n\n # Pulse attacks end after duration\n if self.attack_type_str == "pulse":\n steps_since_start = current_step - self.attack_start_step\n dur = self.params["duration"]\n return steps_since_start < dur\n\n return True\n\n\ndef get_attack_type_id(attack_type_str: str) -> int:\n """Map attack type string to integer ID."""\n mapping = {\n "none": 0,\n "sinusoidal": 1,\n "ramp": 2,\n "pulse": 3,\n "stealthy": 4,\n }\n return mapping.get(attack_type_str, 0)\n')
|
| 31 |
-
|
| 32 |
-
# --- src/graders.py ---
|
| 33 |
-
with open('src/graders.py', 'w', encoding='utf-8') as f:
|
| 34 |
-
f.write('"""\nPer-task deterministic graders for the PLL Cyberattack Detection OpenEnv.\n\nEach grader takes an episode history and returns a score in [0.0, 1.0].\nGraders are deterministic given the same episode data.\n"""\n\nfrom typing import List, Dict, Any, Optional\n\n\ndef grade_task_easy(history: List[Dict[str, Any]], attack_start_step: int) -> float:\n """\n Task 1 — Sinusoidal FDI Detection (Easy).\n\n Grader logic (relative to attack onset):\n delay = first_correct_detection_step - attack_start_step\n if delay <= 20: score = 1.0\n elif delay <= 100: score = linear decay from 1.0 to 0.5\n elif delay <= 420: score = 0.2\n else (never detected): score = 0.0\n """\n first_correct_detection_step = None\n\n for entry in history:\n step = entry["step"]\n attack_active = entry["attack_active"]\n attack_detected = entry["attack_detected"]\n\n if attack_active and attack_detected:\n first_correct_detection_step = step\n break\n\n if first_correct_detection_step is None:\n return 0.0\n\n delay = first_correct_detection_step - attack_start_step\n\n if delay <= 20:\n return 1.0\n elif delay <= 100:\n # Linear decay from 1.0 at delay=20 to 0.5 at delay=100\n return 1.0 - 0.5 * (delay - 20) / 80.0\n elif delay <= 420:\n return 0.2\n else:\n return 0.0\n\n\ndef grade_task_medium(history: List[Dict[str, Any]], attack_start_step: int) -> float:\n """\n Task 2 — Multi-Attack Classification (Medium).\n\n Grader logic:\n base_score = fraction of steps (after attack_start) where attack_type is correctly classified\n early_bonus = 0.4 * max(0, 1 - first_correct_classification_step / 100)\n score = min(1.0, base_score * 0.6 + early_bonus)\n """\n steps_after_attack = 0\n correct_classifications = 0\n first_correct_classification_step = None\n\n for entry in history:\n step = entry["step"]\n if step < attack_start_step:\n continue\n\n steps_after_attack += 1\n true_type = entry["true_attack_type"]\n agent_type = entry["agent_attack_type"]\n\n if agent_type == true_type:\n correct_classifications += 1\n if first_correct_classification_step is None:\n first_correct_classification_step = step\n\n if steps_after_attack == 0:\n return 0.0\n\n base_score = correct_classifications / steps_after_attack\n\n if first_correct_classification_step is not None:\n early_bonus = 0.4 * max(0.0, 1.0 - first_correct_classification_step / 100.0)\n else:\n early_bonus = 0.0\n\n score = min(1.0, base_score * 0.6 + early_bonus)\n return max(0.0, score)\n\n\ndef grade_task_hard(\n history: List[Dict[str, Any]],\n loss_of_lock_step: Optional[int],\n attack_start_step: int,\n) -> float:\n """\n Task 3 — Stealthy Low-and-Slow Attack (Hard).\n\n Grader logic:\n if detected before loss_of_lock_step:\n score = 1.0 * (1 - first_detection_step / loss_of_lock_step)\n elif detected after loss_of_lock but before episode end:\n score = 0.3\n else (never detected):\n score = 0.0\n false_alarm_penalty = 0.2 per false alarm before attack starts\n (capped at reducing score to 0.0 minimum)\n """\n first_detection_step = None\n false_alarm_count = 0\n\n for entry in history:\n step = entry["step"]\n attack_active = entry["attack_active"]\n attack_detected = entry["attack_detected"]\n\n # Only count false alarms before the attack starts\n if attack_detected and not attack_active and step < attack_start_step:\n false_alarm_count += 1\n\n if attack_detected and attack_active and first_detection_step is None:\n first_detection_step = step\n\n # Compute base score\n if first_detection_step is None:\n score = 0.0\n elif loss_of_lock_step is not None and first_detection_step < loss_of_lock_step:\n score = 1.0 * (1.0 - first_detection_step / loss_of_lock_step)\n elif loss_of_lock_step is not None and first_detection_step >= loss_of_lock_step:\n score = 0.3\n else:\n # No loss of lock occurred but attack was detected\n score = 0.3\n\n # Apply false alarm penalty\n penalty = 0.2 * false_alarm_count\n score = max(0.0, score - penalty)\n\n return min(1.0, score)')
|
| 35 |
-
|
| 36 |
-
# --- src/detector.py ---
|
| 37 |
-
with open('src/detector.py', 'w', encoding='utf-8') as f:
|
| 38 |
-
f.write('"""\nAdaptive Physics-informed cyberattack detector for the PLL OpenEnv.\n\nUses residual-based and pattern-based features derived from the\nobservation windows to detect, classify, and recommend protective\nactions. The detector builds a baseline from the first 20 observations\n(warmup window) of the episode to normalize the features individually.\n"""\n\nimport numpy as np\nfrom typing import Dict, Any\n\nfrom src.models import Observation\n\n\nclass AdaptiveDetector:\n def __init__(self):\n # Baseline collections\n self.r1_history = []\n self.r3_history = []\n self.r4_history = []\n self.r5_history = []\n\n # Calibrated statistics\n self.mean_R1 = 0.0\n self.std_R1 = 1e-6\n self.mean_R3 = 0.0\n self.std_R3 = 1e-6\n self.mean_R4 = 0.0\n self.std_R4 = 1e-6\n self.mean_R5 = 0.0\n self.std_R5 = 1e-6\n\n self.is_calibrated = False\n\n def detect(self, observation) -> Dict[str, Any]:\n """\n Run physics-informed anomaly detection on the current observation.\n """\n vq = np.array(observation.vq_window, dtype=np.float64)\n vd = np.array(observation.vd_window, dtype=np.float64)\n omega = np.array(observation.omega_window, dtype=np.float64)\n omega_dev = np.array(observation.omega_deviation_window, dtype=np.float64)\n va, vb, vc = observation.raw_voltages\n\n # ---- Step 1: Feature Extraction --------------------------------\n vq_mean = float(np.mean(np.abs(vq)))\n vd_mean = float(np.mean(np.abs(vd)))\n vq_ratio = vq_mean / (vd_mean + 1e-6)\n\n omega_var = float(np.var(omega))\n omega_dev_var = float(np.var(omega_dev))\n vd_var = float(np.var(vd))\n \n abs_v_sum = abs(va) + abs(vb) + abs(vc) + 1e-6\n symmetry_ratio = float(abs(va + vb + vc) / abs_v_sum)\n\n vq_diff = np.diff(vq) if len(vq) > 1 else np.array([0.0])\n vq_trend = float(np.mean(vq_diff))\n vq_spike = float(np.max(np.abs(vq)))\n vq_drift = float(np.sum(vq))\n\n step = observation.step\n\n # ---- Step 2: Baseline Calibration ------------------------------\n if step < 20:\n self.r1_history.append(vq_ratio)\n self.r3_history.append(omega_var)\n self.r4_history.append(vd_var)\n self.r5_history.append(symmetry_ratio)\n \n return {\n "attack_detected": False,\n "attack_type": 0,\n "confidence": 0.0,\n "protective_action": 0,\n "score": 0.0,\n "baseline_score": 0.0\n }\n \n if not self.is_calibrated:\n self.mean_R1 = float(np.mean(self.r1_history))\n self.std_R1 = max(float(np.std(self.r1_history)), 1e-6)\n \n self.mean_R3 = float(np.mean(self.r3_history))\n self.std_R3 = max(float(np.std(self.r3_history)), 1e-6)\n \n self.mean_R4 = float(np.mean(self.r4_history))\n self.std_R4 = max(float(np.std(self.r4_history)), 1e-6)\n \n self.mean_R5 = float(np.mean(self.r5_history))\n self.std_R5 = max(float(np.std(self.r5_history)), 1e-6)\n \n self.is_calibrated = True\n\n # ---- Step 3: Normalized Features ------------------------------\n R1 = (vq_ratio - self.mean_R1) / self.std_R1\n R3 = (omega_var - self.mean_R3) / self.std_R3\n R4 = (vd_var - self.mean_R4) / self.std_R4\n R5 = (symmetry_ratio - self.mean_R5) / self.std_R5\n\n # ---- Step 4: Score --------------------------------------------\n score = 0.4 * R1 + 0.2 * R3 + 0.2 * R5 + 0.2 * R4\n\n # ---- Step 5: Detection ----------------------------------------\n attack_detected = score > 5.0\n confidence = min(1.0, score / 5.0) if attack_detected else 0.0\n\n # ---- Step 6: Classification -----------------------------------\n if not attack_detected:\n attack_type = 0\n else:\n if R3 > 2:\n attack_type = 1 # sinusoidal\n elif abs(vq_trend) > 0.01:\n attack_type = 2 # ramp\n elif vq_spike > 0.1:\n attack_type = 3 # pulse\n else:\n attack_type = 4 # stealthy\n\n # ---- Step 7: Protective Action --------------------------------\n if score > 6:\n protective_action = 3\n elif score > 3:\n protective_action = 2\n else:\n protective_action = 1\n if not attack_detected:\n protective_action = 0\n\n return {\n "attack_detected": bool(attack_detected),\n "attack_type": int(attack_type),\n "confidence": float(confidence),\n "protective_action": int(protective_action),\n "score": float(score),\n "baseline_score": 0.0\n }\n')
|
| 39 |
-
|
| 40 |
-
# --- src/env.py ---
|
| 41 |
-
with open('src/env.py', 'w', encoding='utf-8') as f:
|
| 42 |
-
f.write('"""\nMain environment class for the PLL Cyberattack Detection OpenEnv.\n\nImplements step(), reset(), get_state(), and compute_reward().\nManages the PLL simulation, attack injection, observation windowing,\nepisode history, and grading.\n\nFixes applied vs previous version:\n 1. grade_task_easy() now receives attack_start_step (was missing, causing\n TypeError at episode end for task_id=0).\n 2. attack_active is derived from attack_signal != 0.0 instead of\n is_active() — single source of truth prevents signal/label divergence.\n 3. Lock-loss check guarded by step_count > attack_start_step — prevents\n spurious lock-loss from PLL transient on step 0.\n 4. Task 3 early termination added: done=True when lock_lost, not just at\n step 500. Avoids 200+ meaningless steps after failure.\n 5. _get_observation() updated to remove theta_err_window (ground-truth\n leak) and add omega_deviation_window (raw omega deviation in rad/s),\n matching the corrected Observation model.\n 6. theta_err_window deque removed from instance state.\n 7. Initial raw_voltages fixed: pll is warm-started with one silent step so\n va_m/vb_m/vc_m are non-zero at reset() return.\n 8. omega_deviation_window deque added for the new Observation field.\n"""\n\nimport uuid\nimport numpy as np\nfrom typing import Tuple, Dict, Any, List, Optional\nfrom collections import deque\n\nfrom src.models import Observation, Action, Reward, State\nfrom src.pll_sim import SRFPLLSimulator, OMEGA0\nfrom src.attacks import (\n AttackGenerator,\n sample_sinusoidal_params,\n sample_ramp_params,\n sample_pulse_params,\n sample_stealthy_params,\n sample_attack_start,\n get_attack_type_id,\n)\nfrom src.graders import grade_task_easy, grade_task_medium, grade_task_hard\nfrom src.detector import AdaptiveDetector\n\n\nWINDOW_SIZE = 20\nMAX_STEPS = 500\nLOCK_LOSS_THRESHOLD = 0.0873 # 5 degrees in radians\n\nDETECTION_THRESHOLD = 2.0\nEARLY_DETECTION_WINDOW = 100\nFALSE_ALARM_PENALTY = -0.2\nTRUE_POSITIVE_REWARD = 0.1\nTRUE_NEGATIVE_REWARD = 0.05\nMISSED_DETECTION_PENALTY = -0.05\nCLASSIFICATION_BONUS = 0.05\nLOCK_LOSS_PENALTY = -2.0\n\n\nclass PLLAttackEnv:\n """OpenEnv-compliant PLL cyberattack detection environment."""\n\n def __init__(self):\n self.pll = SRFPLLSimulator()\n self.rng: Optional[np.random.Generator] = None\n self.task_id = 0\n self.step_count = 0\n self.episode_id = ""\n self.done = False\n\n # Attack state\n self.attack_generator: Optional[AttackGenerator] = None\n self.attack_active = False\n self.attack_type = 0\n self.attack_params: Dict[str, Any] = {}\n self.attack_start_step = 0\n self.true_attack_type = 0\n\n # Detection tracking\n self.first_detection_recorded = False\n self.first_detection_step = 0\n\n # Lock loss tracking (Task 2 / hard)\n self.lock_lost = False\n self.lock_loss_step: Optional[int] = None\n self.lock_loss_penalized = False\n\n # Observation windows\n self.vq_window: deque = deque(maxlen=WINDOW_SIZE)\n self.vd_window: deque = deque(maxlen=WINDOW_SIZE)\n self.omega_window: deque = deque(maxlen=WINDOW_SIZE)\n self.omega_deviation_window: deque = deque(maxlen=WINDOW_SIZE) # Fix 8\n\n # Detector\n self.detector = AdaptiveDetector()\n\n # Episode history for grading\n self.history: List[Dict[str, Any]] = []\n\n # ------------------------------------------------------------------\n # Public API\n # ------------------------------------------------------------------\n\n def reset(self, task_id: int = 0, seed: Optional[int] = None) -> Observation:\n """\n Reset the environment for a new episode.\n\n Args:\n task_id: 0=easy (sinusoidal), 1=medium (multi-type),\n 2=hard (stealthy).\n seed: Optional RNG seed for reproducibility.\n\n Returns:\n Initial Observation with non-zero raw_voltages.\n """\n self.rng = np.random.default_rng(seed) # seed=None → random\n\n self.task_id = task_id\n self.step_count = 0\n self.episode_id = str(uuid.uuid4())\n self.done = False\n\n # Reset PLL simulator\n self.pll.reset()\n\n # Reset detection tracking\n self.first_detection_recorded = False\n self.first_detection_step = 0\n\n # Reset lock-loss tracking\n self.lock_lost = False\n self.lock_loss_step = None\n self.lock_loss_penalized = False\n\n # Reset history\n self.history = []\n\n # Reset observation windows (Fix 6: no theta_err_window)\n self.vq_window = deque(maxlen=WINDOW_SIZE)\n self.vd_window = deque(maxlen=WINDOW_SIZE)\n self.omega_window = deque(maxlen=WINDOW_SIZE)\n self.omega_deviation_window = deque(maxlen=WINDOW_SIZE)\n\n # Reset detector\n self.detector = AdaptiveDetector()\n\n # Sample attack for this episode\n self._setup_attack()\n\n # Fix 7: warm-start PLL with WINDOW_SIZE silent steps so that\n # windows contain realistic (non-zero) PLL-settled values and\n # raw_voltages are non-zero on the first observation.\n for _ in range(WINDOW_SIZE):\n pll_out = self.pll.step(0.0) # no attack during warm-up\n omega_norm = (pll_out["omega_hat"] - OMEGA0) / OMEGA0\n omega_dev = pll_out["omega_hat"] - OMEGA0\n self.vq_window.append(pll_out["vq"])\n self.vd_window.append(pll_out["vd"])\n self.omega_window.append(omega_norm)\n self.omega_deviation_window.append(omega_dev)\n # step_count stays at 0 — warm-up steps are invisible to the agent\n\n return self._get_observation()\n\n def step(self, action: Action) -> Tuple[Observation, Reward, bool, Dict[str, Any]]:\n """\n Advance the environment by one step.\n\n Args:\n action: Agent\'s Action for this step.\n\n Returns:\n (observation, reward, done, info)\n """\n if self.done:\n return (\n self._get_observation(),\n Reward(\n total=0.0, detection_reward=0.0, classification_bonus=0.0,\n early_detection_bonus=0.0, false_alarm_penalty=0.0,\n lock_loss_penalty=0.0,\n ),\n True,\n {"message": "Episode already done. Call /reset to start a new episode."},\n )\n\n # --- Attack signal ------------------------------------------------\n # attack_active uses is_active() (step-based). It does NOT depend on the instantaneous\n # signal value, because the attack signal can cross zero even while the attack is active.\n attack_signal = self.attack_generator.get_signal(self.step_count, self.pll.t)\n self.attack_active = self.attack_generator.is_active(self.step_count)\n\n # --- Advance PLL --------------------------------------------------\n pll_out = self.pll.step(attack_signal)\n\n # --- Update observation windows -----------------------------------\n omega_norm = (pll_out["omega_hat"] - OMEGA0) / OMEGA0\n omega_dev = pll_out["omega_hat"] - OMEGA0 # raw deviation (rad/s)\n self.vq_window.append(pll_out["vq"])\n self.vd_window.append(pll_out["vd"])\n self.omega_window.append(omega_norm)\n self.omega_deviation_window.append(omega_dev)\n\n # --- Lock-loss check (Task 2 / hard only) -------------------------\n PLL_CONVERGENCE_STEPS = 60 # PLL transient settles by ~step 50, use 60 for margin\n if (\n self.task_id == 2\n and not self.lock_lost\n and self.step_count > self.attack_start_step\n and self.step_count > PLL_CONVERGENCE_STEPS # ← guard against startup transient\n ):\n if abs(pll_out["theta_err"]) > LOCK_LOSS_THRESHOLD:\n self.lock_lost = True\n self.lock_loss_step = self.step_count\n\n # --- Reward -------------------------------------------------------\n reward = self.compute_reward(action)\n\n # --- Record history entry for graders ----------------------------\n self.history.append({\n "step": self.step_count,\n "attack_active": self.attack_active,\n "attack_detected": action.attack_detected,\n "true_attack_type": self.true_attack_type,\n "agent_attack_type": action.attack_type,\n "theta_err": pll_out["theta_err"],\n })\n\n # --- Advance step counter ----------------------------------------\n self.step_count += 1\n\n # --- Episode termination -----------------------------------------\n # Fix 4: Task 2 terminates early on lock-loss, not just at MAX_STEPS\n if self.step_count >= MAX_STEPS:\n self.done = True\n elif self.task_id == 2 and self.lock_lost:\n self.done = True # early termination — no point continuing\n\n # --- Physics-informed detector (evaluation/debug only) ------------\n detector_output = self.detector.detect(self._get_observation())\n\n # --- Build info --------------------------------------------------\n info: Dict[str, Any] = {\n "detector": detector_output,\n "detector_features": {"step": self.step_count, "raw_score": detector_output.get("score")}\n }\n if self.done:\n info["grader_score"] = self._compute_grader_score()\n info["episode_id"] = self.episode_id\n info["total_steps"] = self.step_count\n info["lock_lost"] = self.lock_lost\n\n return self._get_observation(), reward, self.done, info\n\n def compute_reward(self, action: Action) -> Reward:\n """\n Compute the dense reward signal for the current step.\n\n Reward components:\n detection_reward: +0.10 true positive (per step)\n +0.05 true negative (per step)\n -0.05 missed detection (per step)\n false_alarm_penalty: -0.20 per false-positive step\n classification_bonus: +0.05 per step correct type (task 1 only)\n early_detection_bonus: one-time sparse, scaled by detection speed\n lock_loss_penalty: -2.00 one-time on lock loss (task 2 only)\n """\n detection_reward = 0.0\n false_alarm_penalty = 0.0\n classification_bonus = 0.0\n early_detection_bonus = 0.0\n lock_loss_penalty = 0.0\n\n if self.attack_active:\n if action.attack_detected:\n detection_reward = TRUE_POSITIVE_REWARD\n # One-time early detection bonus on first correct detection\n if not self.first_detection_recorded:\n self.first_detection_step = self.step_count\n self.first_detection_recorded = True\n # Relative steps since attack started\n t = self.first_detection_step - self.attack_start_step\n early_detection_bonus = max(0.0, 1.0 - t / EARLY_DETECTION_WINDOW)\n else:\n detection_reward = MISSED_DETECTION_PENALTY\n else:\n if action.attack_detected:\n false_alarm_penalty = FALSE_ALARM_PENALTY\n else:\n detection_reward = TRUE_NEGATIVE_REWARD\n\n # Task 1 (medium): per-step classification bonus\n if self.task_id == 1 and self.attack_active:\n if action.attack_type == self.true_attack_type:\n classification_bonus = CLASSIFICATION_BONUS\n\n # Task 2 (hard): one-time lock-loss penalty\n if self.task_id == 2 and self.lock_lost and not self.lock_loss_penalized:\n lock_loss_penalty = LOCK_LOSS_PENALTY\n self.lock_loss_penalized = True\n\n total = (\n detection_reward\n + false_alarm_penalty\n + classification_bonus\n + early_detection_bonus\n + lock_loss_penalty\n )\n\n return Reward(\n total=total,\n detection_reward=detection_reward,\n classification_bonus=classification_bonus,\n early_detection_bonus=early_detection_bonus,\n false_alarm_penalty=false_alarm_penalty,\n lock_loss_penalty=lock_loss_penalty,\n )\n\n def get_state(self) -> State:\n """Return full internal state for debugging / GET /state endpoint."""\n return State(\n theta_true=self.pll.theta_true,\n theta_hat=self.pll.theta_hat,\n omega_hat=self.pll.omega_hat,\n vq_integral=self.pll.vq_integral,\n attack_active=self.attack_active,\n attack_type=self.attack_type,\n attack_params=self.attack_params,\n attack_start_step=self.attack_start_step,\n lock_lost=self.lock_lost,\n step=self.step_count,\n episode_id=self.episode_id,\n task_id=self.task_id,\n )\n\n # ------------------------------------------------------------------\n # Private helpers\n # ------------------------------------------------------------------\n\n def _setup_attack(self) -> None:\n """Sample attack type and parameters based on current task_id."""\n self.attack_start_step = sample_attack_start(self.rng)\n\n if self.task_id == 0:\n # Easy: sinusoidal FDI only\n self.attack_params = sample_sinusoidal_params(self.rng)\n self.true_attack_type = 1\n\n elif self.task_id == 1:\n # Medium: random choice of sinusoidal / ramp / pulse\n choice = int(self.rng.integers(0, 3))\n if choice == 0:\n self.attack_params = sample_sinusoidal_params(self.rng)\n self.true_attack_type = 1\n elif choice == 1:\n self.attack_params = sample_ramp_params(self.rng)\n self.true_attack_type = 2\n else:\n self.attack_params = sample_pulse_params(self.rng)\n self.true_attack_type = 3\n\n elif self.task_id == 2:\n # Hard: stealthy low-and-slow\n self.attack_params = sample_stealthy_params(self.rng)\n self.true_attack_type = 4\n\n self.attack_type = get_attack_type_id(self.attack_params.get("type", "none"))\n self.attack_generator = AttackGenerator(self.attack_params, self.attack_start_step)\n\n def _get_observation(self) -> Observation:\n """\n Build the current Observation from internal windows.\n\n Fix 5: theta_err_window replaced with omega_deviation_window.\n theta_err requires knowing theta_true (not observable in a real\n inverter) and leaked ground truth directly to the agent.\n omega_deviation (omega_hat - OMEGA0 in rad/s) is a realistic proxy\n that correlates with phase drift under stealthy attacks.\n """\n return Observation(\n vq_window=list(self.vq_window),\n vd_window=list(self.vd_window),\n omega_window=list(self.omega_window),\n omega_deviation_window=list(self.omega_deviation_window), # Fix 5\n raw_voltages=[self.pll.va_m, self.pll.vb_m, self.pll.vc_m],\n task_id=self.task_id,\n step=self.step_count,\n )\n\n def _compute_grader_score(self) -> float:\n """Run the appropriate grader at episode end."""\n if self.task_id == 0:\n return grade_task_easy(self.history, self.attack_start_step)\n elif self.task_id == 1:\n return grade_task_medium(self.history, self.attack_start_step)\n elif self.task_id == 2:\n return grade_task_hard(\n self.history,\n self.lock_loss_step,\n self.attack_start_step,\n )\n return 0.0\n')
|
| 43 |
-
|
| 44 |
-
# --- src/api.py ---
|
| 45 |
-
with open('src/api.py', 'w', encoding='utf-8') as f:
|
| 46 |
-
f.write('"""\nFastAPI application for the PLL Cyberattack Detection OpenEnv.\n\nExposes HTTP endpoints for environment interaction:\n POST /reset — Reset environment with task_id\n POST /step — Submit an action and advance one step\n GET /state — Get current internal state\n GET /health — Health check (returns 200)\n GET /tasks — List available tasks\n"""\n\nimport asyncio\nfrom typing import Any, Dict, Optional\n\nfrom fastapi import FastAPI, HTTPException, Request\nfrom pydantic import BaseModel\n\nfrom src.models import Observation, Action, Reward, State\nfrom src.env import PLLAttackEnv\n\napp = FastAPI(\n title="PLL Cyberattack Detection OpenEnv",\n description="OpenEnv for AI-driven cyberattack detection on SRF-PLLs",\n version="1.0.0",\n)\n\nenv = PLLAttackEnv()\nenv_lock = asyncio.Lock()\n\n\nclass ResetRequest(BaseModel):\n task_id: int = 0\n seed: Optional[int] = None\n\n\nclass StepResponse(BaseModel):\n observation: Observation\n reward: Reward\n done: bool\n info: Dict[str, Any]\n\n\n@app.post("/reset", response_model=Observation)\nasync def reset(req: Request):\n """Reset the environment and return initial observation."""\n async with env_lock:\n try:\n body = await req.body()\n if body:\n data = await req.json()\n request = ResetRequest(**data)\n else:\n request = ResetRequest()\n except Exception:\n request = ResetRequest()\n return env.reset(task_id=request.task_id, seed=request.seed)\n\n\n@app.post("/step", response_model=StepResponse)\nasync def step(action: Action):\n async with env_lock:\n if env.attack_generator is None:\n raise HTTPException(status_code=400, detail="Call /reset before /step")\n obs, reward, done, info = env.step(action)\n return StepResponse(observation=obs, reward=reward, done=done, info=info)\n\n\n@app.get("/state", response_model=State)\nasync def get_state():\n async with env_lock:\n return env.get_state()\n\n\n@app.get("/health")\nasync def health():\n """Health check endpoint."""\n return {\n "status": "ok",\n "version": "1.0.0",\n "environment": "pll-cyberattack-detection",\n "tasks_available": 3,\n "episode_active": env.attack_generator is not None,\n "current_step": env.step_count,\n }\n\n\n@app.get("/tasks")\nasync def list_tasks():\n """List all available tasks."""\n return {\n "tasks": [\n {\n "id": 0,\n "name": "sinusoidal_fdi",\n "difficulty": "easy",\n "description": "Detect sinusoidal FDI attack"\n },\n {\n "id": 1,\n "name": "multi_attack_classification",\n "difficulty": "medium",\n "description": "Classify attack type from observations"\n },\n {\n "id": 2,\n "name": "stealthy_attack_detection",\n "difficulty": "hard",\n "description": "Detect stealthy attack before PLL lock loss"\n },\n ]\n }\n')
|
| 47 |
-
|
| 48 |
-
# --- openenv.yaml ---
|
| 49 |
-
with open('openenv.yaml', 'w', encoding='utf-8') as f:
|
| 50 |
-
f.write('name: pll-cyberattack-detection\nversion: 1.0.0\ndescription: >\n Real-world OpenEnv for cyberattack detection on SRF-based \n Phase-Locked Loops in grid-connected inverters. An agent monitors \n sensor streams and detects False Data Injection attacks before \n they cause loss of grid synchronization.\ntags: [power-systems, cybersecurity, control-systems, openenv, pll, fdi]\nreward_range: [-2.2, 1.15]\nobservation_space:\n type: continuous\n shape: [83]\n fields:\n - name: vq_window\n shape: [20]\n description: q-axis voltage error signal (pu)\n - name: vd_window\n shape: [20]\n description: d-axis voltage (pu)\n - name: omega_window\n shape: [20]\n description: normalized frequency deviation from nominal\n - name: omega_deviation_window\n shape: [20]\n description: frequency deviation from nominal (rad/s)\n - name: raw_voltages\n shape: [3]\n description: raw three-phase voltages [va, vb, vc] (pu)\naction_space:\n type: mixed\n fields:\n - name: attack_detected\n type: bool\n - name: attack_type\n type: int\n range: [0, 4]\n - name: confidence\n type: float\n range: [0.0, 1.0]\n - name: protective_action\n type: int\n range: [0, 3]\ntasks:\n - id: sinusoidal_fdi\n difficulty: easy\n numeric_id: 0\n grader: time_to_detection\n max_score: 1.0\n episode_length: 500\n description: Detect sinusoidal FDI attack within 100 steps of attack start\n - id: multi_attack_classification\n difficulty: medium\n numeric_id: 1\n grader: classification_accuracy\n max_score: 1.0\n episode_length: 500\n description: Classify attack type from observation window\n - id: stealthy_attack_detection\n difficulty: hard\n numeric_id: 2\n grader: pre_lock_loss_detection\n max_score: 1.0\n episode_length: 500\n description: Detect stealthy low-amplitude attack before PLL loss-of-lock\nepisode_length: 500\n')
|
| 51 |
-
|
| 52 |
-
# --- Dockerfile ---
|
| 53 |
-
with open('Dockerfile', 'w', encoding='utf-8') as f:
|
| 54 |
-
f.write('FROM python:3.10-slim\n\nWORKDIR /app\n\nCOPY requirements.txt .\nRUN pip install --no-cache-dir -r requirements.txt\n\nCOPY . .\n\nHEALTHCHECK --interval=30s --timeout=10s --start-period=5s \\\n CMD curl -f http://localhost:7860/health || exit 1\n\nEXPOSE 7860\n\nCMD ["uvicorn", "src.api:app", "--host", "0.0.0.0", "--port", "7860"]\n')
|
| 55 |
-
|
| 56 |
-
# --- requirements.txt ---
|
| 57 |
-
with open('requirements.txt', 'w', encoding='utf-8') as f:
|
| 58 |
-
f.write('fastapi==0.115.0\nuvicorn==0.32.0\npydantic==2.9.0\nnumpy==1.26.4\nopenai>=1.0.0\nrequests>=2.31.0\nopenenv-core>=0.2.0\n')
|
| 59 |
-
|
| 60 |
-
# --- inference.py ---
|
| 61 |
-
with open('inference.py', 'w', encoding='utf-8') as f:
|
| 62 |
-
f.write('"""\nInference Script — PLL Cyberattack Detection OpenEnv\n=====================================================\nMANDATORY environment variables:\n API_BASE_URL The API endpoint for the LLM\n MODEL_NAME The model identifier to use\n HF_TOKEN Your Hugging Face / API key\n\nUses a HYBRID approach:\n - A fast rule-based heuristic agent runs by default (no LLM needed)\n - The heuristic analyzes vq/omega_deviation windows to detect attacks\n - Set USE_LLM=1 env var to use the LLM instead (slower, may fail)\n\nMust be named inference.py and placed at the project root.\nUses OpenAI client for LLM calls when enabled.\n"""\n\nimport os\nimport json\nfrom typing import List, Optional\nimport time\nimport math\nimport requests\nfrom openai import OpenAI\n\nAPI_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")\nMODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")\nHF_TOKEN = os.getenv("HF_TOKEN")\nENV_URL = os.getenv("ENV_URL", "https://krishuggingface-cyberattack-pll.hf.space")\nUSE_LLM = os.environ.get("USE_LLM", "0") == "1"\n\nclient = OpenAI(base_url=API_BASE_URL, api_key=HF_TOKEN)\n\nSYSTEM_PROMPT = """You are an AI agent monitoring a power grid inverter\'s Phase-Locked Loop (PLL).\nYou receive time-windowed sensor readings each step and must detect cyberattacks.\n\nvq_window: q-axis voltage error (should be ~0 when healthy)\nvd_window: d-axis voltage\nomega_window: estimated frequency (normalized, nominal=0)\nomega_deviation_window: frequency deviation from nominal in rad/s (useful for detecting slow phase drift)\nraw_voltages: [va, vb, vc] at current step\ntask_id: 0=detect only, 1=classify type, 2=detect stealthy attack\n\nFor task_id=0: Focus on detecting any attack (attack_detected=True/False).\nFor task_id=1: Also classify the attack type (1=sinusoidal, 2=ramp, 3=pulse).\nFor task_id=2: Detect very subtle attacks before the PLL loses lock. Look for slow drifts in omega_deviation and vq.\n\nAnalysis tips:\n- In healthy state, vq values should be near 0 and stable.\n- Sinusoidal attacks cause oscillating patterns in vq.\n- Ramp attacks cause steadily increasing vq magnitude.\n- Pulse attacks cause sudden step changes in vq.\n- Stealthy attacks cause very slow, gradual drift in omega_deviation_window.\n- Look at trends across the full window, not just the latest value.\n\nRespond ONLY with valid JSON, no explanation:\n{\n "attack_detected": <bool>,\n "attack_type": <int 0-4>,\n "confidence": <float 0.0-1.0>,\n "protective_action": <int 0-3>\n}"""\n\nTASK_NAMES = {\n 0: "Sinusoidal FDI Detection (Easy)",\n 1: "Multi-Attack Classification (Medium)",\n 2: "Stealthy Attack Detection (Hard)",\n}\n\nDEFAULT_ACTION = {\n "attack_detected": False,\n "attack_type": 0,\n "confidence": 0.5,\n "protective_action": 0,\n}\n\n\n# =====================================================================\n# Logging Helpers (OpenEnv compliance)\n# =====================================================================\n\ndef log_start(task: str, env: str, model: str) -> None:\n print(f"[START] task={task} env={env} model={model}", flush=True)\n\n\ndef log_step(step: int, action: dict, reward: float, done: bool, error) -> None:\n action_str = json.dumps(action, separators=(\',\', \':\'))\n error_val = error if error else "null"\n print(f"[STEP] step={step} action={action_str} reward={reward:.2f} done={str(done).lower()} error={error_val}", flush=True)\n\n\ndef log_end(success: bool, steps: int, score: float, rewards: list) -> None:\n rewards_str = ",".join(f"{r:.2f}" for r in rewards)\n print(f"[END] success={str(success).lower()} steps={steps} score={score:.3f} rewards={rewards_str}", flush=True)\n\n\n# =====================================================================\n# Detector-Based Agent\n# =====================================================================\n\ndef detector_agent(prev_info: dict) -> Optional[dict]:\n """Reads the environment\'s adaptive detector output from the previous step."""\n det = prev_info.get("detector", {})\n if not det or "attack_detected" not in det:\n return None\n \n return {\n "attack_detected": det.get("attack_detected", False),\n "attack_type": det.get("attack_type", 0),\n "confidence": det.get("confidence", 0.5),\n "protective_action": det.get("protective_action", 0),\n }\n\n\n# =====================================================================\n# Rule-Based Heuristic Agent\n# =====================================================================\n\nclass HeuristicState:\n """Tracks running state for the heuristic agent across steps."""\n def __init__(self):\n self.reset()\n\n def reset(self):\n self.vq_history = [] # all vq_mean(abs) values\n self.omega_dev_history = [] # all omega_dev_mean(abs) values\n self.attack_detected = False # latched detection flag\n self.predicted_type = 0 # latched classification\n self.settled_baseline = None # omega_dev baseline when PLL settles\n self.peak_vq = 0.0 # highest vq_mean seen\n\n\n_hstate = HeuristicState()\n\n\ndef heuristic_agent(obs: dict) -> dict:\n """\n Rule-based attack detector using cumulative state tracking.\n No LLM needed — runs instantly.\n\n The key insight is that the PLL\'s closed-loop response transforms\n attack signals, so we track statistics over time rather than\n trying to classify from a single 20-step vq window shape.\n """\n global _hstate\n vq = obs["vq_window"]\n omega_dev = obs["omega_deviation_window"]\n task_id = obs["task_id"]\n step = obs["step"]\n\n if step == 0:\n _hstate.reset()\n\n # --- Compute per-step features ---\n vq_abs = [abs(v) for v in vq]\n vq_mean = sum(vq_abs) / len(vq_abs)\n vq_max = max(vq_abs)\n vq_latest = abs(vq[-1])\n\n omega_dev_abs = [abs(v) for v in omega_dev]\n omega_dev_mean = sum(omega_dev_abs) / len(omega_dev_abs)\n\n # Track history\n _hstate.vq_history.append(vq_mean)\n _hstate.omega_dev_history.append(omega_dev_mean)\n _hstate.peak_vq = max(_hstate.peak_vq, vq_mean)\n\n # Record baseline around step 45-50 (PLL settled)\n if step == 50:\n _hstate.settled_baseline = omega_dev_mean\n\n # -----------------------------------------------------------------\n # Detection: is vq significantly elevated?\n # After PLL warm-start settles (~step 20-30), healthy vq < 0.005\n # -----------------------------------------------------------------\n if step < 25:\n # PLL still settling, don\'t detect\n detected = False\n else:\n detected = vq_mean > 0.01 or vq_max > 0.025\n\n # Latch detection on\n if detected:\n _hstate.attack_detected = True\n\n # -----------------------------------------------------------------\n # Task 0: Binary detection only\n # -----------------------------------------------------------------\n if task_id == 0:\n return {\n "attack_detected": _hstate.attack_detected,\n "attack_type": 1 if _hstate.attack_detected else 0,\n "confidence": min(1.0, vq_mean * 50) if _hstate.attack_detected else 0.8,\n "protective_action": 1 if _hstate.attack_detected else 0,\n }\n\n # -----------------------------------------------------------------\n # Task 1: Classification using cumulative patterns\n # -----------------------------------------------------------------\n if task_id == 1:\n if not _hstate.attack_detected:\n return {\n "attack_detected": False,\n "attack_type": 0,\n "confidence": 0.7,\n "protective_action": 0,\n }\n\n # Classify using cumulative vq_history\n # Only classify after enough attack data (10+ steps of elevated vq)\n n_elevated = sum(1 for v in _hstate.vq_history if v > 0.01)\n\n if n_elevated < 5:\n # Not enough data yet, use simple guess\n attack_type = 1\n else:\n # Get recent vq trend (last 10 elevated values)\n elevated = [v for v in _hstate.vq_history if v > 0.005]\n recent = elevated[-min(20, len(elevated)):]\n\n # Feature 1: Is vq currently high or has it decayed?\n current_vs_peak = vq_mean / _hstate.peak_vq if _hstate.peak_vq > 0 else 0\n\n # Feature 2: How many zero crossings in current window\n zero_crossings = sum(1 for i in range(1, len(vq)) if vq[i] * vq[i-1] < 0)\n\n # Feature 3: Is vq growing or shrinking over recent history\n if len(recent) >= 6:\n first_third = sum(recent[:len(recent)//3]) / (len(recent)//3)\n last_third = sum(recent[-len(recent)//3:]) / (len(recent)//3)\n growth = last_third / first_third if first_third > 0.001 else 1.0\n else:\n growth = 1.0\n\n # Classification logic:\n # Sinusoidal: persistent oscillation, zero crossings, stable amplitude\n # Ramp: growing vq over time (growth > 1)\n # Pulse: high initial vq that decays to near zero (current_vs_peak < 0.3)\n\n if current_vs_peak < 0.15 and _hstate.peak_vq > 0.05:\n # vq has decayed significantly from peak → pulse (ended)\n attack_type = 3\n elif current_vs_peak < 0.4 and n_elevated > 30:\n # vq decayed after a long time → pulse\n attack_type = 3\n elif zero_crossings >= 2 and growth < 1.5:\n # Active oscillation without growing → sinusoidal\n attack_type = 1\n elif growth > 1.3:\n # Growing signal → ramp\n attack_type = 2\n elif zero_crossings >= 1:\n # Some oscillation → sinusoidal\n attack_type = 1\n else:\n # Default: if mono-decrease, pulse; else sinusoidal\n vq_diffs = [vq[i] - vq[i-1] for i in range(1, len(vq))]\n neg = sum(1 for d in vq_diffs if d < 0)\n if neg > 14: # 14/19 = 73% decreasing\n attack_type = 3\n else:\n attack_type = 1\n\n _hstate.predicted_type = attack_type\n\n return {\n "attack_detected": True,\n "attack_type": _hstate.predicted_type,\n "confidence": 0.8,\n "protective_action": 1,\n }\n\n # -----------------------------------------------------------------\n # Task 2: Stealthy attack — detect omega_dev rising above baseline\n # -----------------------------------------------------------------\n if task_id == 2:\n drift_detected = False\n confidence = 0.3\n\n if step > 50 and _hstate.settled_baseline is not None:\n baseline = _hstate.settled_baseline\n\n # Compare current to baseline\n ratio = omega_dev_mean / baseline if baseline > 0.01 else omega_dev_mean * 100\n\n # Check if omega_dev is rising relative to recent history\n if len(_hstate.omega_dev_history) > 10:\n recent_10 = _hstate.omega_dev_history[-10:]\n old_10 = _hstate.omega_dev_history[-20:-10] if len(_hstate.omega_dev_history) > 20 else _hstate.omega_dev_history[:10]\n recent_avg = sum(recent_10) / len(recent_10)\n old_avg = sum(old_10) / len(old_10)\n rising = recent_avg > old_avg * 1.1\n else:\n rising = False\n\n if ratio > 2.0:\n drift_detected = True\n confidence = 0.9\n elif ratio > 1.3 and rising:\n drift_detected = True\n confidence = 0.8\n elif rising and vq_mean > 0.1:\n drift_detected = True\n confidence = 0.6\n elif vq_mean > 0.2:\n drift_detected = True\n confidence = 0.5\n\n if drift_detected:\n _hstate.attack_detected = True\n\n return {\n "attack_detected": drift_detected,\n "attack_type": 4 if drift_detected else 0,\n "confidence": confidence,\n "protective_action": 2 if drift_detected else 0,\n }\n\n return DEFAULT_ACTION.copy()\n\n\n# =====================================================================\n# LLM Agent (optional, set USE_LLM=1)\n# =====================================================================\n\ndef parse_llm_response(response_text: str) -> dict:\n """Parse LLM response JSON, returning default action on failure."""\n try:\n text = response_text.strip()\n if text.startswith("```"):\n lines = text.split("\\n")\n json_lines = []\n in_block = False\n for line in lines:\n if line.strip().startswith("```") and not in_block:\n in_block = True\n continue\n elif line.strip().startswith("```") and in_block:\n break\n elif in_block:\n json_lines.append(line)\n text = "\\n".join(json_lines)\n\n parsed = json.loads(text)\n action = {\n "attack_detected": bool(parsed.get("attack_detected", False)),\n "attack_type": max(0, min(4, int(parsed.get("attack_type", 0)))),\n "confidence": max(0.0, min(1.0, float(parsed.get("confidence", 0.5)))),\n "protective_action": max(0, min(3, int(parsed.get("protective_action", 0)))),\n }\n return action\n except (json.JSONDecodeError, KeyError, TypeError, ValueError):\n return DEFAULT_ACTION.copy()\n\n\ndef format_observation(obs: dict) -> str:\n """Format observation dict into a concise string for the LLM."""\n parts = [\n f"Step: {obs[\'step\']}",\n f"Task: {obs[\'task_id\']}",\n f"vq_window (last 20): {[round(v, 6) for v in obs[\'vq_window\']]}",\n f"vd_window (last 20): {[round(v, 6) for v in obs[\'vd_window\']]}",\n f"omega_window (last 20): {[round(v, 6) for v in obs[\'omega_window\']]}",\n f"omega_deviation_window (last 20): {[round(v, 6) for v in obs[\'omega_deviation_window\']]}",\n f"raw_voltages: {[round(v, 6) for v in obs[\'raw_voltages\']]}",\n ]\n return "\\n".join(parts)\n\n\ndef llm_agent(obs: dict) -> dict:\n """Call the LLM to decide an action. Falls back to heuristic on error."""\n try:\n obs_text = format_observation(obs)\n completion = client.chat.completions.create(\n model=MODEL_NAME,\n messages=[\n {"role": "system", "content": SYSTEM_PROMPT},\n {"role": "user", "content": obs_text},\n ],\n temperature=0.1,\n max_tokens=200,\n )\n llm_response = completion.choices[0].message.content\n return parse_llm_response(llm_response)\n except Exception as e:\n print(f" LLM error ({type(e).__name__}: {e}), falling back to heuristic")\n return heuristic_agent(obs)\n\n\n# =====================================================================\n# Episode Runner\n# =====================================================================\n\ndef run_episode(task_id: int) -> float:\n log_start(task=TASK_NAMES[task_id], env="pll-cyberattack-detection", model=MODEL_NAME if USE_LLM else "rule-based-heuristic")\n\n print(f"\\n{\'=\'*60}")\n print(f"Task {task_id}: {TASK_NAMES[task_id]}")\n print(f"Agent: {\'LLM (\' + MODEL_NAME + \')\' if USE_LLM else \'Rule-Based Heuristic\'}")\n print(f"{\'=\'*60}")\n\n step_count = 0\n grader_score = 0.0\n rewards = []\n\n try:\n # Reset environment\n reset_response = requests.post(\n f"{ENV_URL}/reset",\n json={"task_id": task_id},\n timeout=30,\n )\n reset_response.raise_for_status()\n obs = reset_response.json()\n\n done = False\n total_reward = 0.0\n prev_info = {}\n\n while not done:\n # Choose agent\n if USE_LLM:\n action = llm_agent(obs)\n else:\n if step_count == 0:\n action = DEFAULT_ACTION.copy()\n else:\n det_action = detector_agent(prev_info) if "detector" in prev_info else None\n heur_action = heuristic_agent(obs)\n \n if not det_action:\n action = heur_action\n elif det_action["confidence"] < 0.5:\n action = heur_action\n else:\n action = det_action\n\n # Step environment\n step_response = requests.post(\n f"{ENV_URL}/step",\n json=action,\n timeout=30,\n )\n step_response.raise_for_status()\n result = step_response.json()\n\n obs = result["observation"]\n reward = result["reward"]\n done = result["done"]\n info = result["info"]\n total_reward += reward["total"]\n rewards.append(reward["total"])\n log_step(step=step_count, action=action, reward=reward["total"], done=done, error=None)\n\n prev_info = info\n step_count += 1\n\n # Print progress every 50 steps\n if step_count % 50 == 0:\n print(f" Step {step_count:3d} | Reward: {reward[\'total\']:+.4f} | "\n f"Cumulative: {total_reward:+.4f} | "\n f"Detected: {action[\'attack_detected\']} | "\n f"Type: {action[\'attack_type\']}")\n\n # Extract grader score\n grader_score = info.get("grader_score", 0.0)\n print(f"\\n Episode complete: {step_count} steps")\n print(f" Total reward: {total_reward:+.4f}")\n print(f" Grader score: {grader_score:.4f}")\n finally:\n log_end(success=grader_score > 0.0, steps=step_count, score=grader_score, rewards=rewards)\n\n return grader_score\n\n\nif __name__ == "__main__":\n agent_name = f"LLM ({MODEL_NAME})" if USE_LLM else "Rule-Based Heuristic"\n print("PLL Cyberattack Detection — Agentic Inference")\n print(f"Agent: {agent_name}")\n print(f"Environment: {ENV_URL}")\n if not USE_LLM:\n print("(Set USE_LLM=1 to use LLM agent instead of heuristic)")\n\n start_time = time.time()\n scores = []\n\n for task_id in range(3):\n score = run_episode(task_id)\n print(f"Task {task_id} score: {score:.4f}")\n scores.append(score)\n\n elapsed = time.time() - start_time\n\n print(f"\\n{\'=\'*60}")\n print("FINAL RESULTS")\n print(f"{\'=\'*60}")\n for i, score in enumerate(scores):\n print(f" Task {i} ({TASK_NAMES[i]}): {score:.4f}")\n print(f"\\n Average score: {sum(scores)/len(scores):.4f}")\n print(f" Total time: {elapsed:.1f}s ({elapsed/60:.1f} min)")\n print(f"{\'=\'*60}")\n')
|
| 63 |
-
|
| 64 |
-
# --- README.md ---
|
| 65 |
-
with open('README.md', 'w', encoding='utf-8') as f:
|
| 66 |
-
f.write('# PLL Cyberattack Detection — OpenEnv\n\n[](https://huggingface.co/spaces/krishuggingface/CyberAttack-PLL)\n[](Dockerfile)\n[](openenv.yaml)\n[](https://python.org)\n\n> AI-driven cyberattack detection on SRF Phase-Locked Loops (PLLs) in grid-connected inverters.\n\n## Overview\n\nPhase-Locked Loops (PLLs) are critical components in grid-connected power converters that synchronize the inverter\'s output with the utility grid. The Synchronous Reference Frame PLL (SRF-PLL) estimates grid frequency and phase angle by tracking the q-axis voltage component — making it a high-value target for **False Data Injection (FDI)** cyberattacks.\n\nThis OpenEnv environment simulates an SRF-PLL under various FDI attack scenarios. An AI agent monitors time-windowed sensor observations (voltages, frequency deviations) and must detect, classify, and respond to attacks in real time before they cause loss of grid synchronization.\n\n## Architecture\n\n```\nGrid Voltage (50Hz)\n │\n ▼\n[FDI Attack Injection] ◄── Attacker injects false signal on va\n │\n ▼\nClarke Transform (αβ)\n │\n ▼\nPark Transform (dq) ◄── uses estimated angle θ̂\n │\n ▼\nPI Controller ──► ω̂, θ̂ updated\n │\n ▼\nAgent observes: vq_window, omega_deviation_window, raw_voltages\n │\n ▼\nAgent outputs: attack_detected, attack_type, confidence\n```\n\n## Inference & Detection Strategy\n\nThe environment natively features an **Adaptive Physics-Informed Detector** (`src/detector.py`) that calibrates anomaly residuals (R1, R3, R4, R5) during the PLL warm-up phase to identify stealthy voltage and frequency deviations.\n\nThe default inference client (`inference.py`) deploys a **Smart Blending Agent** strategy:\n1. It relies primarily on the environment\'s `AdaptiveDetector` output passed via `info["detector"]`.\n2. As a **safety net**, if the detector\'s classification confidence drops below 50% (`< 0.5`) on ambiguous anomalies, the client dynamically falls back to an independent, cumulative **Rule-Based Heuristic Agent**.\n3. Optionally, an LLM agent (e.g., `Qwen/Qwen2.5-72B-Instruct`) can be enabled natively via the `USE_LLM=1` environment variable.\n\n## Tasks\n\n| Task | ID | Difficulty | Attack Type | Objective | Score |\n|------|----|-----------|-------------|-----------|-------|\n| Sinusoidal FDI Detection | 0 | Easy | Sinusoidal injection | Detect within 100 steps | Time-based decay |\n| Multi-Attack Classification | 1 | Medium | Sinusoidal/Ramp/Pulse | Classify attack type | Accuracy + speed |\n| Stealthy Attack Detection | 2 | Hard | Low-amplitude phase drift | Detect before lock loss | Prevention score |\n\n## Observation Space\n\nEach step provides a JSON observation with the following fields:\n\n| Field | Shape | Description |\n|-------|-------|-------------|\n| `vq_window` | `[20]` | q-axis voltage error signal (pu) |\n| `vd_window` | `[20]` | d-axis voltage (pu) |\n| `omega_window` | `[20]` | Normalized frequency deviation from nominal |\n| `omega_deviation_window` | `[20]` | Frequency deviation from nominal (rad/s) |\n| `raw_voltages` | `[3]` | Raw three-phase voltages `[va, vb, vc]` (pu) |\n| `step` | scalar | Current simulation step |\n| `task_id` | scalar | Task identifier (0, 1, or 2) |\n\n**Total observation dimension**: 83 (20+20+20+20+3)\n\n## Action Space\n\nAgents return a JSON action each step:\n\n| Field | Type | Range | Description |\n|-------|------|-------|-------------|\n| `attack_detected` | `bool` | — | Whether an attack is detected |\n| `attack_type` | `int` | 0–4 | 0=none, 1=sinusoidal, 2=ramp, 3=pulse, 4=stealthy |\n| `confidence` | `float` | 0.0–1.0 | Agent\'s confidence in its classification |\n| `protective_action` | `int` | 0–3 | 0=none, 1=alert, 2=reduce power, 3=disconnect |\n\n## API Endpoints\n\n### Reset Environment\n```bash\ncurl -X POST http://localhost:7860/reset \\\n -H "Content-Type: application/json" \\\n -d \'{"task_id": 0, "seed": 42}\'\n```\n\n### Step\n```bash\ncurl -X POST http://localhost:7860/step \\\n -H "Content-Type: application/json" \\\n -d \'{"attack_detected": false, "attack_type": 0, "confidence": 0.5, "protective_action": 0}\'\n```\n\n### Get State\n```bash\ncurl http://localhost:7860/state\n```\n\n### Health Check\n```bash\ncurl http://localhost:7860/health\n```\n\n## Quick Start\n\n### With Docker\n\n```bash\ndocker build -t pll-cyberattack-env .\ndocker run -p 7860:7860 pll-cyberattack-env\n```\n\n### Without Docker\n\n```bash\npip install -r requirements.txt\nuvicorn src.api:app --host 0.0.0.0 --port 7860\n```\n\n## Environment Variables\n\n| Variable | Required | Default | Description |\n|----------|----------|---------|-------------|\n| `API_BASE_URL` | No | `https://router.huggingface.co/v1` | LLM API endpoint |\n| `MODEL_NAME` | No | `Qwen/Qwen2.5-72B-Instruct` | Model identifier |\n| `HF_TOKEN` | Yes | — | HuggingFace API token |\n\n## Baseline Performance\n\nThe default hybrid strategy (Adaptive Detector + Heuristic Fallback) achieves the following baseline scores evaluated locally over 500-step episodes:\n\n* **Task 0 (Sinusoidal FDI):** 1.0000 \n* **Task 1 (Multi-Attack Classification):** 0.8720\n* **Task 2 (Stealthy Drift):** 0.1639\n* **Average Score:** `0.6786`\n\n## Live Demo\n\n🚀 **HuggingFace Space**: [https://huggingface.co/spaces/krishuggingface/CyberAttack-PLL](https://huggingface.co/spaces/krishuggingface/CyberAttack-PLL)\n')
|
| 67 |
-
|
| 68 |
-
# %% [Cell 3] Run Environment
|
| 69 |
-
# (Example code for starting API server or inference omitted for clarity)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,65 +1,134 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
import pytest
|
| 4 |
-
|
| 5 |
-
from
|
| 6 |
-
from
|
| 7 |
-
from
|
| 8 |
-
from
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
)
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
env =
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
env.reset(task_id=
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Environment contract: reset/step/state, determinism, hidden ground truth, reward bounds."""
|
| 2 |
+
|
| 3 |
import pytest
|
| 4 |
+
|
| 5 |
+
from pll_cyber.environment import PLLCyberEnvironment
|
| 6 |
+
from pll_cyber.models import PLLAction, PLLObservation
|
| 7 |
+
from pll_cyber.rewards import REWARD_MAX, REWARD_MIN
|
| 8 |
+
from pll_cyber.scenarios import MAX_STEPS
|
| 9 |
+
|
| 10 |
+
QUIET = PLLAction(attack_detected=False, confidence=0.9)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@pytest.mark.parametrize("task_id", [0, 1, 2])
|
| 14 |
+
def test_reset_returns_clean_initial_observation(task_id):
|
| 15 |
+
env = PLLCyberEnvironment()
|
| 16 |
+
obs = env.reset(task_id=task_id, seed=3)
|
| 17 |
+
assert isinstance(obs, PLLObservation)
|
| 18 |
+
assert obs.step == 0 and not obs.done and obs.reward is None
|
| 19 |
+
assert obs.task_id == task_id and obs.task_brief
|
| 20 |
+
assert len(obs.waveforms.va) == 25 and len(obs.trend.f_local_hz) >= 1
|
| 21 |
+
assert env.state.step_count == 0 and env.state.scenario is None
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_episode_length_and_termination():
|
| 25 |
+
env = PLLCyberEnvironment()
|
| 26 |
+
obs = env.reset(task_id=1, seed=0)
|
| 27 |
+
n = 0
|
| 28 |
+
while not obs.done:
|
| 29 |
+
obs = env.step(QUIET)
|
| 30 |
+
n += 1
|
| 31 |
+
assert n == MAX_STEPS
|
| 32 |
+
assert obs.episode_result is not None and 0.0 <= obs.episode_result.grader_score <= 1.0
|
| 33 |
+
after = env.step(QUIET) # stepping a finished episode is harmless
|
| 34 |
+
assert after.done and after.reward == 0.0
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_reset_fully_clears_previous_episode():
|
| 38 |
+
env = PLLCyberEnvironment()
|
| 39 |
+
env.reset(task_id=2, seed=1)
|
| 40 |
+
for _ in range(15):
|
| 41 |
+
env.step(PLLAction(attack_detected=True, protective_action=3))
|
| 42 |
+
obs = env.reset(task_id=2, seed=1)
|
| 43 |
+
assert obs.inverter_mode == "normal" and obs.step == 0
|
| 44 |
+
assert env.state.cumulative_reward == 0.0
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _trajectory(task_id, seed, policy):
|
| 48 |
+
env = PLLCyberEnvironment()
|
| 49 |
+
obs = env.reset(task_id=task_id, seed=seed)
|
| 50 |
+
traj = [obs.model_dump_json()]
|
| 51 |
+
while not obs.done:
|
| 52 |
+
obs = env.step(policy(obs))
|
| 53 |
+
traj.append(obs.model_dump_json())
|
| 54 |
+
return traj
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@pytest.mark.parametrize("task_id", [0, 1, 2])
|
| 58 |
+
def test_same_seed_is_bit_identical(task_id):
|
| 59 |
+
pol = lambda o: PLLAction(attack_detected=o.step > 12, attack_type=1, protective_action=2 if o.step > 20 else 0)
|
| 60 |
+
assert _trajectory(task_id, 42, pol) == _trajectory(task_id, 42, pol)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_different_seeds_differ():
|
| 64 |
+
pol = lambda o: QUIET
|
| 65 |
+
assert _trajectory(0, 1, pol)[1] != _trajectory(0, 2, pol)[1]
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_unseeded_reset_reports_its_seed():
|
| 69 |
+
env = PLLCyberEnvironment()
|
| 70 |
+
env.reset(task_id=0)
|
| 71 |
+
assert isinstance(env.state.seed, int)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def test_ground_truth_hidden_until_done():
|
| 75 |
+
env = PLLCyberEnvironment()
|
| 76 |
+
obs = env.reset(task_id=1, seed=5)
|
| 77 |
+
dumped = obs.model_dump_json()
|
| 78 |
+
for leak in ("onset", "attack_params", "theta_true", "true_attack"):
|
| 79 |
+
assert leak not in dumped
|
| 80 |
+
while not obs.done:
|
| 81 |
+
assert env.state.scenario is None and env.state.grader_score is None
|
| 82 |
+
obs = env.step(QUIET)
|
| 83 |
+
assert env.state.scenario is not None and env.state.grader_score is not None
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@pytest.mark.parametrize("task_id", [0, 1, 2])
|
| 87 |
+
def test_rewards_within_declared_range(task_id):
|
| 88 |
+
env = PLLCyberEnvironment()
|
| 89 |
+
import random
|
| 90 |
+
rng = random.Random(task_id)
|
| 91 |
+
for seed in range(8):
|
| 92 |
+
obs = env.reset(task_id=task_id, seed=seed)
|
| 93 |
+
while not obs.done:
|
| 94 |
+
a = PLLAction(attack_detected=rng.random() < 0.5, attack_type=rng.randint(0, 4),
|
| 95 |
+
confidence=rng.random(), protective_action=rng.choice([0, 1, 2, 3]))
|
| 96 |
+
obs = env.step(a)
|
| 97 |
+
assert REWARD_MIN - 1e-9 <= obs.reward <= REWARD_MAX + 1e-9
|
| 98 |
+
b = obs.reward_breakdown
|
| 99 |
+
parts = (b.detection + b.early_detection_bonus + b.calibration + b.classification
|
| 100 |
+
+ b.mitigation_cost + b.harm_penalty)
|
| 101 |
+
assert parts == pytest.approx(b.total)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def test_task_selection_by_name_and_validation():
|
| 105 |
+
env = PLLCyberEnvironment()
|
| 106 |
+
assert env.reset(task="hard", seed=0).task_id == 2
|
| 107 |
+
assert env.reset(task="attack_classification_under_disturbances", seed=0).task_id == 1
|
| 108 |
+
with pytest.raises(ValueError):
|
| 109 |
+
env.reset(task_id=7)
|
| 110 |
+
with pytest.raises(Exception):
|
| 111 |
+
PLLAction(attack_detected=True, attack_type=9)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def test_disconnect_is_latched_and_holdover_reversible():
|
| 115 |
+
env = PLLCyberEnvironment()
|
| 116 |
+
env.reset(task_id=2, seed=2)
|
| 117 |
+
assert env.step(PLLAction(attack_detected=True, protective_action=2)).inverter_mode == "holdover"
|
| 118 |
+
assert env.step(PLLAction(attack_detected=False)).inverter_mode == "normal"
|
| 119 |
+
assert env.step(PLLAction(attack_detected=True, protective_action=3)).inverter_mode == "disconnected"
|
| 120 |
+
assert env.step(PLLAction(attack_detected=False)).inverter_mode == "disconnected"
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def test_openenv_yaml_matches_code():
|
| 124 |
+
from pathlib import Path
|
| 125 |
+
|
| 126 |
+
import yaml
|
| 127 |
+
|
| 128 |
+
from pll_cyber.scenarios import TASKS
|
| 129 |
+
|
| 130 |
+
spec = yaml.safe_load((Path(__file__).parents[1] / "openenv.yaml").read_text(encoding="utf-8"))
|
| 131 |
+
assert spec["app"] == "server.app:app" and spec["port"] == 7860
|
| 132 |
+
assert spec["reward_range"] == [round(REWARD_MIN, 2), round(REWARD_MAX, 2)]
|
| 133 |
+
assert spec["episode"]["steps"] == MAX_STEPS
|
| 134 |
+
assert [(t["id"], t["name"]) for t in spec["tasks"]] == [(i, TASKS[i].name) for i in range(3)]
|
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Graders: range, determinism, non-constancy and resistance to degenerate strategies."""
|
| 2 |
+
|
| 3 |
+
import itertools
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
from pll_cyber.baselines import ConstantAgent, PhysicsHeuristicAgent, RandomAgent
|
| 9 |
+
from pll_cyber.environment import PLLCyberEnvironment
|
| 10 |
+
from pll_cyber.evaluation import evaluate, run_episode
|
| 11 |
+
from pll_cyber.graders import grade_episode
|
| 12 |
+
|
| 13 |
+
SEEDS = range(40)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class Oracle:
|
| 17 |
+
"""Cheats by reading ground truth — upper bound used only to prove tasks are solvable."""
|
| 18 |
+
|
| 19 |
+
def __init__(self, env):
|
| 20 |
+
self.env = env
|
| 21 |
+
|
| 22 |
+
def act(self, obs):
|
| 23 |
+
sc = self.env._sc
|
| 24 |
+
lab = sc.onset_step is not None and obs["step"] >= sc.onset_step
|
| 25 |
+
return {"attack_detected": lab, "attack_type": sc.attack.type_id if lab else 0, "confidence": 1.0,
|
| 26 |
+
"protective_action": 2 if (lab and sc.task_id == 2) else 0}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _scores(make_agent, task_id, seeds=SEEDS):
|
| 30 |
+
env = PLLCyberEnvironment()
|
| 31 |
+
return np.array([run_episode(make_agent(env), task_id, s, env)["score"] for s in seeds])
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@pytest.mark.parametrize("task_id", [0, 1, 2])
|
| 35 |
+
def test_oracle_solves_every_task(task_id):
|
| 36 |
+
s = _scores(lambda env: Oracle(env), task_id)
|
| 37 |
+
assert s.mean() > 0.95 and s.min() >= 0.9
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@pytest.mark.parametrize("task_id", [0, 1, 2])
|
| 41 |
+
def test_degenerate_policies_score_low(task_id):
|
| 42 |
+
always = _scores(lambda env: ConstantAgent(True, [1, 1, 4][task_id]), task_id)
|
| 43 |
+
never = _scores(lambda env: ConstantAgent(False), task_id)
|
| 44 |
+
seeds = itertools.count(100)
|
| 45 |
+
rand = _scores(lambda env: RandomAgent(seed=next(seeds)), task_id)
|
| 46 |
+
assert always.max() <= 0.05 # "always alarm" earns nothing anywhere
|
| 47 |
+
assert never.mean() < 0.30 # only the attack-free share
|
| 48 |
+
assert rand.mean() < 0.30
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_always_mitigate_is_punished_on_hard_task():
|
| 52 |
+
hold = _scores(lambda env: ConstantAgent(True, 4, protective_action=2), 2)
|
| 53 |
+
trip = _scores(lambda env: ConstantAgent(True, 4, protective_action=3), 2)
|
| 54 |
+
assert hold.mean() < 0.05 and trip.mean() < 0.05
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@pytest.mark.parametrize("task_id", [0, 1, 2])
|
| 58 |
+
def test_scores_in_range_and_not_constant(task_id):
|
| 59 |
+
s = _scores(lambda env: PhysicsHeuristicAgent(), task_id)
|
| 60 |
+
assert ((s >= 0.0) & (s <= 1.0)).all()
|
| 61 |
+
seeds = itertools.count(500)
|
| 62 |
+
r = _scores(lambda env: RandomAgent(seed=next(seeds)), task_id)
|
| 63 |
+
assert np.std(np.concatenate([s, r])) > 0.05
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_heuristic_baseline_is_meaningful_but_not_saturated_on_hard():
|
| 67 |
+
res = evaluate(PhysicsHeuristicAgent, [0, 1, 2], SEEDS)
|
| 68 |
+
assert res[0]["mean"] > 0.9 and res[1]["mean"] > 0.8
|
| 69 |
+
assert 0.5 < res[2]["mean"] < 0.9 # headroom left for better agents
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_grader_is_deterministic_function_of_log():
|
| 73 |
+
log = [{"step": t, "detected": t >= 12, "attack_type": 2, "protective_action": 0} for t in range(40)]
|
| 74 |
+
a = grade_episode(1, log, 10, 50.0, true_type=2)
|
| 75 |
+
b = grade_episode(1, log, 10, 50.0, true_type=2)
|
| 76 |
+
assert a == b
|
| 77 |
+
assert a["detection_delay_ms"] == 100.0 and a["false_alarm_rate"] == 0.0
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def test_hard_grader_rewards_timely_mitigation():
|
| 81 |
+
def log_for(mitigate_at):
|
| 82 |
+
return [{"step": t, "detected": t >= 10, "attack_type": 4,
|
| 83 |
+
"protective_action": 2 if t >= mitigate_at else 0} for t in range(40)]
|
| 84 |
+
early = grade_episode(2, log_for(11), 10, 50.0, unmitigated_desync_step=18, actual_desync_step=None)
|
| 85 |
+
late = grade_episode(2, log_for(25), 10, 50.0, unmitigated_desync_step=18, actual_desync_step=18)
|
| 86 |
+
assert early["protected"] and not late["protected"]
|
| 87 |
+
assert early["score"] > late["score"] + 0.4
|
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Baseline inference script: parsing, LLM loop with a fake client, log format, failure fallback."""
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
import types
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
import inference
|
| 9 |
+
from inference import LLMAgent, LocalEnv, parse_action, run_episode
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def test_parse_action_variants():
|
| 13 |
+
assert parse_action('{"attack_detected": true, "attack_type": 2, "confidence": 0.7, "protective_action": 1}') == \
|
| 14 |
+
{"attack_detected": True, "attack_type": 2, "confidence": 0.7, "protective_action": 1}
|
| 15 |
+
fenced = 'Sure!\n```json\n{"attack_detected": "true", "attack_type": 9, "confidence": 3}\n```'
|
| 16 |
+
assert parse_action(fenced) == {"attack_detected": True, "attack_type": 4, "confidence": 1.0, "protective_action": 0}
|
| 17 |
+
assert parse_action("no json here") is None
|
| 18 |
+
assert parse_action('{"attack_type": "x"}') is None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class _FakeClient:
|
| 22 |
+
"""Mimics openai.OpenAI().chat.completions.create."""
|
| 23 |
+
|
| 24 |
+
def __init__(self, reply=None, exc=None):
|
| 25 |
+
self.calls = 0
|
| 26 |
+
|
| 27 |
+
def create(**kw):
|
| 28 |
+
self.calls += 1
|
| 29 |
+
assert kw["messages"][0]["role"] == "system"
|
| 30 |
+
if exc:
|
| 31 |
+
raise exc
|
| 32 |
+
msg = types.SimpleNamespace(content=reply)
|
| 33 |
+
return types.SimpleNamespace(choices=[types.SimpleNamespace(message=msg)])
|
| 34 |
+
|
| 35 |
+
self.chat = types.SimpleNamespace(completions=types.SimpleNamespace(create=create))
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@pytest.fixture(autouse=True)
|
| 39 |
+
def _reset_breaker():
|
| 40 |
+
LLMAgent._disabled = False
|
| 41 |
+
LLMAgent._consecutive_failures = 0
|
| 42 |
+
yield
|
| 43 |
+
LLMAgent._disabled = False
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
LOG = re.compile(r"^\[(START|STEP|END)\] ")
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_llm_episode_and_log_format(capsys):
|
| 50 |
+
fake = _FakeClient('{"attack_detected": false, "attack_type": 0, "confidence": 0.9, "protective_action": 0}')
|
| 51 |
+
res = run_episode(0, 1, lambda sid: LocalEnv(), lambda: LLMAgent(fake), "fake-model")
|
| 52 |
+
out = capsys.readouterr().out.strip().splitlines()
|
| 53 |
+
assert fake.calls == 40 and res["success"] and res["stats"]["llm"] == 40
|
| 54 |
+
assert out[0].startswith("[START] task=sinusoidal_fdi_detection env=pll-cyberattack-detection model=fake-model")
|
| 55 |
+
assert all(LOG.match(line) for line in out)
|
| 56 |
+
assert sum(line.startswith("[STEP]") for line in out) == 40
|
| 57 |
+
end = out[-1]
|
| 58 |
+
assert re.match(r"^\[END\] success=true steps=40 score=\d\.\d{3} rewards=(-?\d+\.\d\d,){39}-?\d+\.\d\d$", end)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def test_auth_failure_falls_back_to_heuristic(capsys):
|
| 62 |
+
class E(Exception):
|
| 63 |
+
status_code = 402
|
| 64 |
+
fake = _FakeClient(exc=E("no credits"))
|
| 65 |
+
res = run_episode(2, 1, lambda sid: LocalEnv(), lambda: LLMAgent(fake), "fake-model")
|
| 66 |
+
assert res["success"] and fake.calls == 1 # disabled after the first permanent error
|
| 67 |
+
assert res["stats"]["fallback"] == 40 and res["score"] > 0.01
|
| 68 |
+
assert "llm_disabled_fallback" in capsys.readouterr().out
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_unparseable_output_does_not_crash(capsys):
|
| 72 |
+
fake = _FakeClient("I think there might be an attack")
|
| 73 |
+
res = run_episode(1, 3, lambda sid: LocalEnv(), lambda: LLMAgent(fake), "fake-model")
|
| 74 |
+
assert res["success"] and res["stats"]["fallback"] == 40 and not LLMAgent._disabled
|
| 75 |
+
assert "unparseable_llm_output" in capsys.readouterr().out
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def test_eval_seeds_cover_scenarios():
|
| 79 |
+
from pll_cyber.scenarios import sample_scenario
|
| 80 |
+
kinds = {t: {sample_scenario(t, s).attack.kind for s in inference.EVAL_SEEDS[t][:3]} for t in range(3)}
|
| 81 |
+
assert kinds[0] == {"sinusoidal", "none"}
|
| 82 |
+
assert kinds[1] == {"sinusoidal", "pulse", "ramp"}
|
| 83 |
+
assert kinds[2] == {"phase_spoofing", "none"}
|
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Physics regression tests: transforms, PLL dynamics, grid events and attacks."""
|
| 2 |
+
|
| 3 |
+
import math
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
from pll_cyber.attacks import Attack, AttackSpec
|
| 9 |
+
from pll_cyber.environment import PLLCyberEnvironment
|
| 10 |
+
from pll_cyber.evaluation import run_episode
|
| 11 |
+
from pll_cyber.grid import GridConfig, GridEvent, GridModel
|
| 12 |
+
from pll_cyber.models import PLLAction
|
| 13 |
+
from pll_cyber.pll import PLLGains, SRFPLL, clarke, park, wrap_angle
|
| 14 |
+
from pll_cyber.scenarios import DT
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def balanced(theta, v=1.0):
|
| 18 |
+
return [v * math.cos(theta - k * 2 * math.pi / 3) for k in range(3)]
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@pytest.mark.parametrize("theta", np.linspace(-3, 3, 13))
|
| 22 |
+
def test_clarke_park_identities(theta):
|
| 23 |
+
a, b = clarke(*balanced(theta))
|
| 24 |
+
assert a == pytest.approx(math.cos(theta), abs=1e-12)
|
| 25 |
+
assert b == pytest.approx(math.sin(theta), abs=1e-12)
|
| 26 |
+
vd, vq = park(a, b, theta) # locked
|
| 27 |
+
assert vd == pytest.approx(1.0, abs=1e-12) and vq == pytest.approx(0.0, abs=1e-12)
|
| 28 |
+
vd, vq = park(a, b, theta - 0.1) # θ̂ lags by 0.1 rad → vq = sin(0.1)
|
| 29 |
+
assert vq == pytest.approx(math.sin(0.1), abs=1e-12)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_gain_design():
|
| 33 |
+
g = PLLGains(omega_n=100.0, zeta=0.7071)
|
| 34 |
+
assert g.ki == pytest.approx(1e4)
|
| 35 |
+
assert g.kp == pytest.approx(141.42, rel=1e-3)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _simulate(events=(), n_windows=20, theta0_err=0.0):
|
| 39 |
+
grid = GridModel(GridConfig(h5_pu=0.0, h7_pu=0.0, f_drift_amp_hz=0.0, events=list(events)))
|
| 40 |
+
pll = SRFPLL(DT, theta0=theta0_err)
|
| 41 |
+
errs, freqs = [], []
|
| 42 |
+
for _ in range(n_windows):
|
| 43 |
+
out = grid.generate(250, DT)
|
| 44 |
+
r = pll.run(*out["v"])
|
| 45 |
+
errs += [math.degrees(wrap_angle(h - t)) for h, t in zip(r.theta_hat, out["theta"])]
|
| 46 |
+
freqs += r.f_hat
|
| 47 |
+
return np.array(errs), np.array(freqs)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_pll_locks_from_initial_error():
|
| 51 |
+
errs, _ = _simulate(theta0_err=math.radians(30), n_windows=6)
|
| 52 |
+
assert abs(errs[0]) == pytest.approx(30 - 3.6, abs=0.5) # θ advanced one sample (ω·Ts = 3.6°)
|
| 53 |
+
assert np.abs(errs[750:]).max() < 0.2 # locked within 150 ms
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_pll_tracks_frequency_step_without_steady_state_error():
|
| 57 |
+
# Type-2 loop: zero steady-state phase error for a frequency step
|
| 58 |
+
ev = GridEvent("frequency_event", 0.1, {"rocof_hz_s": 100.0, "delta_f_hz": 0.5})
|
| 59 |
+
errs, freqs = _simulate([ev], n_windows=20)
|
| 60 |
+
assert np.abs(errs[-1000:]).max() < 0.05
|
| 61 |
+
assert freqs[-1] == pytest.approx(50.5, abs=0.01)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def test_phase_jump_recovers():
|
| 65 |
+
errs, _ = _simulate([GridEvent("phase_jump", 0.2, {"angle_deg": 25.0})], n_windows=12)
|
| 66 |
+
jump = int(0.2 / DT)
|
| 67 |
+
assert np.abs(errs[jump:jump + 20]).max() > 20 # the jump is seen…
|
| 68 |
+
assert np.abs(errs[jump + 500:]).max() < 0.2 # …and tracked out within 100 ms
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@pytest.mark.parametrize("task_id", [0, 1, 2])
|
| 72 |
+
def test_natural_events_never_desynchronise(task_id):
|
| 73 |
+
env = PLLCyberEnvironment()
|
| 74 |
+
for seed in range(40):
|
| 75 |
+
obs = env.reset(task_id=task_id, seed=seed)
|
| 76 |
+
if env._sc.attack.present:
|
| 77 |
+
continue
|
| 78 |
+
while not obs.done:
|
| 79 |
+
obs = env.step(PLLAction(attack_detected=False))
|
| 80 |
+
assert obs.episode_result.scenario["unmitigated_desync_step"] is None
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _vq_trace(task_id, seed, remove_attack=False):
|
| 84 |
+
env = PLLCyberEnvironment()
|
| 85 |
+
obs = env.reset(task_id=task_id, seed=seed)
|
| 86 |
+
sc = env._sc
|
| 87 |
+
if remove_attack: # same grid, same noise streams, no attacker
|
| 88 |
+
env._attack = Attack(AttackSpec())
|
| 89 |
+
vq = []
|
| 90 |
+
while not obs.done:
|
| 91 |
+
obs = env.step(PLLAction(attack_detected=False))
|
| 92 |
+
vq.append(obs.features.vq_rms_pu)
|
| 93 |
+
return sc, obs.episode_result.scenario, np.array(vq)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def test_phase_spoofing_is_stealthy_but_destructive():
|
| 97 |
+
checked = 0
|
| 98 |
+
for seed in range(30):
|
| 99 |
+
sc, res, vq_att = _vq_trace(2, seed)
|
| 100 |
+
if not sc.attack.present:
|
| 101 |
+
continue
|
| 102 |
+
_, _, vq_clean = _vq_trace(2, seed, remove_attack=True)
|
| 103 |
+
# destructive: the unprotected inverter always loses synchronism, 0.3–0.75 s after onset
|
| 104 |
+
assert res["unmitigated_desync_step"] is not None
|
| 105 |
+
assert 6 <= res["unmitigated_desync_step"] - sc.onset_step <= 15
|
| 106 |
+
# stealthy: the attack leaves the PLL's own residual (vq) essentially unchanged
|
| 107 |
+
assert (np.abs(vq_att - vq_clean) <= 0.002 + 0.15 * vq_clean).all()
|
| 108 |
+
checked += 1
|
| 109 |
+
assert checked >= 15
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def test_pmu_disciplined_holdover_protects():
|
| 113 |
+
class Oracle:
|
| 114 |
+
def __init__(self, env):
|
| 115 |
+
self.env = env
|
| 116 |
+
|
| 117 |
+
def act(self, obs):
|
| 118 |
+
sc = self.env._sc
|
| 119 |
+
lab = sc.onset_step is not None and obs["step"] >= sc.onset_step
|
| 120 |
+
return {"attack_detected": lab, "attack_type": 4 if lab else 0, "confidence": 1.0,
|
| 121 |
+
"protective_action": 2 if lab else 0}
|
| 122 |
+
|
| 123 |
+
env = PLLCyberEnvironment()
|
| 124 |
+
for seed in range(30):
|
| 125 |
+
r = run_episode(Oracle(env), 2, seed, env)
|
| 126 |
+
if r["scenario"]["attack"]["kind"] != "none":
|
| 127 |
+
assert r["scenario"]["actual_desync_step"] is None, seed
|
| 128 |
+
assert r["scenario"]["max_sync_error_deg"] < 10.0
|
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HTTP/WebSocket API contract (OpenEnv runtime validation surface + session-aware simulation)."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
from fastapi.testclient import TestClient
|
| 5 |
+
|
| 6 |
+
from server.app import app
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@pytest.fixture(scope="module")
|
| 10 |
+
def client():
|
| 11 |
+
return TestClient(app)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def test_openenv_standard_endpoints(client):
|
| 15 |
+
assert client.get("/health").json() == {"status": "healthy"}
|
| 16 |
+
meta = client.get("/metadata").json()
|
| 17 |
+
assert meta["name"] and meta["description"]
|
| 18 |
+
schema = client.get("/schema").json()
|
| 19 |
+
assert {"action", "observation", "state"} <= set(schema)
|
| 20 |
+
assert "attack_detected" in schema["action"]["properties"]
|
| 21 |
+
assert client.post("/mcp", json={}).json()["jsonrpc"] == "2.0"
|
| 22 |
+
paths = client.get("/openapi.json").json()["paths"]
|
| 23 |
+
assert {"/reset", "/step", "/state"} <= set(paths)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_reset_accepts_empty_body(client):
|
| 27 |
+
r = client.post("/reset")
|
| 28 |
+
assert r.status_code == 200 and r.json()["observation"]["step"] == 0
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_full_episode_over_http(client):
|
| 32 |
+
r = client.post("/reset", json={"task_id": 2, "seed": 4, "session_id": "ep"})
|
| 33 |
+
assert r.status_code == 200
|
| 34 |
+
done, steps = False, 0
|
| 35 |
+
while not done:
|
| 36 |
+
r = client.post("/step", json={"action": {"attack_detected": False}, "session_id": "ep"})
|
| 37 |
+
assert r.status_code == 200
|
| 38 |
+
done = r.json()["done"]
|
| 39 |
+
steps += 1
|
| 40 |
+
assert steps == 40
|
| 41 |
+
res = r.json()["observation"]["episode_result"]
|
| 42 |
+
assert 0.0 <= res["grader_score"] <= 1.0
|
| 43 |
+
assert client.get("/state", params={"session_id": "ep"}).json()["scenario"] is not None
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_sessions_are_isolated(client):
|
| 47 |
+
client.post("/reset", json={"task_id": 0, "seed": 1, "session_id": "a"})
|
| 48 |
+
client.post("/reset", json={"task_id": 1, "seed": 1, "session_id": "b"})
|
| 49 |
+
for _ in range(3):
|
| 50 |
+
client.post("/step", json={"action": {"attack_detected": False}, "session_id": "a"})
|
| 51 |
+
sa = client.get("/state", params={"session_id": "a"}).json()
|
| 52 |
+
sb = client.get("/state", params={"session_id": "b"}).json()
|
| 53 |
+
assert sa["step_count"] == 3 and sb["step_count"] == 0 and sb["task_id"] == 1
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_flat_action_and_validation_errors(client):
|
| 57 |
+
client.post("/reset", json={"task_id": 0, "seed": 1})
|
| 58 |
+
ok = client.post("/step", json={"attack_detected": True, "attack_type": 1, "confidence": 0.5,
|
| 59 |
+
"protective_action": 0})
|
| 60 |
+
assert ok.status_code == 200
|
| 61 |
+
assert client.post("/step", json={"action": {"attack_detected": True, "attack_type": 9}}).status_code == 422
|
| 62 |
+
assert client.post("/step", json={"action": {"attack_detected": True, "bogus": 1}}).status_code == 422
|
| 63 |
+
assert client.post("/step", json={"action": {"attack_detected": False}, "session_id": "zz"}).status_code == 404
|
| 64 |
+
assert client.post("/reset", json={"task_id": 5}).status_code == 422
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_state_hides_ground_truth_mid_episode(client):
|
| 68 |
+
client.post("/reset", json={"task_id": 2, "seed": 1, "session_id": "gt"})
|
| 69 |
+
client.post("/step", json={"action": {"attack_detected": False}, "session_id": "gt"})
|
| 70 |
+
st = client.get("/state", params={"session_id": "gt"}).json()
|
| 71 |
+
assert st["scenario"] is None and st["grader_score"] is None
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def test_websocket_session(client):
|
| 75 |
+
with client.websocket_connect("/ws") as ws:
|
| 76 |
+
ws.send_json({"type": "reset", "data": {"task_id": 1, "seed": 2}})
|
| 77 |
+
m = ws.receive_json()
|
| 78 |
+
assert m["type"] == "observation" and m["data"]["observation"]["task_id"] == 1
|
| 79 |
+
ws.send_json({"type": "step", "data": {"attack_detected": False}})
|
| 80 |
+
m = ws.receive_json()
|
| 81 |
+
assert m["data"]["observation"]["step"] == 1
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_tasks_and_demo(client):
|
| 85 |
+
t = client.get("/tasks").json()
|
| 86 |
+
assert len(t["tasks"]) == 3 and t["reward_range"][0] < 0 < t["reward_range"][1]
|
| 87 |
+
d = client.get("/demo", params={"task_id": 0, "seed": 1}).json()
|
| 88 |
+
assert len(d["trace"]) == 40 and 0 <= d["score"] <= 1
|
| 89 |
+
assert client.get("/").status_code == 200
|
|
@@ -1,5 +1,5 @@
|
|
| 1 |
version = 1
|
| 2 |
-
revision =
|
| 3 |
requires-python = ">=3.10"
|
| 4 |
resolution-markers = [
|
| 5 |
"python_full_version >= '3.14' and sys_platform == 'win32'",
|
|
@@ -888,6 +888,15 @@ wheels = [
|
|
| 888 |
{ url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" },
|
| 889 |
]
|
| 890 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 891 |
[[package]]
|
| 892 |
name = "jaraco-classes"
|
| 893 |
version = "3.4.0"
|
|
@@ -1438,7 +1447,7 @@ wheels = [
|
|
| 1438 |
|
| 1439 |
[[package]]
|
| 1440 |
name = "openenv-core"
|
| 1441 |
-
version = "0.
|
| 1442 |
source = { registry = "https://pypi.org/simple" }
|
| 1443 |
dependencies = [
|
| 1444 |
{ name = "fastapi" },
|
|
@@ -1457,9 +1466,9 @@ dependencies = [
|
|
| 1457 |
{ name = "uvicorn" },
|
| 1458 |
{ name = "websockets" },
|
| 1459 |
]
|
| 1460 |
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
| 1461 |
wheels = [
|
| 1462 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1463 |
]
|
| 1464 |
|
| 1465 |
[[package]]
|
|
@@ -1573,10 +1582,10 @@ resolution-markers = [
|
|
| 1573 |
"python_full_version < '3.11'",
|
| 1574 |
]
|
| 1575 |
dependencies = [
|
| 1576 |
-
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }
|
| 1577 |
-
{ name = "python-dateutil"
|
| 1578 |
-
{ name = "pytz"
|
| 1579 |
-
{ name = "tzdata"
|
| 1580 |
]
|
| 1581 |
sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" }
|
| 1582 |
wheels = [
|
|
@@ -1645,9 +1654,9 @@ resolution-markers = [
|
|
| 1645 |
"python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'",
|
| 1646 |
]
|
| 1647 |
dependencies = [
|
| 1648 |
-
{ name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }
|
| 1649 |
-
{ name = "python-dateutil"
|
| 1650 |
-
{ name = "tzdata", marker = "
|
| 1651 |
]
|
| 1652 |
sdist = { url = "https://files.pythonhosted.org/packages/da/99/b342345300f13440fe9fe385c3c481e2d9a595ee3bab4d3219247ac94e9a/pandas-3.0.2.tar.gz", hash = "sha256:f4753e73e34c8d83221ba58f232433fca2748be8b18dbca02d242ed153945043", size = 4645855, upload-time = "2026-03-31T06:48:30.816Z" }
|
| 1653 |
wheels = [
|
|
@@ -1818,24 +1827,48 @@ wheels = [
|
|
| 1818 |
|
| 1819 |
[[package]]
|
| 1820 |
name = "pll-cyberattack-detection"
|
| 1821 |
-
version = "
|
| 1822 |
source = { editable = "." }
|
| 1823 |
dependencies = [
|
| 1824 |
{ name = "fastapi" },
|
| 1825 |
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
| 1826 |
{ name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
|
|
|
| 1827 |
{ name = "openenv-core" },
|
| 1828 |
{ name = "pydantic" },
|
|
|
|
|
|
|
| 1829 |
{ name = "uvicorn" },
|
| 1830 |
]
|
| 1831 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1832 |
[package.metadata]
|
| 1833 |
requires-dist = [
|
| 1834 |
-
{ name = "fastapi" },
|
| 1835 |
-
{ name = "
|
| 1836 |
-
{ name = "
|
| 1837 |
-
{ name = "
|
| 1838 |
-
{ name = "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1839 |
]
|
| 1840 |
|
| 1841 |
[[package]]
|
|
@@ -2068,6 +2101,24 @@ wheels = [
|
|
| 2068 |
{ url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" },
|
| 2069 |
]
|
| 2070 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2071 |
[[package]]
|
| 2072 |
name = "python-dateutil"
|
| 2073 |
version = "2.9.0.post0"
|
|
@@ -2396,8 +2447,8 @@ name = "secretstorage"
|
|
| 2396 |
version = "3.5.0"
|
| 2397 |
source = { registry = "https://pypi.org/simple" }
|
| 2398 |
dependencies = [
|
| 2399 |
-
{ name = "cryptography"
|
| 2400 |
-
{ name = "jeepney"
|
| 2401 |
]
|
| 2402 |
sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" }
|
| 2403 |
wheels = [
|
|
|
|
| 1 |
version = 1
|
| 2 |
+
revision = 5
|
| 3 |
requires-python = ">=3.10"
|
| 4 |
resolution-markers = [
|
| 5 |
"python_full_version >= '3.14' and sys_platform == 'win32'",
|
|
|
|
| 888 |
{ url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" },
|
| 889 |
]
|
| 890 |
|
| 891 |
+
[[package]]
|
| 892 |
+
name = "iniconfig"
|
| 893 |
+
version = "2.3.1"
|
| 894 |
+
source = { registry = "https://pypi.org/simple" }
|
| 895 |
+
sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
|
| 896 |
+
wheels = [
|
| 897 |
+
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
|
| 898 |
+
]
|
| 899 |
+
|
| 900 |
[[package]]
|
| 901 |
name = "jaraco-classes"
|
| 902 |
version = "3.4.0"
|
|
|
|
| 1447 |
|
| 1448 |
[[package]]
|
| 1449 |
name = "openenv-core"
|
| 1450 |
+
version = "0.3.0"
|
| 1451 |
source = { registry = "https://pypi.org/simple" }
|
| 1452 |
dependencies = [
|
| 1453 |
{ name = "fastapi" },
|
|
|
|
| 1466 |
{ name = "uvicorn" },
|
| 1467 |
{ name = "websockets" },
|
| 1468 |
]
|
| 1469 |
+
sdist = { url = "https://files.pythonhosted.org/packages/ce/d6/3bebe8afb55fcc3ea9251c4c2dfbab2879e31089bc91a8fe9696e5ce019b/openenv_core-0.3.0.tar.gz", hash = "sha256:c7fee2035badab5be497eb6f4afb2cb417de000f82cc19afd72fb5ec332c431d", size = 164720, upload-time = "2026-05-11T11:37:57.274Z" }
|
| 1470 |
wheels = [
|
| 1471 |
+
{ url = "https://files.pythonhosted.org/packages/f8/f5/aafa43138589bfd5d369a8d02ea365aae9d6fe55ac0b3894368d6d69bd03/openenv_core-0.3.0-py3-none-any.whl", hash = "sha256:859e875c9d5211b157c30fb9abc681606fcf0bf1b6ffcdf404678992823a1df0", size = 194313, upload-time = "2026-05-11T11:37:55.537Z" },
|
| 1472 |
]
|
| 1473 |
|
| 1474 |
[[package]]
|
|
|
|
| 1582 |
"python_full_version < '3.11'",
|
| 1583 |
]
|
| 1584 |
dependencies = [
|
| 1585 |
+
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } },
|
| 1586 |
+
{ name = "python-dateutil" },
|
| 1587 |
+
{ name = "pytz" },
|
| 1588 |
+
{ name = "tzdata" },
|
| 1589 |
]
|
| 1590 |
sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" }
|
| 1591 |
wheels = [
|
|
|
|
| 1654 |
"python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'",
|
| 1655 |
]
|
| 1656 |
dependencies = [
|
| 1657 |
+
{ name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" } },
|
| 1658 |
+
{ name = "python-dateutil" },
|
| 1659 |
+
{ name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
|
| 1660 |
]
|
| 1661 |
sdist = { url = "https://files.pythonhosted.org/packages/da/99/b342345300f13440fe9fe385c3c481e2d9a595ee3bab4d3219247ac94e9a/pandas-3.0.2.tar.gz", hash = "sha256:f4753e73e34c8d83221ba58f232433fca2748be8b18dbca02d242ed153945043", size = 4645855, upload-time = "2026-03-31T06:48:30.816Z" }
|
| 1662 |
wheels = [
|
|
|
|
| 1827 |
|
| 1828 |
[[package]]
|
| 1829 |
name = "pll-cyberattack-detection"
|
| 1830 |
+
version = "2.0.0"
|
| 1831 |
source = { editable = "." }
|
| 1832 |
dependencies = [
|
| 1833 |
{ name = "fastapi" },
|
| 1834 |
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
| 1835 |
{ name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
| 1836 |
+
{ name = "openai" },
|
| 1837 |
{ name = "openenv-core" },
|
| 1838 |
{ name = "pydantic" },
|
| 1839 |
+
{ name = "python-dotenv" },
|
| 1840 |
+
{ name = "requests" },
|
| 1841 |
{ name = "uvicorn" },
|
| 1842 |
]
|
| 1843 |
|
| 1844 |
+
[package.optional-dependencies]
|
| 1845 |
+
dev = [
|
| 1846 |
+
{ name = "httpx" },
|
| 1847 |
+
{ name = "pytest" },
|
| 1848 |
+
]
|
| 1849 |
+
|
| 1850 |
[package.metadata]
|
| 1851 |
requires-dist = [
|
| 1852 |
+
{ name = "fastapi", specifier = ">=0.110" },
|
| 1853 |
+
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" },
|
| 1854 |
+
{ name = "numpy", specifier = ">=1.26" },
|
| 1855 |
+
{ name = "openai", specifier = ">=1.40" },
|
| 1856 |
+
{ name = "openenv-core", specifier = ">=0.3.0,<0.4" },
|
| 1857 |
+
{ name = "pydantic", specifier = ">=2.6" },
|
| 1858 |
+
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
|
| 1859 |
+
{ name = "python-dotenv", specifier = ">=1.0" },
|
| 1860 |
+
{ name = "requests", specifier = ">=2.31" },
|
| 1861 |
+
{ name = "uvicorn", specifier = ">=0.29" },
|
| 1862 |
+
]
|
| 1863 |
+
provides-extras = ["dev"]
|
| 1864 |
+
|
| 1865 |
+
[[package]]
|
| 1866 |
+
name = "pluggy"
|
| 1867 |
+
version = "1.7.0"
|
| 1868 |
+
source = { registry = "https://pypi.org/simple" }
|
| 1869 |
+
sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }
|
| 1870 |
+
wheels = [
|
| 1871 |
+
{ url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
|
| 1872 |
]
|
| 1873 |
|
| 1874 |
[[package]]
|
|
|
|
| 2101 |
{ url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" },
|
| 2102 |
]
|
| 2103 |
|
| 2104 |
+
[[package]]
|
| 2105 |
+
name = "pytest"
|
| 2106 |
+
version = "9.1.1"
|
| 2107 |
+
source = { registry = "https://pypi.org/simple" }
|
| 2108 |
+
dependencies = [
|
| 2109 |
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
| 2110 |
+
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
| 2111 |
+
{ name = "iniconfig" },
|
| 2112 |
+
{ name = "packaging" },
|
| 2113 |
+
{ name = "pluggy" },
|
| 2114 |
+
{ name = "pygments" },
|
| 2115 |
+
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
| 2116 |
+
]
|
| 2117 |
+
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" }
|
| 2118 |
+
wheels = [
|
| 2119 |
+
{ 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" },
|
| 2120 |
+
]
|
| 2121 |
+
|
| 2122 |
[[package]]
|
| 2123 |
name = "python-dateutil"
|
| 2124 |
version = "2.9.0.post0"
|
|
|
|
| 2447 |
version = "3.5.0"
|
| 2448 |
source = { registry = "https://pypi.org/simple" }
|
| 2449 |
dependencies = [
|
| 2450 |
+
{ name = "cryptography" },
|
| 2451 |
+
{ name = "jeepney" },
|
| 2452 |
]
|
| 2453 |
sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" }
|
| 2454 |
wheels = [
|
|
@@ -1,314 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
validate_gains.py — KP/KI Gain Validation for PLL Attack Detection
|
| 3 |
-
====================================================================
|
| 4 |
-
Runs the full PLL simulation + heuristic agent across multiple KP/KI
|
| 5 |
-
values and all attack types. Reports settling time, detection accuracy,
|
| 6 |
-
and false-positive rate for each gain combination.
|
| 7 |
-
|
| 8 |
-
Usage:
|
| 9 |
-
python validate_gains.py
|
| 10 |
-
"""
|
| 11 |
-
|
| 12 |
-
import sys, os, math, json
|
| 13 |
-
import numpy as np
|
| 14 |
-
from collections import deque
|
| 15 |
-
|
| 16 |
-
# ---- Inline PLL (parameterized KP/KI) ----
|
| 17 |
-
|
| 18 |
-
V_NOM = 1.0
|
| 19 |
-
F0 = 50.0
|
| 20 |
-
OMEGA0 = 2.0 * math.pi * F0
|
| 21 |
-
DT = 1e-3
|
| 22 |
-
WINDOW_SIZE = 20
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
def wrap_angle(angle):
|
| 26 |
-
return (angle + math.pi) % (2.0 * math.pi) - math.pi
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
class PLL:
|
| 30 |
-
def __init__(self, kp, ki):
|
| 31 |
-
self.kp = kp
|
| 32 |
-
self.ki = ki
|
| 33 |
-
self.reset()
|
| 34 |
-
|
| 35 |
-
def reset(self):
|
| 36 |
-
self.t = 0.0
|
| 37 |
-
self.theta_true = 0.0
|
| 38 |
-
self.theta_hat = 0.0
|
| 39 |
-
self.omega_hat = OMEGA0
|
| 40 |
-
self.vq_integral = 0.0
|
| 41 |
-
self.vd = 0.0
|
| 42 |
-
self.vq = 0.0
|
| 43 |
-
|
| 44 |
-
def step(self, attack_signal=0.0):
|
| 45 |
-
va = V_NOM * math.sin(self.theta_true)
|
| 46 |
-
vb = V_NOM * math.sin(self.theta_true - 2.0 * math.pi / 3.0)
|
| 47 |
-
vc = V_NOM * math.sin(self.theta_true + 2.0 * math.pi / 3.0)
|
| 48 |
-
va_m = va + attack_signal
|
| 49 |
-
vb_m = vb
|
| 50 |
-
v_alpha = va_m
|
| 51 |
-
v_beta = (va_m + 2.0 * vb_m) / math.sqrt(3.0)
|
| 52 |
-
cos_th = math.cos(self.theta_hat)
|
| 53 |
-
sin_th = math.sin(self.theta_hat)
|
| 54 |
-
vd = v_alpha * cos_th + v_beta * sin_th
|
| 55 |
-
vq = -v_alpha * sin_th + v_beta * cos_th
|
| 56 |
-
self.vq_integral += vq * DT
|
| 57 |
-
omega_hat = OMEGA0 + self.kp * vq + self.ki * self.vq_integral
|
| 58 |
-
self.theta_hat += omega_hat * DT
|
| 59 |
-
self.theta_true += OMEGA0 * DT
|
| 60 |
-
theta_err = wrap_angle(self.theta_hat - self.theta_true)
|
| 61 |
-
self.t += DT
|
| 62 |
-
self.vd = vd
|
| 63 |
-
self.vq = vq
|
| 64 |
-
self.omega_hat = omega_hat
|
| 65 |
-
return {"vq": vq, "vd": vd, "omega_hat": omega_hat, "theta_err": theta_err}
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
# ---- Attack generators (from attacks.py) ----
|
| 69 |
-
|
| 70 |
-
def make_sinusoidal(amp, freq, phase):
|
| 71 |
-
def gen(step, t):
|
| 72 |
-
return amp * math.sin(2 * math.pi * freq * t + phase)
|
| 73 |
-
return gen
|
| 74 |
-
|
| 75 |
-
def make_ramp(rate):
|
| 76 |
-
def gen(step, t):
|
| 77 |
-
return rate * step
|
| 78 |
-
return gen
|
| 79 |
-
|
| 80 |
-
def make_pulse(mag, dur):
|
| 81 |
-
def gen(step, t):
|
| 82 |
-
return mag if step < dur else 0.0
|
| 83 |
-
return gen
|
| 84 |
-
|
| 85 |
-
def make_stealthy(amp, drift_rate):
|
| 86 |
-
state = {"delta": 0.0}
|
| 87 |
-
def gen(step, t):
|
| 88 |
-
state["delta"] += drift_rate * DT
|
| 89 |
-
return amp * math.sin(2 * math.pi * F0 * t + state["delta"])
|
| 90 |
-
return gen
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
# ---- Heuristic agent (simplified from inference.py) ----
|
| 94 |
-
|
| 95 |
-
class HeuristicDetector:
|
| 96 |
-
def __init__(self):
|
| 97 |
-
self.reset()
|
| 98 |
-
|
| 99 |
-
def reset(self):
|
| 100 |
-
self.vq_history = []
|
| 101 |
-
self.omega_dev_history = []
|
| 102 |
-
self.attack_detected = False
|
| 103 |
-
self.settled_baseline = None
|
| 104 |
-
self.peak_vq = 0.0
|
| 105 |
-
|
| 106 |
-
def decide(self, vq_window, omega_dev_window, step, task_id):
|
| 107 |
-
vq_abs = [abs(v) for v in vq_window]
|
| 108 |
-
vq_mean = sum(vq_abs) / len(vq_abs)
|
| 109 |
-
vq_max = max(vq_abs)
|
| 110 |
-
omega_dev_abs = [abs(v) for v in omega_dev_window]
|
| 111 |
-
omega_dev_mean = sum(omega_dev_abs) / len(omega_dev_abs)
|
| 112 |
-
self.vq_history.append(vq_mean)
|
| 113 |
-
self.omega_dev_history.append(omega_dev_mean)
|
| 114 |
-
self.peak_vq = max(self.peak_vq, vq_mean)
|
| 115 |
-
if step == 50:
|
| 116 |
-
self.settled_baseline = omega_dev_mean
|
| 117 |
-
|
| 118 |
-
# Task 0/1: vq-based detection
|
| 119 |
-
if task_id in (0, 1):
|
| 120 |
-
if step < 25:
|
| 121 |
-
detected = False
|
| 122 |
-
else:
|
| 123 |
-
detected = vq_mean > 0.01 or vq_max > 0.025
|
| 124 |
-
if detected:
|
| 125 |
-
self.attack_detected = True
|
| 126 |
-
return self.attack_detected
|
| 127 |
-
|
| 128 |
-
# Task 2: stealthy — omega deviation drift
|
| 129 |
-
if task_id == 2:
|
| 130 |
-
if step > 50 and self.settled_baseline is not None:
|
| 131 |
-
baseline = self.settled_baseline
|
| 132 |
-
ratio = omega_dev_mean / baseline if baseline > 0.01 else omega_dev_mean * 100
|
| 133 |
-
if len(self.omega_dev_history) > 10:
|
| 134 |
-
recent_10 = self.omega_dev_history[-10:]
|
| 135 |
-
old_10 = self.omega_dev_history[-20:-10] if len(self.omega_dev_history) > 20 else self.omega_dev_history[:10]
|
| 136 |
-
recent_avg = sum(recent_10) / len(recent_10)
|
| 137 |
-
old_avg = sum(old_10) / len(old_10)
|
| 138 |
-
rising = recent_avg > old_avg * 1.1
|
| 139 |
-
else:
|
| 140 |
-
rising = False
|
| 141 |
-
if ratio > 2.0 or (ratio > 1.3 and rising) or (rising and vq_mean > 0.1) or vq_mean > 0.2:
|
| 142 |
-
self.attack_detected = True
|
| 143 |
-
return self.attack_detected
|
| 144 |
-
|
| 145 |
-
return False
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
# ---- Validation runner ----
|
| 149 |
-
|
| 150 |
-
def measure_settling(pll, threshold=0.001, max_steps=200):
|
| 151 |
-
"""Run PLL with no attack and find when vq settles below threshold."""
|
| 152 |
-
pll.reset()
|
| 153 |
-
for s in range(max_steps):
|
| 154 |
-
out = pll.step(0.0)
|
| 155 |
-
if s > 5 and abs(out["vq"]) < threshold:
|
| 156 |
-
# Check it stays settled for 10 more steps
|
| 157 |
-
settled = True
|
| 158 |
-
for _ in range(10):
|
| 159 |
-
o2 = pll.step(0.0)
|
| 160 |
-
if abs(o2["vq"]) >= threshold:
|
| 161 |
-
settled = False
|
| 162 |
-
break
|
| 163 |
-
if settled:
|
| 164 |
-
return s
|
| 165 |
-
return max_steps
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
def run_episode(kp, ki, attack_fn, attack_start, task_id, max_steps=500):
|
| 169 |
-
"""
|
| 170 |
-
Run one full episode and return detection metrics.
|
| 171 |
-
Returns: (first_detect_step, n_correct_detect, n_missed, n_false_alarm, n_steps)
|
| 172 |
-
"""
|
| 173 |
-
pll = PLL(kp, ki)
|
| 174 |
-
det = HeuristicDetector()
|
| 175 |
-
vq_win = deque(maxlen=WINDOW_SIZE)
|
| 176 |
-
vd_win = deque(maxlen=WINDOW_SIZE)
|
| 177 |
-
omega_dev_win = deque(maxlen=WINDOW_SIZE)
|
| 178 |
-
|
| 179 |
-
# Warm-start
|
| 180 |
-
for _ in range(WINDOW_SIZE):
|
| 181 |
-
out = pll.step(0.0)
|
| 182 |
-
vq_win.append(out["vq"])
|
| 183 |
-
vd_win.append(out["vd"])
|
| 184 |
-
omega_dev_win.append(out["omega_hat"] - OMEGA0)
|
| 185 |
-
|
| 186 |
-
first_detect = None
|
| 187 |
-
n_tp = 0 # true positive steps
|
| 188 |
-
n_miss = 0 # missed detection steps
|
| 189 |
-
n_fp = 0 # false positive steps
|
| 190 |
-
n_tn = 0 # true negative steps
|
| 191 |
-
|
| 192 |
-
for step in range(max_steps):
|
| 193 |
-
attack_active = (step >= attack_start)
|
| 194 |
-
if attack_active:
|
| 195 |
-
steps_since = step - attack_start
|
| 196 |
-
sig = attack_fn(steps_since, pll.t)
|
| 197 |
-
else:
|
| 198 |
-
sig = 0.0
|
| 199 |
-
|
| 200 |
-
out = pll.step(sig)
|
| 201 |
-
vq_win.append(out["vq"])
|
| 202 |
-
vd_win.append(out["vd"])
|
| 203 |
-
omega_dev_win.append(out["omega_hat"] - OMEGA0)
|
| 204 |
-
|
| 205 |
-
detected = det.decide(list(vq_win), list(omega_dev_win), step, task_id)
|
| 206 |
-
|
| 207 |
-
if attack_active:
|
| 208 |
-
if detected:
|
| 209 |
-
n_tp += 1
|
| 210 |
-
if first_detect is None:
|
| 211 |
-
first_detect = step
|
| 212 |
-
else:
|
| 213 |
-
n_miss += 1
|
| 214 |
-
else:
|
| 215 |
-
if detected:
|
| 216 |
-
n_fp += 1
|
| 217 |
-
else:
|
| 218 |
-
n_tn += 1
|
| 219 |
-
|
| 220 |
-
return first_detect, n_tp, n_miss, n_fp, n_tn
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
def validate_gains(kp, ki, n_trials=20):
|
| 224 |
-
"""Run multiple trials for each attack type and report metrics."""
|
| 225 |
-
rng = np.random.default_rng(42)
|
| 226 |
-
results = {}
|
| 227 |
-
|
| 228 |
-
attack_configs = [
|
| 229 |
-
("sinusoidal", 0, lambda r: make_sinusoidal(
|
| 230 |
-
r.uniform(0.05, 0.20), r.uniform(5.0, 20.0), r.uniform(0, 2*math.pi))),
|
| 231 |
-
("ramp", 1, lambda r: make_ramp(r.uniform(0.0002, 0.001))),
|
| 232 |
-
("pulse", 1, lambda r: make_pulse(r.uniform(0.1, 0.3), int(r.integers(20, 81)))),
|
| 233 |
-
("stealthy", 2, lambda r: make_stealthy(0.03, r.uniform(0.05, 0.2))),
|
| 234 |
-
]
|
| 235 |
-
|
| 236 |
-
for atk_name, task_id, atk_factory in attack_configs:
|
| 237 |
-
detect_rates = []
|
| 238 |
-
false_alarm_rates = []
|
| 239 |
-
detect_delays = []
|
| 240 |
-
|
| 241 |
-
for _ in range(n_trials):
|
| 242 |
-
attack_start = int(rng.integers(30, 81))
|
| 243 |
-
atk_fn = atk_factory(rng)
|
| 244 |
-
first_det, n_tp, n_miss, n_fp, n_tn = run_episode(
|
| 245 |
-
kp, ki, atk_fn, attack_start, task_id)
|
| 246 |
-
|
| 247 |
-
total_attack_steps = n_tp + n_miss
|
| 248 |
-
total_safe_steps = n_fp + n_tn
|
| 249 |
-
dr = n_tp / max(1, total_attack_steps)
|
| 250 |
-
far = n_fp / max(1, total_safe_steps)
|
| 251 |
-
detect_rates.append(dr)
|
| 252 |
-
false_alarm_rates.append(far)
|
| 253 |
-
if first_det is not None:
|
| 254 |
-
detect_delays.append(first_det - attack_start)
|
| 255 |
-
|
| 256 |
-
avg_dr = sum(detect_rates) / len(detect_rates)
|
| 257 |
-
avg_far = sum(false_alarm_rates) / len(false_alarm_rates)
|
| 258 |
-
avg_delay = sum(detect_delays) / len(detect_delays) if detect_delays else float('inf')
|
| 259 |
-
pct_detected = len(detect_delays) / n_trials * 100
|
| 260 |
-
|
| 261 |
-
results[atk_name] = {
|
| 262 |
-
"detect_rate": avg_dr,
|
| 263 |
-
"false_alarm_rate": avg_far,
|
| 264 |
-
"avg_delay_steps": avg_delay,
|
| 265 |
-
"pct_episodes_detected": pct_detected,
|
| 266 |
-
}
|
| 267 |
-
|
| 268 |
-
return results
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
def main():
|
| 272 |
-
# Gain combinations to test
|
| 273 |
-
gain_sets = [
|
| 274 |
-
(50.0, 1500.0, "Current (zeta=0.645, t_s=160ms)"),
|
| 275 |
-
(266.0, 17689.0, "Ultra Fast (zeta=1.0, t_s=30ms)"),
|
| 276 |
-
(500.0, 62500.0, "Instant (zeta=1.0, t_s=16ms)"),
|
| 277 |
-
]
|
| 278 |
-
|
| 279 |
-
print("=" * 90)
|
| 280 |
-
print("PLL KP/KI Gain Validation - Attack Detection Accuracy")
|
| 281 |
-
print("=" * 90)
|
| 282 |
-
|
| 283 |
-
for kp, ki, label in gain_sets:
|
| 284 |
-
pll = PLL(kp, ki)
|
| 285 |
-
settling = measure_settling(pll)
|
| 286 |
-
omega_n = math.sqrt(ki)
|
| 287 |
-
zeta = kp / (2 * omega_n)
|
| 288 |
-
|
| 289 |
-
print(f"\n{'-' * 90}")
|
| 290 |
-
print(f" KP={kp:.0f} KI={ki:.0f} | omega_n={omega_n:.1f} rad/s zeta={zeta:.3f} "
|
| 291 |
-
f"settling={settling} steps | {label}")
|
| 292 |
-
print(f"{'-' * 90}")
|
| 293 |
-
|
| 294 |
-
results = validate_gains(kp, ki, n_trials=30)
|
| 295 |
-
|
| 296 |
-
print(f" {'Attack Type':<14} {'Detect Rate':>12} {'FA Rate':>10} {'Avg Delay':>12} {'Episodes Det':>14}")
|
| 297 |
-
for atk, m in results.items():
|
| 298 |
-
det_str = f"{m['detect_rate']*100:.1f}%"
|
| 299 |
-
fa_str = f"{m['false_alarm_rate']*100:.1f}%"
|
| 300 |
-
delay_str = f"{m['avg_delay_steps']:.0f} steps" if m['avg_delay_steps'] != float('inf') else "never"
|
| 301 |
-
ep_str = f"{m['pct_episodes_detected']:.0f}%"
|
| 302 |
-
print(f" {atk:<14} {det_str:>12} {fa_str:>10} {delay_str:>12} {ep_str:>14}")
|
| 303 |
-
|
| 304 |
-
# Summary recommendation
|
| 305 |
-
print(f"\n{'=' * 90}")
|
| 306 |
-
print("RECOMMENDATION")
|
| 307 |
-
print("=" * 90)
|
| 308 |
-
print("Choose gains that maximize detection rate + episodes detected")
|
| 309 |
-
print("while keeping false alarm rate at 0% and settling time < 30 steps.")
|
| 310 |
-
print("=" * 90)
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
if __name__ == "__main__":
|
| 314 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
File without changes
|