holy agent
This commit is contained in:
220
README.md
220
README.md
@@ -1,2 +1,222 @@
|
|||||||
# HPCSimSite
|
# HPCSimSite
|
||||||
HPC simulation site
|
HPC simulation site
|
||||||
|
|
||||||
|
# F1 Virtual Race Engineer — Enrichment Module
|
||||||
|
|
||||||
|
This repo contains a minimal, dependency-free Python module to enrich Raspberry Pi telemetry (derived from FastF1) with HPC-style analytics features. It simulates the first LLM stage (data enrichment) using deterministic heuristics so you can run the pipeline locally and in CI without external services.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
- Accepts lap-level telemetry JSON records.
|
||||||
|
- Produces an enriched record with:
|
||||||
|
- aero_efficiency (0..1)
|
||||||
|
- tire_degradation_index (0..1, higher=worse)
|
||||||
|
- ers_charge (0..1)
|
||||||
|
- fuel_optimization_score (0..1)
|
||||||
|
- driver_consistency (0..1)
|
||||||
|
- weather_impact (low|medium|high)
|
||||||
|
|
||||||
|
## Expected input schema
|
||||||
|
Fields are extensible; these cover the initial POC.
|
||||||
|
|
||||||
|
Required (or sensible defaults applied):
|
||||||
|
- lap: int
|
||||||
|
- speed: float (km/h)
|
||||||
|
- throttle: float (0..1)
|
||||||
|
- brake: float (0..1)
|
||||||
|
- tire_compound: string (soft|medium|hard|inter|wet)
|
||||||
|
- fuel_level: float (0..1)
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
- ers: float (0..1)
|
||||||
|
- track_temp: float (Celsius)
|
||||||
|
- rain_probability: float (0..1)
|
||||||
|
|
||||||
|
Example telemetry line (JSONL):
|
||||||
|
{"lap":27,"speed":282,"throttle":0.91,"brake":0.05,"tire_compound":"medium","fuel_level":0.47}
|
||||||
|
|
||||||
|
## Output schema (enriched)
|
||||||
|
Example:
|
||||||
|
{"lap":27,"aero_efficiency":0.83,"tire_degradation_index":0.65,"ers_charge":0.72,"fuel_optimization_score":0.91,"driver_consistency":0.89,"weather_impact":"medium"}
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
### Run the CLI
|
||||||
|
The CLI reads JSON Lines (one JSON object per line) from stdin or a file and writes enriched JSON lines to stdout or a file.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/enrich_telemetry.py -i telemetry.jsonl -o enriched.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
Or stream:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat telemetry.jsonl | python3 scripts/enrich_telemetry.py > enriched.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
### Library usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
from hpcsim.enrichment import Enricher
|
||||||
|
|
||||||
|
enricher = Enricher()
|
||||||
|
out = enricher.enrich({
|
||||||
|
"lap": 1,
|
||||||
|
"speed": 250,
|
||||||
|
"throttle": 0.8,
|
||||||
|
"brake": 0.1,
|
||||||
|
"tire_compound": "medium",
|
||||||
|
"fuel_level": 0.6,
|
||||||
|
})
|
||||||
|
print(out)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- The enrichment maintains state across laps (e.g., cumulative tire wear, consistency from last up to 5 laps). If you restart the process mid-race, these will reset; you can re-feed prior laps to restore state.
|
||||||
|
- If your FastF1-derived telemetry has a different shape, share a sample and we can add adapters.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Run minimal tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m unittest tests/test_enrichment.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## API reference (Enrichment Service)
|
||||||
|
|
||||||
|
Base URL (local): http://localhost:8000
|
||||||
|
|
||||||
|
Interactive docs: http://localhost:8000/docs (Swagger) and http://localhost:8000/redoc
|
||||||
|
|
||||||
|
### Run the API server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/serve.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional downstream forwarding:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export NEXT_STAGE_CALLBACK_URL="http://localhost:9000/next-stage"
|
||||||
|
python3 scripts/serve.py
|
||||||
|
```
|
||||||
|
|
||||||
|
When set, every enriched record is also POSTed to the callback URL (best-effort, async). Ingestion still returns 200 even if forwarding fails.
|
||||||
|
|
||||||
|
### POST /ingest/telemetry
|
||||||
|
|
||||||
|
Accepts raw Raspberry Pi or FastF1-style telemetry, normalizes field names, enriches it, stores a recent copy, and returns the enriched record.
|
||||||
|
|
||||||
|
- Content-Type: application/json
|
||||||
|
- Request body (flexible/aliases allowed):
|
||||||
|
- lap (int) — aliases: Lap, LapNumber
|
||||||
|
- speed (float, km/h) — alias: Speed
|
||||||
|
- throttle (0..1) — alias: Throttle
|
||||||
|
- brake (0..1) — aliases: Brake, Brakes
|
||||||
|
- tire_compound (string: soft|medium|hard|inter|wet) — aliases: Compound, TyreCompound, Tire
|
||||||
|
- fuel_level (0..1) — aliases: Fuel, FuelRel, FuelLevel
|
||||||
|
- ers (0..1) — aliases: ERS, ERSCharge (optional)
|
||||||
|
- track_temp (Celsius) — alias: TrackTemp (optional)
|
||||||
|
- rain_probability (0..1) — aliases: RainProb, PrecipProb (optional)
|
||||||
|
|
||||||
|
Example request:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:8000/ingest/telemetry \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"LapNumber": 27,
|
||||||
|
"Speed": 282,
|
||||||
|
"Throttle": 0.91,
|
||||||
|
"Brakes": 0.05,
|
||||||
|
"TyreCompound": "medium",
|
||||||
|
"FuelRel": 0.47
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Response 200 (application/json):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"lap": 27,
|
||||||
|
"aero_efficiency": 0.83,
|
||||||
|
"tire_degradation_index": 0.65,
|
||||||
|
"ers_charge": 0.72,
|
||||||
|
"fuel_optimization_score": 0.91,
|
||||||
|
"driver_consistency": 0.89,
|
||||||
|
"weather_impact": "medium"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Errors:
|
||||||
|
- 400 if the body cannot be normalized/enriched
|
||||||
|
|
||||||
|
### POST /enriched
|
||||||
|
|
||||||
|
Accepts an already-enriched record (useful if enrichment runs elsewhere). Stores and echoes it back.
|
||||||
|
|
||||||
|
- Content-Type: application/json
|
||||||
|
- Request body:
|
||||||
|
- lap: int
|
||||||
|
- aero_efficiency: float (0..1)
|
||||||
|
- tire_degradation_index: float (0..1)
|
||||||
|
- ers_charge: float (0..1)
|
||||||
|
- fuel_optimization_score: float (0..1)
|
||||||
|
- driver_consistency: float (0..1)
|
||||||
|
- weather_impact: string (low|medium|high)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:8000/enriched \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"lap": 99,
|
||||||
|
"aero_efficiency": 0.8,
|
||||||
|
"tire_degradation_index": 0.5,
|
||||||
|
"ers_charge": 0.6,
|
||||||
|
"fuel_optimization_score": 0.9,
|
||||||
|
"driver_consistency": 0.95,
|
||||||
|
"weather_impact": "low"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /enriched
|
||||||
|
|
||||||
|
Returns an array of the most recent enriched records.
|
||||||
|
|
||||||
|
- Query params:
|
||||||
|
- limit: int (1..200, default 50)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "http://localhost:8000/enriched?limit=10"
|
||||||
|
```
|
||||||
|
|
||||||
|
Response 200 example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "lap": 27, "aero_efficiency": 0.83, "tire_degradation_index": 0.65, "ers_charge": 0.72, "fuel_optimization_score": 0.91, "driver_consistency": 0.89, "weather_impact": "medium" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /healthz
|
||||||
|
|
||||||
|
Health check.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:8000/healthz
|
||||||
|
```
|
||||||
|
|
||||||
|
Response 200 example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "status": "ok", "stored": 5 }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- Authentication/authorization is not enabled yet; add API keys or headers if deploying externally.
|
||||||
|
- Storage is in-memory (most recent ~200 records). For persistence, we can add Redis/SQLite.
|
||||||
|
- Forwarding to downstream (e.g., strategy LLM stage) is opt-in via `NEXT_STAGE_CALLBACK_URL`.
|
||||||
|
|||||||
1
hpcsim/__init__.py
Normal file
1
hpcsim/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
__all__ = ["enrichment"]
|
||||||
BIN
hpcsim/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
hpcsim/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
hpcsim/__pycache__/adapter.cpython-312.pyc
Normal file
BIN
hpcsim/__pycache__/adapter.cpython-312.pyc
Normal file
Binary file not shown.
BIN
hpcsim/__pycache__/api.cpython-312.pyc
Normal file
BIN
hpcsim/__pycache__/api.cpython-312.pyc
Normal file
Binary file not shown.
BIN
hpcsim/__pycache__/enrichment.cpython-312.pyc
Normal file
BIN
hpcsim/__pycache__/enrichment.cpython-312.pyc
Normal file
Binary file not shown.
103
hpcsim/adapter.py
Normal file
103
hpcsim/adapter.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_telemetry(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Normalize Pi/FastF1-like telemetry payload to Enricher expected schema.
|
||||||
|
|
||||||
|
Accepted aliases:
|
||||||
|
- speed: Speed
|
||||||
|
- throttle: Throttle
|
||||||
|
- brake: Brake, Brakes
|
||||||
|
- tire_compound: Compound, TyreCompound, Tire
|
||||||
|
- fuel_level: Fuel, FuelRel, FuelLevel
|
||||||
|
- ers: ERS, ERSCharge
|
||||||
|
- track_temp: TrackTemp
|
||||||
|
- rain_probability: RainProb, PrecipProb
|
||||||
|
- lap: Lap, LapNumber
|
||||||
|
Values are clamped and defaulted if missing.
|
||||||
|
"""
|
||||||
|
aliases = {
|
||||||
|
"lap": ["lap", "Lap", "LapNumber"],
|
||||||
|
"speed": ["speed", "Speed"],
|
||||||
|
"throttle": ["throttle", "Throttle"],
|
||||||
|
"brake": ["brake", "Brake", "Brakes"],
|
||||||
|
"tire_compound": ["tire_compound", "Compound", "TyreCompound", "Tire"],
|
||||||
|
"fuel_level": ["fuel_level", "Fuel", "FuelRel", "FuelLevel"],
|
||||||
|
"ers": ["ers", "ERS", "ERSCharge"],
|
||||||
|
"track_temp": ["track_temp", "TrackTemp"],
|
||||||
|
"rain_probability": ["rain_probability", "RainProb", "PrecipProb"],
|
||||||
|
}
|
||||||
|
|
||||||
|
out: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
def pick(key: str, default=None):
|
||||||
|
for k in aliases.get(key, [key]):
|
||||||
|
if k in payload and payload[k] is not None:
|
||||||
|
return payload[k]
|
||||||
|
return default
|
||||||
|
|
||||||
|
def clamp01(x, default=0.0):
|
||||||
|
try:
|
||||||
|
v = float(x)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
return max(0.0, min(1.0, v))
|
||||||
|
|
||||||
|
# Map values with sensible defaults
|
||||||
|
lap = pick("lap", 0)
|
||||||
|
try:
|
||||||
|
lap = int(lap)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
lap = 0
|
||||||
|
|
||||||
|
speed = pick("speed", 0.0)
|
||||||
|
try:
|
||||||
|
speed = float(speed)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
speed = 0.0
|
||||||
|
|
||||||
|
throttle = clamp01(pick("throttle", 0.0), 0.0)
|
||||||
|
brake = clamp01(pick("brake", 0.0), 0.0)
|
||||||
|
|
||||||
|
tire_compound = pick("tire_compound", "medium")
|
||||||
|
if isinstance(tire_compound, str):
|
||||||
|
tire_compound = tire_compound.lower()
|
||||||
|
else:
|
||||||
|
tire_compound = "medium"
|
||||||
|
|
||||||
|
fuel_level = clamp01(pick("fuel_level", 0.5), 0.5)
|
||||||
|
|
||||||
|
ers = pick("ers", None)
|
||||||
|
if ers is not None:
|
||||||
|
ers = clamp01(ers, None)
|
||||||
|
|
||||||
|
track_temp = pick("track_temp", None)
|
||||||
|
try:
|
||||||
|
track_temp = float(track_temp) if track_temp is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
track_temp = None
|
||||||
|
|
||||||
|
rain_prob = pick("rain_probability", None)
|
||||||
|
try:
|
||||||
|
rain_prob = clamp01(rain_prob, None) if rain_prob is not None else None
|
||||||
|
except Exception:
|
||||||
|
rain_prob = None
|
||||||
|
|
||||||
|
out.update({
|
||||||
|
"lap": lap,
|
||||||
|
"speed": speed,
|
||||||
|
"throttle": throttle,
|
||||||
|
"brake": brake,
|
||||||
|
"tire_compound": tire_compound,
|
||||||
|
"fuel_level": fuel_level,
|
||||||
|
})
|
||||||
|
if ers is not None:
|
||||||
|
out["ers"] = ers
|
||||||
|
if track_temp is not None:
|
||||||
|
out["track_temp"] = track_temp
|
||||||
|
if rain_prob is not None:
|
||||||
|
out["rain_probability"] = rain_prob
|
||||||
|
|
||||||
|
return out
|
||||||
83
hpcsim/api.py
Normal file
83
hpcsim/api.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Body, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .enrichment import Enricher
|
||||||
|
from .adapter import normalize_telemetry
|
||||||
|
|
||||||
|
app = FastAPI(title="HPCSim Enrichment API", version="0.1.0")
|
||||||
|
|
||||||
|
# Single Enricher instance keeps state across laps
|
||||||
|
_enricher = Enricher()
|
||||||
|
|
||||||
|
# Simple in-memory store of recent enriched records
|
||||||
|
_recent: List[Dict[str, Any]] = []
|
||||||
|
_MAX_RECENT = 200
|
||||||
|
|
||||||
|
# Optional callback URL to forward enriched data to next stage
|
||||||
|
_CALLBACK_URL = os.getenv("NEXT_STAGE_CALLBACK_URL")
|
||||||
|
|
||||||
|
|
||||||
|
class EnrichedRecord(BaseModel):
|
||||||
|
lap: int
|
||||||
|
aero_efficiency: float
|
||||||
|
tire_degradation_index: float
|
||||||
|
ers_charge: float
|
||||||
|
fuel_optimization_score: float
|
||||||
|
driver_consistency: float
|
||||||
|
weather_impact: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/ingest/telemetry")
|
||||||
|
async def ingest_telemetry(payload: Dict[str, Any] = Body(...)):
|
||||||
|
"""Receive raw telemetry (from Pi), normalize, enrich, return enriched.
|
||||||
|
|
||||||
|
Optionally forwards to NEXT_STAGE_CALLBACK_URL if set.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
normalized = normalize_telemetry(payload)
|
||||||
|
enriched = _enricher.enrich(normalized)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Failed to enrich: {e}")
|
||||||
|
|
||||||
|
_recent.append(enriched)
|
||||||
|
if len(_recent) > _MAX_RECENT:
|
||||||
|
del _recent[: len(_recent) - _MAX_RECENT]
|
||||||
|
|
||||||
|
# Async forward to next stage if configured
|
||||||
|
if _CALLBACK_URL:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
await client.post(_CALLBACK_URL, json=enriched)
|
||||||
|
except Exception:
|
||||||
|
# Don't fail ingestion if forwarding fails; log could be added here
|
||||||
|
pass
|
||||||
|
|
||||||
|
return JSONResponse(enriched)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/enriched")
|
||||||
|
async def post_enriched(enriched: EnrichedRecord):
|
||||||
|
"""Allow posting externally enriched records (bypass local computation)."""
|
||||||
|
rec = enriched.model_dump()
|
||||||
|
_recent.append(rec)
|
||||||
|
if len(_recent) > _MAX_RECENT:
|
||||||
|
del _recent[: len(_recent) - _MAX_RECENT]
|
||||||
|
return JSONResponse(rec)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/enriched")
|
||||||
|
async def list_enriched(limit: int = 50):
|
||||||
|
limit = max(1, min(200, limit))
|
||||||
|
return JSONResponse(_recent[-limit:])
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/healthz")
|
||||||
|
async def healthz():
|
||||||
|
return {"status": "ok", "stored": len(_recent)}
|
||||||
168
hpcsim/enrichment.py
Normal file
168
hpcsim/enrichment.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
import math
|
||||||
|
|
||||||
|
|
||||||
|
# --- Contracts ---
|
||||||
|
# Input telemetry (example, extensible):
|
||||||
|
# {
|
||||||
|
# "lap": 27,
|
||||||
|
# "speed": 282, # km/h
|
||||||
|
# "throttle": 0.91, # 0..1
|
||||||
|
# "brake": 0.05, # 0..1
|
||||||
|
# "tire_compound": "medium",# soft|medium|hard|inter|wet
|
||||||
|
# "fuel_level": 0.47, # 0..1 (fraction of race fuel)
|
||||||
|
# "ers": 0.72, # optional 0..1
|
||||||
|
# "track_temp": 38, # optional Celsius
|
||||||
|
# "rain_probability": 0.2 # optional 0..1
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# Output enrichment:
|
||||||
|
# {
|
||||||
|
# "lap": 27,
|
||||||
|
# "aero_efficiency": 0.83, # 0..1
|
||||||
|
# "tire_degradation_index": 0.65, # 0..1 (higher=worse)
|
||||||
|
# "ers_charge": 0.72, # 0..1
|
||||||
|
# "fuel_optimization_score": 0.91, # 0..1
|
||||||
|
# "driver_consistency": 0.89, # 0..1
|
||||||
|
# "weather_impact": "low|medium|high"
|
||||||
|
# }
|
||||||
|
|
||||||
|
|
||||||
|
_TIRES_BASE_WEAR = {
|
||||||
|
"soft": 0.012,
|
||||||
|
"medium": 0.008,
|
||||||
|
"hard": 0.006,
|
||||||
|
"inter": 0.015,
|
||||||
|
"wet": 0.02,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EnricherState:
|
||||||
|
last_lap: Optional[int] = None
|
||||||
|
lap_speeds: Dict[int, float] = field(default_factory=dict)
|
||||||
|
lap_throttle_avg: Dict[int, float] = field(default_factory=dict)
|
||||||
|
cumulative_wear: float = 0.0 # 0..1 approx
|
||||||
|
|
||||||
|
|
||||||
|
class Enricher:
|
||||||
|
"""Heuristic enrichment engine to simulate HPC analytics on telemetry.
|
||||||
|
|
||||||
|
Stateless inputs are enriched with stateful estimates (wear, consistency, etc.).
|
||||||
|
Designed for predictable, dependency-free behavior.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.state = EnricherState()
|
||||||
|
|
||||||
|
# --- Public API ---
|
||||||
|
def enrich(self, telemetry: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
lap = int(telemetry.get("lap", 0))
|
||||||
|
speed = float(telemetry.get("speed", 0.0))
|
||||||
|
throttle = float(telemetry.get("throttle", 0.0))
|
||||||
|
brake = float(telemetry.get("brake", 0.0))
|
||||||
|
tire_compound = str(telemetry.get("tire_compound", "medium")).lower()
|
||||||
|
fuel_level = float(telemetry.get("fuel_level", 0.5))
|
||||||
|
ers = telemetry.get("ers")
|
||||||
|
track_temp = telemetry.get("track_temp")
|
||||||
|
rain_prob = telemetry.get("rain_probability")
|
||||||
|
|
||||||
|
# Update per-lap aggregates
|
||||||
|
self._update_lap_stats(lap, speed, throttle)
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
aero_eff = self._compute_aero_efficiency(speed, throttle, brake)
|
||||||
|
tire_deg = self._compute_tire_degradation(lap, speed, throttle, tire_compound, track_temp)
|
||||||
|
ers_charge = self._compute_ers_charge(ers, throttle, brake)
|
||||||
|
fuel_opt = self._compute_fuel_optimization(fuel_level, throttle)
|
||||||
|
consistency = self._compute_driver_consistency()
|
||||||
|
weather_impact = self._compute_weather_impact(rain_prob, track_temp)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"lap": lap,
|
||||||
|
"aero_efficiency": round(aero_eff, 3),
|
||||||
|
"tire_degradation_index": round(tire_deg, 3),
|
||||||
|
"ers_charge": round(ers_charge, 3),
|
||||||
|
"fuel_optimization_score": round(fuel_opt, 3),
|
||||||
|
"driver_consistency": round(consistency, 3),
|
||||||
|
"weather_impact": weather_impact,
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Internals ---
|
||||||
|
def _update_lap_stats(self, lap: int, speed: float, throttle: float) -> None:
|
||||||
|
if lap <= 0:
|
||||||
|
return
|
||||||
|
# Store simple aggregates for consistency metrics
|
||||||
|
self.state.lap_speeds[lap] = speed
|
||||||
|
self.state.lap_throttle_avg[lap] = 0.8 * self.state.lap_throttle_avg.get(lap, throttle) + 0.2 * throttle
|
||||||
|
self.state.last_lap = lap
|
||||||
|
|
||||||
|
def _compute_aero_efficiency(self, speed: float, throttle: float, brake: float) -> float:
|
||||||
|
# Heuristic: favor high speed with low throttle variance (efficiency) and minimal braking at high speeds
|
||||||
|
# Normalize speed into 0..1 assuming 0..330 km/h typical
|
||||||
|
speed_n = max(0.0, min(1.0, speed / 330.0))
|
||||||
|
brake_penalty = 0.4 * brake
|
||||||
|
throttle_bonus = 0.2 * throttle
|
||||||
|
base = 0.5 * speed_n + throttle_bonus - brake_penalty
|
||||||
|
return max(0.0, min(1.0, base))
|
||||||
|
|
||||||
|
def _compute_tire_degradation(self, lap: int, speed: float, throttle: float, tire_compound: str, track_temp: Optional[float]) -> float:
|
||||||
|
base_wear = _TIRES_BASE_WEAR.get(tire_compound, _TIRES_BASE_WEAR["medium"]) # per lap
|
||||||
|
temp_factor = 1.0
|
||||||
|
if isinstance(track_temp, (int, float)):
|
||||||
|
if track_temp > 42:
|
||||||
|
temp_factor = 1.25
|
||||||
|
elif track_temp < 15:
|
||||||
|
temp_factor = 0.9
|
||||||
|
stress = 0.5 + 0.5 * throttle + 0.2 * max(0.0, (speed - 250.0) / 100.0)
|
||||||
|
wear_this_lap = base_wear * stress * temp_factor
|
||||||
|
# Update cumulative wear but cap at 1.0
|
||||||
|
self.state.cumulative_wear = min(1.0, self.state.cumulative_wear + wear_this_lap)
|
||||||
|
return self.state.cumulative_wear
|
||||||
|
|
||||||
|
def _compute_ers_charge(self, ers: Optional[float], throttle: float, brake: float) -> float:
|
||||||
|
if isinstance(ers, (int, float)):
|
||||||
|
# simple recovery under braking, depletion under throttle
|
||||||
|
ers_level = float(ers) + 0.1 * brake - 0.05 * throttle
|
||||||
|
else:
|
||||||
|
# infer ers trend if not provided
|
||||||
|
ers_level = 0.6 + 0.05 * brake - 0.03 * throttle
|
||||||
|
return max(0.0, min(1.0, ers_level))
|
||||||
|
|
||||||
|
def _compute_fuel_optimization(self, fuel_level: float, throttle: float) -> float:
|
||||||
|
# Reward keeping throttle moderate when fuel is low and pushing when fuel is high
|
||||||
|
fuel_n = max(0.0, min(1.0, fuel_level))
|
||||||
|
ideal_throttle = 0.5 + 0.4 * fuel_n # higher fuel -> higher ideal throttle
|
||||||
|
penalty = abs(throttle - ideal_throttle)
|
||||||
|
score = 1.0 - penalty
|
||||||
|
return max(0.0, min(1.0, score))
|
||||||
|
|
||||||
|
def _compute_driver_consistency(self) -> float:
|
||||||
|
# Use last up to 5 laps speed variance to estimate consistency (lower variance -> higher consistency)
|
||||||
|
laps = sorted(self.state.lap_speeds.keys())[-5:]
|
||||||
|
if not laps:
|
||||||
|
return 0.5
|
||||||
|
speeds = [self.state.lap_speeds[l] for l in laps]
|
||||||
|
mean = sum(speeds) / len(speeds)
|
||||||
|
var = sum((s - mean) ** 2 for s in speeds) / len(speeds)
|
||||||
|
# Map variance to 0..1; assume 0..(30 km/h)^2 typical range
|
||||||
|
norm = min(1.0, var / (30.0 ** 2))
|
||||||
|
return max(0.0, 1.0 - norm)
|
||||||
|
|
||||||
|
def _compute_weather_impact(self, rain_prob: Optional[float], track_temp: Optional[float]) -> str:
|
||||||
|
score = 0.0
|
||||||
|
if isinstance(rain_prob, (int, float)):
|
||||||
|
score += 0.7 * float(rain_prob)
|
||||||
|
if isinstance(track_temp, (int, float)):
|
||||||
|
if track_temp < 12: # cold tires harder
|
||||||
|
score += 0.2
|
||||||
|
if track_temp > 45: # overheating
|
||||||
|
score += 0.2
|
||||||
|
if score < 0.3:
|
||||||
|
return "low"
|
||||||
|
if score < 0.6:
|
||||||
|
return "medium"
|
||||||
|
return "high"
|
||||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fastapi==0.115.2
|
||||||
|
uvicorn==0.31.1
|
||||||
|
httpx==0.27.2
|
||||||
47
scripts/enrich_telemetry.py
Normal file
47
scripts/enrich_telemetry.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from typing import Iterable, Dict, Any
|
||||||
|
|
||||||
|
from hpcsim.enrichment import Enricher
|
||||||
|
|
||||||
|
|
||||||
|
def iter_json_lines(stream) -> Iterable[Dict[str, Any]]:
|
||||||
|
for line in stream:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
yield json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
print(f"Skipping invalid JSON line: {line}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Enrich telemetry JSON lines with HPC-style metrics")
|
||||||
|
parser.add_argument("--input", "-i", help="Input file path (JSON lines). Reads stdin if omitted.")
|
||||||
|
parser.add_argument("--output", "-o", help="Output file path (JSON lines). Writes stdout if omitted.")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
enricher = Enricher()
|
||||||
|
|
||||||
|
fin = open(args.input, "r") if args.input else sys.stdin
|
||||||
|
fout = open(args.output, "w") if args.output else sys.stdout
|
||||||
|
|
||||||
|
try:
|
||||||
|
for rec in iter_json_lines(fin):
|
||||||
|
enriched = enricher.enrich(rec)
|
||||||
|
print(json.dumps(enriched), file=fout)
|
||||||
|
fout.flush()
|
||||||
|
finally:
|
||||||
|
if fin is not sys.stdin:
|
||||||
|
fin.close()
|
||||||
|
if fout is not sys.stdout:
|
||||||
|
fout.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
8
scripts/serve.py
Normal file
8
scripts/serve.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run("hpcsim.api:app", host="0.0.0.0", port=8000, reload=False)
|
||||||
BIN
tests/__pycache__/test_adapter.cpython-312.pyc
Normal file
BIN
tests/__pycache__/test_adapter.cpython-312.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_api.cpython-312.pyc
Normal file
BIN
tests/__pycache__/test_api.cpython-312.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_enrichment.cpython-312.pyc
Normal file
BIN
tests/__pycache__/test_enrichment.cpython-312.pyc
Normal file
Binary file not shown.
32
tests/test_adapter.py
Normal file
32
tests/test_adapter.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from hpcsim.adapter import normalize_telemetry
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdapter(unittest.TestCase):
|
||||||
|
def test_alias_mapping_and_clamping(self):
|
||||||
|
raw = {
|
||||||
|
"LapNumber": "12",
|
||||||
|
"Speed": "280.5",
|
||||||
|
"Throttle": 1.2, # will clamp
|
||||||
|
"Brakes": -0.1, # will clamp
|
||||||
|
"TyreCompound": "Soft",
|
||||||
|
"FuelRel": 1.5, # will clamp
|
||||||
|
"ERS": 0.45,
|
||||||
|
"TrackTemp": "41",
|
||||||
|
"RainProb": 0.3,
|
||||||
|
}
|
||||||
|
norm = normalize_telemetry(raw)
|
||||||
|
self.assertEqual(norm["lap"], 12)
|
||||||
|
self.assertAlmostEqual(norm["speed"], 280.5, places=2)
|
||||||
|
self.assertEqual(norm["throttle"], 1.0)
|
||||||
|
self.assertEqual(norm["brake"], 0.0)
|
||||||
|
self.assertEqual(norm["tire_compound"], "soft")
|
||||||
|
self.assertEqual(norm["fuel_level"], 1.0)
|
||||||
|
self.assertIn("ers", norm)
|
||||||
|
self.assertIn("track_temp", norm)
|
||||||
|
self.assertIn("rain_probability", norm)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
47
tests/test_api.py
Normal file
47
tests/test_api.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from hpcsim.api import app
|
||||||
|
|
||||||
|
|
||||||
|
class TestAPI(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = TestClient(app)
|
||||||
|
|
||||||
|
def test_ingest_and_list(self):
|
||||||
|
payload = {
|
||||||
|
"lap": 1,
|
||||||
|
"speed": 250,
|
||||||
|
"throttle": 0.8,
|
||||||
|
"brake": 0.1,
|
||||||
|
"tire_compound": "medium",
|
||||||
|
"fuel_level": 0.6,
|
||||||
|
}
|
||||||
|
r = self.client.post("/ingest/telemetry", json=payload)
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
enriched = r.json()
|
||||||
|
self.assertIn("aero_efficiency", enriched)
|
||||||
|
|
||||||
|
list_r = self.client.get("/enriched", params={"limit": 5})
|
||||||
|
self.assertEqual(list_r.status_code, 200)
|
||||||
|
data = list_r.json()
|
||||||
|
self.assertTrue(isinstance(data, list) and len(data) >= 1)
|
||||||
|
|
||||||
|
def test_post_enriched(self):
|
||||||
|
enriched = {
|
||||||
|
"lap": 99,
|
||||||
|
"aero_efficiency": 0.8,
|
||||||
|
"tire_degradation_index": 0.5,
|
||||||
|
"ers_charge": 0.6,
|
||||||
|
"fuel_optimization_score": 0.9,
|
||||||
|
"driver_consistency": 0.95,
|
||||||
|
"weather_impact": "low",
|
||||||
|
}
|
||||||
|
r = self.client.post("/enriched", json=enriched)
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertEqual(r.json()["lap"], 99)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
46
tests/test_enrichment.py
Normal file
46
tests/test_enrichment.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from hpcsim.enrichment import Enricher
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnrichment(unittest.TestCase):
|
||||||
|
def test_basic_ranges(self):
|
||||||
|
e = Enricher()
|
||||||
|
sample = {
|
||||||
|
"lap": 1,
|
||||||
|
"speed": 250,
|
||||||
|
"throttle": 0.8,
|
||||||
|
"brake": 0.1,
|
||||||
|
"tire_compound": "medium",
|
||||||
|
"fuel_level": 0.6,
|
||||||
|
"ers": 0.5,
|
||||||
|
"track_temp": 35,
|
||||||
|
"rain_probability": 0.1,
|
||||||
|
}
|
||||||
|
out = e.enrich(sample)
|
||||||
|
self.assertIn("aero_efficiency", out)
|
||||||
|
self.assertTrue(0.0 <= out["aero_efficiency"] <= 1.0)
|
||||||
|
self.assertTrue(0.0 <= out["tire_degradation_index"] <= 1.0)
|
||||||
|
self.assertTrue(0.0 <= out["ers_charge"] <= 1.0)
|
||||||
|
self.assertTrue(0.0 <= out["fuel_optimization_score"] <= 1.0)
|
||||||
|
self.assertTrue(0.0 <= out["driver_consistency"] <= 1.0)
|
||||||
|
self.assertIn(out["weather_impact"], {"low", "medium", "high"})
|
||||||
|
|
||||||
|
def test_stateful_wear_increases(self):
|
||||||
|
e = Enricher()
|
||||||
|
prev = 0.0
|
||||||
|
for lap in range(1, 6):
|
||||||
|
out = e.enrich({
|
||||||
|
"lap": lap,
|
||||||
|
"speed": 260,
|
||||||
|
"throttle": 0.9,
|
||||||
|
"brake": 0.05,
|
||||||
|
"tire_compound": "soft",
|
||||||
|
"fuel_level": 0.7,
|
||||||
|
})
|
||||||
|
self.assertGreaterEqual(out["tire_degradation_index"], prev)
|
||||||
|
prev = out["tire_degradation_index"]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user