Compare commits
No commits in common. "ea896b1012bbf9dd8e33ba3024c9b36b78a1cfc9" and "2effd7b88da64dffac0148c631fd09629a35f0d2" have entirely different histories.
ea896b1012
...
2effd7b88d
@ -21,8 +21,6 @@ completed.
|
||||
generation.
|
||||
- `code/config/experiments/`: smoke, calibration, and locked-template study
|
||||
specifications.
|
||||
- `docs/calibration/`: calibration policy, traceable audit, and machine-readable
|
||||
freeze decisions.
|
||||
- `paper/exoskeleton/IEEEtran/main2.tex`: canonical manuscript source.
|
||||
|
||||
## Reproducible environment
|
||||
@ -67,12 +65,6 @@ XDG_CACHE_HOME=/tmp/exoskeleton-xdg-cache \
|
||||
Equivalent executor/config pairs are documented in
|
||||
`code/experiments/README.md`.
|
||||
|
||||
The current calibration decision is recorded in
|
||||
`docs/calibration/CALIBRATION_AUDIT_2026-07-27.md`. It deliberately leaves H1
|
||||
and the bilateral gain/energy settings unfrozen; calibration values are not
|
||||
manuscript Results. The companion formula-linked workbook is
|
||||
`outputs/calibration-20260727/calibration_audit_2026-07-27.xlsx`.
|
||||
|
||||
## Manuscript build
|
||||
|
||||
Compile from `paper/exoskeleton` so the `assets/` paths resolve:
|
||||
|
||||
@ -2,20 +2,16 @@
|
||||
|
||||
from .metrics import (
|
||||
audit_h4_energy,
|
||||
compute_h1_audit_metrics,
|
||||
compute_h1_composite,
|
||||
compute_h2_wrench_metrics,
|
||||
compute_h3_power_mismatch,
|
||||
compute_bilateral_diagnostics,
|
||||
derive_trial_metrics,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"audit_h4_energy",
|
||||
"compute_h1_audit_metrics",
|
||||
"compute_h1_composite",
|
||||
"compute_h2_wrench_metrics",
|
||||
"compute_h3_power_mismatch",
|
||||
"compute_bilateral_diagnostics",
|
||||
"derive_trial_metrics",
|
||||
]
|
||||
|
||||
@ -80,10 +80,7 @@ def _scalar_cell(value: Any) -> Any:
|
||||
)
|
||||
|
||||
|
||||
def _identity_row(
|
||||
trial: Mapping[str, Any],
|
||||
trial_metadata: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
def _identity_row(trial: Mapping[str, Any]) -> dict[str, Any]:
|
||||
trajectory = trial["trajectory"]
|
||||
method = trial["method"]
|
||||
row = {
|
||||
@ -98,11 +95,6 @@ def _identity_row(
|
||||
}
|
||||
for name, value in sorted(trial.get("factors", {}).items()):
|
||||
row[f"factor_{name}"] = _scalar_cell(value)
|
||||
metadata = {} if trial_metadata is None else trial_metadata
|
||||
for name in ("h2_data_group_id", "h2_data_seed_record_hash"):
|
||||
value = metadata.get(name)
|
||||
if value is not None:
|
||||
row[name] = _scalar_cell(value)
|
||||
return row
|
||||
|
||||
|
||||
@ -117,8 +109,6 @@ def _atomic_write_csv(path: Path, rows: Sequence[Mapping[str, Any]]) -> None:
|
||||
"trajectory_id",
|
||||
"trajectory_family",
|
||||
"replicate",
|
||||
"h2_data_group_id",
|
||||
"h2_data_seed_record_hash",
|
||||
]
|
||||
all_fields = {key for row in rows for key in row}
|
||||
fieldnames = [name for name in identity_order if name in all_fields]
|
||||
@ -164,17 +154,7 @@ def generate_paper_source_data(
|
||||
for trial in plan["trials"]:
|
||||
trial_dir = batch_dir / "raw" / trial["trial_id"]
|
||||
sample_path = trial_dir / "samples.npz"
|
||||
trial_manifest_path = trial_dir / "trial_manifest.json"
|
||||
input_files[str(sample_path.relative_to(batch_dir))] = file_sha256(sample_path)
|
||||
input_files[
|
||||
str(trial_manifest_path.relative_to(batch_dir))
|
||||
] = file_sha256(trial_manifest_path)
|
||||
trial_manifest = load_document(trial_manifest_path)
|
||||
trial_metadata = trial_manifest.get("metadata", {})
|
||||
if not isinstance(trial_metadata, Mapping):
|
||||
raise ValueError(
|
||||
f"{trial_manifest_path} metadata must be a mapping"
|
||||
)
|
||||
with np.load(sample_path, allow_pickle=False) as archive:
|
||||
samples = {name: archive[name] for name in archive.files}
|
||||
method_id = trial["method"]["method_id"]
|
||||
@ -187,12 +167,7 @@ def generate_paper_source_data(
|
||||
if trial_configuration is None
|
||||
else derive_trial_metrics(samples, trial_configuration)
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
**_identity_row(trial, trial_metadata),
|
||||
**metrics,
|
||||
}
|
||||
)
|
||||
rows.append({**_identity_row(trial), **metrics})
|
||||
|
||||
metric_path = derived_dir / "trial_metrics.jsonl"
|
||||
atomic_write_jsonl(metric_path, rows)
|
||||
@ -202,15 +177,7 @@ def generate_paper_source_data(
|
||||
family_rows: list[dict[str, Any]] = []
|
||||
prefix = f"{family}_"
|
||||
for row in rows:
|
||||
provenance_identity_fields = {
|
||||
"h2_data_group_id",
|
||||
"h2_data_seed_record_hash",
|
||||
}
|
||||
if not any(
|
||||
key.startswith(prefix)
|
||||
and key not in provenance_identity_fields
|
||||
for key in row
|
||||
):
|
||||
if not any(key.startswith(prefix) for key in row):
|
||||
continue
|
||||
selected = {
|
||||
key: value
|
||||
@ -224,8 +191,6 @@ def generate_paper_source_data(
|
||||
"trajectory_id",
|
||||
"trajectory_family",
|
||||
"replicate",
|
||||
"h2_data_group_id",
|
||||
"h2_data_seed_record_hash",
|
||||
}
|
||||
or key.startswith("factor_")
|
||||
or key.startswith(prefix)
|
||||
|
||||
@ -12,7 +12,7 @@ from typing import Any, Mapping
|
||||
import numpy as np
|
||||
|
||||
|
||||
METRIC_SCHEMA_VERSION = "1.1.0"
|
||||
METRIC_SCHEMA_VERSION = "1.0.0"
|
||||
|
||||
|
||||
class MetricError(ValueError):
|
||||
@ -187,132 +187,11 @@ def compute_h1_composite(
|
||||
}
|
||||
|
||||
|
||||
def compute_h1_audit_metrics(
|
||||
*,
|
||||
differential_valid: Any,
|
||||
validity_reason_code: Any,
|
||||
reach_clip_code: Any,
|
||||
joint_limit_active: Any,
|
||||
geometry_degenerate: Any,
|
||||
low_manipulability: Any,
|
||||
slave_min_singular_value: Any,
|
||||
slave_manipulability: Any,
|
||||
validity_reason_labels: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Summarize H1 stratum isolation and invalid-sample explanations."""
|
||||
differential = _vector(
|
||||
differential_valid, "differential_valid", dtype=bool
|
||||
)
|
||||
reason_raw = _vector(validity_reason_code, "validity_reason_code")
|
||||
reach_raw = _vector(reach_clip_code, "reach_clip_code")
|
||||
joint_limit = _vector(
|
||||
joint_limit_active, "joint_limit_active", dtype=bool
|
||||
)
|
||||
geometry = _vector(
|
||||
geometry_degenerate, "geometry_degenerate", dtype=bool
|
||||
)
|
||||
low_manip = _vector(
|
||||
low_manipulability, "low_manipulability", dtype=bool
|
||||
)
|
||||
minimum_singular = _vector(
|
||||
slave_min_singular_value, "slave_min_singular_value"
|
||||
)
|
||||
manipulability = _vector(slave_manipulability, "slave_manipulability")
|
||||
_same_rows(
|
||||
{
|
||||
"differential_valid": differential,
|
||||
"validity_reason_code": reason_raw,
|
||||
"reach_clip_code": reach_raw,
|
||||
"joint_limit_active": joint_limit,
|
||||
"geometry_degenerate": geometry,
|
||||
"low_manipulability": low_manip,
|
||||
"slave_min_singular_value": minimum_singular,
|
||||
"slave_manipulability": manipulability,
|
||||
}
|
||||
)
|
||||
_finite(reason_raw, "validity_reason_code")
|
||||
_finite(reach_raw, "reach_clip_code")
|
||||
_finite(minimum_singular, "slave_min_singular_value")
|
||||
_finite(manipulability, "slave_manipulability")
|
||||
if np.any(minimum_singular < 0.0) or np.any(manipulability < 0.0):
|
||||
raise MetricError(
|
||||
"H1 singular-value and manipulability diagnostics must be non-negative"
|
||||
)
|
||||
if not np.all(reason_raw == np.floor(reason_raw)) or np.any(reason_raw < 0):
|
||||
raise MetricError("validity_reason_code must contain non-negative integers")
|
||||
if not np.all(reach_raw == np.floor(reach_raw)) or not set(
|
||||
np.asarray(reach_raw, dtype=int).tolist()
|
||||
).issubset({-1, 0, 1}):
|
||||
raise MetricError("reach_clip_code must contain only -1, 0, or 1")
|
||||
reason = np.asarray(reason_raw, dtype=int)
|
||||
reach = np.asarray(reach_raw, dtype=int)
|
||||
|
||||
if validity_reason_labels is None:
|
||||
labels: list[str] = []
|
||||
elif (
|
||||
not isinstance(validity_reason_labels, list)
|
||||
or any(not isinstance(label, str) or not label for label in validity_reason_labels)
|
||||
):
|
||||
raise MetricError("H1 validity_reason_labels must be a list of strings")
|
||||
else:
|
||||
labels = list(validity_reason_labels)
|
||||
maximum_code = int(np.max(reason))
|
||||
if labels and maximum_code >= len(labels):
|
||||
raise MetricError(
|
||||
"H1 validity_reason_labels does not cover every recorded code"
|
||||
)
|
||||
|
||||
unique_codes, counts = np.unique(reason, return_counts=True)
|
||||
histogram = {
|
||||
(labels[int(code)] if labels else f"code_{int(code)}"): int(count)
|
||||
for code, count in zip(unique_codes, counts)
|
||||
}
|
||||
invalid = ~differential
|
||||
invalid_codes = reason[invalid]
|
||||
if invalid_codes.size:
|
||||
invalid_unique, invalid_counts = np.unique(
|
||||
invalid_codes, return_counts=True
|
||||
)
|
||||
highest = int(np.max(invalid_counts))
|
||||
# Ties use the lowest stable enum code.
|
||||
primary_code = int(
|
||||
np.min(invalid_unique[invalid_counts == highest])
|
||||
)
|
||||
primary_reason = (
|
||||
labels[primary_code] if labels else f"code_{primary_code}"
|
||||
)
|
||||
else:
|
||||
primary_reason = labels[0] if labels else "code_0"
|
||||
|
||||
return {
|
||||
"h1_reach_clip_fraction": float(np.mean(reach != 0)),
|
||||
"h1_reach_clip_lower_fraction": float(np.mean(reach == -1)),
|
||||
"h1_reach_clip_upper_fraction": float(np.mean(reach == 1)),
|
||||
"h1_low_manipulability_fraction": float(np.mean(low_manip)),
|
||||
"h1_joint_limit_active_fraction": float(np.mean(joint_limit)),
|
||||
"h1_geometry_degenerate_fraction": float(np.mean(geometry)),
|
||||
"h1_min_slave_min_singular_value": float(np.min(minimum_singular)),
|
||||
"h1_min_slave_manipulability": float(np.min(manipulability)),
|
||||
"h1_validity_reason_histogram": histogram,
|
||||
"h1_primary_invalid_reason": primary_reason,
|
||||
"h1_invalid_reason_sample_count": int(invalid_codes.size),
|
||||
"h1_unexplained_invalid_fraction": float(
|
||||
np.mean(invalid & (reason == 0))
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def compute_h2_wrench_metrics(
|
||||
wrench_estimated: Any,
|
||||
wrench_reference: Any,
|
||||
*,
|
||||
sample_mask: Any | None = None,
|
||||
numerical_rank_deficient: Any | None = None,
|
||||
operationally_ill_conditioned: Any | None = None,
|
||||
numerical_rank_threshold: Any | None = None,
|
||||
operational_min_scaled_singular_threshold: Any | None = None,
|
||||
scaled_singular_values: Any | None = None,
|
||||
condition_number: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
estimate = _matrix(wrench_estimated, "wrench_estimated", columns=6)
|
||||
reference = _matrix(wrench_reference, "wrench_reference", columns=6)
|
||||
@ -329,7 +208,7 @@ def compute_h2_wrench_metrics(
|
||||
error = estimate[mask] - reference[mask]
|
||||
force_norm = np.linalg.norm(error[:, :3], axis=1)
|
||||
moment_norm = np.linalg.norm(error[:, 3:], axis=1)
|
||||
result = {
|
||||
return {
|
||||
"h2_sample_count": int(error.shape[0]),
|
||||
"h2_force_rmse_N": float(np.sqrt(np.mean(force_norm**2))),
|
||||
"h2_moment_rmse_Nm": float(np.sqrt(np.mean(moment_norm**2))),
|
||||
@ -338,104 +217,6 @@ def compute_h2_wrench_metrics(
|
||||
"h2_force_bias_xyz_N": np.mean(error[:, :3], axis=0).tolist(),
|
||||
"h2_moment_bias_xyz_Nm": np.mean(error[:, 3:], axis=0).tolist(),
|
||||
}
|
||||
if numerical_rank_deficient is not None:
|
||||
numerical_flag = _vector(
|
||||
numerical_rank_deficient,
|
||||
"numerical_rank_deficient",
|
||||
dtype=bool,
|
||||
)
|
||||
if numerical_flag.shape[0] != n:
|
||||
raise MetricError("H2 numerical-rank flag length mismatch")
|
||||
result["h2_numerical_rank_deficient_fraction"] = float(
|
||||
np.mean(numerical_flag[mask])
|
||||
)
|
||||
if operationally_ill_conditioned is not None:
|
||||
operational_flag = _vector(
|
||||
operationally_ill_conditioned,
|
||||
"operationally_ill_conditioned",
|
||||
dtype=bool,
|
||||
)
|
||||
if operational_flag.shape[0] != n:
|
||||
raise MetricError("H2 operational-condition flag length mismatch")
|
||||
result["h2_operationally_ill_conditioned_fraction"] = float(
|
||||
np.mean(operational_flag[mask])
|
||||
)
|
||||
if numerical_rank_threshold is not None:
|
||||
numerical_threshold = _vector(
|
||||
numerical_rank_threshold,
|
||||
"numerical_rank_threshold",
|
||||
)
|
||||
if numerical_threshold.shape[0] != n:
|
||||
raise MetricError("H2 numerical-rank threshold length mismatch")
|
||||
_finite(numerical_threshold, "numerical_rank_threshold")
|
||||
result["h2_numerical_rank_threshold_min"] = float(
|
||||
np.min(numerical_threshold[mask])
|
||||
)
|
||||
result["h2_numerical_rank_threshold_max"] = float(
|
||||
np.max(numerical_threshold[mask])
|
||||
)
|
||||
if operational_min_scaled_singular_threshold is not None:
|
||||
operational_threshold = _vector(
|
||||
operational_min_scaled_singular_threshold,
|
||||
"operational_min_scaled_singular_threshold",
|
||||
)
|
||||
if operational_threshold.shape[0] != n:
|
||||
raise MetricError("H2 operational threshold length mismatch")
|
||||
_finite(
|
||||
operational_threshold,
|
||||
"operational_min_scaled_singular_threshold",
|
||||
)
|
||||
selected_threshold = operational_threshold[mask]
|
||||
if not np.allclose(
|
||||
selected_threshold,
|
||||
selected_threshold[0],
|
||||
rtol=0.0,
|
||||
atol=1e-15,
|
||||
):
|
||||
raise MetricError(
|
||||
"H2 operational threshold must be constant within a trial"
|
||||
)
|
||||
result["h2_operational_min_scaled_singular_threshold"] = float(
|
||||
selected_threshold[0]
|
||||
)
|
||||
if scaled_singular_values is not None:
|
||||
singular_values = _matrix(
|
||||
scaled_singular_values,
|
||||
"scaled_singular_values",
|
||||
)
|
||||
if singular_values.shape[0] != n:
|
||||
raise MetricError("H2 singular-value sample count mismatch")
|
||||
_finite(singular_values, "scaled_singular_values")
|
||||
result["h2_min_scaled_singular_value"] = float(
|
||||
np.min(singular_values[mask, -1])
|
||||
)
|
||||
if condition_number is not None:
|
||||
condition = _vector(condition_number, "condition_number")
|
||||
if condition.shape[0] != n:
|
||||
raise MetricError("H2 condition-number sample count mismatch")
|
||||
if np.any(np.isnan(condition)) or np.any(condition < 0.0):
|
||||
raise MetricError(
|
||||
"H2 condition number must be non-negative and not NaN"
|
||||
)
|
||||
selected_condition = condition[mask]
|
||||
finite_condition = selected_condition[np.isfinite(selected_condition)]
|
||||
result["h2_infinite_condition_number_fraction"] = float(
|
||||
np.mean(~np.isfinite(selected_condition))
|
||||
)
|
||||
result["h2_max_finite_condition_number"] = (
|
||||
float(np.max(finite_condition))
|
||||
if finite_condition.size
|
||||
else None
|
||||
)
|
||||
# A rank-deficient sample has infinite condition number. JSON cannot
|
||||
# represent infinity, so the explicit fraction above carries that
|
||||
# condition while this field is null in that case.
|
||||
result["h2_max_condition_number"] = (
|
||||
float(np.max(selected_condition))
|
||||
if np.all(np.isfinite(selected_condition))
|
||||
else None
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _dt_array(dt: Any, count: int) -> np.ndarray:
|
||||
@ -458,7 +239,6 @@ def compute_h3_power_mismatch(
|
||||
dt: Any,
|
||||
force_scale: float = 1.0,
|
||||
epsilon_energy_J: float = 1e-12,
|
||||
minimum_power_activity_J: float = 0.0,
|
||||
return_valid: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
tau_m = _matrix(tau_master_raw, "tau_master_raw")
|
||||
@ -492,39 +272,22 @@ def compute_h3_power_mismatch(
|
||||
raise MetricError("return_valid length mismatch")
|
||||
force_scale = float(force_scale)
|
||||
epsilon_energy_J = float(epsilon_energy_J)
|
||||
minimum_power_activity_J = float(minimum_power_activity_J)
|
||||
if not np.isfinite(force_scale) or force_scale < 0.0:
|
||||
raise MetricError("force_scale must be finite and non-negative")
|
||||
if not np.isfinite(epsilon_energy_J) or epsilon_energy_J <= 0.0:
|
||||
raise MetricError("epsilon_energy_J must be finite and positive")
|
||||
if (
|
||||
not np.isfinite(minimum_power_activity_J)
|
||||
or minimum_power_activity_J < 0.0
|
||||
):
|
||||
raise MetricError(
|
||||
"minimum_power_activity_J must be finite and non-negative"
|
||||
)
|
||||
|
||||
power_master = np.einsum("ij,ij->i", tau_m, qd_m)
|
||||
power_slave = chi.astype(float) * np.einsum("ij,ij->i", tau_s, qd_s)
|
||||
scaled_slave = force_scale * power_slave
|
||||
numerator = float(np.sum(np.abs(power_master - scaled_slave) * intervals))
|
||||
power_activity = float(
|
||||
denominator = float(
|
||||
0.5
|
||||
* np.sum((np.abs(power_master) + np.abs(scaled_slave)) * intervals)
|
||||
+ epsilon_energy_J
|
||||
)
|
||||
denominator = power_activity + epsilon_energy_J
|
||||
normalized = numerator / denominator
|
||||
normalized_valid = bool(power_activity >= minimum_power_activity_J)
|
||||
return {
|
||||
# Kept for backward-compatible diagnostics. Confirmatory analysis must
|
||||
# use the validity flag/gated value when a nonzero activity gate is set.
|
||||
"h3_epsilon_P_act": normalized,
|
||||
"h3_epsilon_P_act_gated": normalized if normalized_valid else None,
|
||||
"h3_normalized_metric_valid": normalized_valid,
|
||||
"h3_minimum_power_activity_J": minimum_power_activity_J,
|
||||
"h3_power_activity_J": power_activity,
|
||||
"h3_absolute_power_mismatch_J": numerator,
|
||||
"h3_epsilon_P_act": numerator / denominator,
|
||||
"h3_power_mismatch_numerator_J": numerator,
|
||||
"h3_power_normalizer_J": denominator,
|
||||
"h3_return_valid_fraction": float(np.mean(chi)),
|
||||
@ -655,8 +418,6 @@ def audit_h4_energy(
|
||||
"h4_projected_floor_deficit_J": projected_deficit,
|
||||
"h4_delta_B_J": float(shadow_deficit - projected_deficit),
|
||||
"h4_D_proj": numerator / denominator,
|
||||
"h4_energy_min_J": energy_min_J,
|
||||
"h4_energy_max_J": energy_max_J,
|
||||
"h4_projection_distortion_numerator_Nms": numerator,
|
||||
"h4_projection_distortion_normalizer_Nms": denominator,
|
||||
"h4_shadow_energy_min_J": float(shadow_min),
|
||||
@ -666,80 +427,6 @@ def audit_h4_energy(
|
||||
}
|
||||
|
||||
|
||||
def compute_bilateral_diagnostics(
|
||||
*,
|
||||
master_tracking_error_rad: Any,
|
||||
slave_tracking_error_rad: Any,
|
||||
feedback_torque_Nm: Any,
|
||||
contact_force_N: Any,
|
||||
projection_factor: Any,
|
||||
projection_tolerance: float = 1e-12,
|
||||
contact_force_threshold_N: float = 1e-6,
|
||||
) -> dict[str, Any]:
|
||||
"""Compute secondary task/transparency diagnostics from stored samples."""
|
||||
master_error = _vector(
|
||||
master_tracking_error_rad, "master_tracking_error_rad"
|
||||
)
|
||||
slave_error = _vector(
|
||||
slave_tracking_error_rad, "slave_tracking_error_rad"
|
||||
)
|
||||
feedback = _matrix(feedback_torque_Nm, "feedback_torque_Nm")
|
||||
contact = _vector(contact_force_N, "contact_force_N")
|
||||
rho = _vector(projection_factor, "projection_factor")
|
||||
_same_rows(
|
||||
{
|
||||
"master_tracking_error_rad": master_error,
|
||||
"slave_tracking_error_rad": slave_error,
|
||||
"feedback_torque_Nm": feedback,
|
||||
"contact_force_N": contact,
|
||||
"projection_factor": rho,
|
||||
}
|
||||
)
|
||||
for array, name in (
|
||||
(master_error, "master_tracking_error_rad"),
|
||||
(slave_error, "slave_tracking_error_rad"),
|
||||
(feedback, "feedback_torque_Nm"),
|
||||
(contact, "contact_force_N"),
|
||||
(rho, "projection_factor"),
|
||||
):
|
||||
_finite(array, name)
|
||||
projection_tolerance = float(projection_tolerance)
|
||||
contact_force_threshold_N = float(contact_force_threshold_N)
|
||||
if not np.isfinite(projection_tolerance) or projection_tolerance < 0.0:
|
||||
raise MetricError("projection_tolerance must be finite and non-negative")
|
||||
if (
|
||||
not np.isfinite(contact_force_threshold_N)
|
||||
or contact_force_threshold_N < 0.0
|
||||
):
|
||||
raise MetricError(
|
||||
"contact_force_threshold_N must be finite and non-negative"
|
||||
)
|
||||
feedback_norm = np.linalg.norm(feedback, axis=1)
|
||||
return {
|
||||
"bilateral_master_tracking_rmse_rad": float(
|
||||
np.sqrt(np.mean(np.square(master_error)))
|
||||
),
|
||||
"bilateral_slave_tracking_rmse_rad": float(
|
||||
np.sqrt(np.mean(np.square(slave_error)))
|
||||
),
|
||||
"bilateral_feedback_torque_rms_Nm": float(
|
||||
np.sqrt(np.mean(np.square(feedback_norm)))
|
||||
),
|
||||
"bilateral_feedback_torque_peak_Nm": float(np.max(feedback_norm)),
|
||||
"bilateral_contact_force_rms_N": float(
|
||||
np.sqrt(np.mean(np.square(contact)))
|
||||
),
|
||||
"bilateral_contact_force_peak_N": float(np.max(contact)),
|
||||
"bilateral_contact_fraction": float(
|
||||
np.mean(contact > contact_force_threshold_N)
|
||||
),
|
||||
"bilateral_projection_intervention_fraction": float(
|
||||
np.mean(rho < (1.0 - projection_tolerance))
|
||||
),
|
||||
"bilateral_projection_factor_min": float(np.min(rho)),
|
||||
}
|
||||
|
||||
|
||||
def _field(
|
||||
samples: Mapping[str, Any],
|
||||
fields: Mapping[str, str],
|
||||
@ -758,57 +445,6 @@ def _field(
|
||||
return samples[stored_name]
|
||||
|
||||
|
||||
def _constant_trial_value(value: Any, name: str) -> float:
|
||||
"""Resolve a scalar or constant non-empty sample vector."""
|
||||
array = np.asarray(value, dtype=float)
|
||||
if array.ndim == 0:
|
||||
result = float(array)
|
||||
elif array.ndim == 1 and array.size > 0:
|
||||
_finite(array, name)
|
||||
result = float(array[0])
|
||||
if not np.allclose(array, result, rtol=0.0, atol=1e-15):
|
||||
raise MetricError(f"{name} must be constant within one trial")
|
||||
else:
|
||||
raise MetricError(f"{name} must be a scalar or non-empty vector")
|
||||
if not np.isfinite(result):
|
||||
raise MetricError(f"{name} must be finite")
|
||||
return result
|
||||
|
||||
|
||||
def _energy_bound_for_trial(
|
||||
samples: Mapping[str, Any],
|
||||
fields: Mapping[str, str],
|
||||
family_config: Mapping[str, Any],
|
||||
logical_name: str,
|
||||
default_field: str,
|
||||
) -> float:
|
||||
"""Prefer stored effective bounds and reject config/sample disagreement."""
|
||||
stored = _field(
|
||||
samples,
|
||||
fields,
|
||||
logical_name,
|
||||
default_field,
|
||||
optional=True,
|
||||
)
|
||||
configured = family_config.get(logical_name)
|
||||
if stored is None and configured is None:
|
||||
raise MetricError(
|
||||
f"H4 requires {logical_name} in samples or metric configuration"
|
||||
)
|
||||
if stored is None:
|
||||
return _constant_trial_value(configured, logical_name)
|
||||
stored_value = _constant_trial_value(stored, default_field)
|
||||
if configured is not None:
|
||||
configured_value = _constant_trial_value(configured, logical_name)
|
||||
if not np.isclose(
|
||||
stored_value, configured_value, rtol=0.0, atol=1e-15
|
||||
):
|
||||
raise MetricError(
|
||||
f"{logical_name} disagrees with stored effective configuration"
|
||||
)
|
||||
return stored_value
|
||||
|
||||
|
||||
def derive_trial_metrics(
|
||||
samples: Mapping[str, Any],
|
||||
configuration: Mapping[str, Any],
|
||||
@ -897,69 +533,6 @@ def derive_trial_metrics(
|
||||
**thresholds,
|
||||
)
|
||||
)
|
||||
audit_fields = family_config.get("audit_fields", [])
|
||||
if audit_fields:
|
||||
required_audit_fields = {
|
||||
"map_validity_reason_code",
|
||||
"map_reach_clip_code",
|
||||
"map_joint_limit_active",
|
||||
"map_geometry_degenerate",
|
||||
"map_slave_min_singular_value",
|
||||
"map_slave_manipulability",
|
||||
"map_low_manipulability",
|
||||
}
|
||||
if (
|
||||
not isinstance(audit_fields, list)
|
||||
or any(
|
||||
not isinstance(name, str) or not name
|
||||
for name in audit_fields
|
||||
)
|
||||
):
|
||||
raise MetricError("H1 audit_fields must be a list of names")
|
||||
configured_audit_fields = set(audit_fields)
|
||||
if configured_audit_fields != required_audit_fields:
|
||||
missing_audit = sorted(
|
||||
required_audit_fields - configured_audit_fields
|
||||
)
|
||||
unknown_audit = sorted(
|
||||
configured_audit_fields - required_audit_fields
|
||||
)
|
||||
raise MetricError(
|
||||
"H1 audit_fields must declare the complete audit set; "
|
||||
f"missing={missing_audit}, unknown={unknown_audit}"
|
||||
)
|
||||
for stored_name in sorted(required_audit_fields):
|
||||
if stored_name not in samples:
|
||||
raise MetricError(
|
||||
f"missing configured H1 audit field {stored_name!r}"
|
||||
)
|
||||
result.update(
|
||||
compute_h1_audit_metrics(
|
||||
differential_valid=differential_valid,
|
||||
validity_reason_code=samples[
|
||||
"map_validity_reason_code"
|
||||
],
|
||||
reach_clip_code=samples["map_reach_clip_code"],
|
||||
joint_limit_active=samples[
|
||||
"map_joint_limit_active"
|
||||
],
|
||||
geometry_degenerate=samples[
|
||||
"map_geometry_degenerate"
|
||||
],
|
||||
low_manipulability=samples[
|
||||
"map_low_manipulability"
|
||||
],
|
||||
slave_min_singular_value=samples[
|
||||
"map_slave_min_singular_value"
|
||||
],
|
||||
slave_manipulability=samples[
|
||||
"map_slave_manipulability"
|
||||
],
|
||||
validity_reason_labels=family_config.get(
|
||||
"validity_reason_labels"
|
||||
),
|
||||
)
|
||||
)
|
||||
elif family == "h2":
|
||||
result.update(
|
||||
compute_h2_wrench_metrics(
|
||||
@ -982,48 +555,6 @@ def derive_trial_metrics(
|
||||
"wrench_sample_mask",
|
||||
optional=True,
|
||||
),
|
||||
numerical_rank_deficient=_field(
|
||||
samples,
|
||||
fields,
|
||||
"numerical_rank_deficient",
|
||||
"solver_numerical_rank_deficient",
|
||||
optional=True,
|
||||
),
|
||||
operationally_ill_conditioned=_field(
|
||||
samples,
|
||||
fields,
|
||||
"operationally_ill_conditioned",
|
||||
"solver_operationally_ill_conditioned",
|
||||
optional=True,
|
||||
),
|
||||
numerical_rank_threshold=_field(
|
||||
samples,
|
||||
fields,
|
||||
"numerical_rank_threshold",
|
||||
"solver_numerical_rank_threshold",
|
||||
optional=True,
|
||||
),
|
||||
operational_min_scaled_singular_threshold=_field(
|
||||
samples,
|
||||
fields,
|
||||
"operational_min_scaled_singular_threshold",
|
||||
"operational_min_scaled_singular_threshold",
|
||||
optional=True,
|
||||
),
|
||||
scaled_singular_values=_field(
|
||||
samples,
|
||||
fields,
|
||||
"scaled_singular_values",
|
||||
"scaled_singular_values",
|
||||
optional=True,
|
||||
),
|
||||
condition_number=_field(
|
||||
samples,
|
||||
fields,
|
||||
"condition_number",
|
||||
"solver_condition_number",
|
||||
optional=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
elif family == "h3":
|
||||
@ -1057,26 +588,9 @@ def derive_trial_metrics(
|
||||
epsilon_energy_J=family_config.get(
|
||||
"epsilon_energy_J", 1e-12
|
||||
),
|
||||
minimum_power_activity_J=family_config.get(
|
||||
"minimum_power_activity_J", 0.0
|
||||
),
|
||||
)
|
||||
)
|
||||
elif family == "h4":
|
||||
energy_min_J = _energy_bound_for_trial(
|
||||
samples,
|
||||
fields,
|
||||
family_config,
|
||||
"energy_min_J",
|
||||
"configured_energy_min_J",
|
||||
)
|
||||
energy_max_J = _energy_bound_for_trial(
|
||||
samples,
|
||||
fields,
|
||||
family_config,
|
||||
"energy_max_J",
|
||||
"configured_energy_max_J",
|
||||
)
|
||||
result.update(
|
||||
audit_h4_energy(
|
||||
energy_before_J=_field(
|
||||
@ -1107,8 +621,8 @@ def derive_trial_metrics(
|
||||
"energy_preclip_J",
|
||||
optional=True,
|
||||
),
|
||||
energy_min_J=energy_min_J,
|
||||
energy_max_J=energy_max_J,
|
||||
energy_min_J=family_config["energy_min_J"],
|
||||
energy_max_J=family_config["energy_max_J"],
|
||||
epsilon_torque_impulse_Nms=family_config.get(
|
||||
"epsilon_torque_impulse_Nms", 1e-12
|
||||
),
|
||||
@ -1117,47 +631,6 @@ def derive_trial_metrics(
|
||||
),
|
||||
)
|
||||
)
|
||||
elif family == "bilateral":
|
||||
result.update(
|
||||
compute_bilateral_diagnostics(
|
||||
master_tracking_error_rad=_field(
|
||||
samples,
|
||||
fields,
|
||||
"master_tracking_error_rad",
|
||||
"master_tracking_error",
|
||||
),
|
||||
slave_tracking_error_rad=_field(
|
||||
samples,
|
||||
fields,
|
||||
"slave_tracking_error_rad",
|
||||
"slave_tracking_error",
|
||||
),
|
||||
feedback_torque_Nm=_field(
|
||||
samples,
|
||||
fields,
|
||||
"feedback_torque_Nm",
|
||||
"tau_master_applied",
|
||||
),
|
||||
contact_force_N=_field(
|
||||
samples,
|
||||
fields,
|
||||
"contact_force_N",
|
||||
"contact_force_norm",
|
||||
),
|
||||
projection_factor=_field(
|
||||
samples,
|
||||
fields,
|
||||
"projection_factor",
|
||||
"rho",
|
||||
),
|
||||
projection_tolerance=family_config.get(
|
||||
"projection_tolerance", 1e-12
|
||||
),
|
||||
contact_force_threshold_N=family_config.get(
|
||||
"contact_force_threshold_N", 1e-6
|
||||
),
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise MetricError(f"unknown metric family {family!r}")
|
||||
return result
|
||||
|
||||
@ -1,52 +0,0 @@
|
||||
{
|
||||
"study_id": "g0c_bilateral_calibration_v2_energy",
|
||||
"split": "calibration",
|
||||
"root_seed": 2026072721,
|
||||
"replicates": 2,
|
||||
"methods": [
|
||||
"proposed_energy"
|
||||
],
|
||||
"trajectories": [
|
||||
{
|
||||
"id": "contact_energy_tradeoff",
|
||||
"family": "contact_roundtrip",
|
||||
"duration_s": 1.5,
|
||||
"contact_probe_fraction": 0.03
|
||||
}
|
||||
],
|
||||
"factors": {
|
||||
"map_policy": [
|
||||
"source_stamped"
|
||||
],
|
||||
"return_delay_s": [
|
||||
0.08
|
||||
],
|
||||
"forward_delay_s": [
|
||||
0.0
|
||||
],
|
||||
"wall_damping": [
|
||||
30.0
|
||||
],
|
||||
"wall_stiffness": [
|
||||
400.0,
|
||||
800.0
|
||||
],
|
||||
"feedback_strength": [
|
||||
0.2,
|
||||
0.35,
|
||||
0.5
|
||||
],
|
||||
"energy_min": [
|
||||
0.05
|
||||
],
|
||||
"energy_max": [
|
||||
0.1
|
||||
],
|
||||
"energy_initial": [
|
||||
0.055,
|
||||
0.07,
|
||||
0.09
|
||||
]
|
||||
},
|
||||
"status": "Stage A energy-challenging calibration: 0.005, 0.020, and 0.040 J initial headroom bracket the approximately 0.017 J v1 contact shadow deficit"
|
||||
}
|
||||
@ -1,106 +0,0 @@
|
||||
{
|
||||
"study_id": "g0c_bilateral_calibration_v2_network",
|
||||
"split": "calibration",
|
||||
"root_seed": 2026072722,
|
||||
"replicates": 1,
|
||||
"methods": [
|
||||
"proposed_energy",
|
||||
"direct_energy",
|
||||
"matched_wrench_energy"
|
||||
],
|
||||
"trajectories": [
|
||||
{
|
||||
"id": "free_space_network_screen",
|
||||
"family": "free_space",
|
||||
"duration_s": 1.2,
|
||||
"contact_probe_fraction": 0.0
|
||||
},
|
||||
{
|
||||
"id": "contact_network_screen",
|
||||
"family": "contact_roundtrip",
|
||||
"duration_s": 1.2,
|
||||
"contact_probe_fraction": 0.03
|
||||
}
|
||||
],
|
||||
"factors": {
|
||||
"map_policy": [
|
||||
"source_stamped"
|
||||
],
|
||||
"network_profile": [
|
||||
{
|
||||
"profile_id": "nominal",
|
||||
"forward_delay_s": 0.0,
|
||||
"return_delay_s": 0.0,
|
||||
"forward_jitter_s": 0.0,
|
||||
"return_jitter_s": 0.0,
|
||||
"forward_packet_loss": 0.0,
|
||||
"return_packet_loss": 0.0
|
||||
},
|
||||
{
|
||||
"profile_id": "return_delay_80ms",
|
||||
"forward_delay_s": 0.0,
|
||||
"return_delay_s": 0.08,
|
||||
"forward_jitter_s": 0.0,
|
||||
"return_jitter_s": 0.0,
|
||||
"forward_packet_loss": 0.0,
|
||||
"return_packet_loss": 0.0
|
||||
},
|
||||
{
|
||||
"profile_id": "forward_delay_40ms",
|
||||
"forward_delay_s": 0.04,
|
||||
"return_delay_s": 0.0,
|
||||
"forward_jitter_s": 0.0,
|
||||
"return_jitter_s": 0.0,
|
||||
"forward_packet_loss": 0.0,
|
||||
"return_packet_loss": 0.0
|
||||
},
|
||||
{
|
||||
"profile_id": "asymmetric_delay",
|
||||
"forward_delay_s": 0.04,
|
||||
"return_delay_s": 0.08,
|
||||
"forward_jitter_s": 0.0,
|
||||
"return_jitter_s": 0.0,
|
||||
"forward_packet_loss": 0.0,
|
||||
"return_packet_loss": 0.0
|
||||
},
|
||||
{
|
||||
"profile_id": "asymmetric_delay_plus_jitter",
|
||||
"forward_delay_s": 0.04,
|
||||
"return_delay_s": 0.08,
|
||||
"forward_jitter_s": 0.004,
|
||||
"return_jitter_s": 0.004,
|
||||
"forward_packet_loss": 0.0,
|
||||
"return_packet_loss": 0.0
|
||||
},
|
||||
{
|
||||
"profile_id": "asymmetric_delay_plus_loss",
|
||||
"forward_delay_s": 0.04,
|
||||
"return_delay_s": 0.08,
|
||||
"forward_jitter_s": 0.0,
|
||||
"return_jitter_s": 0.0,
|
||||
"forward_packet_loss": 0.02,
|
||||
"return_packet_loss": 0.02
|
||||
}
|
||||
],
|
||||
"wall_damping": [
|
||||
30.0
|
||||
],
|
||||
"wall_stiffness": [
|
||||
800.0
|
||||
],
|
||||
"feedback_strength": [
|
||||
0.35
|
||||
],
|
||||
"energy_min": [
|
||||
0.05
|
||||
],
|
||||
"energy_max": [
|
||||
0.1
|
||||
],
|
||||
"energy_initial": [
|
||||
0.07
|
||||
]
|
||||
},
|
||||
"requires_stage_a_selection": true,
|
||||
"status": "Stage B template only: feedback_strength and all three energy values are placeholders from the middle Stage A cell and must be replaced by the selected Stage A profile before execution"
|
||||
}
|
||||
@ -1,68 +0,0 @@
|
||||
{
|
||||
"study_id": "g0c_sew_calibration_v2",
|
||||
"split": "calibration",
|
||||
"root_seed": 2026072721,
|
||||
"replicates": 3,
|
||||
"methods": [
|
||||
"sew",
|
||||
"scaled_joint_space",
|
||||
"bounded_dls_ik",
|
||||
"task_priority_ik"
|
||||
],
|
||||
"trajectories": [
|
||||
{
|
||||
"id": "nominal_seeded_excursion",
|
||||
"family": "nominal",
|
||||
"sample_count": 81,
|
||||
"instance_variation": {
|
||||
"center_std_rad": 0.006,
|
||||
"delta_scale_range": [0.90, 1.10],
|
||||
"harmonic_weight_range": [-0.10, 0.10]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "wide_valid_seeded_excursion",
|
||||
"family": "reach_boundary",
|
||||
"sample_count": 81,
|
||||
"instance_variation": {
|
||||
"center_std_rad": 0.005,
|
||||
"delta_scale_range": [0.92, 1.08],
|
||||
"harmonic_weight_range": [-0.08, 0.08]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "valid_near_singularity",
|
||||
"family": "low_manipulability_valid",
|
||||
"sample_count": 81,
|
||||
"low_manipulability_min_singular_threshold": 0.05,
|
||||
"instance_variation": {
|
||||
"center_std_rad": 0.004,
|
||||
"center_jitter_mask": [1, 1, 1, 0, 1, 1, 1],
|
||||
"delta_scale_range": [0.94, 1.06],
|
||||
"harmonic_weight_range": [-0.06, 0.06]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "isolated_upper_reach_clip",
|
||||
"family": "reach_clip_upper",
|
||||
"sample_count": 81,
|
||||
"instance_variation": {
|
||||
"center_std_rad": 0.004,
|
||||
"center_jitter_mask": [1, 1, 1, 0, 1, 1, 1],
|
||||
"delta_scale_range": [0.94, 1.06],
|
||||
"harmonic_weight_range": [-0.06, 0.06]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "master_joint_limit_neighborhood",
|
||||
"family": "joint_limit",
|
||||
"sample_count": 81,
|
||||
"instance_variation": {
|
||||
"center_std_rad": 0.004,
|
||||
"center_jitter_mask": [0, 1, 1, 1, 1, 1, 1],
|
||||
"delta_scale_range": [0.94, 1.06],
|
||||
"harmonic_weight_range": [-0.06, 0.06]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,52 +0,0 @@
|
||||
{
|
||||
"study_id": "g0c_estimator_sensitivity_calibration_v2",
|
||||
"split": "calibration",
|
||||
"root_seed": 2026072703,
|
||||
"replicates": 2,
|
||||
"methods": [
|
||||
"scaled_dls",
|
||||
"undamped_svd",
|
||||
"no_bias",
|
||||
"no_friction"
|
||||
],
|
||||
"trajectories": [
|
||||
{
|
||||
"id": "dynamic_wrench_sweep_v2",
|
||||
"family": "synthetic_dynamic",
|
||||
"sample_count": 192
|
||||
}
|
||||
],
|
||||
"factors": {
|
||||
"characteristic_length_m": [
|
||||
0.2,
|
||||
0.3,
|
||||
0.4
|
||||
],
|
||||
"damping": [
|
||||
0.02,
|
||||
0.03,
|
||||
0.05
|
||||
],
|
||||
"h2_data_root_seed": [
|
||||
2026072703
|
||||
],
|
||||
"min_scaled_singular": [
|
||||
0.02,
|
||||
0.1
|
||||
],
|
||||
"model_error_std": [
|
||||
0.0,
|
||||
0.02
|
||||
],
|
||||
"operational_min_scaled_singular": [
|
||||
0.05
|
||||
],
|
||||
"torque_noise_std_Nm": [
|
||||
0.002,
|
||||
0.02
|
||||
],
|
||||
"truth_characteristic_length_m": [
|
||||
0.3
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
{
|
||||
"enabled": [
|
||||
"h3",
|
||||
"h4",
|
||||
"bilateral"
|
||||
],
|
||||
"h3": {
|
||||
"force_scale": 1.0,
|
||||
"epsilon_energy_J": 1e-12,
|
||||
"minimum_power_activity_J": 0.001
|
||||
},
|
||||
"h4": {
|
||||
"include_methods": [
|
||||
"proposed_energy",
|
||||
"direct_energy",
|
||||
"matched_wrench_energy"
|
||||
],
|
||||
"epsilon_torque_impulse_Nms": 1e-12,
|
||||
"audit_tolerance_J": 1e-10
|
||||
},
|
||||
"bilateral": {
|
||||
"include_methods": [
|
||||
"proposed_energy",
|
||||
"direct_energy",
|
||||
"matched_wrench_energy"
|
||||
],
|
||||
"projection_tolerance": 1e-12,
|
||||
"contact_force_threshold_N": 1e-6
|
||||
},
|
||||
"status": "H3 reports an absolute mismatch for every trial and gates normalized epsilon below 1 mJ port activity; H4 reads effective energy bounds from each stored trial; bilateral diagnostics prevent selecting a trivially weak gain from Dproj alone"
|
||||
}
|
||||
@ -1,39 +0,0 @@
|
||||
{
|
||||
"enabled": [
|
||||
"h1"
|
||||
],
|
||||
"h1": {
|
||||
"thresholds": {
|
||||
"position_threshold_m": 0.005,
|
||||
"orientation_threshold_rad": 0.05,
|
||||
"joint_step_threshold_rad": 0.25,
|
||||
"swivel_step_threshold_rad": 0.25,
|
||||
"input_step_threshold_rad": 0.05
|
||||
},
|
||||
"audit_fields": [
|
||||
"map_validity_reason_code",
|
||||
"map_reach_clip_code",
|
||||
"map_joint_limit_active",
|
||||
"map_geometry_degenerate",
|
||||
"map_slave_min_singular_value",
|
||||
"map_slave_manipulability",
|
||||
"map_low_manipulability"
|
||||
],
|
||||
"validity_reason_labels": [
|
||||
"none",
|
||||
"reach_clipped_lower",
|
||||
"reach_clipped_upper",
|
||||
"joint_limit_active",
|
||||
"geometry_degenerate",
|
||||
"invalid_input",
|
||||
"master_limit_violation",
|
||||
"joint_limit_violation",
|
||||
"task_tolerance_exceeded",
|
||||
"solver_not_converged",
|
||||
"numerical_failure",
|
||||
"low_manipulability",
|
||||
"unspecified_nonsmooth"
|
||||
]
|
||||
},
|
||||
"status": "calibration-v2 only; validity reasons and seeded instances must be audited before threshold freeze"
|
||||
}
|
||||
@ -27,20 +27,6 @@ execute_h2_synthetic h2_smoke.json / h2_calibration.json
|
||||
execute_bilateral_simulation smoke.json / bilateral_calibration.json
|
||||
```
|
||||
|
||||
Auditable second-stage calibration specifications are:
|
||||
|
||||
```text
|
||||
execute_h1_retargeting h1_calibration_v2.json
|
||||
execute_h2_synthetic h2_calibration_v2.json
|
||||
execute_bilateral_simulation bilateral_calibration_v2_energy.json
|
||||
execute_bilateral_simulation bilateral_calibration_v2_network.json
|
||||
```
|
||||
|
||||
The bilateral network specification is a gated Stage B template. Its
|
||||
`requires_stage_a_selection` flag means the haptic parameters are placeholders;
|
||||
do not execute it as a locked study until the energy/gain Stage A acceptance
|
||||
gate has passed.
|
||||
|
||||
An executor callable receives one immutable trial mapping and returns:
|
||||
|
||||
```python
|
||||
@ -70,19 +56,10 @@ python -m analysis.make_paper_artifacts \
|
||||
--metric-config code/config/experiments/metrics_h1_calibration.json
|
||||
```
|
||||
|
||||
Use the matching independent metric configuration:
|
||||
|
||||
```text
|
||||
h1_calibration.json metrics_h1_calibration.json
|
||||
h1_calibration_v2.json metrics_h1_calibration_v2.json
|
||||
h2_calibration*.json metrics_h2.json
|
||||
bilateral_calibration.json metrics_bilateral.json
|
||||
bilateral_calibration_v2_*.json metrics_bilateral_v2.json
|
||||
```
|
||||
|
||||
The bilateral configurations derive H3 for every mapping/supervisor condition,
|
||||
but their H4 tables contain only tank-supervised methods; PO/PC and bypass
|
||||
conditions cannot be silently mixed into a tank audit.
|
||||
Use `metrics_h2.json` for H2 batches and `metrics_bilateral.json` for bilateral
|
||||
batches. The bilateral configuration derives H3 for every mapping/supervisor
|
||||
condition, but its H4 table contains only the three tank-supervised methods;
|
||||
PO/PC and bypass conditions cannot be silently mixed into a tank audit.
|
||||
|
||||
The declared minimal storage contract is JSON for manifests/plans, NPZ for
|
||||
numeric sample arrays, JSON Lines for events/trial metrics, and CSV for paper
|
||||
|
||||
@ -33,9 +33,8 @@ from core.retargeting_baselines import (
|
||||
build_canonical_sew_target_baselines,
|
||||
)
|
||||
from core.wrench_solver import ScaledDLSSolver, UndampedSVDSolver
|
||||
from experiments.hashing import stable_hash
|
||||
from experiments.io import TrialPayload
|
||||
from experiments.rng import generator_from_record, named_seed_record
|
||||
from experiments.rng import generator_from_record
|
||||
from simulate_closed_loop import (
|
||||
SCENARIOS,
|
||||
SimulationConfig,
|
||||
@ -68,30 +67,6 @@ def _factor(trial: Mapping[str, Any], name: str, default: Any) -> Any:
|
||||
return factors.get(name, default)
|
||||
|
||||
|
||||
def _profiled_factor(
|
||||
trial: Mapping[str, Any],
|
||||
name: str,
|
||||
default: Any,
|
||||
*,
|
||||
profile_name: str,
|
||||
) -> Any:
|
||||
"""Resolve a direct factor, then a coupled profile, then a default.
|
||||
|
||||
Direct factors deliberately take precedence. Profiles let calibration
|
||||
studies express a small set of valid, directional network conditions
|
||||
without expanding the Cartesian product of every delay/jitter/loss level.
|
||||
"""
|
||||
factors = trial.get("factors", {})
|
||||
if not isinstance(factors, Mapping):
|
||||
raise ValueError("trial factors must be a mapping")
|
||||
if name in factors:
|
||||
return factors[name]
|
||||
profile = factors.get(profile_name, {})
|
||||
if not isinstance(profile, Mapping):
|
||||
raise ValueError(f"{profile_name} factor must be a mapping")
|
||||
return profile.get(name, default)
|
||||
|
||||
|
||||
def _enum_code(member: Enum) -> int:
|
||||
return list(type(member)).index(member)
|
||||
|
||||
@ -102,12 +77,7 @@ def _master_trajectory(
|
||||
lower: np.ndarray,
|
||||
upper: np.ndarray,
|
||||
) -> np.ndarray:
|
||||
"""Generate one seeded, paired, continuous master trajectory instance.
|
||||
|
||||
The trajectory random stream belongs to the pair, not the method. Thus
|
||||
different replicates are genuine trajectory instances while all methods
|
||||
inside one pair receive bit-identical master samples.
|
||||
"""
|
||||
"""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:
|
||||
@ -135,85 +105,19 @@ def _master_trajectory(
|
||||
delta = np.zeros(7)
|
||||
delta[0] = -0.22
|
||||
elif family == "low_manipulability":
|
||||
# Legacy calibration-v1 family retained only for reproducibility. It
|
||||
# is reach-clipped and must not be treated as an isolated low-
|
||||
# manipulability stratum; v2 uses ``low_manipulability_valid``.
|
||||
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 == "low_manipulability_valid":
|
||||
# Just inside the slave upper-reach boundary: low minimum singular
|
||||
# value without the reach clipping that confounded calibration v1.
|
||||
center = center.copy()
|
||||
center[3] = 1.13
|
||||
delta = np.array([0.025, -0.020, 0.015, 0.10, 0.015, 0.020, -0.015])
|
||||
elif family == "reach_clip_upper":
|
||||
# Deliberately outside the slave upper reach for the entire excursion.
|
||||
center = center.copy()
|
||||
center[3] = 0.85
|
||||
delta = np.array([0.025, -0.020, 0.015, 0.10, 0.015, 0.020, -0.015])
|
||||
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])
|
||||
|
||||
variation = specification.get("instance_variation", {})
|
||||
if not isinstance(variation, Mapping):
|
||||
raise ValueError("H1 instance_variation must be a mapping")
|
||||
randomize = bool(variation.get("enabled", True))
|
||||
center_std = float(variation.get("center_std_rad", 0.006))
|
||||
delta_scale_range = np.asarray(
|
||||
variation.get("delta_scale_range", [0.92, 1.08]), dtype=float
|
||||
)
|
||||
harmonic_range = np.asarray(
|
||||
variation.get("harmonic_weight_range", [-0.08, 0.08]), dtype=float
|
||||
)
|
||||
jitter_mask = np.asarray(
|
||||
variation.get("center_jitter_mask", [1, 1, 1, 1, 1, 1, 1]),
|
||||
dtype=float,
|
||||
)
|
||||
if not np.isfinite(center_std) or center_std < 0.0:
|
||||
raise ValueError("H1 center_std_rad must be finite and non-negative")
|
||||
if (
|
||||
delta_scale_range.shape != (2,)
|
||||
or not np.all(np.isfinite(delta_scale_range))
|
||||
or delta_scale_range[0] <= 0.0
|
||||
or delta_scale_range[1] < delta_scale_range[0]
|
||||
):
|
||||
raise ValueError("H1 delta_scale_range must be two ordered positives")
|
||||
if (
|
||||
harmonic_range.shape != (2,)
|
||||
or not np.all(np.isfinite(harmonic_range))
|
||||
or harmonic_range[1] < harmonic_range[0]
|
||||
or np.max(np.abs(harmonic_range)) >= 1.0
|
||||
):
|
||||
raise ValueError(
|
||||
"H1 harmonic_weight_range must be ordered and inside (-1, 1)"
|
||||
)
|
||||
if jitter_mask.shape != (7,) or not np.all(np.isfinite(jitter_mask)):
|
||||
raise ValueError("H1 center_jitter_mask must have seven finite entries")
|
||||
|
||||
if randomize:
|
||||
seeds = trial.get("seeds")
|
||||
if not isinstance(seeds, Mapping):
|
||||
raise ValueError("H1 trial has no paired seed record")
|
||||
trajectory_rng = generator_from_record(seeds, "trajectory")
|
||||
center = center + center_std * jitter_mask * trajectory_rng.normal(size=7)
|
||||
delta = delta * trajectory_rng.uniform(
|
||||
delta_scale_range[0], delta_scale_range[1], size=7
|
||||
)
|
||||
harmonic_weight = float(
|
||||
trajectory_rng.uniform(harmonic_range[0], harmonic_range[1])
|
||||
)
|
||||
else:
|
||||
harmonic_weight = 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)
|
||||
excursion *= 1.0 + harmonic_weight * np.sin(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):
|
||||
@ -223,74 +127,6 @@ def _master_trajectory(
|
||||
return trajectory
|
||||
|
||||
|
||||
class H1ValidityReason(str, Enum):
|
||||
"""Primary reason a sample is unusable for smooth/differential mapping."""
|
||||
|
||||
NONE = "none"
|
||||
REACH_CLIPPED_LOWER = "reach_clipped_lower"
|
||||
REACH_CLIPPED_UPPER = "reach_clipped_upper"
|
||||
JOINT_LIMIT_ACTIVE = "joint_limit_active"
|
||||
GEOMETRY_DEGENERATE = "geometry_degenerate"
|
||||
INVALID_INPUT = "invalid_input"
|
||||
MASTER_LIMIT_VIOLATION = "master_limit_violation"
|
||||
JOINT_LIMIT_VIOLATION = "joint_limit_violation"
|
||||
TASK_TOLERANCE_EXCEEDED = "task_tolerance_exceeded"
|
||||
SOLVER_NOT_CONVERGED = "solver_not_converged"
|
||||
NUMERICAL_FAILURE = "numerical_failure"
|
||||
LOW_MANIPULABILITY = "low_manipulability"
|
||||
UNSPECIFIED_NONSMOOTH = "unspecified_nonsmooth"
|
||||
|
||||
|
||||
_H1_FAILURE_TO_VALIDITY_REASON = {
|
||||
RetargetingFailure.INVALID_INPUT: H1ValidityReason.INVALID_INPUT,
|
||||
RetargetingFailure.MASTER_LIMIT_VIOLATION:
|
||||
H1ValidityReason.MASTER_LIMIT_VIOLATION,
|
||||
RetargetingFailure.DEGENERATE_GEOMETRY:
|
||||
H1ValidityReason.GEOMETRY_DEGENERATE,
|
||||
RetargetingFailure.JOINT_LIMIT_VIOLATION:
|
||||
H1ValidityReason.JOINT_LIMIT_VIOLATION,
|
||||
RetargetingFailure.TASK_TOLERANCE_EXCEEDED:
|
||||
H1ValidityReason.TASK_TOLERANCE_EXCEEDED,
|
||||
RetargetingFailure.SOLVER_NOT_CONVERGED:
|
||||
H1ValidityReason.SOLVER_NOT_CONVERGED,
|
||||
RetargetingFailure.NUMERICAL_FAILURE:
|
||||
H1ValidityReason.NUMERICAL_FAILURE,
|
||||
}
|
||||
|
||||
|
||||
def _h1_validity_reason(
|
||||
*,
|
||||
smooth: bool,
|
||||
failure: RetargetingFailure,
|
||||
events: tuple[str, ...],
|
||||
low_manipulability: bool,
|
||||
) -> H1ValidityReason:
|
||||
"""Classify branch validity without overwriting pose-solver failure."""
|
||||
if smooth:
|
||||
return H1ValidityReason.NONE
|
||||
event_set = set(events)
|
||||
if "reach_clipped_lower" in event_set:
|
||||
return H1ValidityReason.REACH_CLIPPED_LOWER
|
||||
if "reach_clipped_upper" in event_set:
|
||||
return H1ValidityReason.REACH_CLIPPED_UPPER
|
||||
if {
|
||||
"master_shoulder_wrist_degenerate",
|
||||
"master_arm_plane_degenerate",
|
||||
"reference_axis_fallback",
|
||||
"invalid_master_geometry",
|
||||
} & event_set:
|
||||
return H1ValidityReason.GEOMETRY_DEGENERATE
|
||||
if "joint_limit_active" in event_set:
|
||||
return H1ValidityReason.JOINT_LIMIT_ACTIVE
|
||||
if failure is not RetargetingFailure.NONE:
|
||||
return _H1_FAILURE_TO_VALIDITY_REASON.get(
|
||||
failure, H1ValidityReason.UNSPECIFIED_NONSMOOTH
|
||||
)
|
||||
if low_manipulability:
|
||||
return H1ValidityReason.LOW_MANIPULABILITY
|
||||
return H1ValidityReason.UNSPECIFIED_NONSMOOTH
|
||||
|
||||
|
||||
def _slave_swivel(
|
||||
model: pin.Model,
|
||||
data: pin.Data,
|
||||
@ -356,31 +192,12 @@ def execute_h1_retargeting(trial: Mapping[str, Any]) -> TrialPayload:
|
||||
solver_cost = np.empty(sample_count)
|
||||
swivel = np.empty(sample_count)
|
||||
degeneracy = np.zeros(sample_count, dtype=np.int8)
|
||||
validity_reason = np.empty(sample_count, dtype=np.int16)
|
||||
reach_clip_code = np.zeros(sample_count, dtype=np.int8)
|
||||
joint_limit_active = np.zeros(sample_count, dtype=np.int8)
|
||||
geometry_degenerate = np.zeros(sample_count, dtype=np.int8)
|
||||
slave_min_singular_value = np.empty(sample_count)
|
||||
slave_manipulability = np.empty(sample_count)
|
||||
low_manipulability = 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"])
|
||||
low_manipulability_threshold = float(
|
||||
_trajectory_spec(trial).get(
|
||||
"low_manipulability_min_singular_threshold", 0.05
|
||||
)
|
||||
)
|
||||
if (
|
||||
not np.isfinite(low_manipulability_threshold)
|
||||
or low_manipulability_threshold <= 0.0
|
||||
):
|
||||
raise ValueError(
|
||||
"H1 low-manipulability singular-value threshold must be positive"
|
||||
)
|
||||
seed = None
|
||||
previous_swivel = 0.0
|
||||
for index, q_m in enumerate(q_master):
|
||||
@ -406,44 +223,6 @@ def execute_h1_retargeting(trial: Mapping[str, Any]) -> TrialPayload:
|
||||
)
|
||||
swivel[index] = previous_swivel
|
||||
degeneracy[index] = int(is_degenerate)
|
||||
jacobian = pin.computeFrameJacobian(
|
||||
models.slave,
|
||||
slave_data,
|
||||
result.q_slave,
|
||||
wrist_id,
|
||||
pin.ReferenceFrame.LOCAL_WORLD_ALIGNED,
|
||||
)
|
||||
singular_values = np.linalg.svd(jacobian, compute_uv=False)
|
||||
slave_min_singular_value[index] = float(singular_values[-1])
|
||||
slave_manipulability[index] = float(np.prod(singular_values))
|
||||
is_low_manipulability = bool(
|
||||
singular_values[-1] <= low_manipulability_threshold
|
||||
)
|
||||
low_manipulability[index] = int(is_low_manipulability)
|
||||
event_set = set(result.events)
|
||||
if "reach_clipped_lower" in event_set:
|
||||
reach_clip_code[index] = -1
|
||||
elif "reach_clipped_upper" in event_set:
|
||||
reach_clip_code[index] = 1
|
||||
joint_limit_active[index] = int("joint_limit_active" in event_set)
|
||||
geometry_degenerate[index] = int(
|
||||
bool(
|
||||
{
|
||||
"master_shoulder_wrist_degenerate",
|
||||
"master_arm_plane_degenerate",
|
||||
"reference_axis_fallback",
|
||||
"invalid_master_geometry",
|
||||
}
|
||||
& event_set
|
||||
)
|
||||
)
|
||||
reason = _h1_validity_reason(
|
||||
smooth=result.smooth,
|
||||
failure=result.failure,
|
||||
events=result.events,
|
||||
low_manipulability=is_low_manipulability,
|
||||
)
|
||||
validity_reason[index] = _enum_code(reason)
|
||||
if result.success:
|
||||
seed = result.q_slave.copy()
|
||||
for event in result.events:
|
||||
@ -452,18 +231,6 @@ def execute_h1_retargeting(trial: Mapping[str, Any]) -> TrialPayload:
|
||||
"sample_index": index,
|
||||
"event": str(event),
|
||||
"failure_code": int(failure_code[index]),
|
||||
"validity_reason": reason.value,
|
||||
"validity_reason_code": int(validity_reason[index]),
|
||||
}
|
||||
)
|
||||
if not result.smooth:
|
||||
events.append(
|
||||
{
|
||||
"sample_index": index,
|
||||
"event": "differential_invalid",
|
||||
"failure_code": int(failure_code[index]),
|
||||
"validity_reason": reason.value,
|
||||
"validity_reason_code": int(validity_reason[index]),
|
||||
}
|
||||
)
|
||||
|
||||
@ -489,13 +256,6 @@ def execute_h1_retargeting(trial: Mapping[str, Any]) -> TrialPayload:
|
||||
"map_commanded_reset": np.zeros(sample_count, dtype=np.int8),
|
||||
"map_degeneracy_transition": degeneracy,
|
||||
"map_failure_code": failure_code,
|
||||
"map_validity_reason_code": validity_reason,
|
||||
"map_reach_clip_code": reach_clip_code,
|
||||
"map_joint_limit_active": joint_limit_active,
|
||||
"map_geometry_degenerate": geometry_degenerate,
|
||||
"map_slave_min_singular_value": slave_min_singular_value,
|
||||
"map_slave_manipulability": slave_manipulability,
|
||||
"map_low_manipulability": low_manipulability,
|
||||
"map_solver_status": solver_status,
|
||||
"map_solver_iterations": iterations,
|
||||
"map_runtime_s": runtime_s,
|
||||
@ -510,21 +270,6 @@ def execute_h1_retargeting(trial: Mapping[str, Any]) -> TrialPayload:
|
||||
"failure_enum": {
|
||||
member.value: _enum_code(member) for member in RetargetingFailure
|
||||
},
|
||||
"validity_reason_enum": {
|
||||
member.value: _enum_code(member) for member in H1ValidityReason
|
||||
},
|
||||
"reach_clip_code": {"lower": -1, "none": 0, "upper": 1},
|
||||
"low_manipulability_definition": {
|
||||
"quantity": (
|
||||
"minimum singular value of the LOCAL_WORLD_ALIGNED "
|
||||
"slave-wrist geometric Jacobian"
|
||||
),
|
||||
"comparison": "<=",
|
||||
"threshold": low_manipulability_threshold,
|
||||
},
|
||||
"trajectory_instance_hash": stable_hash(
|
||||
q_master, prefix="h1-master-trajectory-instance"
|
||||
),
|
||||
"trajectory_family": _trajectory_spec(trial).get("family", "nominal"),
|
||||
},
|
||||
)
|
||||
@ -536,54 +281,6 @@ def _orthogonal(rng: np.random.Generator, size: int) -> np.ndarray:
|
||||
return q * signs
|
||||
|
||||
|
||||
def _h2_data_seed_group(
|
||||
trial: Mapping[str, Any],
|
||||
) -> tuple[str, dict[str, Any], dict[str, list[int]]]:
|
||||
"""Return H2 data streams independent of estimator tuning choices.
|
||||
|
||||
``pair_id`` intentionally includes every factor cell, which is the right
|
||||
default for most studies but would give each characteristic-length or
|
||||
damping candidate a different synthetic data realization. H2 calibration
|
||||
instead defines a second, explicitly recorded grouping unit from only the
|
||||
physical perturbation factors. Every estimator candidate in that group
|
||||
therefore receives byte-identical truth, model-error, sensor-noise, and
|
||||
trajectory streams.
|
||||
"""
|
||||
data_root_seed = int(_factor(trial, "h2_data_root_seed", 0))
|
||||
if data_root_seed < 0:
|
||||
raise ValueError("h2_data_root_seed must be non-negative")
|
||||
physical_factors = {
|
||||
"min_scaled_singular": float(
|
||||
_factor(trial, "min_scaled_singular", 0.05)
|
||||
),
|
||||
"model_error_std": float(_factor(trial, "model_error_std", 0.01)),
|
||||
"torque_noise_std_Nm": float(
|
||||
_factor(trial, "torque_noise_std_Nm", 0.01)
|
||||
),
|
||||
"truth_characteristic_length_m": float(
|
||||
_factor(trial, "truth_characteristic_length_m", 0.30)
|
||||
),
|
||||
}
|
||||
basis = {
|
||||
"study_id": str(trial.get("study_id", "")),
|
||||
"split": str(trial.get("split", "")),
|
||||
"data_root_seed": data_root_seed,
|
||||
"trajectory": dict(_trajectory_spec(trial)),
|
||||
"replicate": int(trial.get("replicate", 0)),
|
||||
"physical_factors": physical_factors,
|
||||
}
|
||||
group_id = (
|
||||
"h2-data-"
|
||||
+ stable_hash(basis, prefix="h2-physical-data-group")[:16]
|
||||
)
|
||||
seed_record = named_seed_record(
|
||||
data_root_seed,
|
||||
basis,
|
||||
("trajectory", "truth_model", "model_error", "sensor"),
|
||||
)
|
||||
return group_id, basis, seed_record
|
||||
|
||||
|
||||
def execute_h2_synthetic(trial: Mapping[str, Any]) -> TrialPayload:
|
||||
"""Run a paired, truth/estimator-separated H2 sensitivity trial."""
|
||||
method_id = _method_id(trial)
|
||||
@ -601,35 +298,18 @@ def execute_h2_synthetic(trial: Mapping[str, Any]) -> TrialPayload:
|
||||
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))
|
||||
truth_characteristic_length = float(
|
||||
_factor(trial, "truth_characteristic_length_m", 0.30)
|
||||
)
|
||||
operational_singular_threshold = float(
|
||||
_factor(trial, "operational_min_scaled_singular", 0.05)
|
||||
)
|
||||
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")
|
||||
if truth_characteristic_length <= 0.0:
|
||||
raise ValueError("truth_characteristic_length_m must be positive")
|
||||
if operational_singular_threshold <= 0.0:
|
||||
raise ValueError(
|
||||
"operational_min_scaled_singular must be positive"
|
||||
)
|
||||
|
||||
data_group_id, data_group_basis, data_seeds = _h2_data_seed_group(trial)
|
||||
truth_model_rng = generator_from_record(data_seeds, "truth_model")
|
||||
model_error_rng = generator_from_record(data_seeds, "model_error")
|
||||
sensor_rng = generator_from_record(data_seeds, "sensor")
|
||||
trajectory_rng = generator_from_record(data_seeds, "trajectory")
|
||||
u = _orthogonal(truth_model_rng, 6)
|
||||
v = _orthogonal(truth_model_rng, 7)
|
||||
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, :]
|
||||
# The physical truth is generated with a fixed reference length. The
|
||||
# scanned characteristic length belongs only to the estimator scaling;
|
||||
# otherwise changing ell would silently change the ground-truth Jacobian.
|
||||
truth_inverse_scaling = np.diag(
|
||||
[truth_characteristic_length] * 3 + [1.0] * 3
|
||||
inverse_scaling = np.diag(
|
||||
[characteristic_length] * 3 + [1.0] * 3
|
||||
)
|
||||
|
||||
phase = np.linspace(0.0, 2.0 * np.pi, count, endpoint=False)
|
||||
@ -654,16 +334,7 @@ def execute_h2_synthetic(trial: Mapping[str, Any]) -> TrialPayload:
|
||||
friction=friction,
|
||||
characteristic_length_m=characteristic_length,
|
||||
damping=damping,
|
||||
calibration_id=(
|
||||
"g0c-synthetic-candidate-"
|
||||
+ stable_hash(
|
||||
{
|
||||
"characteristic_length_m": characteristic_length,
|
||||
"damping": damping,
|
||||
},
|
||||
prefix="h2-calibration-candidate",
|
||||
)[:16]
|
||||
),
|
||||
calibration_id="g0c-synthetic-frozen-v1",
|
||||
)
|
||||
solver = (
|
||||
UndampedSVDSolver(characteristic_length)
|
||||
@ -683,31 +354,25 @@ def execute_h2_synthetic(trial: Mapping[str, Any]) -> TrialPayload:
|
||||
singular_log = np.empty((count, 6))
|
||||
rank = np.empty(count, dtype=np.int16)
|
||||
status = np.empty(count, dtype=np.int16)
|
||||
rank_threshold = np.empty(count)
|
||||
condition_number = np.empty(count)
|
||||
numerical_rank_deficient = np.empty(count, dtype=np.int8)
|
||||
operationally_ill_conditioned = np.empty(count, dtype=np.int8)
|
||||
truth_jacobian = np.empty((count, 42))
|
||||
estimator_jacobian = np.empty((count, 42))
|
||||
residual_raw = np.empty((count, 7))
|
||||
residual_corrected = np.empty((count, 7))
|
||||
sensor_noise = np.empty((count, 7))
|
||||
for index in range(count):
|
||||
smooth_change = 0.015 * np.sin(phase[index])
|
||||
J_truth = truth_inverse_scaling @ (
|
||||
J_truth = inverse_scaling @ (
|
||||
base_scaled_truth
|
||||
+ smooth_change * truth_model_rng.normal(size=(6, 7))
|
||||
+ smooth_change * model_rng.normal(size=(6, 7))
|
||||
)
|
||||
J_estimator = J_truth + truth_inverse_scaling @ (
|
||||
model_error_std * model_error_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]
|
||||
sensor_noise[index] = sensor_rng.normal(0.0, noise_std, 7)
|
||||
measured = (
|
||||
interaction
|
||||
+ bias
|
||||
+ friction.torque(qd[index])
|
||||
+ sensor_noise[index]
|
||||
+ sensor_rng.normal(0.0, noise_std, 7)
|
||||
)
|
||||
estimate = estimator.estimate(
|
||||
J_estimator,
|
||||
@ -720,13 +385,6 @@ def execute_h2_synthetic(trial: Mapping[str, Any]) -> TrialPayload:
|
||||
singular_log[index] = estimate.solve.singular_values
|
||||
rank[index] = estimate.solve.rank
|
||||
status[index] = _enum_code(estimate.solve.status)
|
||||
rank_threshold[index] = estimate.solve.rank_threshold
|
||||
condition_number[index] = estimate.solve.condition_number
|
||||
numerical_rank_deficient[index] = int(estimate.solve.rank < 6)
|
||||
operationally_ill_conditioned[index] = int(
|
||||
estimate.solve.singular_values[-1]
|
||||
<= operational_singular_threshold
|
||||
)
|
||||
truth_jacobian[index] = J_truth.reshape(-1)
|
||||
estimator_jacobian[index] = J_estimator.reshape(-1)
|
||||
residual_raw[index] = estimate.residual.raw_residual_nm
|
||||
@ -744,18 +402,8 @@ def execute_h2_synthetic(trial: Mapping[str, Any]) -> TrialPayload:
|
||||
"scaled_singular_values": singular_log,
|
||||
"solver_rank": rank,
|
||||
"solver_status": status,
|
||||
"solver_numerical_rank_threshold": rank_threshold,
|
||||
"solver_condition_number": condition_number,
|
||||
"solver_numerical_rank_deficient": numerical_rank_deficient,
|
||||
"solver_operationally_ill_conditioned": (
|
||||
operationally_ill_conditioned
|
||||
),
|
||||
"operational_min_scaled_singular_threshold": np.full(
|
||||
count, operational_singular_threshold
|
||||
),
|
||||
"tau_residual_raw": residual_raw,
|
||||
"tau_residual_corrected": residual_corrected,
|
||||
"sensor_noise_Nm": sensor_noise,
|
||||
},
|
||||
metadata={
|
||||
"evidence_scope": (
|
||||
@ -764,30 +412,12 @@ def execute_h2_synthetic(trial: Mapping[str, Any]) -> TrialPayload:
|
||||
"method_id": method_id,
|
||||
"truth_estimator_models_separated": True,
|
||||
"calibration_id": calibration.calibration_id,
|
||||
"h2_data_group_id": data_group_id,
|
||||
"h2_data_group_basis": data_group_basis,
|
||||
"h2_data_seed_record": data_seeds,
|
||||
"h2_data_seed_record_hash": stable_hash(
|
||||
data_seeds, prefix="h2-data-seed-record"
|
||||
),
|
||||
"h2_data_root_seed": int(
|
||||
_factor(trial, "h2_data_root_seed", 0)
|
||||
),
|
||||
"truth_characteristic_length_m": truth_characteristic_length,
|
||||
"operational_ill_conditioning_definition": {
|
||||
"quantity": "minimum singular value of estimator-scaled Jacobian",
|
||||
"comparison": "<=",
|
||||
"threshold": operational_singular_threshold,
|
||||
"numerical_rank_is_reported_separately": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _bilateral_scenario_and_config(
|
||||
trial: Mapping[str, Any],
|
||||
) -> tuple[Any, SimulationConfig]:
|
||||
"""Build and validate the effective bilateral scenario/configuration."""
|
||||
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:
|
||||
@ -806,113 +436,15 @@ def _bilateral_scenario_and_config(
|
||||
contact_probe_fraction=float(
|
||||
trajectory.get("contact_probe_fraction", 0.0)
|
||||
),
|
||||
feedback_delay_s=float(
|
||||
_profiled_factor(
|
||||
trial,
|
||||
"return_delay_s",
|
||||
0.04,
|
||||
profile_name="network_profile",
|
||||
)
|
||||
),
|
||||
forward_delay_s=float(
|
||||
_profiled_factor(
|
||||
trial,
|
||||
"forward_delay_s",
|
||||
0.0,
|
||||
profile_name="network_profile",
|
||||
)
|
||||
),
|
||||
return_jitter_s=float(
|
||||
_profiled_factor(
|
||||
trial,
|
||||
"return_jitter_s",
|
||||
0.0,
|
||||
profile_name="network_profile",
|
||||
)
|
||||
),
|
||||
forward_jitter_s=float(
|
||||
_profiled_factor(
|
||||
trial,
|
||||
"forward_jitter_s",
|
||||
0.0,
|
||||
profile_name="network_profile",
|
||||
)
|
||||
),
|
||||
return_packet_loss=float(
|
||||
_profiled_factor(
|
||||
trial,
|
||||
"return_packet_loss",
|
||||
0.0,
|
||||
profile_name="network_profile",
|
||||
)
|
||||
),
|
||||
forward_packet_loss=float(
|
||||
_profiled_factor(
|
||||
trial,
|
||||
"forward_packet_loss",
|
||||
0.0,
|
||||
profile_name="network_profile",
|
||||
)
|
||||
),
|
||||
forward_timeout_s=float(
|
||||
_profiled_factor(
|
||||
trial,
|
||||
"forward_timeout_s",
|
||||
0.20,
|
||||
profile_name="network_profile",
|
||||
)
|
||||
),
|
||||
return_timeout_s=float(
|
||||
_profiled_factor(
|
||||
trial,
|
||||
"return_timeout_s",
|
||||
0.20,
|
||||
profile_name="network_profile",
|
||||
)
|
||||
),
|
||||
feedback_strength=float(
|
||||
_profiled_factor(
|
||||
trial,
|
||||
"feedback_strength",
|
||||
0.50,
|
||||
profile_name="haptic_profile",
|
||||
)
|
||||
),
|
||||
energy_min=float(
|
||||
_profiled_factor(
|
||||
trial,
|
||||
"energy_min",
|
||||
0.05,
|
||||
profile_name="haptic_profile",
|
||||
)
|
||||
),
|
||||
energy_max=float(
|
||||
_profiled_factor(
|
||||
trial,
|
||||
"energy_max",
|
||||
0.055,
|
||||
profile_name="haptic_profile",
|
||||
)
|
||||
),
|
||||
energy_initial=float(
|
||||
_profiled_factor(
|
||||
trial,
|
||||
"energy_initial",
|
||||
0.05,
|
||||
profile_name="haptic_profile",
|
||||
)
|
||||
),
|
||||
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)),
|
||||
)
|
||||
config.validate()
|
||||
return scenario, config
|
||||
|
||||
|
||||
def execute_bilateral_simulation(trial: Mapping[str, Any]) -> TrialPayload:
|
||||
"""Run one paired H3/H4 rigid-body trial with a frozen network trace."""
|
||||
scenario, config = _bilateral_scenario_and_config(trial)
|
||||
trajectory = _trajectory_spec(trial)
|
||||
models = load_models(add_simulated_tcp=True)
|
||||
mapper = build_mapper(models)
|
||||
wall, wall_metadata, q_slave_start = make_wall(config, models, mapper)
|
||||
@ -941,19 +473,6 @@ def execute_bilateral_simulation(trial: Mapping[str, Any]) -> TrialPayload:
|
||||
samples["energy_before_J"] = samples["energy_before"].copy()
|
||||
samples["energy_after_J"] = samples["tank_energy"].copy()
|
||||
samples["energy_preclip_J"] = samples["energy_preclip"].copy()
|
||||
sample_count = samples["time"].shape[0]
|
||||
samples["configured_feedback_strength"] = np.full(
|
||||
sample_count, config.feedback_strength
|
||||
)
|
||||
samples["configured_energy_min_J"] = np.full(
|
||||
sample_count, config.energy_min
|
||||
)
|
||||
samples["configured_energy_max_J"] = np.full(
|
||||
sample_count, config.energy_max
|
||||
)
|
||||
samples["configured_energy_initial_J"] = np.full(
|
||||
sample_count, config.energy_initial
|
||||
)
|
||||
return TrialPayload(
|
||||
samples=samples,
|
||||
events=(),
|
||||
@ -967,22 +486,6 @@ def execute_bilateral_simulation(trial: Mapping[str, Any]) -> TrialPayload:
|
||||
"supervisor": scenario.supervisor,
|
||||
"map_policy": scenario.map_policy,
|
||||
},
|
||||
"effective_haptic_config": {
|
||||
"feedback_strength": config.feedback_strength,
|
||||
"energy_min_J": config.energy_min,
|
||||
"energy_max_J": config.energy_max,
|
||||
"energy_initial_J": config.energy_initial,
|
||||
},
|
||||
"effective_network_config": {
|
||||
"forward_delay_s": config.forward_delay_s,
|
||||
"return_delay_s": config.feedback_delay_s,
|
||||
"forward_jitter_s": config.forward_jitter_s,
|
||||
"return_jitter_s": config.return_jitter_s,
|
||||
"forward_packet_loss": config.forward_packet_loss,
|
||||
"return_packet_loss": config.return_packet_loss,
|
||||
"forward_timeout_s": config.forward_timeout_s,
|
||||
"return_timeout_s": config.return_timeout_s,
|
||||
},
|
||||
"wall": wall_metadata,
|
||||
"online_metrics_are_diagnostic_only": result.metrics,
|
||||
},
|
||||
|
||||
@ -1,98 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Configuration propagation and bounded-size contracts for bilateral v2."""
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
CODE_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(CODE_ROOT))
|
||||
|
||||
from experiments.executors import _bilateral_scenario_and_config # noqa: E402
|
||||
from experiments.plan import build_trial_plan, load_document # noqa: E402
|
||||
from experiments.rng import named_seed_record # noqa: E402
|
||||
|
||||
|
||||
CONFIG_ROOT = CODE_ROOT / "config" / "experiments"
|
||||
|
||||
|
||||
def bilateral_trial():
|
||||
return {
|
||||
"method": {"method_id": "proposed_energy"},
|
||||
"trajectory": {
|
||||
"trajectory_id": "unit_contact",
|
||||
"family": "contact_roundtrip",
|
||||
"duration_s": 0.8,
|
||||
"contact_probe_fraction": 0.01,
|
||||
},
|
||||
"factors": {
|
||||
"map_policy": "source_stamped",
|
||||
"network_profile": {
|
||||
"forward_delay_s": 0.04,
|
||||
"return_delay_s": 0.08,
|
||||
"forward_jitter_s": 0.003,
|
||||
"return_jitter_s": 0.004,
|
||||
"forward_packet_loss": 0.01,
|
||||
"return_packet_loss": 0.02,
|
||||
},
|
||||
"haptic_profile": {
|
||||
"feedback_strength": 0.25,
|
||||
"energy_min": 0.0,
|
||||
"energy_max": 0.3,
|
||||
"energy_initial": 0.1,
|
||||
},
|
||||
# A direct factor must override the coupled profile.
|
||||
"forward_delay_s": 0.02,
|
||||
"energy_initial": 0.12,
|
||||
},
|
||||
"seeds": named_seed_record(7, {"test": "bilateral-v2"}),
|
||||
}
|
||||
|
||||
|
||||
class BilateralCalibrationV2Test(unittest.TestCase):
|
||||
def test_haptic_and_network_factors_reach_simulation_config(self):
|
||||
scenario, config = _bilateral_scenario_and_config(bilateral_trial())
|
||||
self.assertEqual(scenario.map_policy, "source_stamped")
|
||||
self.assertEqual(config.feedback_strength, 0.25)
|
||||
self.assertEqual(config.energy_min, 0.0)
|
||||
self.assertEqual(config.energy_max, 0.3)
|
||||
self.assertEqual(config.energy_initial, 0.12)
|
||||
self.assertEqual(config.forward_delay_s, 0.02)
|
||||
self.assertEqual(config.feedback_delay_s, 0.08)
|
||||
self.assertEqual(config.forward_jitter_s, 0.003)
|
||||
self.assertEqual(config.return_jitter_s, 0.004)
|
||||
self.assertEqual(config.forward_packet_loss, 0.01)
|
||||
self.assertEqual(config.return_packet_loss, 0.02)
|
||||
|
||||
def test_v2_grids_are_directional_and_bounded(self):
|
||||
energy = build_trial_plan(
|
||||
load_document(CONFIG_ROOT / "bilateral_calibration_v2_energy.json")
|
||||
)
|
||||
network = build_trial_plan(
|
||||
load_document(CONFIG_ROOT / "bilateral_calibration_v2_network.json")
|
||||
)
|
||||
self.assertEqual(energy["pair_count"], 36)
|
||||
self.assertEqual(energy["trial_count"], 36)
|
||||
self.assertEqual(network["pair_count"], 12)
|
||||
self.assertEqual(network["trial_count"], 36)
|
||||
|
||||
profiles = {
|
||||
trial["factors"]["network_profile"]["profile_id"]
|
||||
for trial in network["trials"]
|
||||
}
|
||||
self.assertEqual(
|
||||
profiles,
|
||||
{
|
||||
"nominal",
|
||||
"return_delay_80ms",
|
||||
"forward_delay_40ms",
|
||||
"asymmetric_delay",
|
||||
"asymmetric_delay_plus_jitter",
|
||||
"asymmetric_delay_plus_loss",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -12,13 +12,10 @@ CODE_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(CODE_ROOT))
|
||||
|
||||
from analysis.metrics import ( # noqa: E402
|
||||
MetricError,
|
||||
audit_h4_energy,
|
||||
compute_h1_composite,
|
||||
compute_h2_wrench_metrics,
|
||||
compute_h3_power_mismatch,
|
||||
compute_bilateral_diagnostics,
|
||||
derive_trial_metrics,
|
||||
)
|
||||
|
||||
|
||||
@ -50,35 +47,9 @@ class IndependentExperimentMetricsTest(unittest.TestCase):
|
||||
def test_h2_separates_force_and_moment_rmse(self):
|
||||
reference = np.zeros((3, 6))
|
||||
estimate = np.tile([3.0, 4.0, 0.0, 0.0, 0.0, 2.0], (3, 1))
|
||||
metrics = compute_h2_wrench_metrics(
|
||||
estimate,
|
||||
reference,
|
||||
numerical_rank_deficient=[False, False, True],
|
||||
operationally_ill_conditioned=[True, False, True],
|
||||
numerical_rank_threshold=[1e-9, 2e-9, 3e-9],
|
||||
operational_min_scaled_singular_threshold=[0.05] * 3,
|
||||
scaled_singular_values=np.array(
|
||||
[
|
||||
[1.0, 0.04],
|
||||
[1.0, 0.06],
|
||||
[1.0, 0.01],
|
||||
]
|
||||
),
|
||||
condition_number=[25.0, 16.0, 100.0],
|
||||
)
|
||||
metrics = compute_h2_wrench_metrics(estimate, reference)
|
||||
self.assertAlmostEqual(metrics["h2_force_rmse_N"], 5.0)
|
||||
self.assertAlmostEqual(metrics["h2_moment_rmse_Nm"], 2.0)
|
||||
self.assertAlmostEqual(
|
||||
metrics["h2_numerical_rank_deficient_fraction"], 1.0 / 3.0
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
metrics["h2_operationally_ill_conditioned_fraction"], 2.0 / 3.0
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
metrics["h2_operational_min_scaled_singular_threshold"], 0.05
|
||||
)
|
||||
self.assertAlmostEqual(metrics["h2_min_scaled_singular_value"], 0.01)
|
||||
self.assertAlmostEqual(metrics["h2_max_condition_number"], 100.0)
|
||||
|
||||
def test_h3_is_zero_for_identical_aligned_ports(self):
|
||||
torque = np.array([[1.0, 2.0], [-2.0, 1.0], [0.5, -0.5]])
|
||||
@ -91,25 +62,6 @@ class IndependentExperimentMetricsTest(unittest.TestCase):
|
||||
dt=0.002,
|
||||
)
|
||||
self.assertAlmostEqual(metrics["h3_epsilon_P_act"], 0.0)
|
||||
self.assertTrue(metrics["h3_normalized_metric_valid"])
|
||||
self.assertEqual(metrics["h3_epsilon_P_act_gated"], 0.0)
|
||||
|
||||
def test_h3_gates_low_activity_but_keeps_absolute_mismatch(self):
|
||||
metrics = compute_h3_power_mismatch(
|
||||
tau_master_raw=np.array([[2.0e-5], [1.0e-5]]),
|
||||
qd_master=np.ones((2, 1)),
|
||||
tau_slave_source=np.zeros((2, 1)),
|
||||
qd_slave_source=np.ones((2, 1)),
|
||||
dt=0.002,
|
||||
minimum_power_activity_J=1.0e-3,
|
||||
)
|
||||
self.assertFalse(metrics["h3_normalized_metric_valid"])
|
||||
self.assertIsNone(metrics["h3_epsilon_P_act_gated"])
|
||||
self.assertGreater(metrics["h3_absolute_power_mismatch_J"], 0.0)
|
||||
self.assertEqual(
|
||||
metrics["h3_absolute_power_mismatch_J"],
|
||||
metrics["h3_power_mismatch_numerator_J"],
|
||||
)
|
||||
|
||||
def test_h4_reconstructs_floor_and_projection_distortion(self):
|
||||
metrics = audit_h4_energy(
|
||||
@ -149,60 +101,6 @@ class IndependentExperimentMetricsTest(unittest.TestCase):
|
||||
)
|
||||
self.assertGreater(metrics["h4_software_preclip_max_error_J"], 0.0)
|
||||
|
||||
def test_h4_uses_stored_trial_specific_energy_bounds(self):
|
||||
samples = {
|
||||
"energy_before_J": np.array([0.15]),
|
||||
"energy_after_J": np.array([0.14]),
|
||||
"tau_master_candidate": np.array([[0.1]]),
|
||||
"tau_master_applied": np.array([[0.1]]),
|
||||
"qd_master": np.array([[1.0]]),
|
||||
"dt": np.array([0.1]),
|
||||
"configured_energy_min_J": np.array([0.0]),
|
||||
"configured_energy_max_J": np.array([0.2]),
|
||||
}
|
||||
metrics = derive_trial_metrics(
|
||||
samples,
|
||||
{
|
||||
"enabled": ["h4"],
|
||||
"h4": {"audit_tolerance_J": 1e-12},
|
||||
},
|
||||
)
|
||||
self.assertEqual(metrics["h4_energy_min_J"], 0.0)
|
||||
self.assertEqual(metrics["h4_energy_max_J"], 0.2)
|
||||
|
||||
with self.assertRaisesRegex(MetricError, "disagrees"):
|
||||
derive_trial_metrics(
|
||||
samples,
|
||||
{
|
||||
"enabled": ["h4"],
|
||||
"h4": {
|
||||
"energy_min_J": 0.05,
|
||||
"energy_max_J": 0.2,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def test_bilateral_diagnostics_expose_task_and_intervention_cost(self):
|
||||
metrics = compute_bilateral_diagnostics(
|
||||
master_tracking_error_rad=[0.1, 0.2],
|
||||
slave_tracking_error_rad=[0.2, 0.4],
|
||||
feedback_torque_Nm=np.array([[3.0, 4.0], [0.0, 0.0]]),
|
||||
contact_force_N=[0.0, 2.0],
|
||||
projection_factor=[1.0, 0.5],
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
metrics["bilateral_master_tracking_rmse_rad"],
|
||||
np.sqrt(0.025),
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
metrics["bilateral_feedback_torque_rms_Nm"],
|
||||
5.0 / np.sqrt(2.0),
|
||||
)
|
||||
self.assertEqual(
|
||||
metrics["bilateral_projection_intervention_fraction"], 0.5
|
||||
)
|
||||
self.assertEqual(metrics["bilateral_contact_fraction"], 0.5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@ -1,173 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""H1 calibration-v2 seeded pairing and audit-semantic contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
CODE_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(CODE_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(CODE_ROOT))
|
||||
|
||||
from analysis.metrics import derive_trial_metrics # noqa: E402
|
||||
from core.model_contract import ( # noqa: E402
|
||||
MASTER_JOINT_NAMES,
|
||||
finite_joint_limits,
|
||||
load_models,
|
||||
)
|
||||
from experiments.executors import ( # noqa: E402
|
||||
H1ValidityReason,
|
||||
_master_trajectory,
|
||||
execute_h1_retargeting,
|
||||
)
|
||||
from experiments.plan import build_trial_plan, load_document # noqa: E402
|
||||
|
||||
|
||||
CONFIG_PATH = (
|
||||
CODE_ROOT / "config" / "experiments" / "h1_calibration_v2.json"
|
||||
)
|
||||
METRIC_CONFIG_PATH = (
|
||||
CODE_ROOT / "config" / "experiments" / "metrics_h1_calibration_v2.json"
|
||||
)
|
||||
|
||||
|
||||
class H1CalibrationV2Test(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.models = load_models(add_simulated_tcp=True)
|
||||
cls.lower, cls.upper = finite_joint_limits(
|
||||
cls.models.master, MASTER_JOINT_NAMES
|
||||
)
|
||||
cls.plan = build_trial_plan(load_document(CONFIG_PATH))
|
||||
cls.metric_configuration = load_document(METRIC_CONFIG_PATH)
|
||||
|
||||
def _trajectory(self, trial):
|
||||
return _master_trajectory(
|
||||
trial, lower=self.lower, upper=self.upper
|
||||
)
|
||||
|
||||
def _sew_trial(self, family: str, sample_count: int = 7):
|
||||
trial = next(
|
||||
item
|
||||
for item in self.plan["trials"]
|
||||
if item["method"]["method_id"] == "sew"
|
||||
and item["trajectory"]["family"] == family
|
||||
and item["replicate"] == 0
|
||||
)
|
||||
short_trial = deepcopy(trial)
|
||||
short_trial["trajectory"]["sample_count"] = sample_count
|
||||
return short_trial
|
||||
|
||||
def test_v2_plan_is_bounded_seeded_and_strictly_paired(self) -> None:
|
||||
self.assertEqual(self.plan["pair_count"], 15)
|
||||
self.assertEqual(self.plan["trial_count"], 60)
|
||||
by_pair = {}
|
||||
by_trajectory = {}
|
||||
for trial in self.plan["trials"]:
|
||||
trajectory = self._trajectory(trial)
|
||||
self.assertTrue(np.all(trajectory > self.lower))
|
||||
self.assertTrue(np.all(trajectory < self.upper))
|
||||
by_pair.setdefault(trial["pair_id"], []).append(trajectory)
|
||||
by_trajectory.setdefault(
|
||||
trial["trajectory"]["trajectory_id"], {}
|
||||
).setdefault(trial["replicate"], trajectory)
|
||||
|
||||
for trajectories in by_pair.values():
|
||||
self.assertEqual(len(trajectories), 4)
|
||||
for candidate in trajectories[1:]:
|
||||
np.testing.assert_array_equal(candidate, trajectories[0])
|
||||
|
||||
for instances in by_trajectory.values():
|
||||
self.assertEqual(set(instances), {0, 1, 2})
|
||||
self.assertFalse(
|
||||
np.array_equal(instances[0], instances[1]),
|
||||
"replicates must not be deterministic pseudo-replicates",
|
||||
)
|
||||
self.assertFalse(np.array_equal(instances[1], instances[2]))
|
||||
|
||||
def test_valid_low_manipulability_is_not_reach_clipped(self) -> None:
|
||||
payload = execute_h1_retargeting(
|
||||
self._sew_trial("low_manipulability_valid")
|
||||
)
|
||||
samples = payload.samples
|
||||
self.assertTrue(np.all(samples["map_pose_success"]))
|
||||
self.assertTrue(np.all(samples["map_differential_valid"]))
|
||||
self.assertTrue(np.any(samples["map_low_manipulability"]))
|
||||
self.assertTrue(np.all(samples["map_reach_clip_code"] == 0))
|
||||
self.assertTrue(
|
||||
np.all(
|
||||
samples["map_validity_reason_code"]
|
||||
== list(H1ValidityReason).index(H1ValidityReason.NONE)
|
||||
)
|
||||
)
|
||||
|
||||
def test_reach_clip_has_reason_despite_pose_success(self) -> None:
|
||||
payload = execute_h1_retargeting(
|
||||
self._sew_trial("reach_clip_upper")
|
||||
)
|
||||
samples = payload.samples
|
||||
upper_code = list(H1ValidityReason).index(
|
||||
H1ValidityReason.REACH_CLIPPED_UPPER
|
||||
)
|
||||
self.assertTrue(np.all(samples["map_pose_success"]))
|
||||
self.assertTrue(np.all(samples["map_differential_valid"] == 0))
|
||||
self.assertTrue(np.all(samples["map_failure_code"] == 0))
|
||||
self.assertTrue(np.all(samples["map_reach_clip_code"] == 1))
|
||||
self.assertTrue(
|
||||
np.all(samples["map_validity_reason_code"] == upper_code)
|
||||
)
|
||||
invalid_events = [
|
||||
event
|
||||
for event in payload.events
|
||||
if event["event"] == "differential_invalid"
|
||||
]
|
||||
self.assertEqual(len(invalid_events), len(samples["sample_index"]))
|
||||
self.assertTrue(
|
||||
all(
|
||||
event["validity_reason"]
|
||||
== H1ValidityReason.REACH_CLIPPED_UPPER.value
|
||||
for event in invalid_events
|
||||
)
|
||||
)
|
||||
metrics = derive_trial_metrics(
|
||||
samples, self.metric_configuration
|
||||
)
|
||||
self.assertEqual(metrics["h1_reach_clip_upper_fraction"], 1.0)
|
||||
self.assertEqual(metrics["h1_reach_clip_fraction"], 1.0)
|
||||
self.assertEqual(
|
||||
metrics["h1_primary_invalid_reason"],
|
||||
H1ValidityReason.REACH_CLIPPED_UPPER.value,
|
||||
)
|
||||
self.assertEqual(
|
||||
metrics["h1_validity_reason_histogram"],
|
||||
{H1ValidityReason.REACH_CLIPPED_UPPER.value: 7},
|
||||
)
|
||||
self.assertEqual(metrics["h1_unexplained_invalid_fraction"], 0.0)
|
||||
self.assertGreater(
|
||||
metrics["h1_min_slave_min_singular_value"], 0.0
|
||||
)
|
||||
|
||||
def test_metric_reason_labels_match_executor_enum(self) -> None:
|
||||
self.assertEqual(
|
||||
self.metric_configuration["h1"]["validity_reason_labels"],
|
||||
[member.value for member in H1ValidityReason],
|
||||
)
|
||||
|
||||
def test_every_invalid_sample_has_a_nonzero_audit_reason(self) -> None:
|
||||
payload = execute_h1_retargeting(self._sew_trial("joint_limit"))
|
||||
samples = payload.samples
|
||||
invalid = np.asarray(samples["map_differential_valid"]) == 0
|
||||
self.assertTrue(np.any(invalid))
|
||||
self.assertTrue(
|
||||
np.all(np.asarray(samples["map_validity_reason_code"])[invalid] != 0)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -1,232 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""H2 data pairing and conditioning-stratum regression tests."""
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
CODE_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(CODE_ROOT))
|
||||
|
||||
from experiments.executors import execute_h2_synthetic # noqa: E402
|
||||
from experiments.plan import build_trial_plan, load_document # noqa: E402
|
||||
|
||||
|
||||
def h2_pairing_specification():
|
||||
return {
|
||||
"study_id": "h2_pairing_contract",
|
||||
"split": "calibration",
|
||||
"root_seed": 37,
|
||||
"replicates": 2,
|
||||
"methods": ["scaled_dls", "undamped_svd"],
|
||||
"trajectories": [
|
||||
{
|
||||
"trajectory_id": "short_dynamic_wrench",
|
||||
"family": "synthetic_dynamic",
|
||||
"sample_count": 12,
|
||||
}
|
||||
],
|
||||
"factors": {
|
||||
"characteristic_length_m": [0.2, 0.4],
|
||||
"damping": [0.02, 0.05],
|
||||
"h2_data_root_seed": [1701],
|
||||
"min_scaled_singular": [0.02],
|
||||
"model_error_std": [0.02],
|
||||
"operational_min_scaled_singular": [0.05],
|
||||
"torque_noise_std_Nm": [0.002, 0.02],
|
||||
"truth_characteristic_length_m": [0.3],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def select_trial(
|
||||
plan,
|
||||
*,
|
||||
replicate,
|
||||
method,
|
||||
characteristic_length,
|
||||
damping,
|
||||
noise,
|
||||
):
|
||||
matches = [
|
||||
trial
|
||||
for trial in plan["trials"]
|
||||
if trial["replicate"] == replicate
|
||||
and trial["method"]["method_id"] == method
|
||||
and trial["factors"]["characteristic_length_m"]
|
||||
== characteristic_length
|
||||
and trial["factors"]["damping"] == damping
|
||||
and trial["factors"]["torque_noise_std_Nm"] == noise
|
||||
]
|
||||
if len(matches) != 1:
|
||||
raise AssertionError(f"expected one H2 trial, found {len(matches)}")
|
||||
return matches[0]
|
||||
|
||||
|
||||
class H2SyntheticPairingTest(unittest.TestCase):
|
||||
def test_algorithm_candidates_reuse_identical_physical_data(self):
|
||||
plan = build_trial_plan(h2_pairing_specification())
|
||||
first_trial = select_trial(
|
||||
plan,
|
||||
replicate=0,
|
||||
method="scaled_dls",
|
||||
characteristic_length=0.2,
|
||||
damping=0.02,
|
||||
noise=0.002,
|
||||
)
|
||||
second_trial = select_trial(
|
||||
plan,
|
||||
replicate=0,
|
||||
method="undamped_svd",
|
||||
characteristic_length=0.4,
|
||||
damping=0.05,
|
||||
noise=0.002,
|
||||
)
|
||||
# The general experiment pair changes with ell/damping. H2's explicit
|
||||
# physical-data group must still bind both candidates to the same data.
|
||||
self.assertNotEqual(first_trial["pair_id"], second_trial["pair_id"])
|
||||
self.assertNotEqual(first_trial["seeds"], second_trial["seeds"])
|
||||
|
||||
first = execute_h2_synthetic(first_trial)
|
||||
second = execute_h2_synthetic(second_trial)
|
||||
self.assertEqual(
|
||||
first.metadata["h2_data_group_id"],
|
||||
second.metadata["h2_data_group_id"],
|
||||
)
|
||||
self.assertEqual(
|
||||
first.metadata["h2_data_seed_record"],
|
||||
second.metadata["h2_data_seed_record"],
|
||||
)
|
||||
self.assertNotEqual(
|
||||
first.metadata["calibration_id"],
|
||||
second.metadata["calibration_id"],
|
||||
"scanned estimator candidates must not share a frozen-looking ID",
|
||||
)
|
||||
for field in (
|
||||
"wrench_reference",
|
||||
"qd_slave",
|
||||
"jacobian_truth",
|
||||
"jacobian_estimator",
|
||||
"sensor_noise_Nm",
|
||||
"tau_residual_raw",
|
||||
):
|
||||
np.testing.assert_array_equal(
|
||||
first.samples[field], second.samples[field]
|
||||
)
|
||||
self.assertFalse(
|
||||
np.array_equal(
|
||||
first.samples["wrench_estimated"],
|
||||
second.samples["wrench_estimated"],
|
||||
)
|
||||
)
|
||||
|
||||
def test_replicates_and_physical_factor_cells_get_distinct_data(self):
|
||||
plan = build_trial_plan(h2_pairing_specification())
|
||||
baseline = execute_h2_synthetic(
|
||||
select_trial(
|
||||
plan,
|
||||
replicate=0,
|
||||
method="scaled_dls",
|
||||
characteristic_length=0.2,
|
||||
damping=0.02,
|
||||
noise=0.002,
|
||||
)
|
||||
)
|
||||
next_replicate = execute_h2_synthetic(
|
||||
select_trial(
|
||||
plan,
|
||||
replicate=1,
|
||||
method="scaled_dls",
|
||||
characteristic_length=0.2,
|
||||
damping=0.02,
|
||||
noise=0.002,
|
||||
)
|
||||
)
|
||||
different_noise = execute_h2_synthetic(
|
||||
select_trial(
|
||||
plan,
|
||||
replicate=0,
|
||||
method="scaled_dls",
|
||||
characteristic_length=0.2,
|
||||
damping=0.02,
|
||||
noise=0.02,
|
||||
)
|
||||
)
|
||||
self.assertNotEqual(
|
||||
baseline.metadata["h2_data_group_id"],
|
||||
next_replicate.metadata["h2_data_group_id"],
|
||||
)
|
||||
self.assertNotEqual(
|
||||
baseline.metadata["h2_data_group_id"],
|
||||
different_noise.metadata["h2_data_group_id"],
|
||||
)
|
||||
self.assertFalse(
|
||||
np.array_equal(
|
||||
baseline.samples["jacobian_truth"],
|
||||
next_replicate.samples["jacobian_truth"],
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
np.array_equal(
|
||||
baseline.samples["tau_residual_raw"],
|
||||
different_noise.samples["tau_residual_raw"],
|
||||
)
|
||||
)
|
||||
|
||||
def test_operational_flag_is_separate_from_numerical_rank(self):
|
||||
specification = h2_pairing_specification()
|
||||
specification["replicates"] = 1
|
||||
specification["factors"]["characteristic_length_m"] = [0.3]
|
||||
specification["factors"]["damping"] = [0.03]
|
||||
specification["factors"]["torque_noise_std_Nm"] = [0.01]
|
||||
specification["factors"]["operational_min_scaled_singular"] = [10.0]
|
||||
trial = build_trial_plan(specification)["trials"][0]
|
||||
payload = execute_h2_synthetic(trial)
|
||||
|
||||
np.testing.assert_array_equal(
|
||||
payload.samples["solver_numerical_rank_deficient"],
|
||||
np.zeros(12, dtype=np.int8),
|
||||
)
|
||||
np.testing.assert_array_equal(
|
||||
payload.samples["solver_operationally_ill_conditioned"],
|
||||
np.ones(12, dtype=np.int8),
|
||||
)
|
||||
np.testing.assert_array_equal(
|
||||
payload.samples["operational_min_scaled_singular_threshold"],
|
||||
np.full(12, 10.0),
|
||||
)
|
||||
definition = payload.metadata[
|
||||
"operational_ill_conditioning_definition"
|
||||
]
|
||||
self.assertTrue(definition["numerical_rank_is_reported_separately"])
|
||||
|
||||
def test_v2_scan_has_sixteen_physical_groups_and_576_trials(self):
|
||||
specification = load_document(
|
||||
CODE_ROOT / "config" / "experiments" / "h2_calibration_v2.json"
|
||||
)
|
||||
plan = build_trial_plan(specification)
|
||||
self.assertEqual(plan["pair_count"], 144)
|
||||
self.assertEqual(plan["trial_count"], 576)
|
||||
|
||||
group_ids = set()
|
||||
for trial in plan["trials"]:
|
||||
payload = execute_h2_synthetic(
|
||||
{
|
||||
**trial,
|
||||
"trajectory": {
|
||||
**trial["trajectory"],
|
||||
"sample_count": 8,
|
||||
},
|
||||
}
|
||||
)
|
||||
group_ids.add(payload.metadata["h2_data_group_id"])
|
||||
if len(group_ids) == 16:
|
||||
break
|
||||
self.assertEqual(len(group_ids), 16)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -28,11 +28,7 @@ def wrench_executor(_trial):
|
||||
samples={
|
||||
"wrench_estimated": estimate,
|
||||
"wrench_reference": reference,
|
||||
},
|
||||
metadata={
|
||||
"h2_data_group_id": "h2-data-test-group",
|
||||
"h2_data_seed_record_hash": "abc123",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -71,12 +67,6 @@ class PaperSourceDataTest(unittest.TestCase):
|
||||
table = list(csv.DictReader(stream))
|
||||
self.assertEqual(len(table), 2)
|
||||
self.assertIn("h2_force_rmse_N", table[0])
|
||||
self.assertEqual(
|
||||
table[0]["h2_data_group_id"], "h2-data-test-group"
|
||||
)
|
||||
self.assertEqual(
|
||||
table[0]["h2_data_seed_record_hash"], "abc123"
|
||||
)
|
||||
self.assertTrue((batch / "paper" / "artifact_manifest.json").is_file())
|
||||
|
||||
def test_metric_family_method_filter_prevents_mixed_supervisors(self):
|
||||
|
||||
@ -1,233 +0,0 @@
|
||||
# Calibration Audit — 2026-07-27
|
||||
|
||||
## Scope and decision rule
|
||||
|
||||
This audit covers pre-prototype numerical and rigid-body simulation evidence
|
||||
only. It does not contain physical prototype, independent F/T, fixture, or
|
||||
human-subject results, and none of its values may be copied into the manuscript
|
||||
Results section.
|
||||
|
||||
The calibration code was first committed as
|
||||
`4503a12bf10886902e69d4be3874a76d3be577d6`. All three reported v2 raw batches
|
||||
record that commit with `dirty=false`. The independent metric code and configs
|
||||
were tested with 98 passing tests and one explicitly unsupported legacy MuJoCo
|
||||
demo skipped.
|
||||
|
||||
The decision terms **verified contract**, **provisional numerical setting**,
|
||||
**frozen setting**, and **not freeze-ready** follow
|
||||
`docs/calibration/CALIBRATION_POLICY.md`.
|
||||
|
||||
The companion formula-linked audit workbook is
|
||||
`outputs/calibration-20260727/calibration_audit_2026-07-27.xlsx`. It contains
|
||||
13 visually inspected worksheets and has SHA-256
|
||||
`24df38f67ba1aa428928348561b452db181f963ed30ae18a570aabc4bd8ad920`.
|
||||
|
||||
## Traceable batches
|
||||
|
||||
| Study | Completed | Plan hash | Metric-config hash | Row hash | Source-data SHA-256 |
|
||||
|---|---:|---|---|---|---|
|
||||
| H1 calibration v2 | 60/60 | `e86b4aec90912954fdee97834dee08dd630647a89000a24402fa56f25b3aea83` | `2be0e67feda3f862c3510b3318af47437977c2b87a17ab810ce68a49212d5215` | `9e274b01db38c97112ecd1e7b50050d5c342cf316a22ea9b2d2c0ea4acf347d4` | `85110ad917d6b933656571b15c764b26d6f8215bea6d6baa338d759ed51fc0ee` |
|
||||
| H2 calibration v2 | 576/576 | `87f797edb5d2d6378a42b95616fca8ed733d0c81de5e22c422b006ead7071b12` | `caaf8c3b1c8180eb19afb98d986e8a54b8a897bd1ab706f78e39c41441932a31` | `fcac031716e967d3832d8eb6f770f580bd68bb6c8f715d55d04b5878f86f0fbf` | `e3a9bc29003193b559fbfc595ce2595b19a29a7135b9f14057128a8c5f504710` |
|
||||
| Bilateral energy/gain v2 Stage A | 36/36 | `c10fa2cd84392f9dbf62809b0960bfb38d1e7aac4791ef0345a7353c45d8e062` | `37ca1e72ae55eb2a4594ef8e6395302d76ebfea87c4bca43615e86b3eeb89ec2` | `3f3f7f09387f02fe88c1b070815bd108f7e6bf63a091861ae62c1fac1dcdf421` | H3 `baa82701ea4c3e116657b9ef4d12aa795849957934f9dc5d58d5811405936da7`; H4 `47a9cc05f22724edf078a525e31ace6332b6048ab36ed72a005a50e8ecb79453`; diagnostics `a765cb6cc87c8fc89849c4d0a5d2560a768769169caa625baf41ddfd18f7af63` |
|
||||
|
||||
All batches validated with zero failed trials and no validator warnings.
|
||||
|
||||
## H1 — SEW retargeting
|
||||
|
||||
### Design corrections verified
|
||||
|
||||
- Five trajectory strata are now separate: nominal, wider valid reach,
|
||||
valid near-singularity, intentional upper-reach clipping, and master
|
||||
joint-limit stress.
|
||||
- Each stratum has three genuinely different seeded trajectory instances.
|
||||
All four methods receive a bit-identical master trajectory within a pair.
|
||||
The audit found one trajectory hash per pair and 15 unique hashes across the
|
||||
15 pairs.
|
||||
- Reach clipping, active limits, geometry degeneracy, low manipulability, pose
|
||||
failure, and differential invalidity are distinct machine-readable fields.
|
||||
No invalid sample in v2 had an unexplained reason.
|
||||
|
||||
### Calibration outcome
|
||||
|
||||
| Method | Trials with \(C_r=1\) | Main observation |
|
||||
|---|---:|---|
|
||||
| SEW | 6/15 | Passed nominal, wider valid reach, and valid near-singularity; failed the intentionally invalid reach-clip and joint-limit strata |
|
||||
| Task-priority IK | 6/15 | Same trajectory-level composite outcomes as SEW in every pair |
|
||||
| Bounded DLS IK | 6/15 | Same trajectory-level composite outcomes as SEW in every pair |
|
||||
| Scaled joint-space map | 15/15 | Mapping branch remained numerically valid in several strata, but task-space error exceeded the locked calibration tolerances |
|
||||
|
||||
The paired difference \(C_{r,\mathrm{SEW}}-C_{r,\mathrm{taskIK}}\) was zero in
|
||||
all 15 pairs. Every method had \(D_r=0\), including all valid trajectories.
|
||||
Consequently, this calibration does not support the H1 superiority hypothesis
|
||||
and cannot calibrate or freeze the discontinuity threshold.
|
||||
|
||||
The valid near-singularity stratum is no longer mixed with reach clipping:
|
||||
SEW, task-priority IK, and bounded DLS had 100% valid samples and zero reach
|
||||
clips; the SEW slave-output low-manipulability fraction was approximately
|
||||
0.502. The intentional upper-reach-clip stratum had a clip fraction of 1.0
|
||||
and `reach_clipped_upper` as the primary invalid reason. The joint-limit
|
||||
stratum had no reach clipping and correctly reported `joint_limit_active`.
|
||||
|
||||
Runtime tails over all v2 samples were:
|
||||
|
||||
| Method | P50 | P95 | P99 | Maximum |
|
||||
|---|---:|---:|---:|---:|
|
||||
| Scaled joint-space | 0.140 ms | 0.159 ms | 0.235 ms | 0.475 ms |
|
||||
| Bounded DLS IK | 0.191 ms | 1.693 ms | 1.760 ms | 5.156 ms |
|
||||
| SEW | 0.845 ms | 10.290 ms | 12.007 ms | 15.643 ms |
|
||||
| Task-priority IK | 0.322 ms | 10.139 ms | 10.423 ms | 10.713 ms |
|
||||
|
||||
These are workstation numerical timings, not a hardware real-time claim.
|
||||
|
||||
### H1 decision
|
||||
|
||||
**Not freeze-ready.** Before a locked H1 study:
|
||||
|
||||
1. add valid branch-transition and near-degeneracy trajectories that can
|
||||
actually exercise \(D_r\);
|
||||
2. declare a target control period and separate target construction,
|
||||
recovery, and differential-map timing;
|
||||
3. tune both SEW and the primary task-priority baseline only on a new
|
||||
calibration split;
|
||||
4. require a nonzero number of eligible increments in every continuity
|
||||
stratum; and
|
||||
5. rerun with enough independent trajectories for a precision-based paired
|
||||
confidence interval.
|
||||
|
||||
## H2 — residual-wrench inversion
|
||||
|
||||
### Pairing and conditioning audit
|
||||
|
||||
The v2 plan contains 16 physical-data groups. Each group has 36
|
||||
\(\ell_c\times\lambda\times\)method candidates and one unique seed hash.
|
||||
Independent array hashes confirmed that the wrench reference, joint velocity,
|
||||
truth Jacobian, estimator Jacobian, and sensor noise are identical across all
|
||||
36 candidates within each group.
|
||||
|
||||
Numerical rank and operational ill-conditioning are now separate. No sample was
|
||||
numerically rank deficient, but the minimum scaled singular value reached
|
||||
approximately \(9.83\times10^{-4}\), and 39.9% of samples at the provisional
|
||||
candidate were below the preregistered operational threshold.
|
||||
|
||||
### Parameter scan
|
||||
|
||||
For scaled DLS, trial-mean force/moment RMSE at the strongest candidates was:
|
||||
|
||||
| \(\ell_c\) (m) | \(\lambda\) | Force mean / max (N) | Moment mean / max (Nm) |
|
||||
|---:|---:|---:|---:|
|
||||
| 0.2 | 0.02 | 3.051 / 9.336 | 0.722 / 1.478 |
|
||||
| 0.2 | 0.03 | 2.848 / 8.229 | 0.704 / 1.269 |
|
||||
| 0.2 | 0.05 | **2.763 / 7.322** | 0.705 / **1.126** |
|
||||
| 0.3 | 0.03 | 3.033 / 8.058 | 0.772 / 1.283 |
|
||||
| 0.4 | 0.02 | 3.184 / 8.448 | 0.812 / 1.362 |
|
||||
|
||||
At \(\ell_c=0.2\) m and \(\lambda=0.05\):
|
||||
|
||||
| Method | Force mean / max (N) | Moment mean / max (Nm) |
|
||||
|---|---:|---:|
|
||||
| Scaled DLS | 2.763 / 7.322 | 0.705 / 1.126 |
|
||||
| Undamped SVD | 6.064 / 25.502 | 1.248 / 4.281 |
|
||||
| No bias correction | 3.132 / 7.512 | 0.786 / 1.140 |
|
||||
| No friction correction | 2.965 / 7.091 | 0.759 / 1.153 |
|
||||
|
||||
The paired DLS-minus-undamped mean differences were \(-3.301\) N and
|
||||
\(-0.544\) Nm, but DLS was better in only 10 of 16 physical-data groups. It
|
||||
was deliberately worse in several well-modelled, low-noise cells and much
|
||||
better in the low-singular/model-error tail. This is a bias--variance result,
|
||||
not a universal accuracy result. The largest undamped retained outlier was
|
||||
25.502 N / 4.281 Nm; it was not removed.
|
||||
|
||||
### H2 decision
|
||||
|
||||
**Provisional numerical setting:** use scaled DLS with
|
||||
\(\ell_c=0.2\) m and \(\lambda=0.05\) only for the next synthetic pilot.
|
||||
Keep \(\ell_c=0.2,\lambda=0.03\) and undamped SVD as sensitivity conditions.
|
||||
|
||||
**Not a frozen physical calibration.** The length scale must ultimately be
|
||||
anchored to robot geometry and physical calibration, and H2 cannot support a
|
||||
paper wrench-accuracy claim before independent six-axis F/T truth, TCP/F/T
|
||||
transforms, torque conversion, payload/friction calibration, timestamp
|
||||
alignment, and causal acceleration estimation are available.
|
||||
|
||||
## H3/H4 — bilateral mapping and final-port energy supervision
|
||||
|
||||
### Contracts already verified
|
||||
|
||||
The v1 stored-array audit verified:
|
||||
|
||||
- fixed-branch \(A\)-port virtual-work error at numerical precision;
|
||||
- same-input proposed-versus-matched-wrench behavior, with their contact
|
||||
normalized-power-mismatch difference approximately zero;
|
||||
- 96/96 tank trials passing the independent final-applied-port audit;
|
||||
- maximum preclip floor deficit of zero;
|
||||
- maximum accounting and software-preclip error of
|
||||
\(6.94\times10^{-18}\) J; and
|
||||
- zero downstream torque modification after the audited projection.
|
||||
|
||||
These are verified implementation contracts, not H3/H4 performance
|
||||
acceptance.
|
||||
|
||||
The v1 free-space power normalizer was only about \(2\times10^{-4}\) J, so its
|
||||
normalized mismatch was not suitable as a primary endpoint. Metric schema v2
|
||||
therefore always reports absolute mismatch and marks normalized H3 values
|
||||
invalid below a 1 mJ activity gate.
|
||||
|
||||
### Stage A outcome
|
||||
|
||||
Stage A scanned feedback strength 0.2/0.35/0.5 and initial headroom
|
||||
0.005/0.020/0.040 J at two wall stiffness levels.
|
||||
|
||||
- 36/36 independent H4 audits passed.
|
||||
- Maximum accounting and software-preclip error was
|
||||
\(1.39\times10^{-17}\) J.
|
||||
- Downstream modification and projected floor deficit were both zero.
|
||||
- \(D_{\mathrm{proj}}\) remained 0.756--0.905, with a mean of 0.837.
|
||||
- Projection intervention occupied 0.328--0.531 of samples.
|
||||
- Every trial reached the configured 80 N contact-force limit.
|
||||
- Slave tracking RMSE was 0.807--1.035 rad.
|
||||
|
||||
The audit therefore verifies the final-port implementation, while the tested
|
||||
contact envelope is unsuitable for selecting a transparency setting. Choosing
|
||||
the numerically smallest \(D_{\mathrm{proj}}\) cell would merely freeze a
|
||||
force-limited, poorly tracked trajectory.
|
||||
|
||||
### H3/H4 decision
|
||||
|
||||
- **Verified contract:** differential-dual virtual work and final-applied-port
|
||||
energy accounting.
|
||||
- **Not freeze-ready:** feedback strength, energy bounds, H4 distortion
|
||||
threshold, contact envelope, network envelope, and H3 superiority margin.
|
||||
- **Stage B not executed:** its configuration explicitly requires an accepted
|
||||
Stage A profile. Running it with the placeholder middle cell would violate
|
||||
the calibration policy.
|
||||
|
||||
The next bilateral calibration must first establish a stable fixture envelope
|
||||
without force limiting or large tracking error, then introduce a separate,
|
||||
controlled energy-challenging excitation. Contact stability and energy-budget
|
||||
excitation must not be forced by the same aggressive trajectory. Only after
|
||||
that gate passes should the directional network screen cover forward/return
|
||||
delay, jitter, and loss.
|
||||
|
||||
## Freeze record
|
||||
|
||||
| Item | Decision | Permitted use now |
|
||||
|---|---|---|
|
||||
| H1 thresholds and SEW superiority margin | Not freeze-ready | trajectory/debug development only |
|
||||
| H2 scaled DLS \(\ell_c=0.2\) m, \(\lambda=0.05\) | Provisional | next synthetic pilot only |
|
||||
| \(A^\top\) virtual-work implementation | Verified contract | deterministic regression gate |
|
||||
| Final-output energy accounting | Verified contract | deterministic regression gate |
|
||||
| H3 normalized endpoint | Definition repaired; margin not frozen | use gated value plus absolute mismatch in calibration |
|
||||
| Feedback gain and tank energy window | Not freeze-ready | continue calibration |
|
||||
| Bilateral Stage B network matrix | Blocked by Stage A selection gate | do not execute as a locked or confirmatory study |
|
||||
|
||||
## Required next execution order
|
||||
|
||||
1. Redesign and rerun H1 continuity trajectories.
|
||||
2. Run the H2 provisional setting on a larger held-out synthetic pilot while
|
||||
preserving the 16-group pairing contract.
|
||||
3. Establish a stable, unsaturated fixture/contact envelope.
|
||||
4. Design a distinct bounded energy-challenging excitation and rerun Stage A.
|
||||
5. Replace Stage B placeholder haptic values only after Stage A passes.
|
||||
6. Build the prototype measurement chain and complete physical calibration.
|
||||
7. Freeze new `locked` JSON plans, metric configs, margins, exclusions, and a
|
||||
clean source commit before collecting confirmatory data.
|
||||
@ -1,67 +0,0 @@
|
||||
# Calibration and Evidence-Locking Policy
|
||||
|
||||
This document defines when a numerical setting may move from development to a
|
||||
locked experiment. Calibration output is engineering evidence, not a paper
|
||||
result.
|
||||
|
||||
## Evidence states
|
||||
|
||||
Every setting or implementation contract receives one of four states:
|
||||
|
||||
1. **Verified contract**: a deterministic identity or accounting invariant has
|
||||
passed an independent reconstruction from stored arrays.
|
||||
2. **Provisional numerical setting**: calibration supports using the setting in
|
||||
another calibration or pilot, but not in a locked experiment.
|
||||
3. **Frozen setting**: the value, selection rule, analysis configuration, source
|
||||
commit, and admissible operating envelope are fixed before locked data are
|
||||
inspected.
|
||||
4. **Not freeze-ready**: the calibration design is confounded, lacks the
|
||||
required comparison, or has an unacceptable safety/transparency trade-off.
|
||||
|
||||
## Freeze gate
|
||||
|
||||
A setting can be frozen only if all of the following are true:
|
||||
|
||||
- trials are generated from a clean, immutable Git commit;
|
||||
- paired methods receive identical trajectory, model, sensor, and network
|
||||
inputs where the hypothesis requires pairing;
|
||||
- calibration instances are genuinely distinct rather than timing-only
|
||||
repetitions;
|
||||
- failure, invalidity, timeout, clipping, intervention, and exclusion reasons
|
||||
are machine-readable and retained;
|
||||
- the primary endpoint is numerically well-defined for the selected stratum;
|
||||
- the selected value is supported by a parameter scan or an external physical
|
||||
calibration, not by a single untested level;
|
||||
- tail behavior and failed trials are reviewed before the value is selected;
|
||||
- the locked configuration and independent metric configuration receive new
|
||||
hashes after the decision.
|
||||
|
||||
## H1--H4 decision boundaries
|
||||
|
||||
- **H1**: freeze only after reach clipping, joint-limit stress,
|
||||
low-manipulability stress, and valid branch-continuity trajectories are
|
||||
separately identifiable. The experimental unit is a complete trajectory.
|
||||
- **H2**: numerical calibration may select a robust DLS region, but physical
|
||||
wrench-accuracy claims require an independent six-axis F/T reference,
|
||||
timestamp calibration, payload/friction calibration, and a causal
|
||||
acceleration estimate.
|
||||
- **H3**: the fixed-branch virtual-work identity is a deterministic contract.
|
||||
Actual closed-loop power mismatch is a different performance endpoint and
|
||||
must not be replaced by the identity check. Near-zero-power trials require a
|
||||
preregistered normalizer gate or an absolute mismatch outcome.
|
||||
- **H4**: passing the energy-accounting audit verifies implementation
|
||||
correctness only. Energy capacity and feedback gain remain not freeze-ready
|
||||
if the projection distortion is excessive or the tested network/contact
|
||||
envelope is incomplete.
|
||||
|
||||
## Data handling
|
||||
|
||||
Raw batches remain under `output/experiments/` and are intentionally ignored by
|
||||
Git because they can be regenerated from the plan, source commit, and recorded
|
||||
seeds. The source-controlled audit records plan hashes, source-data hashes,
|
||||
anomalies, and freeze decisions. No calibration value may be copied into the
|
||||
manuscript Results section.
|
||||
|
||||
Locked experiments must use a new batch directory and must never overwrite a
|
||||
calibration batch. A locked run is invalid if the worktree is dirty or if its
|
||||
recorded source and model hashes do not match the approved lock record.
|
||||
@ -1,75 +0,0 @@
|
||||
{
|
||||
"kind": "calibration_decision",
|
||||
"decision_date": "2026-07-27",
|
||||
"scope": "pre-prototype numerical and rigid-body simulation only",
|
||||
"raw_source_commit": "4503a12bf10886902e69d4be3874a76d3be577d6",
|
||||
"paper_results_authorized": false,
|
||||
"audit_artifact": {
|
||||
"path": "outputs/calibration-20260727/calibration_audit_2026-07-27.xlsx",
|
||||
"sha256": "24df38f67ba1aa428928348561b452db181f963ed30ae18a570aabc4bd8ad920",
|
||||
"worksheets": 13,
|
||||
"formula_error_count": 0,
|
||||
"visual_inspection_completed": true
|
||||
},
|
||||
"batches": {
|
||||
"h1_v2": {
|
||||
"path": "output/experiments/h1-calibration-v2-clean",
|
||||
"completed_trials": 60,
|
||||
"failed_trials": 0,
|
||||
"plan_hash": "e86b4aec90912954fdee97834dee08dd630647a89000a24402fa56f25b3aea83",
|
||||
"metric_configuration_hash": "2be0e67feda3f862c3510b3318af47437977c2b87a17ab810ce68a49212d5215",
|
||||
"row_hash": "9e274b01db38c97112ecd1e7b50050d5c342cf316a22ea9b2d2c0ea4acf347d4",
|
||||
"source_data_sha256": "85110ad917d6b933656571b15c764b26d6f8215bea6d6baa338d759ed51fc0ee"
|
||||
},
|
||||
"h2_v2": {
|
||||
"path": "output/experiments/h2-calibration-v2-clean",
|
||||
"completed_trials": 576,
|
||||
"failed_trials": 0,
|
||||
"plan_hash": "87f797edb5d2d6378a42b95616fca8ed733d0c81de5e22c422b006ead7071b12",
|
||||
"metric_configuration_hash": "caaf8c3b1c8180eb19afb98d986e8a54b8a897bd1ab706f78e39c41441932a31",
|
||||
"row_hash": "fcac031716e967d3832d8eb6f770f580bd68bb6c8f715d55d04b5878f86f0fbf",
|
||||
"source_data_sha256": "e3a9bc29003193b559fbfc595ce2595b19a29a7135b9f14057128a8c5f504710"
|
||||
},
|
||||
"bilateral_stage_a_v2": {
|
||||
"path": "output/experiments/bilateral-calibration-v2-energy-clean",
|
||||
"completed_trials": 36,
|
||||
"failed_trials": 0,
|
||||
"plan_hash": "c10fa2cd84392f9dbf62809b0960bfb38d1e7aac4791ef0345a7353c45d8e062",
|
||||
"metric_configuration_hash": "37ca1e72ae55eb2a4594ef8e6395302d76ebfea87c4bca43615e86b3eeb89ec2",
|
||||
"row_hash": "3f3f7f09387f02fe88c1b070815bd108f7e6bf63a091861ae62c1fac1dcdf421",
|
||||
"source_data_sha256": {
|
||||
"h3": "baa82701ea4c3e116657b9ef4d12aa795849957934f9dc5d58d5811405936da7",
|
||||
"h4": "47a9cc05f22724edf078a525e31ace6332b6048ab36ed72a005a50e8ecb79453",
|
||||
"bilateral_diagnostics": "a765cb6cc87c8fc89849c4d0a5d2560a768769169caa625baf41ddfd18f7af63"
|
||||
}
|
||||
}
|
||||
},
|
||||
"decisions": {
|
||||
"h1": {
|
||||
"status": "not_freeze_ready",
|
||||
"reason": "SEW and primary task-priority IK tied on C_r in all 15 pairs and no valid trajectory produced D_r=1",
|
||||
"next_gate": "valid branch-transition trajectories plus a declared timing budget"
|
||||
},
|
||||
"h2": {
|
||||
"status": "provisional_numerical_setting",
|
||||
"method": "scaled_dls",
|
||||
"characteristic_length_m": 0.2,
|
||||
"damping": 0.05,
|
||||
"permitted_use": "next synthetic pilot only",
|
||||
"physical_claim_authorized": false
|
||||
},
|
||||
"h3_virtual_work": {
|
||||
"status": "verified_contract",
|
||||
"performance_margin_frozen": false
|
||||
},
|
||||
"h4_final_port_accounting": {
|
||||
"status": "verified_contract",
|
||||
"gain_and_energy_window_frozen": false
|
||||
},
|
||||
"bilateral_stage_a": {
|
||||
"status": "not_freeze_ready",
|
||||
"reason": "all cells reached the 80 N force limit and had 0.807-1.035 rad slave tracking RMSE",
|
||||
"stage_b_authorized": false
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Loading…
Reference in New Issue
Block a user