krishuggingface Claude Opus 5.5 commited on
Commit
c282634
·
1 Parent(s): ec5384c

v2: physically correct SRF-PLL, realistic attacks, exploit-proof graders

Browse files

Physics
- 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 ADDED
@@ -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
.env.example CHANGED
@@ -1,10 +1,13 @@
1
- # LLM Configuration
2
- USE_LLM=1
3
  API_BASE_URL=https://router.huggingface.co/v1
4
  MODEL_NAME=Qwen/Qwen2.5-72B-Instruct
5
- # Get your token from https://huggingface.co/settings/tokens
6
- HF_TOKEN=your_huggingface_token_here
7
 
8
- # Environment Configuration
9
- # Use http://0.0.0.0:7860 or http://localhost:7860 locally
10
- ENV_URL=http://localhost:7860
 
 
 
 
 
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
.gitattributes ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ *.sh text eol=lf
2
+ Dockerfile text eol=lf
.github/workflows/ci.yml ADDED
@@ -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
.github/workflows/deploy.yml CHANGED
@@ -2,22 +2,18 @@ name: Deploy to Hugging Face Space
2
 
3
  on:
4
  push:
5
- branches:
6
- - master
7
 
8
  jobs:
9
  deploy:
10
  runs-on: ubuntu-latest
11
-
12
  steps:
13
- - name: Checkout repository
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
 
.gitignore CHANGED
@@ -1,24 +1,26 @@
1
  # Python
2
  __pycache__/
3
- *.pyc
4
- *.pyo
5
  *.egg-info/
6
  dist/
7
  build/
8
 
9
- # Environment
10
  .env
11
  .venv/
12
  venv/
13
 
14
- # Testing
15
  .pytest_cache/
 
 
 
16
 
17
- # Data
18
- sample_data/
 
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
 
Dockerfile CHANGED
@@ -1,17 +1,25 @@
1
- FROM python:3.10-slim
2
 
3
- WORKDIR /app
 
 
 
4
 
5
- RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
 
 
6
 
7
  COPY requirements.txt .
8
- RUN pip install --no-cache-dir -r requirements.txt
9
 
10
- COPY . .
11
-
12
- HEALTHCHECK --interval=30s --timeout=10s --start-period=5s \
13
- CMD curl -f http://localhost:7860/health || exit 1
14
 
 
15
  EXPOSE 7860
16
 
17
- CMD ["uvicorn", "src.api:app", "--host", "0.0.0.0", "--port", "7860"]
 
 
 
 
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}"]
README.md CHANGED
@@ -1,162 +1,265 @@
1
  ---
2
- title: PLL Cyberattack Detection
3
  emoji: ⚡
4
  colorFrom: blue
5
  colorTo: purple
6
  sdk: docker
7
- app_file: Dockerfile
8
  pinned: false
 
 
 
 
 
9
  ---
