openphysicalai 0.2.1__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.
@@ -0,0 +1,9 @@
1
+ """OpenPhysicalAI Lab core (early scaffold).
2
+
3
+ Interfaces and budget logic only. MuJoCo backend, scheduler and real policies
4
+ arrive in Phase 1+ (see docs/OpenPhysicalAI_Lab_Plan_v3.md).
5
+ """
6
+ __version__ = "0.2.1"
7
+
8
+ from .budget import Budget, BudgetCheck, evaluate_budget # noqa: F401
9
+ from .stats import wilson_interval # noqa: F401
@@ -0,0 +1,59 @@
1
+ """Budget contract: separate caps for process-attributed and device-wide usage."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from typing import Dict, List, Optional
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Budget:
10
+ vram_process_peak_gb: float = 3.2
11
+ vram_device_peak_gb: float = 3.8
12
+ ram_process_peak_gb: float = 8.0
13
+ ram_system_peak_gb: float = 14.0
14
+ planning_ms_p95: float = 2500.0
15
+ profile: str = "standard"
16
+
17
+
18
+ @dataclass
19
+ class BudgetCheck:
20
+ name: str
21
+ value: Optional[float]
22
+ cap: float
23
+ ok: bool
24
+ note: str = ""
25
+
26
+
27
+ @dataclass
28
+ class BudgetResult:
29
+ passed: bool
30
+ checks: List[BudgetCheck] = field(default_factory=list)
31
+
32
+
33
+ def evaluate_budget(budget: Budget, observed: Dict[str, Optional[float]], not_applicable: tuple = ()) -> BudgetResult:
34
+ """Compare observed peaks to caps. A missing observation is a failure of measurement, not a pass.
35
+ `not_applicable` lists keys that do not apply on this machine (e.g. VRAM caps on a CPU-only machine)."""
36
+ spec = [
37
+ ("vram_process_peak_gb", budget.vram_process_peak_gb),
38
+ ("vram_device_peak_gb", budget.vram_device_peak_gb),
39
+ ("ram_process_peak_gb", budget.ram_process_peak_gb),
40
+ ("ram_system_peak_gb", budget.ram_system_peak_gb),
41
+ ("planning_ms_p95", budget.planning_ms_p95),
42
+ ]
43
+ checks: List[BudgetCheck] = []
44
+ for name, cap in spec:
45
+ v = observed.get(name)
46
+ if name in not_applicable:
47
+ checks.append(BudgetCheck(name, None, cap, True, "not applicable"))
48
+ elif v is None:
49
+ checks.append(BudgetCheck(name, None, cap, False, "not measured"))
50
+ else:
51
+ checks.append(BudgetCheck(name, float(v), cap, float(v) <= cap))
52
+ return BudgetResult(all(c.ok for c in checks), checks)
53
+
54
+
55
+ PROFILES = {
56
+ "relaxed": Budget(3.2, 3.8, 8.0, 14.0, 4000.0, "relaxed"),
57
+ "standard": Budget(3.2, 3.8, 8.0, 14.0, 2500.0, "standard"),
58
+ "strict": Budget(3.2, 3.8, 8.0, 14.0, 500.0, "strict"),
59
+ }
openphysicalai/cli.py ADDED
@@ -0,0 +1,115 @@
1
+ """opai CLI (scaffold): `opai profile`, `opai check-budget`."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import platform
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+
11
+ from . import __version__
12
+ from .budget import Budget, evaluate_budget
13
+ from .profiler import gpu_snapshot
14
+
15
+
16
+ def cmd_profile(_args) -> int:
17
+ info = {
18
+ "version": __version__,
19
+ "os": platform.platform(),
20
+ "python": sys.version.split()[0],
21
+ "gpu": gpu_snapshot(),
22
+ }
23
+ print(json.dumps(info, indent=2))
24
+ return 0
25
+
26
+
27
+ def cmd_doctor(_args) -> int:
28
+ import psutil
29
+ g = gpu_snapshot()
30
+ mem = psutil.virtual_memory()
31
+ cls = "CPU-only"
32
+ if g:
33
+ t = g["total_gb"]
34
+ cls = "OPAI-2G" if t <= 3 else "OPAI-4G" if t <= 6 else "OPAI-8G" if t <= 12 else "OPAI-16G"
35
+ doc = {
36
+ "version": __version__,
37
+ "platform": platform.platform(),
38
+ "python": sys.version.split()[0],
39
+ "processor": platform.processor(),
40
+ "cpu_count_physical": psutil.cpu_count(logical=False),
41
+ "cpu_count_logical": psutil.cpu_count(logical=True),
42
+ "ram_total_gb": round(mem.total / (1024**3), 2),
43
+ "ram_available_gb": round(mem.available / (1024**3), 2),
44
+ "hardware_class": cls,
45
+ "gpu": g,
46
+ }
47
+ print(json.dumps(doc, indent=2))
48
+ return 0
49
+
50
+
51
+ def cmd_check_budget(args) -> int:
52
+ observed = json.loads(args.observed)
53
+ res = evaluate_budget(Budget(), observed)
54
+ print("PASS" if res.passed else "FAIL")
55
+ for c in res.checks:
56
+ print(f" {'ok ' if c.ok else 'BAD'} {c.name}: {c.value} / {c.cap} {c.note}")
57
+ return 0 if res.passed else 1
58
+
59
+
60
+ def cmd_run(args) -> int:
61
+ from .report import budget_card
62
+ from .runner import RunConfig, run
63
+ cfg_dict = {k: v for k, v in vars(args).items() if k not in ("cmd", "fn", "export_json") and v is not None}
64
+ cfg = RunConfig.from_dict(cfg_dict)
65
+ cfg.frames = False
66
+
67
+ reproduce_cmd = (
68
+ f"opai run --task {cfg.task} --policy {cfg.policy} --agent {cfg.agent} "
69
+ f"--perception {cfg.perception} --verifier {cfg.verifier} --perturb {cfg.perturb} "
70
+ f"--mode {cfg.mode} --budget {cfg.budget} --episodes {cfg.episodes} --seed {cfg.seed}"
71
+ )
72
+
73
+ def emit(e):
74
+ if e.get("event") == "progress" and e["done"] % 10 == 0:
75
+ print(f" episode {e['done']}/{e['total']} successes {e['successes']}", file=sys.stderr)
76
+
77
+ res = run(cfg, emit=emit)
78
+ res["reproduce_command"] = reproduce_cmd
79
+ card_text = budget_card(res)
80
+ print(card_text)
81
+ print(f"\nReproduce command:\n {reproduce_cmd}\n")
82
+
83
+ if getattr(args, "export_json", None):
84
+ out_path = args.export_json
85
+ with open(out_path, "w") as f:
86
+ json.dump(res, f, indent=2)
87
+ print(f"Exported raw run data to {out_path}")
88
+
89
+ return 0
90
+
91
+
92
+ def main(argv=None) -> int:
93
+ p = argparse.ArgumentParser(prog="opai")
94
+ sub = p.add_subparsers(dest="cmd", required=True)
95
+ sub.add_parser("profile").set_defaults(fn=cmd_profile)
96
+ sub.add_parser("doctor", help="inspect host hardware, NVML, and budget class").set_defaults(fn=cmd_doctor)
97
+ cb = sub.add_parser("check-budget")
98
+ cb.add_argument("observed", help='JSON, e.g. \'{"vram_process_peak_gb": 2.1}\'')
99
+ cb.set_defaults(fn=cmd_check_budget)
100
+ r = sub.add_parser("run", help="headless benchmark run, prints a Budget Card")
101
+ for name, default in (("task", "pick_place"), ("policy", "rule"), ("agent", "recovery"), ("perception", "oracle"), ("verifier", "V1"),
102
+ ("perturb", "clean"), ("mode", "sync"), ("budget", "standard")):
103
+ r.add_argument("--" + name, default=default)
104
+ r.add_argument("--episodes", type=int, default=50)
105
+ r.add_argument("--seed", type=int, default=0)
106
+ r.add_argument("--extra-latency-ms", dest="extra_latency_ms", type=float, default=0.0)
107
+ r.add_argument("--velocity-lead", dest="velocity_lead", action=argparse.BooleanOptionalAction, default=True)
108
+ r.add_argument("--export-json", dest="export_json", type=str, default=None, help="path to export raw run.json")
109
+ r.set_defaults(fn=cmd_run)
110
+ args = p.parse_args(argv)
111
+ return args.fn(args)
112
+
113
+
114
+ if __name__ == "__main__":
115
+ raise SystemExit(main())
@@ -0,0 +1,64 @@
1
+ """Seeded failure / perturbation injection. Ground truth of what was injected is logged for scoring
2
+ only and is never exposed to the agent."""
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass, field
7
+ from typing import Dict, List
8
+
9
+ import numpy as np
10
+
11
+ LEVELS = {
12
+ "clean": {"p": 0.00, "pos_noise": 0.000, "pix_noise": 0.0},
13
+ "mild": {"p": 0.10, "pos_noise": 0.010, "pix_noise": 8.0},
14
+ "moderate": {"p": 0.25, "pos_noise": 0.020, "pix_noise": 16.0},
15
+ "severe": {"p": 0.40, "pos_noise": 0.035, "pix_noise": 28.0},
16
+ }
17
+
18
+ EXPECTED_DIAGNOSIS = {"grasp_slip": "object_dropped", "object_displaced": "grasp_miss", "wrong_placement": "wrong_placement"}
19
+
20
+
21
+ @dataclass
22
+ class Injector:
23
+ level: str
24
+ rng: np.random.Generator
25
+ log: List[Dict] = field(default_factory=list)
26
+
27
+ def __post_init__(self):
28
+ self.cfg = LEVELS[self.level]
29
+
30
+ @property
31
+ def pos_noise(self) -> float:
32
+ return self.cfg["pos_noise"]
33
+
34
+ @property
35
+ def pix_noise(self) -> float:
36
+ return self.cfg["pix_noise"]
37
+
38
+ def _roll(self) -> bool:
39
+ return self.cfg["p"] > 0 and self.rng.random() < self.cfg["p"]
40
+
41
+ def displace(self, world, oid: str, step: int) -> bool:
42
+ if self._roll():
43
+ ang = self.rng.uniform(0, 2 * math.pi)
44
+ d = self.rng.uniform(0.06, 0.09)
45
+ x0, y0 = world.obj_pos(oid)[:2]
46
+ nx, ny = x0 + d * math.cos(ang), y0 + d * math.sin(ang)
47
+ nx, ny = min(max(nx, 0.10), 0.36), min(max(ny, -0.25), 0.25)
48
+ world.teleport(oid, nx - x0, ny - y0)
49
+ self.log.append({"type": "object_displaced", "step": step, "object": oid})
50
+ return True
51
+ return False
52
+
53
+ def slip(self, step: int) -> bool:
54
+ if self._roll():
55
+ self.log.append({"type": "grasp_slip", "step": step})
56
+ return True
57
+ return False
58
+
59
+ def placement_offset(self, step: int):
60
+ if self._roll():
61
+ ang = self.rng.uniform(0, 2 * math.pi)
62
+ self.log.append({"type": "wrong_placement", "step": step})
63
+ return 0.12 * math.cos(ang), 0.12 * math.sin(ang)
64
+ return 0.0, 0.0
@@ -0,0 +1,47 @@
1
+ """Core interfaces (frozen in Phase 0). Implementations arrive in later phases."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from typing import Any, Dict, Literal, Optional, Protocol
6
+
7
+ Track = Literal["planner", "learned", "hybrid", "scripted"]
8
+ Assist = Literal["A0", "A1", "A2", "A3"]
9
+ Verifier = Literal["V0", "V1", "V2"]
10
+
11
+
12
+ @dataclass
13
+ class EvaluationContract:
14
+ observation_access: tuple = ("rgb", "proprio")
15
+ control_hz: int = 20
16
+ action_space: str = "skill_level"
17
+ controller_assistance: Assist = "A3"
18
+ verifier_level: Verifier = "V1"
19
+ episodes: int = 100
20
+ seeds: str = "shared"
21
+
22
+
23
+ @dataclass
24
+ class ActionResult:
25
+ action: Any
26
+ t_obs_ns: int = 0
27
+ t_submit_ns: int = 0
28
+ t_ready_ns: int = 0
29
+ meta: Dict[str, Any] = field(default_factory=dict)
30
+
31
+
32
+ class Policy(Protocol):
33
+ name: str
34
+ track: Track
35
+
36
+ def reset(self, task: str, seed: int, contract: EvaluationContract) -> None: ...
37
+
38
+ def act(self, obs: Any, world_state: Optional[dict] = None, deadline_ms: Optional[float] = None) -> ActionResult: ...
39
+
40
+
41
+ class Backend(Protocol):
42
+ def reset(self, seed: int) -> Any: ...
43
+
44
+ def step(self, action: Any) -> Any: ...
45
+
46
+ def evaluation_oracle_success(self) -> bool:
47
+ """Ground-truth scoring. Never exposed to the agent."""
@@ -0,0 +1,237 @@
1
+ """Perception: Oracle (simulator truth + optional noise) and Vision (colour segmentation on the
2
+ software top-down image with Alpha-Beta temporal velocity tracking). Both return the same WorldState dict."""
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections import deque
7
+ from dataclasses import dataclass
8
+ from typing import Dict, List, Optional, Tuple
9
+
10
+ import numpy as np
11
+
12
+ from . import render as R
13
+ from .sim import CONTAINER_HALF_IN, World
14
+
15
+
16
+ def _in_box(x, y, c):
17
+ return abs(x - c[0]) < CONTAINER_HALF_IN and abs(y - c[1]) < CONTAINER_HALF_IN
18
+
19
+
20
+ @dataclass
21
+ class TrackedObjectState:
22
+ pos: np.ndarray
23
+ vel: np.ndarray
24
+ sim_time: float
25
+ samples: int = 1
26
+
27
+
28
+ class TemporalVelocityTracker:
29
+ """Lightweight Alpha-Beta kinematic filter for VisionPerception.
30
+ Estimates 3D object velocity (vx, vy, vz) purely from sequential
31
+ top-down colour-segmentation centroids without touching MuJoCo qvel."""
32
+
33
+ def __init__(
34
+ self,
35
+ alpha: float = 0.75,
36
+ beta: float = 0.45,
37
+ max_speed_mps: float = 0.50,
38
+ min_dt: float = 1e-3,
39
+ max_dt: float = 5.0,
40
+ ) -> None:
41
+ self.alpha = alpha
42
+ self.beta = beta
43
+ self.max_speed_mps = max_speed_mps
44
+ self.min_dt = min_dt
45
+ self.max_dt = max_dt
46
+ self._tracks: Dict[str, TrackedObjectState] = {}
47
+
48
+ def reset(self) -> None:
49
+ self._tracks.clear()
50
+
51
+ def update(
52
+ self, obj_id: str, measured_pos: np.ndarray, sim_time: float
53
+ ) -> Tuple[np.ndarray, np.ndarray]:
54
+ pos = np.asarray(measured_pos, dtype=np.float64)
55
+ if obj_id not in self._tracks:
56
+ self._tracks[obj_id] = TrackedObjectState(
57
+ pos=pos.copy(),
58
+ vel=np.zeros(3, dtype=np.float64),
59
+ sim_time=sim_time,
60
+ samples=1,
61
+ )
62
+ return pos.copy(), np.zeros(3, dtype=np.float64)
63
+
64
+ prev = self._tracks[obj_id]
65
+ dt = sim_time - prev.sim_time
66
+
67
+ if dt < self.min_dt or dt > self.max_dt:
68
+ self._tracks[obj_id] = TrackedObjectState(
69
+ pos=pos.copy(),
70
+ vel=prev.vel.copy() if abs(dt) < self.min_dt else np.zeros(3, dtype=np.float64),
71
+ sim_time=sim_time,
72
+ samples=prev.samples + 1,
73
+ )
74
+ return pos.copy(), self._tracks[obj_id].vel.copy()
75
+
76
+ pred_pos = prev.pos + prev.vel * dt
77
+ residual = pos - pred_pos
78
+
79
+ new_pos = pred_pos + self.alpha * residual
80
+ raw_vel = (pos - prev.pos) / dt
81
+ new_vel = prev.vel + (self.beta / dt) * residual if prev.samples > 2 else raw_vel
82
+
83
+ speed = float(np.linalg.norm(new_vel[:2]))
84
+ if speed < 0.004:
85
+ new_vel[:] = 0.0
86
+ elif speed > self.max_speed_mps:
87
+ new_vel = new_vel * (self.max_speed_mps / speed)
88
+
89
+ new_vel[2] = 0.0
90
+
91
+ self._tracks[obj_id] = TrackedObjectState(
92
+ pos=new_pos,
93
+ vel=new_vel,
94
+ sim_time=sim_time,
95
+ samples=prev.samples + 1,
96
+ )
97
+ return new_pos.copy(), new_vel.copy()
98
+
99
+ @staticmethod
100
+ def predict_intercept(
101
+ pos: np.ndarray,
102
+ vel: np.ndarray,
103
+ planner_latency_s: float,
104
+ arm_travel_time_s: float = 0.32,
105
+ workspace_bounds: Tuple[Tuple[float, float], Tuple[float, float]] = (
106
+ (0.10, 0.36),
107
+ (-0.25, 0.25),
108
+ ),
109
+ ) -> np.ndarray:
110
+ total_lead_s = max(0.0, float(planner_latency_s)) + max(0.0, float(arm_travel_time_s))
111
+ intercept = np.asarray(pos, dtype=np.float64).copy()
112
+ intercept[:2] += np.asarray(vel[:2], dtype=np.float64) * total_lead_s
113
+ (x_min, x_max), (y_min, y_max) = workspace_bounds
114
+ intercept[0] = float(np.clip(intercept[0], x_min, x_max))
115
+ intercept[1] = float(np.clip(intercept[1], y_min, y_max))
116
+ return intercept
117
+
118
+
119
+ class OraclePerception:
120
+ name = "oracle"
121
+
122
+ def __init__(self, rng: np.random.Generator, pos_noise: float = 0.0):
123
+ self.rng, self.pos_noise = rng, pos_noise
124
+
125
+ def perceive(self, w: World) -> Dict:
126
+ c = w.spec.container_pos
127
+ objs = []
128
+ for o in w.spec.objects:
129
+ p = w.obj_pos(o.oid)
130
+ n = self.rng.normal(0, self.pos_noise, 2) if self.pos_noise > 0 else (0.0, 0.0)
131
+ x, y = float(p[0] + n[0]), float(p[1] + n[1])
132
+ d = {"id": o.oid, "color": o.color, "half_size": o.half, "x": round(x, 3), "y": round(y, 3),
133
+ "yaw": round(w.obj_yaw(o.oid), 3), "in_container": bool(w.in_container(o.oid))}
134
+ if np.any(w.vel[o.oid]):
135
+ d["velocity_y"] = float(w.vel[o.oid][1])
136
+ objs.append(d)
137
+ tip = w.tip()
138
+ return {"t": round(w.time, 3), "source": "oracle", "container": {"x": c[0], "y": c[1]},
139
+ "gripper": {"closure": round(w.finger_closure(), 4), "tip": [round(float(v), 3) for v in tip]}, "objects": objs}
140
+
141
+
142
+ def _components(mask: np.ndarray, min_px: int = 30):
143
+ h, w = mask.shape
144
+ seen = np.zeros_like(mask, bool)
145
+ out = []
146
+ for r0, c0 in zip(*np.nonzero(mask)):
147
+ if seen[r0, c0]:
148
+ continue
149
+ q = deque([(r0, c0)])
150
+ seen[r0, c0] = True
151
+ pix = []
152
+ while q:
153
+ r, c = q.popleft()
154
+ pix.append((r, c))
155
+ for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
156
+ rr, cc = r + dr, c + dc
157
+ if 0 <= rr < h and 0 <= cc < w and mask[rr, cc] and not seen[rr, cc]:
158
+ seen[rr, cc] = True
159
+ q.append((rr, cc))
160
+ if len(pix) >= min_px:
161
+ out.append(np.array(pix))
162
+ return out
163
+
164
+
165
+ class VisionPerception:
166
+ name = "vision"
167
+
168
+ def __init__(self, rng: np.random.Generator, pixel_noise: float = 0.0, enable_velocity_tracker: bool = True):
169
+ self.rng, self.pixel_noise = rng, pixel_noise
170
+ self.enable_velocity_tracker = enable_velocity_tracker
171
+ self.tracker = TemporalVelocityTracker(alpha=0.80, beta=0.55)
172
+
173
+ def _detect_raw(self, w: World) -> List[Dict]:
174
+ img = R.render(w, draw_arm=False, rng=self.rng, noise=self.pixel_noise)
175
+ f = img.astype(np.int32)
176
+ objs: List[Dict] = []
177
+ k = 0
178
+ for color in ("red", "green", "yellow", "orange"):
179
+ ref = np.array(R.PALETTE[color])
180
+ m = (np.abs(f - ref).sum(axis=2) < 60 + 3 * self.pixel_noise)
181
+ for pix in _components(m):
182
+ cy_, cx_ = pix.mean(axis=0)
183
+ x, y = R.px_to_world(cy_ + 0.5, cx_ + 0.5)
184
+ area_m2 = len(pix) * R.RES_X * R.RES_Y
185
+ half = math.sqrt(area_m2) / 2
186
+ d = pix - pix.mean(axis=0)
187
+ wx, wy = -d[:, 0] * R.RES_X, -d[:, 1] * R.RES_Y
188
+ zc = np.sum((wx + 1j * wy) ** 4)
189
+ yaw = ((np.angle(zc) - math.pi) / 4) % (math.pi / 2)
190
+ objs.append({
191
+ "id": f"obj_{k}",
192
+ "color": color,
193
+ "half_size": round(half, 3),
194
+ "x": round(x, 3),
195
+ "y": round(y, 3),
196
+ "_raw_x": float(x),
197
+ "_raw_y": float(y),
198
+ "yaw": round(yaw, 3),
199
+ "in_container": bool(_in_box(x, y, w.spec.container_pos)),
200
+ })
201
+ k += 1
202
+ return objs
203
+
204
+ def perceive(self, w: World) -> Dict:
205
+ raw_objs = self._detect_raw(w)
206
+ # Cold-start optical burst: step 100 physics ticks (0.20s) so a 0.02 m/s cube
207
+ # shifts by 4.0mm (~1.7 pixels), overcoming 2.34mm/px quantization on frame 1.
208
+ if self.enable_velocity_tracker and not self.tracker._tracks:
209
+ t0 = w.time
210
+ for o in raw_objs:
211
+ self.tracker.update(o["color"] + "_" + o["id"], np.array([o["_raw_x"], o["_raw_y"], 0.0]), t0)
212
+ if any(np.any(v) for v in w.vel.values()):
213
+ w.step(100)
214
+ raw_objs = self._detect_raw(w)
215
+
216
+ objs: List[Dict] = []
217
+ for o in raw_objs:
218
+ rx, ry = o.pop("_raw_x"), o.pop("_raw_y")
219
+ d = dict(o)
220
+ if self.enable_velocity_tracker:
221
+ track_key = o["color"] + "_" + o["id"]
222
+ filt_pos, est_vel = self.tracker.update(track_key, np.array([rx, ry, 0.0]), w.time)
223
+ d["x"] = round(float(filt_pos[0]), 3)
224
+ d["y"] = round(float(filt_pos[1]), 3)
225
+ if abs(est_vel[1]) >= 0.006:
226
+ d["velocity_y"] = float(round(est_vel[1], 4))
227
+ objs.append(d)
228
+
229
+ c = w.spec.container_pos
230
+ tip = w.tip()
231
+ return {
232
+ "t": round(w.time, 3),
233
+ "source": "vision",
234
+ "container": {"x": c[0], "y": c[1]},
235
+ "gripper": {"closure": round(w.finger_closure(), 4), "tip": [round(float(v), 3) for v in tip]},
236
+ "objects": objs,
237
+ }