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.
@@ -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,9 @@
1
+ # SPDX-FileCopyrightText: 2026 Paweł Kikta
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Lets `python -m proofmass_check` work like the `proofmass-check` command."""
4
+
5
+ import sys
6
+
7
+ from proofmass_check.cli import main
8
+
9
+ sys.exit(main())
@@ -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"