10
- # PLL Cyberattack Detection — OpenEnv
11
-
12
- [![HF Space](https://img.shields.io/badge/🤗%20HuggingFace-Space%20Live-blue)](https://huggingface.co/spaces/krishuggingface/CyberAttack-PLL)
13
- [![Docker](https://img.shields.io/badge/Docker-Ready-2496ED?logo=docker&logoColor=white)](Dockerfile)
14
- [![OpenEnv](https://img.shields.io/badge/OpenEnv-Compliant-green)](openenv.yaml)
15
- [![Python](https://img.shields.io/badge/Python-3.10%2B-3776AB?logo=python&logoColor=white)](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
- | Task | ID | Difficulty | Attack Type | Objective | Score Metric |
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
- ## Observation Space
 
 
76
 
77
- At each step, the environment provides a JSON observation containing:
 
 
 
78
 
79
- | Field | Shape | Description |
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
- **Total observation dimension**: 83 ($20 \times 4 + 3$)
 
 
 
 
 
 
90
 
91
- ## Action Space
 
 
92
 
93
- Agents must return a structured JSON response predicting the system state:
94
 
95
- | Field | Type | Range | Description |
96
- |-------|------|-------|-------------|
97
- | `attack_detected` | `bool` | — | True if malicious injection is suspected. |
98
- | `attack_type` | `int` | 0–4 | 0=None, 1=Sinusoidal, 2=Ramp, 3=Pulse, 4=Stealthy. |
99
- | `confidence` | `float` | 0.0–1.0 | Absolute predictive certainty. |
100
- | `protective_action` | `int` | 0–3 | Suggested mitigation: 0=None, 1=Alert, 2=Reduce Power, 3=Disconnect. |
 
 
 
 
 
 
 
 
 
 
 
 
101
 
102
- ## Setup & API Usage
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
 
104
- The system acts as a standard REST API server over port `7860`.
105
 
106
- ### Local Setup
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
 
108
- **Via Python (Recommended)**:
109
  ```bash
110
  pip install -r requirements.txt
111
- uvicorn src.api:app --host 0.0.0.0 --port 7860
 
 
 
 
 
 
112
  ```
113
 
114
- **Via Docker**:
115
  ```bash
116
- docker build -t pll-cyberattack-env .
117
- docker run -p 7860:7860 pll-cyberattack-env
118
  ```
119
 
120
- ### Environment Variables
121
-
122
- Configure execution behavior locally via a `.env` file (see `.env.example`).
123
-
124
- | Variable | Default | Description |
125
- |----------|---------|-------------|
126
- | `API_BASE_URL` | `https://router.huggingface.co/v1` | Custom endpoint for Language Models. |
127
- | `MODEL_NAME` | `Qwen/Qwen2.5-72B-Instruct` | Internal Model identifier. |
128
- | `HF_TOKEN` | — | HuggingFace or valid proxy API key. |
129
- | `USE_LLM` | `1` | Set to `1` to run the active LLM agent, `0` for pure heuristics. |
130
-
131
- ### REST Endpoints
132
-
133
- 1. **POST `/reset`**
134
- Initializes the environment for a specific task.
135
- ```bash
136
- curl -X POST http://localhost:7860/reset \
137
- -H "Content-Type: application/json" \
138
- -d '{"task_id": 0, "seed": 42}'
139
- ```
140
-
141
- 2. **POST `/step`**
142
- Submit an action based on recent observations and advance by one tick.
143
- ```bash
144
- curl -X POST http://localhost:7860/step \
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
- * **Task 0 (Sinusoidal FDI):** 0.9900
157
- * **Task 1 (Multi-Attack Classification):** ~0.8720
158
- * **Task 2 (Stealthy Drift):** ~0.1639
159
- * **Aggregate System Average:** `0.6786`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
 
161
- ---
162
- 🚀 **Live Environment Hosted on HuggingFace Spaces**: [krishuggingface/CyberAttack-PLL](https://huggingface.co/spaces/krishuggingface/CyberAttack-PLL)
 
 
 
 
 
 
 
 
 
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
+ [![HF Space](https://img.shields.io/badge/🤗%20Space-live-blue)](https://huggingface.co/spaces/krishuggingface/CyberAttack-PLL)
19
+ [![OpenEnv](https://img.shields.io/badge/OpenEnv-validated-green)](openenv.yaml)
20
+ [![CI](https://github.com/KrisKeshav/PLL-Cyberattack-Detection-OpenEnv/actions/workflows/ci.yml/badge.svg)](.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.
inference.py CHANGED
@@ -1,495 +1,327 @@
1
  """
2
- Inference Script — PLL Cyberattack Detection OpenEnv
3
- =====================================================
4
- Environment variables (injected by the judging sandbox):
5
- API_BASE_URL LiteLLM proxy endpoint (MUST be used for all LLM calls)
6
- API_KEY LiteLLM proxy key (MUST be used — do not hardcode keys)
7
- MODEL_NAME Model identifier
8
- ENV_URL Environment server URL (default: http://localhost:7860)
9
-
10
- STDOUT FORMAT (OpenEnv compliance):
 
 
 
 
 
 
 
 
11
  [START] task=<task_name> env=<benchmark> model=<model_name>
12
- [STEP] step=<n> action=<action_str> reward=<0.00> done=<true|false> error=<msg|null>
13
  [END] success=<true|false> steps=<n> score=<score> rewards=<r1,r2,...,rn>
14
  """
15
 
 
 
 
16
  import os
 
17
  import sys
18
- import json
19
  import time
20
- import requests
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
- # ── Config — always read from environment, never hardcode ─────────────────────
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
- # OpenAI client pointed at the proxy — never bypass this
39
- client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
40
-
41
- # Persistent HTTP session for env calls — avoids TCP handshake per step
42
- _session = requests.Session()
43
-
44
- # ── Task metadata ─────────────────────────────────────────────────────────────
45
- TASK_NAMES = {
46
- 0: "Sinusoidal FDI Detection (Easy)",
47
- 1: "Multi-Attack Classification (Medium)",
48
- 2: "Stealthy Attack Detection (Hard)",
49
- }
50
 
51
  BENCHMARK = "pll-cyberattack-detection"
52
-
53
- DEFAULT_ACTION = {
54
- "attack_detected": False,
55
- "attack_type": 0,
56
- "confidence": 0.5,
57
- "protective_action": 0,
 
 
58
  }
59
 
60
- # ── System prompt ─────────────────────────────────────────────────────────────
61
- SYSTEM_PROMPT = """You are an AI agent monitoring a power grid inverter's Phase-Locked Loop (PLL).
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
- # ── Rule-Based Heuristic Agent ────────────────────────────────────────────────
 
144
 
145
- class HeuristicState:
146
- """Tracks running state for the heuristic agent across steps."""
147
 
148
- def __init__(self):
149
- self.reset()
 
 
 
 
 
 
 
150
 
151
- def reset(self):
152
- self.vq_history = []
153
- self.omega_dev_history = []
154
- self.attack_detected = False
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 heuristic_agent(obs: dict) -> dict:
164
- """Rule-based fallback — only called when the LLM request fails."""
165
- global _hstate
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
- if step == 50:
186
- _hstate.settled_baseline = omega_dev_mean
 
 
 
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
- # ── Task 0: binary detection ──────────────────────────────────────────────
193
- if task_id == 0:
194
- return {
195
- "attack_detected": _hstate.attack_detected,
196
- "attack_type": 1 if _hstate.attack_detected else 0,
197
- "confidence": min(1.0, vq_mean * 50) if _hstate.attack_detected else 0.8,
198
- "protective_action": 1 if _hstate.attack_detected else 0,
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
- n_elevated = sum(1 for v in _hstate.vq_history if v > 0.01)
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": drift_detected,
287
- "attack_type": 4 if drift_detected else 0,
288
- "confidence": confidence,
289
- "protective_action": 2 if drift_detected else 0,
290
  }
 
 
291
 
292
- return DEFAULT_ACTION.copy()
293
-
294
- # ── LLM agent (PRIMARY — always called first) ─────────────────────────────────
295
 
296
- def parse_llm_response(response_text: str) -> dict:
297
- try:
298
- text = response_text.strip()
299
- if text.startswith("```"):
300
- lines = text.split("\n")
301
- in_block = False
302
- json_lines: List[str] = []
303
- for line in lines:
304
- if line.strip().startswith("```") and not in_block:
305
- in_block = True
306
- continue
307
- elif line.strip().startswith("```") and in_block:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
  break
309
- elif in_block:
310
- json_lines.append(line)
311
- text = "\n".join(json_lines)
312
-
313
- parsed = json.loads(text)
314
- return {
315
- "attack_detected": bool(parsed.get("attack_detected", False)),
316
- "attack_type": max(0, min(4, int(parsed.get("attack_type", 0)))),
317
- "confidence": max(0.0, min(1.0, float(parsed.get("confidence", 0.5)))),
318
- "protective_action": max(0, min(3, int(parsed.get("protective_action", 0)))),
319
- }
320
- except (json.JSONDecodeError, KeyError, TypeError, ValueError):
321
- return DEFAULT_ACTION.copy()
322
-
323
-
324
- def format_observation(obs: dict) -> str:
325
- return "\n".join([
326
- f"Step: {obs['step']}",
327
- f"Task: {obs['task_id']}",
328
- f"vq_window (last 20): {[round(v, 6) for v in obs['vq_window']]}",
329
- f"vd_window (last 20): {[round(v, 6) for v in obs['vd_window']]}",
330
- f"omega_window (last 20): {[round(v, 6) for v in obs['omega_window']]}",
331
- f"omega_deviation_window (last 20): {[round(v, 6) for v in obs['omega_deviation_window']]}",
332
- f"raw_voltages: {[round(v, 6) for v in obs['raw_voltages']]}",
333
- ])
334
-
335
- _llm_disabled = False # circuit breaker — flips True after first LLM failure
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
- def run_episode(task_id: int) -> float:
368
- task_name = TASK_NAMES[task_id]
 
 
369
 
370
- log_start(task=task_name, env=BENCHMARK, model=MODEL_NAME)
 
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
- step_count = 0
377
- grader_score = 0.0
 
 
 
378
  rewards: List[float] = []
379
- success = False
380
-
381
  try:
382
- reset_resp = _session.post(
383
- f"{ENV_URL}/reset",
384
- json={"task_id": task_id},
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
- # Frame skipping: call LLM every 10 steps, heuristic in between.
396
- # This caps LLM calls at ~150 total across 3 tasks, keeping runtime
397
- # well under the 20-min judging limit even with 3s/call latency.
398
- if step_count % 10 == 0:
399
- action = llm_agent(obs, info)
400
- else:
401
- action = smart_blend_agent(obs, info)
402
-
403
- step_resp = _session.post(
404
- f"{ENV_URL}/step",
405
- json=action,
406
- timeout=60,
407
- )
408
- step_resp.raise_for_status()
409
- result = step_resp.json()
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
- try:
478
- for task_id in range(3):
479
- try:
480
- score = run_episode(task_id)
481
- except Exception as exc:
482
- print(f"[DEBUG] run_episode({task_id}) crashed: {exc}", file=sys.stderr, flush=True)
483
- score = 0.0
484
- scores.append(score)
485
- print(f"[DEBUG] task={task_id} score={score:.4f}", file=sys.stderr, flush=True)
486
- except BaseException as exc:
487
- print(f"[DEBUG] Process interrupted: {type(exc).__name__}: {exc}", file=sys.stderr, flush=True)
488
-
489
- elapsed = time.time() - start_time
490
- avg = sum(scores) / len(scores) if scores else 0.0
491
- print(f"[DEBUG] avg_score={avg:.4f} elapsed={elapsed:.1f}s", file=sys.stderr, flush=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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()
inference_test.py DELETED
@@ -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}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
openenv.yaml CHANGED
@@ -1,65 +1,47 @@
 
1
  name: pll-cyberattack-detection
2
- version: 1.0.0
 
 
 
 
 
 
3
  description: >
4
- Real-world OpenEnv for cyberattack detection on SRF-based
5
- Phase-Locked Loops in grid-connected inverters. An agent monitors
6
- sensor streams and detects False Data Injection attacks before
7
- they cause loss of grid synchronization.
8
- tags: [power-systems, cybersecurity, control-systems, openenv, pll, fdi]
9
- reward_range: [-2.2, 1.15]
10
- observation_space:
11
- type: continuous
12
- shape: [83]
 
 
 
 
13
  fields:
14
- - name: vq_window
15
- shape: [20]
16
- description: q-axis voltage error signal (pu)
17
- - name: vd_window
18
- shape: [20]
19
- description: d-axis voltage (pu)
20
- - name: omega_window
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: sinusoidal_fdi
 
45
  difficulty: easy
46
- numeric_id: 0
47
- grader: time_to_detection
48
- max_score: 1.0
49
- episode_length: 500
50
- description: Detect sinusoidal FDI attack within 100 steps of attack start
51
- - id: multi_attack_classification
52
  difficulty: medium
53
- numeric_id: 1
54
- grader: classification_accuracy
55
- max_score: 1.0
56
- episode_length: 500
57
- description: Classify attack type from observation window
58
- - id: stealthy_attack_detection
59
  difficulty: hard
60
- numeric_id: 2
61
- grader: pre_lock_loss_detection
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]
 
 
 
 
pll_cyber/__init__.py ADDED
@@ -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__"]
pll_cyber/attacks.py ADDED
@@ -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
pll_cyber/baselines.py ADDED
@@ -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])}
pll_cyber/client.py ADDED
@@ -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)
pll_cyber/environment.py ADDED
@@ -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
+ )
pll_cyber/evaluation.py ADDED
@@ -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
pll_cyber/features.py ADDED
@@ -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}
pll_cyber/graders.py ADDED
@@ -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)
pll_cyber/grid.py ADDED
@@ -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}
pll_cyber/models.py ADDED
@@ -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
+ )
pll_cyber/pll.py ADDED
@@ -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
pll_cyber/rewards.py ADDED
@@ -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
pll_cyber/scenarios.py ADDED
@@ -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)
pll_cyber/sensors.py ADDED
@@ -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
pyproject.toml CHANGED
@@ -1,19 +1,42 @@
1
  [build-system]
