proofmass-check 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.
- proofmass_check/__init__.py +9 -0
- proofmass_check/__main__.py +9 -0
- proofmass_check/checks/__init__.py +51 -0
- proofmass_check/checks/drop.py +44 -0
- proofmass_check/checks/grip.py +143 -0
- proofmass_check/checks/joints.py +80 -0
- proofmass_check/checks/load.py +50 -0
- proofmass_check/checks/passive.py +116 -0
- proofmass_check/checks/speed.py +62 -0
- proofmass_check/checks/text.py +25 -0
- proofmass_check/checks/wheeled.py +150 -0
- proofmass_check/cli.py +111 -0
- proofmass_check/engines/__init__.py +33 -0
- proofmass_check/engines/base.py +167 -0
- proofmass_check/engines/newton.py +289 -0
- proofmass_check/engines/physx.py +422 -0
- proofmass_check/geometry.py +61 -0
- proofmass_check/model.py +240 -0
- proofmass_check/plan.py +91 -0
- proofmass_check/report.py +69 -0
- proofmass_check/results.schema.json +63 -0
- proofmass_check/rules.py +112 -0
- proofmass_check/runner.py +94 -0
- proofmass_check/usd_names.py +45 -0
- proofmass_check/usd_reader.py +462 -0
- proofmass_check-0.1.0.dist-info/METADATA +138 -0
- proofmass_check-0.1.0.dist-info/RECORD +31 -0
- proofmass_check-0.1.0.dist-info/WHEEL +4 -0
- proofmass_check-0.1.0.dist-info/entry_points.txt +2 -0
- proofmass_check-0.1.0.dist-info/licenses/LICENSE +202 -0
- proofmass_check-0.1.0.dist-info/licenses/NOTICE +5 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Paweł Kikta
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Physics checks for SimReady OpenUSD assets.
|
|
4
|
+
|
|
5
|
+
Start with the command line: `proofmass-check run asset.usd --engine physx`.
|
|
6
|
+
docs/spec.md describes every check, its rule and its numbers.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Paweł Kikta
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The checks: what each one measures and how it decides PASS or FAIL.
|
|
4
|
+
|
|
5
|
+
Each check asks the engine for a few measurements (engines/base.py) and judges them against the
|
|
6
|
+
numbers in rules.py. No check knows which engine it runs on.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
from proofmass_check import rules
|
|
13
|
+
|
|
14
|
+
Verdict = Literal["PASS", "FAIL", "N/A"]
|
|
15
|
+
|
|
16
|
+
# Every check, in the order a run reports them, with a one-line title.
|
|
17
|
+
TITLES = {
|
|
18
|
+
"LOAD": "the engine builds the asset as the USD describes it",
|
|
19
|
+
"DROP": f"dropped from {rules.DROP_HEIGHT * 100:g} cm, comes to rest on the floor",
|
|
20
|
+
"HOLD": "drives hold the rest pose under gravity",
|
|
21
|
+
"TRACK": f"each drive reaches {rules.TRACK_AT[0]:.0%} and {rules.TRACK_AT[1]:.0%} of its range",
|
|
22
|
+
"LIMIT": "joint limits stop a drive commanded past them",
|
|
23
|
+
"STABLE": f"stable in {rules.RANDOM_POSES} random poses inside the limits",
|
|
24
|
+
"SPEED": f"at a {1 / rules.SPEED_DT:.0f} Hz physics step, each drive stays under its speed limit",
|
|
25
|
+
"PASSIVE": "each joint without a drive, when pushed, reaches both limits and stops there",
|
|
26
|
+
"GRIP": f"holds {rules.GRIP_LOAD:.0%} of gripper_maxPayload pulled two ways",
|
|
27
|
+
"SHAKE": f"holds {rules.GRIP_LOAD:.0%} of gripper_maxPayload shaken at up to 2 g",
|
|
28
|
+
"STAND": f"dropped from {rules.DROP_HEIGHT * 100:g} cm with its wheels braked, stands upright and still",
|
|
29
|
+
"DRIVE": "all driven wheels forward: drives straight along +X at the commanded speed",
|
|
30
|
+
"TURN": "wheels in opposite directions: turns in place at the expected rate",
|
|
31
|
+
"STEER": f"steered to {rules.STEER_FRACTION:.0%} of its range: drives a circle of the Ackermann radius",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class CheckResult:
|
|
37
|
+
id: str
|
|
38
|
+
verdict: Verdict
|
|
39
|
+
detail: str
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def title(self) -> str:
|
|
43
|
+
return TITLES[self.id]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def judge(check_id: str, ok: bool, detail: str) -> CheckResult:
|
|
47
|
+
return CheckResult(check_id, "PASS" if ok else "FAIL", detail)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def not_applicable(check_id: str, reason: str) -> CheckResult:
|
|
51
|
+
return CheckResult(check_id, "N/A", reason)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Paweł Kikta
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""DROP: lifted and let go, the asset comes to rest on the floor."""
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from proofmass_check.checks import CheckResult, judge
|
|
10
|
+
from proofmass_check.engines.base import Engine
|
|
11
|
+
from proofmass_check.geometry import lowest_collider, tilt
|
|
12
|
+
from proofmass_check.model import Asset
|
|
13
|
+
from proofmass_check.rules import DROP_HEIGHT, DROP_SECONDS, FLOOR_TOL, REST_SPEED
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def resting(finite: bool, speed: float, gap: float) -> bool:
|
|
17
|
+
"""At rest: slower than REST_SPEED, with the lowest collider within FLOOR_TOL of the floor."""
|
|
18
|
+
return finite and speed < REST_SPEED and abs(gap) < FLOOR_TOL
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def drop(engine: Engine, asset: Asset, floor: float) -> CheckResult:
|
|
22
|
+
"""The asset may end in any pose: a prop can also come to rest on its side.
|
|
23
|
+
|
|
24
|
+
The tilt is reported, but it does not decide the result.
|
|
25
|
+
"""
|
|
26
|
+
outcome = engine.drop(DROP_HEIGHT, DROP_SECONDS)
|
|
27
|
+
lowest = min(
|
|
28
|
+
(
|
|
29
|
+
lowest_collider(asset.bodies[path], rotation, position)
|
|
30
|
+
for path, (rotation, position) in outcome.poses.items()
|
|
31
|
+
),
|
|
32
|
+
default=math.inf,
|
|
33
|
+
)
|
|
34
|
+
gap = lowest - floor
|
|
35
|
+
root = asset.root_body
|
|
36
|
+
turned = math.degrees(tilt(asset.bodies[root].pose[:3, :3], outcome.poses[root][0])) if outcome.finite else math.nan
|
|
37
|
+
finite = outcome.finite and bool(np.isfinite(gap))
|
|
38
|
+
gap_mm = round(gap * 1000, 2) + 0.0 # adding 0.0 turns -0.0 into 0.0, so the text never says "-0.00 mm"
|
|
39
|
+
return judge(
|
|
40
|
+
"DROP",
|
|
41
|
+
resting(finite, outcome.root_speed, gap),
|
|
42
|
+
f"final speed {outcome.root_speed:.4f} m/s, lowest collider {gap_mm:+.2f} mm from the floor, "
|
|
43
|
+
f"tilted {turned:.0f} deg",
|
|
44
|
+
)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Paweł Kikta
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""GRIP and SHAKE: the gripper holds its rated payload. This follows NVIDIA's FET028 test.
|
|
4
|
+
|
|
5
|
+
The fingers close on a box at the grasp centre. The box has no weight, so only the pull tests the
|
|
6
|
+
grip. A slip is the box moving along the pads. Motion along the closing axis is drift: two fingers
|
|
7
|
+
that each push with their force limit carry the box with them, and that is not a slip.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from proofmass_check.checks import CheckResult, judge, not_applicable
|
|
15
|
+
from proofmass_check.engines.base import Engine, GripBox, GripOutcome, GripSetup
|
|
16
|
+
from proofmass_check.geometry import apply, turn
|
|
17
|
+
from proofmass_check.model import Asset, GripperSite, Joint
|
|
18
|
+
from proofmass_check.rules import (
|
|
19
|
+
GRIP_HOLD_S,
|
|
20
|
+
GRIP_LOAD,
|
|
21
|
+
GRIP_LOADS,
|
|
22
|
+
GRIP_MIN_TRAVEL,
|
|
23
|
+
GRIP_SLIP,
|
|
24
|
+
GRIP_WIDTH,
|
|
25
|
+
LIMIT_TOL,
|
|
26
|
+
SHAKE_HZ,
|
|
27
|
+
Settings,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def choose_site(asset: Asset) -> tuple[GripperSite | None, list[str]]:
|
|
32
|
+
"""The site GRIP tests, and a note for each site it does not test.
|
|
33
|
+
|
|
34
|
+
GRIP tests the first site that has gripper_maxPayload, sits on a rigid body, and belongs to a
|
|
35
|
+
gripper fixed to the world (a free gripper would fall).
|
|
36
|
+
"""
|
|
37
|
+
fixed = bool(asset.world_fixed)
|
|
38
|
+
found, notes = None, []
|
|
39
|
+
for site in asset.sites:
|
|
40
|
+
why = (
|
|
41
|
+
"no gripper_maxPayload" if not site.payload
|
|
42
|
+
else "not on a rigid body" if not site.body
|
|
43
|
+
else "the gripper is not fixed to the world" if not fixed
|
|
44
|
+
else f"GRIP tests one site, {found.name}" if found
|
|
45
|
+
else None
|
|
46
|
+
) # fmt: skip
|
|
47
|
+
if why:
|
|
48
|
+
notes.append(f"gripper site {site.name}: {why}, so GRIP does not test it")
|
|
49
|
+
else:
|
|
50
|
+
found = site
|
|
51
|
+
return found, notes
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def closing_targets(asset: Asset, site: GripperSite) -> dict[str, float]:
|
|
55
|
+
"""The finger joints of a site, each with the limit that closes it.
|
|
56
|
+
|
|
57
|
+
A finger joint is a limited, position-driven joint below the site's body. Its closing limit is
|
|
58
|
+
the one that brings the bodies it moves nearer the grasp centre along the closing axis. A joint
|
|
59
|
+
that moves them less than GRIP_MIN_TRAVEL along that axis does not close the gripper.
|
|
60
|
+
The file pose is taken as each joint at its rest position.
|
|
61
|
+
"""
|
|
62
|
+
below = set(asset.carried(site.body))
|
|
63
|
+
targets = {}
|
|
64
|
+
for joint in asset.joints:
|
|
65
|
+
if joint.drive_kind != "position" or not joint.limited or joint.body0 not in below:
|
|
66
|
+
continue
|
|
67
|
+
moved = [asset.bodies[path] for path in asset.carried(joint.body1)]
|
|
68
|
+
centre = np.mean([apply(b.pose, b.com) for b in moved], axis=0)
|
|
69
|
+
gaps = {}
|
|
70
|
+
for limit in (joint.lower, joint.upper):
|
|
71
|
+
if joint.revolute:
|
|
72
|
+
at_limit = joint.pivot + turn(centre - joint.pivot, joint.direction, limit - joint.rest)
|
|
73
|
+
else:
|
|
74
|
+
at_limit = centre + (limit - joint.rest) * joint.direction
|
|
75
|
+
gaps[limit] = abs((at_limit - site.centre) @ site.closing)
|
|
76
|
+
if abs(gaps[joint.lower] - gaps[joint.upper]) >= GRIP_MIN_TRAVEL:
|
|
77
|
+
targets[joint.name] = min(gaps, key=gaps.get)
|
|
78
|
+
return targets
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def grip_box(site: GripperSite) -> GripBox:
|
|
82
|
+
"""The box: GRIP_WIDTH of the maximum opening across the pads, and the pad size in the other two directions."""
|
|
83
|
+
rotation = np.column_stack([np.cross(site.line, site.forward), site.line, site.forward])
|
|
84
|
+
return GripBox(size=(GRIP_WIDTH * site.opening, site.pad, site.pad), centre=site.centre, rotation=rotation)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def gripped(joints: Sequence[Joint], closed: dict[str, float], targets: dict[str, float], settings: Settings) -> bool:
|
|
88
|
+
"""True when the fingers stopped on the box: at least one finger short of its closing limit.
|
|
89
|
+
|
|
90
|
+
One is enough. With two fingers on separate drives, one can carry the box until it meets its
|
|
91
|
+
limit, and the other then holds the box against it. All fingers at their limits means the
|
|
92
|
+
gripper closed past the box.
|
|
93
|
+
"""
|
|
94
|
+
return any(abs(closed[j.name] - targets[j.name]) > LIMIT_TOL * settings.tolerance(j.revolute) for j in joints)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def grip_and_shake(engine: Engine, asset: Asset, site: GripperSite, settings: Settings) -> list[CheckResult]:
|
|
98
|
+
targets = closing_targets(asset, site)
|
|
99
|
+
if not targets:
|
|
100
|
+
return [
|
|
101
|
+
judge("GRIP", False, f"site {site.name}: no position drive closes the fingers"),
|
|
102
|
+
not_applicable("SHAKE", "GRIP found no finger drive"),
|
|
103
|
+
]
|
|
104
|
+
fingers = [asset.joint(name) for name in targets]
|
|
105
|
+
hold = {j.name: j.rest for j in asset.joints if j.drive_kind == "position" and j.name not in targets}
|
|
106
|
+
setup = GripSetup(
|
|
107
|
+
site=site,
|
|
108
|
+
box=grip_box(site),
|
|
109
|
+
close=targets,
|
|
110
|
+
hold=hold,
|
|
111
|
+
close_seconds=settings.settle_s,
|
|
112
|
+
pull_seconds=GRIP_HOLD_S,
|
|
113
|
+
shake_hz=SHAKE_HZ,
|
|
114
|
+
)
|
|
115
|
+
scene = engine.grip_scene(setup)
|
|
116
|
+
|
|
117
|
+
def held(outcome: GripOutcome) -> tuple[bool, str]:
|
|
118
|
+
motion = np.asarray(outcome.motion, float)
|
|
119
|
+
drift = float(abs(motion @ site.closing))
|
|
120
|
+
slip = float(np.linalg.norm(motion - (motion @ site.closing) * site.closing))
|
|
121
|
+
closed = gripped(fingers, outcome.closed, targets, settings)
|
|
122
|
+
ok = outcome.finite and closed and slip < GRIP_SLIP
|
|
123
|
+
words = f"slid {slip * 1000:.1f} mm" if slip < 0.1 else "the box is lost"
|
|
124
|
+
if GRIP_SLIP <= drift < 0.1:
|
|
125
|
+
words += f", drifted {drift * 1000:.1f} mm with the fingers"
|
|
126
|
+
return ok, words + ("" if ok else " SLIP" if closed else " NOT GRIPPED")
|
|
127
|
+
|
|
128
|
+
payload = site.payload
|
|
129
|
+
rows, best_both = [], []
|
|
130
|
+
for label, direction in (("out of the fingers", site.forward), ("along the grip line", site.line)):
|
|
131
|
+
best, tried = 0.0, []
|
|
132
|
+
for fraction in GRIP_LOADS:
|
|
133
|
+
ok, words = held(scene.trial(fraction * payload, direction, shake=False))
|
|
134
|
+
tried.append(f"{fraction * payload:g} kg {words}")
|
|
135
|
+
if not ok:
|
|
136
|
+
break
|
|
137
|
+
best = fraction
|
|
138
|
+
best_both.append(best)
|
|
139
|
+
rows.append(f"{label} holds {best * payload:g} kg ({', '.join(tried)})")
|
|
140
|
+
grip = judge("GRIP", min(best_both) >= GRIP_LOAD, f"site {site.name}, payload {payload:g} kg: " + "; ".join(rows))
|
|
141
|
+
ok, words = held(scene.trial(GRIP_LOAD * payload, site.forward, shake=True))
|
|
142
|
+
shake = judge("SHAKE", ok, f"{GRIP_LOAD * payload:g} kg at {SHAKE_HZ:g} Hz: {words}")
|
|
143
|
+
return [grip, shake]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Paweł Kikta
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""HOLD, TRACK, LIMIT and STABLE: what the position drives do.
|
|
4
|
+
|
|
5
|
+
Every move starts from the rest pose. The joint under test gets a target, and every other
|
|
6
|
+
position-driven joint holds its rest position.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from proofmass_check.checks import CheckResult, judge, text
|
|
14
|
+
from proofmass_check.engines.base import Engine
|
|
15
|
+
from proofmass_check.model import Joint
|
|
16
|
+
from proofmass_check.rules import LIMIT_TOL, OVERSHOOT, SEED, TRACK_AT, UNSTABLE_SPEED, Settings
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def rest_pose(driven: Sequence[Joint]) -> dict[str, float]:
|
|
20
|
+
return {j.name: j.rest for j in driven}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def move_time(settings: Settings, distance: float, max_speed: float) -> float:
|
|
24
|
+
"""The time a drive gets for a move: settle_s, plus the time its speed limit needs for the distance.
|
|
25
|
+
|
|
26
|
+
A long joint with a speed limit, such as an arm that turns 180 deg at 120 deg/s, cannot get
|
|
27
|
+
there in settle_s alone. A speed limit of 0 or less is read as no limit.
|
|
28
|
+
"""
|
|
29
|
+
return settings.settle_s + (abs(distance) / max_speed if max_speed > 0 else 0.0)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def hold(engine: Engine, driven: Sequence[Joint], settings: Settings) -> CheckResult:
|
|
33
|
+
state = engine.settle(rest_pose(driven), settings.settle_s)
|
|
34
|
+
errors = {j.name: abs(state.positions[j.name] - j.rest) for j in driven}
|
|
35
|
+
ok = state.finite and all(errors[j.name] <= settings.tolerance(j.revolute) for j in driven)
|
|
36
|
+
return judge("HOLD", ok, "; ".join(f"{j.name} {text.error(j, errors[j.name])}" for j in driven))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def track(engine: Engine, driven: Sequence[Joint], limited: Sequence[Joint], settings: Settings) -> CheckResult:
|
|
40
|
+
rows, ok_all = [], True
|
|
41
|
+
for joint in limited:
|
|
42
|
+
for fraction in TRACK_AT:
|
|
43
|
+
target = joint.lower + fraction * (joint.upper - joint.lower)
|
|
44
|
+
state = engine.settle(
|
|
45
|
+
{**rest_pose(driven), joint.name: target}, move_time(settings, target - joint.rest, joint.max_speed)
|
|
46
|
+
)
|
|
47
|
+
error = abs(state.positions[joint.name] - target)
|
|
48
|
+
ok = state.finite and error <= settings.tolerance(joint.revolute)
|
|
49
|
+
ok_all &= ok
|
|
50
|
+
rows.append(f"{joint.name}@{fraction:.0%} {text.error(joint, error)}{'' if ok else ' FAIL'}")
|
|
51
|
+
return judge("TRACK", ok_all, "; ".join(rows))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def limit(engine: Engine, driven: Sequence[Joint], limited: Sequence[Joint], settings: Settings) -> CheckResult:
|
|
55
|
+
"""Each drive is sent OVERSHOOT of its range past its upper limit. It may stop LIMIT_TOL tolerances past it."""
|
|
56
|
+
rows, ok_all = [], True
|
|
57
|
+
for joint in limited:
|
|
58
|
+
target = joint.upper + OVERSHOOT * (joint.upper - joint.lower)
|
|
59
|
+
state = engine.settle(
|
|
60
|
+
{**rest_pose(driven), joint.name: target}, move_time(settings, joint.upper - joint.rest, joint.max_speed)
|
|
61
|
+
)
|
|
62
|
+
past = state.positions[joint.name] - joint.upper
|
|
63
|
+
ok = state.finite and past <= LIMIT_TOL * settings.tolerance(joint.revolute)
|
|
64
|
+
ok_all &= ok
|
|
65
|
+
rows.append(f"{joint.name} {text.error(joint, max(past, 0.0))} past limit{'' if ok else ' FAIL'}")
|
|
66
|
+
return judge("LIMIT", ok_all, "; ".join(rows))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def stable(engine: Engine, driven: Sequence[Joint], limited: Sequence[Joint], settings: Settings) -> CheckResult:
|
|
70
|
+
"""Random poses inside the limits. A pose is unstable when a value is not finite or a joint is too fast."""
|
|
71
|
+
rng = np.random.default_rng(SEED)
|
|
72
|
+
unstable = 0
|
|
73
|
+
for _ in range(settings.random_poses):
|
|
74
|
+
draws = rng.random(len(limited))
|
|
75
|
+
pose = rest_pose(driven) | {
|
|
76
|
+
j.name: j.lower + d * (j.upper - j.lower) for j, d in zip(limited, draws, strict=True)
|
|
77
|
+
}
|
|
78
|
+
state = engine.settle(pose, settings.settle_s)
|
|
79
|
+
unstable += (not state.finite) or state.max_speed > UNSTABLE_SPEED
|
|
80
|
+
return judge("STABLE", unstable == 0, f"{unstable} unstable")
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Paweł Kikta
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""LOAD: the engine builds every joint, and the same total mass as the USD."""
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
|
|
7
|
+
from proofmass_check.checks import CheckResult, judge
|
|
8
|
+
from proofmass_check.engines.base import LoadOutcome
|
|
9
|
+
from proofmass_check.model import Asset
|
|
10
|
+
from proofmass_check.rules import MASS_TOL
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def built(outcome: LoadOutcome) -> bool:
|
|
14
|
+
"""True when the engine and the USD have the same joints. Other checks need this."""
|
|
15
|
+
return not outcome.missing and not outcome.unknown
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load(asset: Asset, outcome: LoadOutcome) -> CheckResult:
|
|
19
|
+
given = [b.mass for b in asset.bodies.values()]
|
|
20
|
+
usd_mass = sum(given) if all(given) else None # only when the USD gives every mass
|
|
21
|
+
engine_mass = sum(outcome.masses.values())
|
|
22
|
+
moving = [j for j in asset.joints if j.moves]
|
|
23
|
+
position = sum(j.drive_kind == "position" for j in moving)
|
|
24
|
+
velocity = sum(j.drive_kind == "velocity" for j in moving)
|
|
25
|
+
detail = f"{len(moving)} joints ({position} position, {velocity} velocity drives), "
|
|
26
|
+
detail += f"mass in the engine {engine_mass:.2f} kg"
|
|
27
|
+
if usd_mass is None:
|
|
28
|
+
detail += f" (the USD gives it for {sum(map(bool, given))} of {len(given)} bodies)"
|
|
29
|
+
else:
|
|
30
|
+
detail += f", in the USD {usd_mass:.2f} kg"
|
|
31
|
+
if outcome.missing:
|
|
32
|
+
detail += f"; joints the engine did not build: {list(outcome.missing)}"
|
|
33
|
+
if outcome.unknown:
|
|
34
|
+
detail += f"; engine joints that are not in the USD: {list(outcome.unknown)}"
|
|
35
|
+
mass_ok = usd_mass is None or abs(engine_mass - usd_mass) <= MASS_TOL * usd_mass
|
|
36
|
+
return judge("LOAD", built(outcome) and mass_ok, detail)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def with_engine_masses(asset: Asset, outcome: LoadOutcome) -> Asset:
|
|
40
|
+
"""The asset with the engine's mass and centre of mass for each body whose mass the USD leaves out.
|
|
41
|
+
|
|
42
|
+
PASSIVE and GRIP need every mass, and only the engine knows the ones it computes itself.
|
|
43
|
+
"""
|
|
44
|
+
bodies = {
|
|
45
|
+
path: body
|
|
46
|
+
if body.mass is not None or path not in outcome.masses
|
|
47
|
+
else dataclasses.replace(body, mass=outcome.masses[path], com=outcome.coms.get(path, body.com))
|
|
48
|
+
for path, body in asset.bodies.items()
|
|
49
|
+
}
|
|
50
|
+
return dataclasses.replace(asset, bodies=bodies)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Paweł Kikta
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""PASSIVE: a joint without a drive, such as a door or a drawer, opens to its limits and stops there.
|
|
4
|
+
|
|
5
|
+
The base push is what the joint needs against gravity, plus a little to get it moving. A joint held
|
|
6
|
+
by friction (a door that stays where you leave it) needs more. So a push that does not reach the
|
|
7
|
+
limit is tried again, PUSH_STEP times stronger each time, up to what a hand adds at the handle.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from proofmass_check.checks import CheckResult, judge, text
|
|
16
|
+
from proofmass_check.engines.base import Engine
|
|
17
|
+
from proofmass_check.geometry import apply
|
|
18
|
+
from proofmass_check.model import Asset, Joint
|
|
19
|
+
from proofmass_check.rules import (
|
|
20
|
+
GRAVITY,
|
|
21
|
+
HAND_FORCE,
|
|
22
|
+
LIMIT_TOL,
|
|
23
|
+
PUSH_ACCEL_ANGULAR,
|
|
24
|
+
PUSH_ACCEL_LINEAR,
|
|
25
|
+
PUSH_GRAVITY_FACTOR,
|
|
26
|
+
PUSH_MIN_LEVER,
|
|
27
|
+
PUSH_SETTLE,
|
|
28
|
+
PUSH_STEP,
|
|
29
|
+
Settings,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def passive_joints(asset: Asset) -> list[Joint]:
|
|
34
|
+
"""The joints PASSIVE pushes: limited, not following another joint, and with no position drive.
|
|
35
|
+
|
|
36
|
+
A damping-only drive (a door closer) counts as no drive, except on a wheeled robot: there it
|
|
37
|
+
turns a wheel.
|
|
38
|
+
"""
|
|
39
|
+
skip = {"position", "velocity"} if asset.wheeled else {"position"}
|
|
40
|
+
return [j for j in asset.joints if j.moves and j.limited and not j.mimic and j.drive_kind not in skip]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def lever(asset: Asset, joint: Joint) -> tuple[float, float]:
|
|
44
|
+
"""The mass a joint carries (kg), and for a revolute joint the distance of its centre of mass from the axis (m)."""
|
|
45
|
+
carried = [asset.bodies[path] for path in asset.carried(joint.body1)] if joint.body1 else []
|
|
46
|
+
unknown = [b.path for b in carried if b.mass is None]
|
|
47
|
+
if unknown: # the engine reports these masses (checks/load.py: with_engine_masses)
|
|
48
|
+
raise ValueError(f"the mass of {unknown} is not known")
|
|
49
|
+
mass = sum(b.mass for b in carried)
|
|
50
|
+
if mass <= 0:
|
|
51
|
+
return 0.0, 0.0
|
|
52
|
+
com = sum(b.mass * apply(b.pose, b.com) for b in carried) / mass
|
|
53
|
+
return mass, float(np.linalg.norm(np.cross(com - joint.pivot, joint.direction)))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def base_push(asset: Asset, joint: Joint, seconds: float) -> float:
|
|
57
|
+
"""The force (N) or torque (N m) that moves a joint to a limit against gravity.
|
|
58
|
+
|
|
59
|
+
PUSH_GRAVITY_FACTOR times the largest gravity load on the joint, plus enough to accelerate what
|
|
60
|
+
it carries (PUSH_ACCEL_LINEAR or PUSH_ACCEL_ANGULAR), plus what a damper needs to cross the range
|
|
61
|
+
in half the push.
|
|
62
|
+
"""
|
|
63
|
+
mass, distance = lever(asset, joint)
|
|
64
|
+
axis = joint.direction
|
|
65
|
+
if joint.revolute:
|
|
66
|
+
arm = max(distance, PUSH_MIN_LEVER)
|
|
67
|
+
tilt_of_axis = float(np.linalg.norm(np.cross(axis, [0.0, 0.0, 1.0]))) # 0 for a vertical axis
|
|
68
|
+
gravity = mass * (PUSH_GRAVITY_FACTOR * GRAVITY * tilt_of_axis * distance + PUSH_ACCEL_ANGULAR * arm**2)
|
|
69
|
+
else:
|
|
70
|
+
gravity = mass * (PUSH_GRAVITY_FACTOR * GRAVITY * abs(axis[2]) + PUSH_ACCEL_LINEAR)
|
|
71
|
+
damping = joint.drive.damping if joint.drive else 0.0
|
|
72
|
+
return gravity + damping * (joint.upper - joint.lower) / (seconds / 2)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def hand_budget(asset: Asset, joint: Joint, seconds: float) -> float:
|
|
76
|
+
"""The most PASSIVE pushes a joint with: base_push plus HAND_FORCE at the handle.
|
|
77
|
+
|
|
78
|
+
On a revolute joint the handle is twice as far from the axis as the centre of mass, and at
|
|
79
|
+
least PUSH_MIN_LEVER away.
|
|
80
|
+
"""
|
|
81
|
+
arm = 1.0 if not joint.revolute else max(2 * lever(asset, joint)[1], PUSH_MIN_LEVER)
|
|
82
|
+
return base_push(asset, joint, seconds) + HAND_FORCE * arm
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def passive(engine: Engine, asset: Asset, joints: Sequence[Joint], settings: Settings) -> CheckResult:
|
|
86
|
+
seconds = PUSH_SETTLE * settings.settle_s
|
|
87
|
+
rows, ok_all = [], True
|
|
88
|
+
for joint in joints:
|
|
89
|
+
for row, ok in _push_both_ways(engine, asset, joint, seconds, settings):
|
|
90
|
+
ok_all &= ok
|
|
91
|
+
rows.append(row + ("" if ok else " FAIL"))
|
|
92
|
+
return judge("PASSIVE", ok_all, "; ".join(rows))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _push_both_ways(engine: Engine, asset: Asset, joint: Joint, seconds: float, settings: Settings):
|
|
96
|
+
effort, budget = base_push(asset, joint, seconds), hand_budget(asset, joint, seconds)
|
|
97
|
+
margin = LIMIT_TOL * settings.tolerance(joint.revolute)
|
|
98
|
+
unit = text.effort_unit(joint)
|
|
99
|
+
for sign, limit, side in ((1, joint.upper, "upper"), (-1, joint.lower, "lower")):
|
|
100
|
+
tried = effort
|
|
101
|
+
error = abs(engine.push(joint.name, sign * tried, seconds) - limit)
|
|
102
|
+
ok = math.isfinite(error) and error <= margin
|
|
103
|
+
if ok:
|
|
104
|
+
yield f"{joint.name}@{side} {text.error(joint, error)} from the limit", True
|
|
105
|
+
continue
|
|
106
|
+
while not ok and tried < budget:
|
|
107
|
+
tried = min(tried * PUSH_STEP, budget)
|
|
108
|
+
error = abs(engine.push(joint.name, sign * tried, seconds) - limit)
|
|
109
|
+
ok = math.isfinite(error) and error <= margin
|
|
110
|
+
if ok:
|
|
111
|
+
yield f"{joint.name}@{side} needs {tried:.3g} {unit} (base push {effort:.3g}, hand {budget:.3g})", True
|
|
112
|
+
else:
|
|
113
|
+
yield (
|
|
114
|
+
f"{joint.name}@{side} {text.error(joint, error)} from the limit at the hand push {budget:.3g} {unit}",
|
|
115
|
+
False,
|
|
116
|
+
)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Paweł Kikta
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""SPEED: at Isaac Sim's default physics step, each drive stays under its speed limit.
|
|
4
|
+
|
|
5
|
+
An asset can be stable at a small step and break at the step most users run. This follows NVIDIA's
|
|
6
|
+
velocity_limit benchmark.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from proofmass_check.checks import CheckResult, judge, text
|
|
15
|
+
from proofmass_check.checks.joints import rest_pose
|
|
16
|
+
from proofmass_check.engines.base import Engine
|
|
17
|
+
from proofmass_check.model import Joint
|
|
18
|
+
from proofmass_check.rules import (
|
|
19
|
+
LIMIT_TOL,
|
|
20
|
+
SPEED_DT,
|
|
21
|
+
SPEED_MARGIN,
|
|
22
|
+
SPEED_SECONDS,
|
|
23
|
+
SPEED_SKIP_STEPS,
|
|
24
|
+
SPEED_TOL,
|
|
25
|
+
Settings,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def far_target(joint: Joint) -> float:
|
|
30
|
+
"""The end of the range farther from rest, SPEED_MARGIN of the range short of it."""
|
|
31
|
+
margin = SPEED_MARGIN * (joint.upper - joint.lower)
|
|
32
|
+
return joint.upper - margin if joint.rest <= (joint.lower + joint.upper) / 2 else joint.lower + margin
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def speed(engine: Engine, driven: Sequence[Joint], tested: Sequence[Joint], settings: Settings) -> CheckResult:
|
|
36
|
+
"""tested: the driven joints with limits and a speed limit.
|
|
37
|
+
|
|
38
|
+
Each one moves to far_target for SPEED_SECONDS. After the first SPEED_SKIP_STEPS steps its speed
|
|
39
|
+
must stay within its limit plus SPEED_TOL. At the end, every driven joint must be inside its
|
|
40
|
+
limits: a coupling that blows up at a large step pushes joints out of them.
|
|
41
|
+
"""
|
|
42
|
+
rows, ok_all = [], True
|
|
43
|
+
for joint in tested:
|
|
44
|
+
motion = engine.move({**rest_pose(driven), joint.name: far_target(joint)}, SPEED_SECONDS, SPEED_DT)
|
|
45
|
+
peak = float(np.max(np.abs(motion.speeds[joint.name][SPEED_SKIP_STEPS:]), initial=0.0))
|
|
46
|
+
inside = motion.finite and all(_inside(j, motion.positions[j.name], settings) for j in driven)
|
|
47
|
+
ok = inside and peak <= joint.max_speed * (1 + SPEED_TOL)
|
|
48
|
+
ok_all &= ok
|
|
49
|
+
rows.append(
|
|
50
|
+
f"{joint.name} {text.speed(joint, peak)} of {text.speed(joint, joint.max_speed, 'g')} "
|
|
51
|
+
f"{text.speed_unit(joint)}" + ("" if inside else ", a joint left its limits") + ("" if ok else " FAIL")
|
|
52
|
+
)
|
|
53
|
+
return judge("SPEED", ok_all, "; ".join(rows))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _inside(joint: Joint, position: float, settings: Settings) -> bool:
|
|
57
|
+
if not math.isfinite(position):
|
|
58
|
+
return False
|
|
59
|
+
if not joint.limited:
|
|
60
|
+
return True
|
|
61
|
+
margin = LIMIT_TOL * settings.tolerance(joint.revolute)
|
|
62
|
+
return joint.lower - margin <= position <= joint.upper + margin
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Paweł Kikta
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Joint values as people read them: degrees for an angle, millimetres for a short distance."""
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
from proofmass_check.model import Joint
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def error(joint: Joint, value: float) -> str:
|
|
11
|
+
"""A joint error or distance: "0.250 deg" or "1.20 mm"."""
|
|
12
|
+
return f"{math.degrees(value):.3f} deg" if joint.revolute else f"{value * 1000:.2f} mm"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def speed(joint: Joint, value: float, digits: str = ".3g") -> str:
|
|
16
|
+
"""A joint speed without its unit: deg/s for a revolute joint, m/s for a prismatic one."""
|
|
17
|
+
return format(math.degrees(value) if joint.revolute else value, digits)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def speed_unit(joint: Joint) -> str:
|
|
21
|
+
return "deg/s" if joint.revolute else "m/s"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def effort_unit(joint: Joint) -> str:
|
|
25
|
+
return "N m" if joint.revolute else "N"
|