aimct 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. aimct/__init__.py +13 -0
  2. aimct/__main__.py +203 -0
  3. aimct/benchmarks/__init__.py +96 -0
  4. aimct/benchmarks/capstone_scoring.py +174 -0
  5. aimct/benchmarks/challenge.py +748 -0
  6. aimct/benchmarks/challenge_scoring.py +222 -0
  7. aimct/benchmarks/challenge_wrappers.py +289 -0
  8. aimct/benchmarks/harness.py +391 -0
  9. aimct/benchmarks/metrics.py +272 -0
  10. aimct/benchmarks/sweep.py +325 -0
  11. aimct/benchmarks/tracking.py +164 -0
  12. aimct/controllers/__init__.py +52 -0
  13. aimct/controllers/_qp.py +163 -0
  14. aimct/controllers/adaptive.py +178 -0
  15. aimct/controllers/base.py +57 -0
  16. aimct/controllers/ilqr.py +480 -0
  17. aimct/controllers/lqr.py +168 -0
  18. aimct/controllers/mpc.py +315 -0
  19. aimct/controllers/observer_feedback.py +127 -0
  20. aimct/controllers/pid.py +240 -0
  21. aimct/controllers/sampling_mpc.py +125 -0
  22. aimct/controllers/state_feedback.py +196 -0
  23. aimct/controllers/swingup.py +218 -0
  24. aimct/dev/__init__.py +14 -0
  25. aimct/dev/__main__.py +46 -0
  26. aimct/dev/preview.py +331 -0
  27. aimct/estimation/__init__.py +29 -0
  28. aimct/estimation/ekf.py +180 -0
  29. aimct/estimation/kalman.py +110 -0
  30. aimct/estimation/luenberger.py +94 -0
  31. aimct/estimation/observability.py +38 -0
  32. aimct/estimation/ukf.py +153 -0
  33. aimct/hybrid/__init__.py +9 -0
  34. aimct/hybrid/shield.py +174 -0
  35. aimct/ml/__init__.py +14 -0
  36. aimct/ml/dynamics.py +120 -0
  37. aimct/ml/mlp.py +125 -0
  38. aimct/ml/planning.py +119 -0
  39. aimct/plot_style.py +257 -0
  40. aimct/rl/__init__.py +38 -0
  41. aimct/rl/dqn.py +209 -0
  42. aimct/rl/env.py +343 -0
  43. aimct/rl/policy_gradient.py +186 -0
  44. aimct/rl/ppo.py +189 -0
  45. aimct/rl/tabular.py +257 -0
  46. aimct/simulate.py +132 -0
  47. aimct/study.py +108 -0
  48. aimct/sysid/__init__.py +23 -0
  49. aimct/sysid/linear.py +145 -0
  50. aimct/systems/__init__.py +31 -0
  51. aimct/systems/base.py +81 -0
  52. aimct/systems/bicycle.py +122 -0
  53. aimct/systems/cartpole.py +64 -0
  54. aimct/systems/dc_motor.py +91 -0
  55. aimct/systems/diffdrive.py +113 -0
  56. aimct/systems/furuta_pendulum.py +193 -0
  57. aimct/systems/linear.py +46 -0
  58. aimct/systems/mass_spring_damper.py +28 -0
  59. aimct/systems/pendulum.py +53 -0
  60. aimct/systems/quadrotor.py +83 -0
  61. aimct/systems/quadrotor3d.py +112 -0
  62. aimct/systems/twolink_arm.py +126 -0
  63. aimct/trajectories.py +297 -0
  64. aimct/viz/__init__.py +41 -0
  65. aimct/viz/artists.py +585 -0
  66. aimct/viz/hud.py +55 -0
  67. aimct/viz/pv_arm.py +219 -0
  68. aimct/viz/replay.py +205 -0
  69. aimct/viz/sandbox.py +335 -0
  70. aimct-0.1.0.dist-info/METADATA +210 -0
  71. aimct-0.1.0.dist-info/RECORD +75 -0
  72. aimct-0.1.0.dist-info/WHEEL +5 -0
  73. aimct-0.1.0.dist-info/entry_points.txt +2 -0
  74. aimct-0.1.0.dist-info/licenses/LICENSE +21 -0
  75. aimct-0.1.0.dist-info/top_level.txt +1 -0
