From 43ec67ff114225788402ecc4fafcd4ed5d93d9e1 Mon Sep 17 00:00:00 2001 From: Karan Dubey <136021382+karandubey006@users.noreply.github.com> Date: Sat, 18 Oct 2025 19:15:41 -0500 Subject: [PATCH] holy agent --- README.md | 220 ++++++++++++++++++ hpcsim/__init__.py | 1 + hpcsim/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 184 bytes hpcsim/__pycache__/adapter.cpython-312.pyc | Bin 0 -> 3415 bytes hpcsim/__pycache__/api.cpython-312.pyc | Bin 0 -> 4206 bytes hpcsim/__pycache__/enrichment.cpython-312.pyc | Bin 0 -> 8037 bytes hpcsim/adapter.py | 103 ++++++++ hpcsim/api.py | 83 +++++++ hpcsim/enrichment.py | 168 +++++++++++++ requirements.txt | 3 + scripts/enrich_telemetry.py | 47 ++++ scripts/serve.py | 8 + .../__pycache__/test_adapter.cpython-312.pyc | Bin 0 -> 1692 bytes tests/__pycache__/test_api.cpython-312.pyc | Bin 0 -> 2489 bytes .../test_enrichment.cpython-312.pyc | Bin 0 -> 2344 bytes tests/test_adapter.py | 32 +++ tests/test_api.py | 47 ++++ tests/test_enrichment.py | 46 ++++ 18 files changed, 758 insertions(+) create mode 100644 hpcsim/__init__.py create mode 100644 hpcsim/__pycache__/__init__.cpython-312.pyc create mode 100644 hpcsim/__pycache__/adapter.cpython-312.pyc create mode 100644 hpcsim/__pycache__/api.cpython-312.pyc create mode 100644 hpcsim/__pycache__/enrichment.cpython-312.pyc create mode 100644 hpcsim/adapter.py create mode 100644 hpcsim/api.py create mode 100644 hpcsim/enrichment.py create mode 100644 requirements.txt create mode 100644 scripts/enrich_telemetry.py create mode 100644 scripts/serve.py create mode 100644 tests/__pycache__/test_adapter.cpython-312.pyc create mode 100644 tests/__pycache__/test_api.cpython-312.pyc create mode 100644 tests/__pycache__/test_enrichment.cpython-312.pyc create mode 100644 tests/test_adapter.py create mode 100644 tests/test_api.py create mode 100644 tests/test_enrichment.py diff --git a/README.md b/README.md index 45c26c1..3669ec7 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,222 @@ # HPCSimSite 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`. diff --git a/hpcsim/__init__.py b/hpcsim/__init__.py new file mode 100644 index 0000000..dde92e0 --- /dev/null +++ b/hpcsim/__init__.py @@ -0,0 +1 @@ +__all__ = ["enrichment"] diff --git a/hpcsim/__pycache__/__init__.cpython-312.pyc b/hpcsim/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..41ddfde688fc66dc2ceb8668820947e1ee4ae6bb GIT binary patch literal 184 zcmX@j%ge<81YDY5G9-cYV-N=h7@>^MASKfoQW(-1qZld~HJNU4rREi7CTHZP=9Tzq zGTvg3k5A0WiH~2&@EN4#mxX?4acWVqes*F}VqQvVQfj4sK~a8IYH~@jenvrZab|9D zW=SfPsUIJonU`4-AFo$X`HRCQH$SB`C)KWq9cU290mVE(;sY}yBjXKj@eaL4_99lG F002_sF(?24 literal 0 HcmV?d00001 diff --git a/hpcsim/__pycache__/adapter.cpython-312.pyc b/hpcsim/__pycache__/adapter.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9af8c242e99b1740e0099a41549f3f90fac6fe1b GIT binary patch literal 3415 zcma)9U2GKB6~6PgKeNBX?%KPyfe8k}+G`sM4XQx_YA-YvwGfa>5Jm2=GqxA+U+>HU zMzhLNA+b0-*h(t4DpK9F4~}^0ODpxQRf1HhFH0A-lPfBL`VjTa1WO^L4?TB0v+Gn< z$w)qP?z!JN=k9mUxp(}RP)J1ZFvA=3zYHMsPdacPc2i-V?*rv7Qjy9OP?5RFFjQv? zY>~Uj75R%ij0Kgu$zK!}`LyR>)Gf`BfKn=z4aLZpOM03m{A5075bi{2F~@8Mwk1u^ z!2+KD8`9lEw?cQ(93=XPQCXFnV^v-i=2)kpc6aTwcF^R$y}DYzrxi_-V{ua={0?(U+ur=aWgOoRwTt$LI&?fpJjI=EI`r5YWxLs=gX_;8g~I*WnO`f)&xL* z^)1oB1fXwiL5JP^PWEsu2#6alBE3`j9Tebn85fm8{<bmQcKkwSh!lMSt) z6*U7d$`xg?P*zmgD9hOr&gbSeENj;)TF%f^S%+a!85Knto)fv8R-smisOXwLZd*rW zy`pLAxO|Sj9FsAR%Z5?V#^v*l=1g3|$^uxwPhXkMm+t!Hu{M({7c1pz3BpY{T1Gy< zh_x*vfH@wsRjn{n(5`5Oarq?Z8C#xpmD6;_fofPEm$PTjWvG~#SMZ!0#K1~!VaCvk z6<93Q&x7iCVI^Ohso?S@JGS|K!npuB}2t@wRlOx z_RNozLRHgc1#5DypcE^Vz>+Gf+N@G7P&)Ioaxt&#`O@5|?U3dO2f+vjafrVYj0iT- zM6ih_K9^(Sb2$b_!6ujpHo?SWzw|UuLdI5hiO=Pe1h=wF`11e*xY(tZ@RVBOr4pzm zeyZ5i(scr_gab;6zr!mLyOa`thf+XC|C>+ZbNM7blcj0u%$=~S=U4`vg9Ij=1s^lha4B(fnvoU=x1cQ&ud|_qSs-4gLx|VuE?t0F z&_eb4%fn-D>%iLBf`Xw8s+Y9IF{rT1&~N(Kd?lyni+bMB9Cb`l6=*p;T3IA~C7)Zk z{#waxX{cGJhewYV$~mQ=zd1@FgOEx;0^&s-SyJ+D|4M)J-0H#AJ%5w-HBMNO=<=y0 zbJe@X{7QT%zhU+6X#Qm7`d_23tqW*ddfkJfy^Zj5a7Uk0L7#6BOt;1+*Z}1&`o^V) z?z@*B+da}%rh5sgK(R}@`+n8IYBD+Y0ubLh+PG<)f!uTO2haL3*a2Y{&M*V2ZLy+d zF)m{w+HJR~b4BGE;fnbZ)F5*W?*T8uT+!h}f$j-T)3Mj?&HV?i@BY68J1FX+9|uuK z>n!K*e;iFLDK(ez74Ssqfx+E3)?s#Q6KISl`n0 zItSWIo!0kH1B2+-(qZoR%wcY`^l3$RtDbm@nwqZ`?do@a_LF0qZ9qF9?RiDR?4lqR z?}iuQbOWauo58f7aQTvf$7r_wRE$!Dn1{1SH)@i(Hvci~Nas1exi_)@u_gEzfIZX; zzE03rb@YXdqQlmm@YK?36X4Z1;$am^L_$(M(nQZp14z}Za!5+-ivrps6=FYW! z?fAj^4SFtsiA*C#BmSby)caAG;vJk$=$4eqIwST3wiwxx7^iY~3s8amV(I{x9?&GAQx zy?<02A}mo#v=Xnc#oN)*`dbu~vi5(kwLiOZs`=Kc@hG18^Pz@+-G>C77uL*+3k8{-~qW2o$rvP0a1G(+V`AbW&+FwTISPV;tkzYNp=HQs@?$;j~k z#=FFWHKU44_&DTBdx$tQGg}3NHZz0ikwQGiVg)X9Os^tL4}QB=KSp?%>h$P%={s61 itJQ+`CeFf;9-2D6-rz?t%s!jmP z_fC{=nGZ4uzFS66iLzgjfq^;ar)5N z;~izYFyNJLIX64AJ3Bl3&Dmp*C6of?=V8=(Gf%t!3N6X zEXJG`3?VDPuV{#QDJ$iDSzlhx%6Wg*?~F@EARo*I^Py}gAI^pyp3jKnquHoK%SKDS zHQVaW70bqmp9GAye0#PXczzNz;`xqj2Sx&-K@uY2%aKb$!=`Kkc72u7h!5dYdb7*AIzZg5B_zT`!;)5NSJ&NIPx4B)Z%lnwb7xgTA{baZe9s z4v`M;Z4#69Byrl;v}MBs^gBHb(nFG`rKbEwkWb&eQiCRI^1WmO>AGu$z6WTmYp&3J z8a3zNR2EVjZ}Le~*bZxjLebJJy;v|OQj#sE3uRjx({q+B?bA)m4!l(2gPM^NZU6I{ zX{8T*-(EF_uzi+p8PxW_ zNtp>|RXVtluW@@{Gc7i#fcdY#Zu?b6bD(?kQ&`qcPK>{LSUq$&{ld7KN$=bDTsrfj zdgS1~9QT3O+|$OZhk8nY7%dtdFN-Xzos!5xcyLxLFC`u4SHViBr&pD zz40|2Z`6u;V>4smjNUZ~B0Zd51?)H@JdLb&Zzh(7-6g!YY3}9w8u4A0Jqxy>Qj^cwT(l=R+r?d1-pxeJb#}w!*w{+3%t5DdcKP4Q)VaiT^dng?ncQfAHgW+M1UR zJ%HPfEZLKO1hK>k(SnTBXao~sUi>-wg>YH)RyzrMIU)W;oWpbIxOf!J;hpH*G(7v^ z+1-z}qEp-+33lt)yA%14iRT22jzU~ubnc~w)_v) zX>&?59+iPfR?I60^wglzguFq8I!HLEFlx=Rg8NFzP$;B8^HA3)E0aYwr!itGR#ABX zk}3M6Vp40!QPmeAx-0F^Yq~)RS8A;ElmhS>HUaBr0XhJbZJAIDgeHzPT-xz0fU+M@ zgcfHlAf;)uSV*-n9>I1{ZOlTMEjK7T*o1z@uLI*TXvroq9cns_*a6oHVq>sB z=ct+dvT~m(>@jXatN{pXNriC${ti4Qlp*L}==K7tM%jEk== zb&kMWHL~^JG72Q>f-l&%lI*#W99T*Yd>p@#-2SKJ_D?#OJA2O0ygPI8@sFn#XKJ0h zZ^U+2W4r$&qOKhvaw~!&T^|H$;l8TWcW#$Q-M-a_B8jgN=7zSc|46Lv*z;-Ir($*6 zzUwXftAYLB)(YzD7c@?ej?N%W%@0xE=)7 zf6ZTkLr&tcL3~=tB+a=LAO<{9zIAkI7`UwK^O^wu!v z@4}f~(v_zrps(Ui(7l=rX10r0l~`s(ygK6J^mdW+pTJz^DGa(-cRBPk@>tA&O~PaT z$Tgpz(-A+&T#EK>IwJZbCaS-R6}( z4-fGGR6KDwZ7nT$uxSOnF&DU-iG$#W(`y1Z4|b=mrI$mXJIG}k9h#P#$}RGIDRA4yA?5=W7{d9);nXrMsE`UAK!9jHh%KhmP07dF?oKdcpCL<2-;yp8>U&W%5vS zK__ZJg|+6SamP$>(jX9ylfI0!F5>q`7tIgG-`ib_^e>FVzCZL|NL-LF_^Vw5wQ#B` zrM@vCJE4=CA?O3cQ&cmosY(tw&BlkFm>&Y7;Rx7%Gv@z4R*CST(A=6W6lWj{$c-$( zf1yq!DWMSx9KadJ$wqnQfT`a#g6#$V58yF*apCOf^Um&zxyz6J(pUtFC!gS|DhX}P6sAqu{s>0318~(ig$xr%!dvq0b;Z3U|5;oQapz1qZ zp|e|Rxbtg4#=U$nS?5%}oew78N}gR`!--Wvz?=DCXPr~^kT=*}!yR7<7;oc)35RlG F?0-CH&e;F} literal 0 HcmV?d00001 diff --git a/hpcsim/__pycache__/enrichment.cpython-312.pyc b/hpcsim/__pycache__/enrichment.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..06078c7f3f5e9ba3dbc33e6134c9f0850b90898e GIT binary patch literal 8037 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 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 diff --git a/hpcsim/api.py b/hpcsim/api.py new file mode 100644 index 0000000..8109887 --- /dev/null +++ b/hpcsim/api.py @@ -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)} diff --git a/hpcsim/enrichment.py b/hpcsim/enrichment.py new file mode 100644 index 0000000..48e0157 --- /dev/null +++ b/hpcsim/enrichment.py @@ -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" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..742213c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.115.2 +uvicorn==0.31.1 +httpx==0.27.2 diff --git a/scripts/enrich_telemetry.py b/scripts/enrich_telemetry.py new file mode 100644 index 0000000..dc71396 --- /dev/null +++ b/scripts/enrich_telemetry.py @@ -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() diff --git a/scripts/serve.py b/scripts/serve.py new file mode 100644 index 0000000..1f57a3a --- /dev/null +++ b/scripts/serve.py @@ -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) diff --git a/tests/__pycache__/test_adapter.cpython-312.pyc b/tests/__pycache__/test_adapter.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d019dbbd2db3859d4e2d774860b91602f3bb6270 GIT binary patch literal 1692 zcma)6&2Jk;6rcUDGj{AIaT>=?+7LB}#!6-91Jp{OnovrqL<369#h2CEGjW{k+S}PR z$WbB{DTr{Qw6+a4m0n)`OR;B z^YP}VXf%w#uFagR{iPuEhX}eB@STnY&I3dckqndZ2Gm*f@_^@>C1g#Km5qmmFlWf@>KkjE&cMdu-~#=es#`AKhCB? zJoKJ!E!E5dWjwHAQA#+@t+A@(7?cO!V)_QPc_de7^hVXRs_j#fg$RY8P$s2)m0m|#lx$hK{Mz_LI!0v4?98zR0QZ4WS~Q{K!{!_8)c`SQki%c zykEC%%AA{bYr4UQykOBVtG1uQUg~xQYDBfEvC0*uf6P@;akgNXoSc02dgenQu`?Sw z(<`J_p!EzawN8tUomsPrcGspwtIl5%X$`7Zw>49@ta7EK0gF~N zbW;SIGd-o5{vU83D6*eIa}WKVyx1PO&`ylE<74d$m)fIa?cvcjBopI*4~GZByUIx% z4Ne^_JRZ2btG03E2~IR|V(V^b#xg5!ClMs_zJ ZDap1vbno5I?tF6R)Ax@Rq>j17{{n(kqG|vD literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_api.cpython-312.pyc b/tests/__pycache__/test_api.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8adacc742859f8271e53951201eb7b3691452a99 GIT binary patch literal 2489 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 literal 0 HcmV?d00001 diff --git a/tests/test_adapter.py b/tests/test_adapter.py new file mode 100644 index 0000000..a003a71 --- /dev/null +++ b/tests/test_adapter.py @@ -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() diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..affa6ba --- /dev/null +++ b/tests/test_api.py @@ -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() diff --git a/tests/test_enrichment.py b/tests/test_enrichment.py new file mode 100644 index 0000000..bb7c855 --- /dev/null +++ b/tests/test_enrichment.py @@ -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()