2
- requires = ["setuptools>=61.0"]
3
- build-backend = "setuptools.backends.legacy:build"
4
 
5
  [project]
6
  name = "pll-cyberattack-detection"
7
- version = "1.0.0"
8
- description = "OpenEnv for cyberattack detection on SRF-PLLs in grid-connected inverters"
 
9
  requires-python = ">=3.10"
 
 
10
  dependencies = [
11
- "fastapi",
12
- "uvicorn",
13
- "pydantic",
14
- "numpy",
15
- "openenv-core>=0.2.0",
 
 
 
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"]
requirements-dev.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ -r requirements.txt
2
+ pytest==9.1.1
3
+ httpx==0.28.1
requirements.txt CHANGED
@@ -1,8 +1,9 @@
1
- fastapi==0.115.0
2
- uvicorn==0.32.0
3
- pydantic==2.9.0
4
- numpy==1.26.4
5
- openai>=1.0.0
6
- requests>=2.31.0
7
- openenv-core>=0.2.0
8
- python-dotenv>=1.0.0
 
 
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
scripts/evaluate_baselines.py ADDED
@@ -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()
scripts/validate_submission.sh ADDED
@@ -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
server/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Server package for the PLL Cyber-Guard OpenEnv environment."""
server/app.py CHANGED
@@ -1,19 +1,222 @@
1
  """
2
- This is the server entry point for openenv validate compatibility.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  """
4
- import uvicorn
5
- from src.api import app # noqa: F401
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
 
8
- def main():
9
- """Starting the FastAPI server."""
10
- uvicorn.run(
11
- "src.api:app",
12
- host="0.0.0.0",
13
- port=7860,
14
- reload=False,
15
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
 
18
  if __name__ == "__main__":
19
- main()
 
 
 
 
 
 
 
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)
server/landing.html ADDED
@@ -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>
src/__init__.py DELETED
@@ -1 +0,0 @@
1
- # PLL Cyberattack Detection OpenEnv
 
 
src/api.py DELETED
@@ -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
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/attacks.py DELETED
@@ -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)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/detector.py DELETED
@@ -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
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/env.py DELETED
@@ -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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/graders.py DELETED
@@ -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))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/models.py DELETED
@@ -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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/pll_sim.py DELETED
@@ -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
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
submission.py DELETED
@@ -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[![HF Space](https://img.shields.io/badge/🤗%20HuggingFace-Space%20Live-blue)](https://huggingface.co/spaces/krishuggingface/CyberAttack-PLL)\n[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED?logo=docker&logoColor=white)](Dockerfile)\n[![OpenEnv](https://img.shields.io/badge/OpenEnv-Compliant-green)](openenv.yaml)\n[![Python](https://img.shields.io/badge/Python-3.10%2B-3776AB?logo=python&logoColor=white)](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)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_environment.py CHANGED
@@ -1,65 +1,134 @@
1
- import sys
2
- sys.path.insert(0, ".")
3
  import pytest
4
- import math
5
- from src.pll_sim import SRFPLLSimulator, OMEGA0
6
- from src.env import PLLAttackEnv
7
- from src.models import Action
8
- from src.attacks import AttackGenerator, sample_sinusoidal_params
9
- import numpy as np
10
-
11
- DUMMY_ACTION = Action(
12
- attack_detected=False,
13
- attack_type=0,
14
- confidence=0.5,
15
- protective_action=0
16
- )
17
-
18
-
19
- def test_episode_terminates_at_500():
20
- """Episode must terminate with done=True at step 500."""
21
- env = PLLAttackEnv()
22
- env.reset(task_id=0, seed=42)
23
- done = False
24
- steps = 0
25
- while not done:
26
- _, _, done, _ = env.step(DUMMY_ACTION)
27
- steps += 1
28
- assert steps == 500, f"Episode ended at step {steps}, expected 500"
29
-
30
- def test_all_tasks_reset():
31
- """All three tasks must reset without error."""
32
- env = PLLAttackEnv()
33
- for task_id in range(3):
34
- obs = env.reset(task_id=task_id, seed=42)
35
- assert obs.task_id == task_id
36
- assert obs.step == 0
37
- assert len(obs.vq_window) == 20
38
-
39
- def test_oracle_agent_nonzero_reward():
40
- """An oracle agent should accumulate positive reward."""
41
- env = PLLAttackEnv()
42
- env.reset(task_id=0, seed=42)
43
- total_reward = 0.0
44
- done = False
45
- while not done:
46
- action = Action(
47
- attack_detected=env.attack_active,
48
- attack_type=env.true_attack_type if env.attack_active else 0,
49
- confidence=1.0,
50
- protective_action=0
51
- )
52
- _, reward, done, _ = env.step(action)
53
- total_reward += reward.total
54
- assert total_reward > 0, f"Oracle agent got non-positive reward: {total_reward}"
55
-
56
- def test_reward_bounds():
57
- """Reward total must stay within [-2.5, 1.5] per step."""
58
- env = PLLAttackEnv()
59
- env.reset(task_id=2, seed=42)
60
- done = False
61
- while not done:
62
- _, reward, done, _ = env.step(DUMMY_ACTION)
63
- assert -2.5 <= reward.total <= 1.5, (
64
- f"Reward out of bounds: {reward.total}"
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)]
tests/test_graders.py ADDED
@@ -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
tests/test_inference.py ADDED
@@ -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"}
tests/test_physics.py ADDED
@@ -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
tests/test_server.py ADDED
@@ -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
uv.lock CHANGED
@@ -1,5 +1,5 @@
1
  version = 1
2
- revision = 3
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.2.3"
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/93/f3/41a5ed932a2507438c985e9d959dcaa1a6c46f293995c064348c0e52dd40/openenv_core-0.2.3.tar.gz", hash = "sha256:48aefd774474556297ce012b80f2ceb271db51253d7fd0838e6e2dcc329db0c3", size = 146944, upload-time = "2026-03-28T18:56:28.415Z" }
1461
  wheels = [
1462
- { url = "https://files.pythonhosted.org/packages/2f/22/38c339e370d198008f2c17ebdda1ae8f23bb4e1509dc7ae8eab6dc9b9cbe/openenv_core-0.2.3-py3-none-any.whl", hash = "sha256:f75a20c94452057a5f53a86e6d71a9f6a461524c3d6a865aa9344d257a92b795", size = 174557, upload-time = "2026-03-28T18:56:26.874Z" },
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" }, marker = "python_full_version < '3.11'" },
1577
- { name = "python-dateutil", marker = "python_full_version < '3.11'" },
1578
- { name = "pytz", marker = "python_full_version < '3.11'" },
1579
- { name = "tzdata", marker = "python_full_version < '3.11'" },
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" }, marker = "python_full_version >= '3.11'" },
1649
- { name = "python-dateutil", marker = "python_full_version >= '3.11'" },
1650
- { name = "tzdata", marker = "(python_full_version >= '3.11' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and sys_platform == 'win32')" },
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 = "1.0.0"
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 = "numpy" },
1836
- { name = "openenv-core", specifier = ">=0.2.0" },
1837
- { name = "pydantic" },
1838
- { name = "uvicorn" },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" },
2400
- { name = "jeepney", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" },
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 = [
validate_gains.py DELETED
@@ -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()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
validate_submission.sh DELETED
File without changes