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.
- aimct/__init__.py +13 -0
- aimct/__main__.py +203 -0
- aimct/benchmarks/__init__.py +96 -0
- aimct/benchmarks/capstone_scoring.py +174 -0
- aimct/benchmarks/challenge.py +748 -0
- aimct/benchmarks/challenge_scoring.py +222 -0
- aimct/benchmarks/challenge_wrappers.py +289 -0
- aimct/benchmarks/harness.py +391 -0
- aimct/benchmarks/metrics.py +272 -0
- aimct/benchmarks/sweep.py +325 -0
- aimct/benchmarks/tracking.py +164 -0
- aimct/controllers/__init__.py +52 -0
- aimct/controllers/_qp.py +163 -0
- aimct/controllers/adaptive.py +178 -0
- aimct/controllers/base.py +57 -0
- aimct/controllers/ilqr.py +480 -0
- aimct/controllers/lqr.py +168 -0
- aimct/controllers/mpc.py +315 -0
- aimct/controllers/observer_feedback.py +127 -0
- aimct/controllers/pid.py +240 -0
- aimct/controllers/sampling_mpc.py +125 -0
- aimct/controllers/state_feedback.py +196 -0
- aimct/controllers/swingup.py +218 -0
- aimct/dev/__init__.py +14 -0
- aimct/dev/__main__.py +46 -0
- aimct/dev/preview.py +331 -0
- aimct/estimation/__init__.py +29 -0
- aimct/estimation/ekf.py +180 -0
- aimct/estimation/kalman.py +110 -0
- aimct/estimation/luenberger.py +94 -0
- aimct/estimation/observability.py +38 -0
- aimct/estimation/ukf.py +153 -0
- aimct/hybrid/__init__.py +9 -0
- aimct/hybrid/shield.py +174 -0
- aimct/ml/__init__.py +14 -0
- aimct/ml/dynamics.py +120 -0
- aimct/ml/mlp.py +125 -0
- aimct/ml/planning.py +119 -0
- aimct/plot_style.py +257 -0
- aimct/rl/__init__.py +38 -0
- aimct/rl/dqn.py +209 -0
- aimct/rl/env.py +343 -0
- aimct/rl/policy_gradient.py +186 -0
- aimct/rl/ppo.py +189 -0
- aimct/rl/tabular.py +257 -0
- aimct/simulate.py +132 -0
- aimct/study.py +108 -0
- aimct/sysid/__init__.py +23 -0
- aimct/sysid/linear.py +145 -0
- aimct/systems/__init__.py +31 -0
- aimct/systems/base.py +81 -0
- aimct/systems/bicycle.py +122 -0
- aimct/systems/cartpole.py +64 -0
- aimct/systems/dc_motor.py +91 -0
- aimct/systems/diffdrive.py +113 -0
- aimct/systems/furuta_pendulum.py +193 -0
- aimct/systems/linear.py +46 -0
- aimct/systems/mass_spring_damper.py +28 -0
- aimct/systems/pendulum.py +53 -0
- aimct/systems/quadrotor.py +83 -0
- aimct/systems/quadrotor3d.py +112 -0
- aimct/systems/twolink_arm.py +126 -0
- aimct/trajectories.py +297 -0
- aimct/viz/__init__.py +41 -0
- aimct/viz/artists.py +585 -0
- aimct/viz/hud.py +55 -0
- aimct/viz/pv_arm.py +219 -0
- aimct/viz/replay.py +205 -0
- aimct/viz/sandbox.py +335 -0
- aimct-0.1.0.dist-info/METADATA +210 -0
- aimct-0.1.0.dist-info/RECORD +75 -0
- aimct-0.1.0.dist-info/WHEEL +5 -0
- aimct-0.1.0.dist-info/entry_points.txt +2 -0
- aimct-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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"
|