exoskeleton/code/experiments/executors.py

493 lines
18 KiB
Python

"""Executable pre-prototype G0c studies.
Each function accepts one immutable trial record produced by
``experiments.plan`` and returns an atomic ``TrialPayload``. The methods in a
pair use the same recorded trajectory/model/sensor/network seeds.
"""
from __future__ import annotations
from dataclasses import replace
from enum import Enum
from typing import Any, Mapping
import numpy as np
import pinocchio as pin
from core.estimation_signals import (
JointFrictionCalibration,
ResidualAblation,
WrenchEstimatorCalibration,
CalibratedResidualWrenchEstimator,
)
from core.model_contract import (
MASTER_JOINT_NAMES,
SLAVE_FRAMES,
SLAVE_JOINT_NAMES,
finite_joint_limits,
load_models,
require_frame,
)
from core.retargeting_baselines import (
RetargetingFailure,
build_canonical_sew_target_baselines,
)
from core.wrench_solver import ScaledDLSSolver, UndampedSVDSolver
from experiments.io import TrialPayload
from experiments.rng import generator_from_record
from simulate_closed_loop import (
SCENARIOS,
SimulationConfig,
build_mapper,
make_wall,
simulate_scenario,
)
def _method_id(trial: Mapping[str, Any]) -> str:
method = trial.get("method")
if not isinstance(method, Mapping) or not isinstance(
method.get("method_id"), str
):
raise ValueError("trial has no method.method_id")
return method["method_id"]
def _trajectory_spec(trial: Mapping[str, Any]) -> Mapping[str, Any]:
trajectory = trial.get("trajectory")
if not isinstance(trajectory, Mapping):
raise ValueError("trial has no trajectory mapping")
return trajectory
def _factor(trial: Mapping[str, Any], name: str, default: Any) -> Any:
factors = trial.get("factors", {})
if not isinstance(factors, Mapping):
raise ValueError("trial factors must be a mapping")
return factors.get(name, default)
def _enum_code(member: Enum) -> int:
return list(type(member)).index(member)
def _master_trajectory(
trial: Mapping[str, Any],
*,
lower: np.ndarray,
upper: np.ndarray,
) -> np.ndarray:
"""Generate a continuous, bounded master trajectory from a frozen spec."""
specification = _trajectory_spec(trial)
sample_count = int(specification.get("sample_count", 81))
if sample_count < 3:
raise ValueError("H1 trajectory sample_count must be at least three")
family = str(specification.get("family", "nominal"))
center = np.asarray(
specification.get(
"center",
[0.534, 0.314, -0.10, 2.14, 0.38, 0.38, -0.72],
),
dtype=float,
)
delta = np.asarray(
specification.get(
"delta",
[0.08, -0.06, 0.05, -0.12, 0.04, 0.05, -0.04],
),
dtype=float,
)
if center.shape != (7,) or delta.shape != (7,):
raise ValueError("H1 center and delta must have seven entries")
if family == "joint_limit":
center = center.copy()
center[0] = upper[0] - 0.03
delta = np.zeros(7)
delta[0] = -0.22
elif family == "low_manipulability":
center = center.copy()
center[3] = 0.08
delta = np.array([0.04, 0.03, -0.04, 0.05, 0.02, -0.02, 0.02])
elif family == "reach_boundary":
delta = 1.75 * delta
elif family == "sew_degeneracy":
center = np.array([0.0, 0.0, 0.0, 0.12, 0.0, 0.0, 0.0])
delta = np.array([0.0, 0.18, 0.0, 0.08, 0.0, -0.08, 0.0])
phase = np.linspace(0.0, 1.0, sample_count)
# One cosine excursion starts and ends at the same configuration with zero
# endpoint velocity, making discontinuities attributable to the mapper.
excursion = 0.5 - 0.5 * np.cos(2.0 * np.pi * phase)
trajectory = center[None, :] + excursion[:, None] * delta[None, :]
margin = 1e-4
if np.any(trajectory < lower + margin) or np.any(trajectory > upper - margin):
raise ValueError(
f"trajectory {specification.get('trajectory_id')} exceeds master limits"
)
return trajectory
def _slave_swivel(
model: pin.Model,
data: pin.Data,
q_slave: np.ndarray,
shoulder_id: int,
elbow_id: int,
wrist_id: int,
previous: float,
) -> tuple[float, bool]:
"""Evaluate a continuous diagnostic arm-plane angle from slave geometry."""
pin.forwardKinematics(model, data, q_slave)
pin.updateFramePlacements(model, data)
shoulder = data.oMf[shoulder_id].translation
elbow = data.oMf[elbow_id].translation
wrist = data.oMf[wrist_id].translation
axis = wrist - shoulder
axis_norm = float(np.linalg.norm(axis))
if axis_norm <= 1e-9:
return previous, True
axis /= axis_norm
radial = elbow - shoulder
radial -= float(radial @ axis) * axis
radial_norm = float(np.linalg.norm(radial))
if radial_norm <= 1e-9:
return previous, True
radial /= radial_norm
reference = np.array([0.0, 0.0, 1.0])
reference -= float(reference @ axis) * axis
if np.linalg.norm(reference) <= 1e-8:
reference = np.array([1.0, 0.0, 0.0])
reference -= float(reference @ axis) * axis
reference /= np.linalg.norm(reference)
wrapped = float(
np.arctan2(axis @ np.cross(reference, radial), reference @ radial)
)
# Unwrap only against the previous accepted diagnostic value.
delta = (wrapped - previous + np.pi) % (2.0 * np.pi) - np.pi
return previous + float(delta), False
def execute_h1_retargeting(trial: Mapping[str, Any]) -> TrialPayload:
"""Run one paired H1 trajectory through SEW or one formal baseline."""
models = load_models(add_simulated_tcp=True)
baselines, sew = build_canonical_sew_target_baselines(models)
methods = {**baselines, sew.name: sew}
method_id = _method_id(trial)
if method_id not in methods:
raise ValueError(f"unknown H1 method {method_id!r}")
method = methods[method_id]
lower, upper = finite_joint_limits(models.master, MASTER_JOINT_NAMES)
q_master = _master_trajectory(trial, lower=lower, upper=upper)
sample_count = q_master.shape[0]
q_slave_log = np.empty((sample_count, models.slave.nq))
position_error = np.empty(sample_count)
orientation_error = np.empty(sample_count)
success = np.empty(sample_count, dtype=np.int8)
smooth = np.empty(sample_count, dtype=np.int8)
failure_code = np.empty(sample_count, dtype=np.int16)
solver_status = np.empty(sample_count, dtype=np.int16)
iterations = np.empty(sample_count, dtype=np.int32)
runtime_s = np.empty(sample_count)
solver_cost = np.empty(sample_count)
swivel = np.empty(sample_count)
degeneracy = np.zeros(sample_count, dtype=np.int8)
events: list[dict[str, Any]] = []
slave_data = models.slave.createData()
shoulder_id = require_frame(models.slave, SLAVE_FRAMES["shoulder"])
elbow_id = require_frame(models.slave, SLAVE_FRAMES["elbow"])
wrist_id = require_frame(models.slave, SLAVE_FRAMES["wrist"])
seed = None
previous_swivel = 0.0
for index, q_m in enumerate(q_master):
result = method.retarget(q_m, q_slave_seed=seed)
q_slave_log[index] = result.q_slave
position_error[index] = result.diagnostics.position_error_m
orientation_error[index] = result.diagnostics.orientation_error_rad
success[index] = int(result.success)
smooth[index] = int(result.smooth)
failure_code[index] = _enum_code(result.failure)
solver_status[index] = _enum_code(result.diagnostics.status)
iterations[index] = result.diagnostics.iterations
runtime_s[index] = result.diagnostics.runtime_s
solver_cost[index] = result.diagnostics.cost
previous_swivel, is_degenerate = _slave_swivel(
models.slave,
slave_data,
result.q_slave,
shoulder_id,
elbow_id,
wrist_id,
previous_swivel,
)
swivel[index] = previous_swivel
degeneracy[index] = int(is_degenerate)
if result.success:
seed = result.q_slave.copy()
for event in result.events:
events.append(
{
"sample_index": index,
"event": str(event),
"failure_code": int(failure_code[index]),
}
)
master_step = np.zeros(sample_count)
if sample_count > 1:
master_step[1:] = np.linalg.norm(
(q_master[1:] - q_master[:-1] + np.pi) % (2.0 * np.pi) - np.pi,
axis=1,
)
samples = {
"sample_index": np.arange(sample_count, dtype=np.int64),
"q_master": q_master,
"map_q_slave": q_slave_log,
"map_pose_success": success,
# H1 requires a valid/smooth branch. Differential A is evaluated in a
# separate diagnostic study and is not silently imputed here.
"map_differential_valid": smooth,
"map_position_error_m": position_error,
"map_orientation_error_rad": orientation_error,
"map_swivel_angle_rad": swivel,
"map_master_step_norm": master_step,
"map_accepted": np.ones(sample_count, dtype=np.int8),
"map_commanded_reset": np.zeros(sample_count, dtype=np.int8),
"map_degeneracy_transition": degeneracy,
"map_failure_code": failure_code,
"map_solver_status": solver_status,
"map_solver_iterations": iterations,
"map_runtime_s": runtime_s,
"map_solver_cost": solver_cost,
}
return TrialPayload(
samples=samples,
events=events,
metadata={
"evidence_scope": "pre-prototype numerical retargeting only",
"method_id": method_id,
"failure_enum": {
member.value: _enum_code(member) for member in RetargetingFailure
},
"trajectory_family": _trajectory_spec(trial).get("family", "nominal"),
},
)
def _orthogonal(rng: np.random.Generator, size: int) -> np.ndarray:
q, r = np.linalg.qr(rng.normal(size=(size, size)))
signs = np.where(np.diag(r) >= 0.0, 1.0, -1.0)
return q * signs
def execute_h2_synthetic(trial: Mapping[str, Any]) -> TrialPayload:
"""Run a paired, truth/estimator-separated H2 sensitivity trial."""
method_id = _method_id(trial)
valid_methods = {"scaled_dls", "undamped_svd", "no_bias", "no_friction"}
if method_id not in valid_methods:
raise ValueError(f"unknown H2 method {method_id!r}")
trajectory = _trajectory_spec(trial)
count = int(trajectory.get("sample_count", 256))
if count < 8:
raise ValueError("H2 synthetic trial needs at least eight samples")
characteristic_length = float(
_factor(trial, "characteristic_length_m", 0.30)
)
damping = float(_factor(trial, "damping", 0.02))
min_singular = float(_factor(trial, "min_scaled_singular", 0.05))
noise_std = float(_factor(trial, "torque_noise_std_Nm", 0.01))
model_error_std = float(_factor(trial, "model_error_std", 0.01))
if min_singular < 0.0 or noise_std < 0.0 or model_error_std < 0.0:
raise ValueError("H2 perturbation factors must be non-negative")
model_rng = generator_from_record(trial["seeds"], "model")
sensor_rng = generator_from_record(trial["seeds"], "sensor")
trajectory_rng = generator_from_record(trial["seeds"], "trajectory")
u = _orthogonal(model_rng, 6)
v = _orthogonal(model_rng, 7)
singular = np.array([1.6, 1.25, 0.95, 0.65, 0.35, min_singular])
base_scaled_truth = u @ np.diag(singular) @ v[:6, :]
inverse_scaling = np.diag(
[characteristic_length] * 3 + [1.0] * 3
)
phase = np.linspace(0.0, 2.0 * np.pi, count, endpoint=False)
amplitudes = np.array([18.0, 12.0, 9.0, 1.8, 1.2, 0.8])
offsets = trajectory_rng.uniform(-np.pi, np.pi, 6)
wrench_reference = amplitudes[None, :] * np.sin(
phase[:, None] * np.arange(1, 7)[None, :] + offsets[None, :]
)
qd = 0.6 * np.sin(
phase[:, None] * np.arange(1, 8)[None, :]
+ trajectory_rng.uniform(-np.pi, np.pi, (1, 7))
)
bias = np.array([0.08, -0.05, 0.035, -0.025, 0.015, -0.01, 0.02])
friction = JointFrictionCalibration(
coulomb_nm=np.array([0.06, 0.05, 0.045, 0.04, 0.02, 0.02, 0.015]),
viscous_nm_per_rad_s=np.array(
[0.018, 0.017, 0.015, 0.014, 0.009, 0.008, 0.007]
),
)
calibration = WrenchEstimatorCalibration(
joint_bias_nm=bias,
friction=friction,
characteristic_length_m=characteristic_length,
damping=damping,
calibration_id="g0c-synthetic-frozen-v1",
)
solver = (
UndampedSVDSolver(characteristic_length)
if method_id == "undamped_svd"
else ScaledDLSSolver(characteristic_length, damping)
)
estimator = CalibratedResidualWrenchEstimator(calibration, solver)
ablation = (
ResidualAblation.no_bias()
if method_id == "no_bias"
else ResidualAblation.no_friction()
if method_id == "no_friction"
else ResidualAblation()
)
wrench_estimated = np.empty((count, 6))
singular_log = np.empty((count, 6))
rank = np.empty(count, dtype=np.int16)
status = np.empty(count, dtype=np.int16)
truth_jacobian = np.empty((count, 42))
estimator_jacobian = np.empty((count, 42))
residual_raw = np.empty((count, 7))
residual_corrected = np.empty((count, 7))
for index in range(count):
smooth_change = 0.015 * np.sin(phase[index])
J_truth = inverse_scaling @ (
base_scaled_truth
+ smooth_change * model_rng.normal(size=(6, 7))
)
J_estimator = J_truth + inverse_scaling @ (
model_error_std * model_rng.normal(size=(6, 7))
)
interaction = J_truth.T @ wrench_reference[index]
measured = (
interaction
+ bias
+ friction.torque(qd[index])
+ sensor_rng.normal(0.0, noise_std, 7)
)
estimate = estimator.estimate(
J_estimator,
measured_torque_nm=measured,
rigid_body_torque_nm=np.zeros(7),
joint_velocity_rad_s=qd[index],
ablation=ablation,
)
wrench_estimated[index] = estimate.solve.wrench
singular_log[index] = estimate.solve.singular_values
rank[index] = estimate.solve.rank
status[index] = _enum_code(estimate.solve.status)
truth_jacobian[index] = J_truth.reshape(-1)
estimator_jacobian[index] = J_estimator.reshape(-1)
residual_raw[index] = estimate.residual.raw_residual_nm
residual_corrected[index] = estimate.residual.residual_nm
return TrialPayload(
samples={
"sample_index": np.arange(count, dtype=np.int64),
"wrench_reference": wrench_reference,
"wrench_estimated": wrench_estimated,
"wrench_sample_mask": np.ones(count, dtype=np.int8),
"qd_slave": qd,
"jacobian_truth": truth_jacobian,
"jacobian_estimator": estimator_jacobian,
"scaled_singular_values": singular_log,
"solver_rank": rank,
"solver_status": status,
"tau_residual_raw": residual_raw,
"tau_residual_corrected": residual_corrected,
},
metadata={
"evidence_scope": (
"synthetic sensitivity only; not independent physical F/T evidence"
),
"method_id": method_id,
"truth_estimator_models_separated": True,
"calibration_id": calibration.calibration_id,
},
)
def execute_bilateral_simulation(trial: Mapping[str, Any]) -> TrialPayload:
"""Run one paired H3/H4 rigid-body trial with a frozen network trace."""
method_id = _method_id(trial)
scenarios = {scenario.key: scenario for scenario in SCENARIOS}
if method_id not in scenarios:
raise ValueError(f"unknown bilateral method {method_id!r}")
scenario = scenarios[method_id]
map_policy = str(_factor(trial, "map_policy", scenario.map_policy))
scenario = replace(scenario, map_policy=map_policy)
trajectory = _trajectory_spec(trial)
trajectory_rng = generator_from_record(trial["seeds"], "trajectory")
seed = int(trajectory_rng.integers(0, np.iinfo(np.int32).max))
config = replace(
SimulationConfig(),
seed=seed,
duration=float(trajectory.get("duration_s", 1.2)),
contact_probe_fraction=float(
trajectory.get("contact_probe_fraction", 0.0)
),
feedback_delay_s=float(_factor(trial, "return_delay_s", 0.04)),
forward_delay_s=float(_factor(trial, "forward_delay_s", 0.0)),
return_jitter_s=float(_factor(trial, "return_jitter_s", 0.0)),
forward_jitter_s=float(_factor(trial, "forward_jitter_s", 0.0)),
return_packet_loss=float(_factor(trial, "return_packet_loss", 0.0)),
forward_packet_loss=float(_factor(trial, "forward_packet_loss", 0.0)),
wall_stiffness=float(_factor(trial, "wall_stiffness", 800.0)),
wall_damping=float(_factor(trial, "wall_damping", 45.0)),
)
models = load_models(add_simulated_tcp=True)
mapper = build_mapper(models)
wall, wall_metadata, q_slave_start = make_wall(config, models, mapper)
if trajectory.get("family") == "free_space":
travel = float(wall_metadata["free_space_travel_m"])
wall = replace(
wall,
point=wall.point + 2.0 * travel * wall.normal,
)
wall_metadata = {
**wall_metadata,
"condition": "free_space",
"point_world_m": wall.point.tolist(),
}
result = simulate_scenario(
scenario, config, models, wall, q_slave_start
)
samples = {name: value.copy() for name, value in result.logs.items()}
samples["tau_master_raw"] = samples["tau_master_mapped"].copy()
samples["tau_slave_source"] = (
samples["tau_slave_residual_source"].copy()
if scenario.mapping == "differential_residual"
else samples["tau_slave_matched_wrench"].copy()
)
samples["return_valid"] = samples["return_packet_active"].astype(np.int8)
samples["energy_before_J"] = samples["energy_before"].copy()
samples["energy_after_J"] = samples["tank_energy"].copy()
samples["energy_preclip_J"] = samples["energy_preclip"].copy()
return TrialPayload(
samples=samples,
events=(),
metadata={
"evidence_scope": (
"pre-prototype rigid-body simulation; no physical or human claim"
),
"scenario": {
"key": scenario.key,
"mapping": scenario.mapping,
"supervisor": scenario.supervisor,
"map_policy": scenario.map_policy,
},
"wall": wall_metadata,
"online_metrics_are_diagnostic_only": result.metrics,
},
)