aimct/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """AI Meets Control Theory — reusable library.
2
+
3
+ Subpackages
4
+ -----------
5
+ systems : dynamical-system models with a common interface
6
+ controllers : PID, state feedback, LQR, MPC, neural, RL policies
7
+ estimation : observers, Kalman filters
8
+ ml : learned dynamics, surrogate models
9
+ rl : agents and environments
10
+ benchmarks : standardized systems + controller comparison harness
11
+ """
12
+
13
+ __version__ = "0.1.0"
aimct/__main__.py ADDED
@@ -0,0 +1,203 @@
1
+ """``python -m aimct`` — run a controller bake-off on a built-in system.
2
+
3
+ python -m aimct compare --system cartpole --out my_study
4
+ python -m aimct compare --system quadrotor --t-final 12
5
+ python -m aimct list
6
+ python -m aimct live arm
7
+ python -m aimct preview mymodule.py:MyPlant --watch
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import sys
14
+
15
+ import numpy as np
16
+
17
+ from .study import run_study
18
+
19
+ # name -> (factory, default study kwargs)
20
+ _PRESETS = {
21
+ "mass_spring_damper": (
22
+ lambda: _msd(), dict(x0=[1.0, 0.0], dt=0.01, t_final=20.0, output_index=0),
23
+ ),
24
+ "pendulum": (
25
+ # regulate a disturbed pendulum back to rest (theta = 0 hangs down);
26
+ # auto_controllers designs the LQR about x = 0, so the reference is 0.
27
+ lambda: _pendulum(),
28
+ dict(x0=[0.6, 0.0], dt=0.01, t_final=6.0, reference=0.0,
29
+ output_index=0,
30
+ Q=np.diag([10.0, 1.0]), R=np.array([[0.5]]),
31
+ u_bounds=(-8.0, 8.0)),
32
+ ),
33
+ "cartpole": (
34
+ lambda: _cartpole(),
35
+ dict(x0=[0.0, 0.0, 0.2, 0.0], dt=0.01, t_final=5.0, output_index=2,
36
+ deriv_index=3, Q=np.diag([10.0, 1.0, 100.0, 10.0]),
37
+ R=np.array([[0.1]]), u_bounds=(-20.0, 20.0)),
38
+ ),
39
+ "quadrotor": (
40
+ lambda: _quad(),
41
+ dict(x0=[0.3, 1.0, 0.15, 0.0, 0.0, 0.0], dt=0.004, t_final=6.0,
42
+ output_index=0,
43
+ Q=np.diag(1.0 / np.array([0.1, 0.1, 0.2, 0.5, 0.5, 3.0]) ** 2),
44
+ R=np.diag(1.0 / np.array([0.15, 0.15]) ** 2),
45
+ u_bounds=(0.0, 0.30)),
46
+ ),
47
+ }
48
+
49
+
50
+ def _msd():
51
+ from .systems import MassSpringDamper
52
+ return MassSpringDamper()
53
+
54
+
55
+ def _pendulum():
56
+ from .systems import Pendulum
57
+ return Pendulum()
58
+
59
+
60
+ def _cartpole():
61
+ from .systems import CartPole
62
+ return CartPole()
63
+
64
+
65
+ def _quad():
66
+ from .systems import PlanarQuadrotor
67
+ return PlanarQuadrotor()
68
+
69
+
70
+ def main(argv=None) -> int:
71
+ p = argparse.ArgumentParser(prog="python -m aimct")
72
+ sub = p.add_subparsers(dest="cmd", required=True)
73
+
74
+ pc = sub.add_parser("compare", help="run LQR + MPC (+ your own) on a system")
75
+ pc.add_argument("--system", required=True, choices=sorted(_PRESETS))
76
+ pc.add_argument("--out", default=None, help="directory for table.md / figure.png")
77
+ pc.add_argument("--t-final", type=float, default=None)
78
+ pc.add_argument("--dt", type=float, default=None)
79
+
80
+ sub.add_parser("list", help="list the built-in systems (* = has a compare preset)")
81
+
82
+ pl = sub.add_parser("live", help="interactive sandbox: drone / arm / diffdrive")
83
+ pl.add_argument("target", nargs="?", default="drone",
84
+ choices=["drone", "drone3d", "arm", "arm3d", "diffdrive",
85
+ "armbalance", "armbalance3d"],
86
+ help="which sandbox (default: drone)")
87
+ pl.add_argument("--headless", action="store_true",
88
+ help="run the physics smoke check without a GUI")
89
+ pl3 = sub.add_parser("live3d", help="alias for `live drone3d` (6-DOF drone sandbox)")
90
+ pl3.add_argument("--headless", action="store_true",
91
+ help="run the physics smoke check without a GUI")
92
+ pl3.add_argument("--matplotlib", action="store_true",
93
+ help="force the lightweight matplotlib-3D renderer")
94
+ pl3.add_argument("--web", action="store_true",
95
+ help="serve the experimental WebGL/Three.js visualizer")
96
+
97
+ pp = sub.add_parser("preview", help="design-time preview for a DynamicalSystem "
98
+ "you are writing (see docs/DEV_PREVIEW.md)")
99
+ pp.add_argument("target", help="'module:Class' or 'path/to/file.py:Class'")
100
+ pp.add_argument("--out", default="design_preview.png",
101
+ help="PNG path to (re)write (default: design_preview.png)")
102
+ pp.add_argument("--watch", action="store_true",
103
+ help="poll the source file and rebuild on every change")
104
+ pp.add_argument("--poll", type=float, default=1.0, help="watch poll interval [s]")
105
+ pp.add_argument("--t-final", type=float, default=4.0)
106
+ pp.add_argument("--dt", type=float, default=0.01)
107
+
108
+ args = p.parse_args(argv)
109
+
110
+ if args.cmd == "preview":
111
+ from .dev import preview_once, watch
112
+
113
+ kw = dict(dt=args.dt, t_final=args.t_final)
114
+ if args.watch:
115
+ print(f"watching {args.target} -> {args.out} (Ctrl+C to stop)")
116
+ try:
117
+ watch(args.target, out=args.out, poll=args.poll, **kw)
118
+ except KeyboardInterrupt:
119
+ print("\nstopped")
120
+ return 0
121
+ report = preview_once(args.target, out=args.out, **kw)
122
+ print(report.summary())
123
+ print(f"\nwrote {args.out}")
124
+ return 0
125
+
126
+ if args.cmd == "list":
127
+ from . import systems as _sys
128
+
129
+ print("runnable via `aimct compare --system <name>`:")
130
+ for name in sorted(_PRESETS):
131
+ print(f" {name}")
132
+ skip = {"DynamicalSystem", "LinearSystem"}
133
+ extra = [c for c in sorted(getattr(_sys, "__all__", []))
134
+ if c not in skip and isinstance(getattr(_sys, c, None), type)]
135
+ print("\nalso in aimct.systems (import directly; no compare preset yet):")
136
+ for c in extra:
137
+ print(f" aimct.systems.{c}")
138
+ return 0
139
+
140
+ if args.cmd in ("live", "live3d"):
141
+ import runpy
142
+ from pathlib import Path
143
+
144
+ target = "drone3d" if args.cmd == "live3d" else args.target
145
+ d = Path(__file__).resolve().parents[2] / "experiments"
146
+ if not d.is_dir():
147
+ print(
148
+ "The interactive sandboxes live under experiments/ in the source\n"
149
+ "checkout and are not shipped in the installed package. Clone the\n"
150
+ "repo and run from there:\n"
151
+ " git clone https://github.com/zalihthomas-ui/ai-meets-control-theory\n"
152
+ " cd ai-meets-control-theory\n"
153
+ f" python -m aimct live {target}",
154
+ file=sys.stderr,
155
+ )
156
+ return 1
157
+ headless = getattr(args, "headless", False)
158
+ if target == "drone":
159
+ script = d / "live_drone" / "live.py"
160
+ elif target in ("arm", "arm3d"):
161
+ # same physics either way; --headless never needs PyVista
162
+ sub = "run.py" if (target == "arm" or headless) else "pv_arm.py"
163
+ script = d / "live_arm" / sub
164
+ elif target in ("armbalance", "armbalance3d"):
165
+ sub = "run.py" if (target == "armbalance" or headless) else "pv_arm.py"
166
+ script = d / "live_arm_balance" / sub
167
+ elif target == "diffdrive":
168
+ script = d / "live_diffdrive" / "run.py"
169
+ elif getattr(args, "web", False):
170
+ script = d / "live_drone_3d" / "web.py"
171
+ elif getattr(args, "matplotlib", False) or getattr(args, "headless", False):
172
+ script = d / "live_drone_3d" / "sim3d.py"
173
+ else:
174
+ # prefer the PyVista renderer; fall back to matplotlib if not installed
175
+ try:
176
+ import pyvista # noqa: F401
177
+ script = d / "live_drone_3d" / "pv3d.py"
178
+ except Exception:
179
+ script = d / "live_drone_3d" / "sim3d.py"
180
+ sys.argv = [str(script)] + (["--headless"] if getattr(args, "headless", False) else [])
181
+ runpy.run_path(str(script), run_name="__main__")
182
+ return 0
183
+
184
+ factory, kw = _PRESETS[args.system]
185
+ kw = dict(kw)
186
+ if args.t_final is not None:
187
+ kw["t_final"] = args.t_final
188
+ if args.dt is not None:
189
+ kw["dt"] = args.dt
190
+ kw["out_dir"] = args.out
191
+
192
+ res = run_study(factory(), title=f"{args.system} bake-off", **kw)
193
+ print(res.to_markdown())
194
+ if hasattr(res, "summary"):
195
+ print()
196
+ print(res.summary())
197
+ if args.out:
198
+ print(f"\nwrote artifacts to {args.out}/")
199
+ return 0
200
+
201
+
202
+ if __name__ == "__main__":
203
+ sys.exit(main())
@@ -0,0 +1,96 @@
1
+ """
2
+ Benchmark suites, metrics, and comparison harnesses.
3
+ """
4
+
5
+ from aimct.benchmarks.capstone_scoring import (
6
+ CAPSTONE_BASELINES,
7
+ CAPSTONE_WEIGHTS,
8
+ capstone_leaderboard_table,
9
+ score_capstone,
10
+ score_capstone_entry,
11
+ )
12
+ from aimct.benchmarks.challenge_scoring import (
13
+ NORMALISERS,
14
+ WEIGHTS,
15
+ BaselineCosts,
16
+ ChallengeScoreResult,
17
+ SafetyEnvelope,
18
+ ScoreWeights,
19
+ evaluate_safety,
20
+ robust_degradation,
21
+ score_run,
22
+ )
23
+ from aimct.benchmarks.challenge_wrappers import (
24
+ ActuatorLag,
25
+ BlackBoxEnvironment,
26
+ BlackBoxPlant,
27
+ ImpulseDisturbance,
28
+ ImpulseInjector,
29
+ ParamPerturbed,
30
+ perturbed_system,
31
+ )
32
+ from aimct.benchmarks.harness import ComparisonResult, compare
33
+ from aimct.benchmarks.metrics import (
34
+ compute_all_metrics,
35
+ control_energy,
36
+ iae,
37
+ ise,
38
+ itae,
39
+ peak_control,
40
+ peak_overshoot,
41
+ peak_time,
42
+ rise_time,
43
+ rmse,
44
+ saturation_duty_cycle,
45
+ settling_time,
46
+ slew_rate,
47
+ steady_state_error,
48
+ )
49
+ from aimct.benchmarks.sweep import SweepResult, sweep
50
+ from aimct.benchmarks.tracking import TrackingResult, track_trajectory
51
+
52
+ __all__ = [
53
+ "rise_time",
54
+ "settling_time",
55
+ "peak_overshoot",
56
+ "peak_time",
57
+ "steady_state_error",
58
+ "rmse",
59
+ "iae",
60
+ "itae",
61
+ "ise",
62
+ "control_energy",
63
+ "peak_control",
64
+ "slew_rate",
65
+ "saturation_duty_cycle",
66
+ "compute_all_metrics",
67
+ "compare",
68
+ "ComparisonResult",
69
+ "sweep",
70
+ "SweepResult",
71
+ "track_trajectory",
72
+ "TrackingResult",
73
+ # Challenge scoring & wrappers
74
+ "WEIGHTS",
75
+ "NORMALISERS",
76
+ "ScoreWeights",
77
+ "BaselineCosts",
78
+ "SafetyEnvelope",
79
+ "ChallengeScoreResult",
80
+ "robust_degradation",
81
+ "evaluate_safety",
82
+ "score_run",
83
+ "ParamPerturbed",
84
+ "perturbed_system",
85
+ "ActuatorLag",
86
+ "ImpulseDisturbance",
87
+ "ImpulseInjector",
88
+ "BlackBoxPlant",
89
+ "BlackBoxEnvironment",
90
+ # Capstone scoring & bake-off
91
+ "CAPSTONE_WEIGHTS",
92
+ "CAPSTONE_BASELINES",
93
+ "score_capstone_entry",
94
+ "score_capstone",
95
+ "capstone_leaderboard_table",
96
+ ]
@@ -0,0 +1,174 @@
1
+ """
2
+ Capstone Evaluation Rubric and Multi-Controller Scoring Engine.
3
+
4
+ Conforms to docs/references/capstone-rubric.md for the Module 09 five-way quadrotor bake-off.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Mapping, Sequence
11
+
12
+ import numpy as np
13
+
14
+ # Standard Capstone Composite Weights (spec §2 & §3)
15
+ CAPSTONE_WEIGHTS: dict[str, float] = {
16
+ "rmse": 0.40,
17
+ "energy": 0.20,
18
+ "slew": 0.10,
19
+ "safety": 0.15,
20
+ "robustness": 0.15,
21
+ }
22
+
23
+ # Canonical Quadrotor Figure-8 Baselines
24
+ CAPSTONE_BASELINES: dict[str, float] = {
25
+ "rmse": 0.050, # 5 cm position tracking error
26
+ "energy": 150.0, # 150 N^2*s total thrust effort
27
+ "slew": 2500.0, # Actuator slew rate penalty
28
+ "safety": 0.10, # Boundary penalty threshold
29
+ }
30
+
31
+
32
+ def score_capstone_entry(
33
+ metrics: Mapping[str, float],
34
+ baseline: Mapping[str, float] | None = None,
35
+ weights: Mapping[str, float] | None = None,
36
+ *,
37
+ robust_factor: float = 1.0,
38
+ safety_ok: bool = True,
39
+ dq_reasons: Sequence[str] | None = None,
40
+ max_ratio: float = 10.0,
41
+ ) -> dict[str, object]:
42
+ """
43
+ Scores a single capstone controller evaluation against baseline costs.
44
+
45
+ Formula (spec §3):
46
+ S = 100 * exp( - [ 0.40 * r_pos + 0.20 * r_energy + 0.10 * r_slew + 0.15 * r_safety ] )
47
+ * S_robust * I(No Hard Disqualifications)
48
+ """
49
+ w = dict(weights or CAPSTONE_WEIGHTS)
50
+ base = dict(baseline or CAPSTONE_BASELINES)
51
+ reasons = list(dq_reasons or [])
52
+
53
+ m_rmse = float(metrics.get("rmse", metrics.get("pos_rmse", metrics.get("itae", 1.0))))
54
+ m_energy = float(metrics.get("control_energy", metrics.get("energy", 100.0)))
55
+ m_slew = float(metrics.get("slew_rate", metrics.get("slew", 1000.0)))
56
+ m_safety = float(metrics.get("violation_penalty", metrics.get("safety", 0.0)))
57
+
58
+ b_rmse = float(base.get("rmse", 0.05))
59
+ b_energy = float(base.get("energy", 150.0))
60
+ b_slew = float(base.get("slew", 2500.0))
61
+ b_safety = float(base.get("safety", 0.10))
62
+
63
+ # Capped cost ratios
64
+ r_rmse = min(max_ratio, m_rmse / max(1e-6, b_rmse))
65
+ r_energy = min(max_ratio, m_energy / max(1e-6, b_energy))
66
+ r_slew = min(max_ratio, m_slew / max(1e-6, b_slew))
67
+ r_safety = min(max_ratio, (m_safety + 1e-4) / max(1e-6, b_safety)) if m_safety > 0 else 0.0
68
+
69
+ norm_cost = (
70
+ w.get("rmse", 0.40) * r_rmse
71
+ + w.get("energy", 0.20) * r_energy
72
+ + w.get("slew", 0.10) * r_slew
73
+ + w.get("safety", 0.15) * r_safety
74
+ )
75
+
76
+ perf_base = float(100.0 * np.exp(-norm_cost))
77
+ r_factor = float(np.clip(robust_factor, 0.20, 1.0))
78
+
79
+ if not safety_ok or len(reasons) > 0:
80
+ status = "DISQUALIFIED"
81
+ composite = 0.0
82
+ else:
83
+ status = "PASS"
84
+ composite = float(perf_base * r_factor)
85
+
86
+ terms = {
87
+ "rmse_ratio": r_rmse,
88
+ "energy_ratio": r_energy,
89
+ "slew_ratio": r_slew,
90
+ "safety_ratio": r_safety,
91
+ "norm_cost": norm_cost,
92
+ }
93
+
94
+ breakdown = {
95
+ "precision": float(np.exp(-r_rmse)),
96
+ "effort": float(np.exp(-r_energy)),
97
+ "smoothness": float(np.exp(-r_slew)),
98
+ "safety": 1.0 if safety_ok else 0.0,
99
+ "robustness": r_factor,
100
+ }
101
+
102
+ return {
103
+ "composite": composite,
104
+ "status": status,
105
+ "terms": terms,
106
+ "breakdown": breakdown,
107
+ "metrics": dict(metrics),
108
+ "dq_reasons": reasons,
109
+ }
110
+
111
+
112
+ def score_capstone(
113
+ metrics_per_controller: Mapping[str, Mapping[str, float]],
114
+ baseline: Mapping[str, float] | None = None,
115
+ weights: Mapping[str, float] | None = None,
116
+ ) -> dict[str, object]:
117
+ """
118
+ Evaluates and ranks all controllers in the five-way bake-off.
119
+ """
120
+ scores: dict[str, dict] = {}
121
+ for name, m in metrics_per_controller.items():
122
+ is_safe = m.get("hard_fail", 0.0) == 0.0
123
+ robust = float(m.get("s_robust", m.get("robustness", 1.0)))
124
+ dq_reasons = []
125
+ if not is_safe:
126
+ dq_reasons.append("Safety envelope or obstacle penetration breached.")
127
+ if float(m.get("mean_latency_ms", 0.0)) > 2.0:
128
+ dq_reasons.append(f"Step latency {m.get('mean_latency_ms'):.2f} ms exceeded 2.0 ms deadline.")
129
+
130
+ scores[name] = score_capstone_entry(
131
+ m,
132
+ baseline=baseline,
133
+ weights=weights,
134
+ robust_factor=robust,
135
+ safety_ok=is_safe and len(dq_reasons) == 0,
136
+ dq_reasons=dq_reasons,
137
+ )
138
+
139
+ # Rank controllers by composite score descending
140
+ ranked = sorted(scores.items(), key=lambda item: item[1]["composite"], reverse=True)
141
+ winner = ranked[0][0] if ranked and ranked[0][1]["composite"] > 0 else "None"
142
+
143
+ return {
144
+ "scores": scores,
145
+ "ranked": ranked,
146
+ "winner": winner,
147
+ "leaderboard_md": capstone_leaderboard_table(scores),
148
+ }
149
+
150
+
151
+ def capstone_leaderboard_table(scores: Mapping[str, dict]) -> str:
152
+ """
153
+ Generates a Markdown leaderboard table for the Capstone report.
154
+ """
155
+ lines = [
156
+ "| Rank | Controller Entry | Score / 100 | Status | Tracking RMSE [m] | Energy $E_u$ | Slew Rate | $S_{\\text{robust}}$ | Latency [ms] |",
157
+ "| :---: | :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: |",
158
+ ]
159
+
160
+ ranked = sorted(scores.items(), key=lambda item: item[1]["composite"], reverse=True)
161
+ for rank, (name, s) in enumerate(ranked, 1):
162
+ m = s["metrics"]
163
+ rmse_val = m.get("rmse", m.get("pos_rmse", float("nan")))
164
+ energy_val = m.get("energy", m.get("control_energy", float("nan")))
165
+ slew_val = m.get("slew", m.get("slew_rate", float("nan")))
166
+ rob_val = m.get("s_robust", s["breakdown"]["robustness"])
167
+ lat_val = m.get("mean_latency_ms", float("nan"))
168
+
169
+ lines.append(
170
+ f"| **{rank}** | **{name}** | **{s['composite']:.1f}** | `{s['status']}` | "
171
+ f"{rmse_val:.4g} | {energy_val:.4g} | {slew_val:.4g} | {rob_val:.2f} | {lat_val:.2f} |"
172
+ )
173
+
174
+ return "\n".join(lines) + "\n"