commit 2effd7b88da64dffac0148c631fd09629a35f0d2 Author: xtkuang <87661715@qq.com> Date: Mon Jul 27 12:29:49 2026 +0800 Initial reproducible teleoperation paper and simulation diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dbc8578 --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# Local environments and Python build products +.venv/ +**/__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ + +# IDE and Codex-local state +.idea/ +.agents/ +.codex/ + +# Generated simulation, analysis, and scratch outputs +output/ +tmp/ +code/logs/ +code/assets/ +code/test/MUJOCO_LOG.TXT + +# Patent drafts and third-party prior-art documents are not part of the +# reproducible paper/code package and may contain unpublished material. +patant/ + +# LaTeX build products and vendor instructions +paper/exoskeleton/Transactions-instructions-only.pdf +# The canonical manuscript is IEEEtran/main2.tex. Keep the historical draft +# and its unused illustrative/result figures local to avoid ambiguous evidence. +paper/exoskeleton/IEEEtran/main.tex +paper/exoskeleton/assets/energy_tank_dynamics.png +paper/exoskeleton/assets/est_errors.png +paper/exoskeleton/assets/fig_alpha.png +paper/exoskeleton/assets/fig_energy.png +paper/exoskeleton/assets/fig_metrics_barchart.png +paper/exoskeleton/assets/fig_tau_fb_norm.png +paper/exoskeleton/assets/sew_error_hist.png +paper/exoskeleton/assets/sew_error_scatter.png +paper/**/*.aux +paper/**/*.bbl +paper/**/*.blg +paper/**/*.fdb_latexmk +paper/**/*.fls +paper/**/*.log +paper/**/*.out +paper/**/*.synctex.gz +paper/**/*.toc diff --git a/README.md b/README.md new file mode 100644 index 0000000..8246870 --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# Heterogeneous 7-DoF Bilateral Teleoperation + +This repository contains the manuscript, canonical robot models, pre-prototype +closed-loop simulator, and reproducible evidence pipeline for heterogeneous +7-DoF master--slave teleoperation. + +The current implementation supports numerical and simulation evidence only. +It must not be used to claim physical wrench accuracy, hardware stability, or +human-subject performance before the corresponding locked hardware studies are +completed. + +## Repository layout + +- `code/core/`: SEW retargeting, H1 baselines, wrench estimation, feedback + mapping, network emulation, command allocation, and passivity supervision. +- `code/simulate_closed_loop.py`: rigid-body bilateral simulation using + `master_7dof.urdf` and `real_slave_7dof.urdf`. +- `code/experiments/`: immutable plans, paired random streams, atomic trial + logging, manifests, validation, and executable H1--H4 adapters. +- `code/analysis/`: independent endpoint reconstruction and paper source-data + generation. +- `code/config/experiments/`: smoke, calibration, and locked-template study + specifications. +- `paper/exoskeleton/IEEEtran/main2.tex`: canonical manuscript source. + +## Reproducible environment + +Python 3.10 is required. From the repository root: + +```bash +uv sync --locked +``` + +The lock file pins the Pinocchio-compatible `urdfdom` and `tinyxml2` ABIs. + +## Verification + +```bash +PYTHONDONTWRITEBYTECODE=1 \ +MPLCONFIGDIR=/tmp/exoskeleton-mpl-cache \ +XDG_CACHE_HOME=/tmp/exoskeleton-xdg-cache \ +.venv/bin/python -m unittest discover -s code/test -p 'test_*.py' -v +``` + +## Minimal H1 smoke study + +```bash +.venv/bin/exo-experiment plan \ + --spec code/config/experiments/h1_smoke.json \ + --output /tmp/h1-plan.json + +.venv/bin/exo-experiment run \ + --plan /tmp/h1-plan.json \ + --batch-dir output/experiments/h1-smoke \ + --executor experiments.executors:execute_h1_retargeting + +.venv/bin/exo-experiment validate \ + --batch-dir output/experiments/h1-smoke + +.venv/bin/exo-paper-artifacts \ + --batch-dir output/experiments/h1-smoke \ + --metric-config code/config/experiments/metrics_h1_calibration.json +``` + +Equivalent executor/config pairs are documented in +`code/experiments/README.md`. + +## Manuscript build + +Compile from `paper/exoskeleton` so the `assets/` paths resolve: + +```bash +cd paper/exoskeleton +latexmk -pdf -interaction=nonstopmode -halt-on-error IEEEtran/main2.tex +``` + +## Evidence locking + +Calibration and pilot studies may run without Git provenance. A `locked` study +requires a clean Git worktree, an immutable commit, and matching hashes for the +environment lock and canonical URDFs. Restoring a locked batch from a different +commit or modified source files is rejected by design. diff --git a/code/analysis/__init__.py b/code/analysis/__init__.py new file mode 100644 index 0000000..aa0d4f8 --- /dev/null +++ b/code/analysis/__init__.py @@ -0,0 +1,17 @@ +"""Independent derivation of trial-level evidence and paper source data.""" + +from .metrics import ( + audit_h4_energy, + compute_h1_composite, + compute_h2_wrench_metrics, + compute_h3_power_mismatch, + derive_trial_metrics, +) + +__all__ = [ + "audit_h4_energy", + "compute_h1_composite", + "compute_h2_wrench_metrics", + "compute_h3_power_mismatch", + "derive_trial_metrics", +] diff --git a/code/analysis/make_paper_artifacts.py b/code/analysis/make_paper_artifacts.py new file mode 100644 index 0000000..1662028 --- /dev/null +++ b/code/analysis/make_paper_artifacts.py @@ -0,0 +1,259 @@ +"""Generate versioned trial metrics and paper source-data tables.""" + +from __future__ import annotations + +import argparse +import csv +import json +import os +import tempfile +from pathlib import Path +from typing import Any, Mapping, Sequence + +import numpy as np + +from experiments.hashing import file_sha256, stable_hash, to_jsonable +from experiments.io import atomic_write_json, atomic_write_jsonl, utc_now +from experiments.plan import load_document +from experiments.schema import SCHEMA_VERSION, STORAGE_FORMATS +from experiments.validate import require_valid_batch + +from .metrics import derive_trial_metrics + + +def _method_selected( + family: str, + family_configuration: Mapping[str, Any], + method_id: str, +) -> bool: + """Apply explicit per-family method filters without silent typos.""" + include = family_configuration.get("include_methods") + exclude = family_configuration.get("exclude_methods") + for name, value in ( + ("include_methods", include), + ("exclude_methods", exclude), + ): + if value is not None and ( + not isinstance(value, list) + or any(not isinstance(item, str) or not item for item in value) + ): + raise ValueError(f"{family}.{name} must be a list of method IDs") + if include is not None and method_id not in include: + return False + if exclude is not None and method_id in exclude: + return False + return True + + +def _trial_metric_configuration( + configuration: Mapping[str, Any], + method_id: str, +) -> dict[str, Any] | None: + enabled = configuration.get("enabled") + if not isinstance(enabled, list) or not enabled: + raise ValueError("metric configuration needs a non-empty enabled list") + selected = [] + trial_configuration: dict[str, Any] = {} + for family in enabled: + if not isinstance(family, str) or not family: + raise ValueError("enabled metric family names must be non-empty strings") + family_configuration = configuration.get(family, {}) + if not isinstance(family_configuration, Mapping): + raise ValueError(f"{family} metric configuration must be a mapping") + if _method_selected(family, family_configuration, method_id): + selected.append(family) + trial_configuration[family] = dict(family_configuration) + if not selected: + return None + trial_configuration["enabled"] = selected + return trial_configuration + + +def _scalar_cell(value: Any) -> Any: + if value is None or isinstance(value, (str, int, float, bool)): + return value + return json.dumps( + to_jsonable(value), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ) + + +def _identity_row(trial: Mapping[str, Any]) -> dict[str, Any]: + trajectory = trial["trajectory"] + method = trial["method"] + row = { + "trial_id": trial["trial_id"], + "pair_id": trial["pair_id"], + "study_id": trial["study_id"], + "split": trial["split"], + "method_id": method["method_id"], + "trajectory_id": trajectory["trajectory_id"], + "trajectory_family": trajectory.get("family", ""), + "replicate": trial["replicate"], + } + for name, value in sorted(trial.get("factors", {}).items()): + row[f"factor_{name}"] = _scalar_cell(value) + return row + + +def _atomic_write_csv(path: Path, rows: Sequence[Mapping[str, Any]]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + identity_order = [ + "trial_id", + "pair_id", + "study_id", + "split", + "method_id", + "trajectory_id", + "trajectory_family", + "replicate", + ] + all_fields = {key for row in rows for key in row} + fieldnames = [name for name in identity_order if name in all_fields] + fieldnames.extend(sorted(all_fields - set(fieldnames))) + fd, temporary_name = tempfile.mkstemp( + prefix=f".{path.name}.", suffix=".tmp", dir=path.parent + ) + temporary = Path(temporary_name) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="") as stream: + writer = csv.DictWriter(stream, fieldnames=fieldnames) + writer.writeheader() + for row in rows: + writer.writerow( + {name: _scalar_cell(row.get(name)) for name in fieldnames} + ) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + except Exception: + temporary.unlink(missing_ok=True) + raise + + +def generate_paper_source_data( + batch_dir: Path, + metric_configuration: Mapping[str, Any], + *, + output_root: Path | None = None, +) -> dict[str, Any]: + """Recompute all configured metrics and emit traceable source-data tables.""" + batch_dir = Path(batch_dir) + require_valid_batch(batch_dir, require_complete=True) + plan = load_document(batch_dir / "plan.json") + output_root = batch_dir if output_root is None else Path(output_root) + derived_dir = output_root / "derived" + source_dir = output_root / "paper" / "source_data" + derived_dir.mkdir(parents=True, exist_ok=True) + source_dir.mkdir(parents=True, exist_ok=True) + + rows: list[dict[str, Any]] = [] + input_files: dict[str, str] = {} + for trial in plan["trials"]: + trial_dir = batch_dir / "raw" / trial["trial_id"] + sample_path = trial_dir / "samples.npz" + input_files[str(sample_path.relative_to(batch_dir))] = file_sha256(sample_path) + 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"] + trial_configuration = _trial_metric_configuration( + metric_configuration, + method_id, + ) + metrics = ( + {} + if trial_configuration is None + else derive_trial_metrics(samples, trial_configuration) + ) + rows.append({**_identity_row(trial), **metrics}) + + metric_path = derived_dir / "trial_metrics.jsonl" + atomic_write_jsonl(metric_path, rows) + table_files: dict[str, str] = {} + family_row_counts: dict[str, int] = {} + for family in metric_configuration["enabled"]: + family_rows: list[dict[str, Any]] = [] + prefix = f"{family}_" + for row in rows: + if not any(key.startswith(prefix) for key in row): + continue + selected = { + key: value + for key, value in row.items() + if key in { + "trial_id", + "pair_id", + "study_id", + "split", + "method_id", + "trajectory_id", + "trajectory_family", + "replicate", + } + or key.startswith("factor_") + or key.startswith(prefix) + } + family_rows.append(selected) + if not family_rows: + raise ValueError( + f"metric family {family!r} selected no methods in this plan" + ) + table_path = source_dir / f"{family}.csv" + _atomic_write_csv(table_path, family_rows) + table_files[str(table_path.relative_to(output_root))] = file_sha256(table_path) + family_row_counts[family] = len(family_rows) + + manifest = { + "kind": "paper_artifact_manifest", + "schema_version": SCHEMA_VERSION, + "created_utc": utc_now(), + "source_batch": str(batch_dir), + "source_plan_hash": plan["plan_hash"], + "metric_configuration": to_jsonable(metric_configuration), + "metric_configuration_hash": stable_hash( + metric_configuration, prefix="metric-configuration" + ), + "row_count": len(rows), + "family_row_counts": family_row_counts, + "row_hash": stable_hash(rows, prefix="trial-metric-rows"), + "storage_formats": { + "trial_metrics": STORAGE_FORMATS["trial_metrics"], + "paper_source_data": STORAGE_FORMATS["paper_source_data"], + }, + "inputs": input_files, + "outputs": { + str(metric_path.relative_to(output_root)): file_sha256(metric_path), + **table_files, + }, + } + manifest_path = output_root / "paper" / "artifact_manifest.json" + atomic_write_json(manifest_path, manifest) + return manifest + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Recompute trial metrics and paper source-data tables" + ) + parser.add_argument("--batch-dir", type=Path, required=True) + parser.add_argument("--metric-config", type=Path, required=True) + parser.add_argument("--output-root", type=Path, default=None) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + configuration = load_document(args.metric_config) + manifest = generate_paper_source_data( + args.batch_dir, + configuration, + output_root=args.output_root, + ) + print(json.dumps(manifest, indent=2, ensure_ascii=False, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/code/analysis/metrics.py b/code/analysis/metrics.py new file mode 100644 index 0000000..5b7bd9c --- /dev/null +++ b/code/analysis/metrics.py @@ -0,0 +1,636 @@ +"""Independent H1--H4 trial metrics. + +These functions consume stored arrays only. They deliberately do not import +the simulation or controller implementations, so paper endpoints can be +reconstructed independently from raw evidence. +""" + +from __future__ import annotations + +from typing import Any, Mapping + +import numpy as np + + +METRIC_SCHEMA_VERSION = "1.0.0" + + +class MetricError(ValueError): + pass + + +def _vector(value: Any, name: str, *, dtype=float) -> np.ndarray: + array = np.asarray(value, dtype=dtype) + if array.ndim != 1 or array.size == 0: + raise MetricError(f"{name} must be a non-empty one-dimensional array") + return array + + +def _matrix(value: Any, name: str, columns: int | None = None) -> np.ndarray: + array = np.asarray(value, dtype=float) + if array.ndim != 2 or array.shape[0] == 0: + raise MetricError(f"{name} must be a non-empty two-dimensional array") + if columns is not None and array.shape[1] != columns: + raise MetricError(f"{name} must have {columns} columns") + return array + + +def _same_rows(named: Mapping[str, np.ndarray]) -> int: + counts = {name: value.shape[0] for name, value in named.items()} + if len(set(counts.values())) != 1: + raise MetricError(f"sample counts differ: {counts}") + return next(iter(counts.values())) + + +def _finite(array: np.ndarray, name: str) -> None: + if not np.all(np.isfinite(array)): + raise MetricError(f"{name} contains non-finite values") + + +def _wrapped_delta(array: np.ndarray) -> np.ndarray: + return (array + np.pi) % (2.0 * np.pi) - np.pi + + +def compute_h1_composite( + *, + mapping_valid: Any, + position_error_m: Any, + orientation_error_rad: Any, + q_slave: Any, + swivel_angle_rad: Any, + master_step_norm: Any, + position_threshold_m: float, + orientation_threshold_rad: float, + joint_step_threshold_rad: float, + swivel_step_threshold_rad: float, + input_step_threshold_rad: float, + accepted: Any | None = None, + commanded_reset: Any | None = None, + degeneracy_transition: Any | None = None, +) -> dict[str, Any]: + """Compute the trajectory-level H1 ``F_r``, ``D_r``, and ``C_r``.""" + valid = _vector(mapping_valid, "mapping_valid", dtype=bool) + e_position = _vector(position_error_m, "position_error_m") + e_orientation = _vector(orientation_error_rad, "orientation_error_rad") + slave = _matrix(q_slave, "q_slave") + swivel = _vector(swivel_angle_rad, "swivel_angle_rad") + input_step = _vector(master_step_norm, "master_step_norm") + n = _same_rows( + { + "mapping_valid": valid, + "position_error_m": e_position, + "orientation_error_rad": e_orientation, + "q_slave": slave, + "swivel_angle_rad": swivel, + "master_step_norm": input_step, + } + ) + accepted_array = ( + np.ones(n, dtype=bool) + if accepted is None + else _vector(accepted, "accepted", dtype=bool) + ) + reset = ( + np.zeros(n, dtype=bool) + if commanded_reset is None + else _vector(commanded_reset, "commanded_reset", dtype=bool) + ) + degeneracy = ( + np.zeros(n, dtype=bool) + if degeneracy_transition is None + else _vector( + degeneracy_transition, + "degeneracy_transition", + dtype=bool, + ) + ) + _same_rows( + { + "accepted": accepted_array, + "commanded_reset": reset, + "degeneracy_transition": degeneracy, + "mapping_valid": valid, + } + ) + for array, name in ( + (e_position, "position_error_m"), + (e_orientation, "orientation_error_rad"), + (slave, "q_slave"), + (swivel, "swivel_angle_rad"), + (input_step, "master_step_norm"), + ): + _finite(array, name) + thresholds = { + "position_threshold_m": position_threshold_m, + "orientation_threshold_rad": orientation_threshold_rad, + "joint_step_threshold_rad": joint_step_threshold_rad, + "swivel_step_threshold_rad": swivel_step_threshold_rad, + "input_step_threshold_rad": input_step_threshold_rad, + } + if any(not np.isfinite(value) or value < 0.0 for value in thresholds.values()): + raise MetricError("H1 thresholds must be finite and non-negative") + + failure_mask = accepted_array & ( + ~valid + | (e_position > position_threshold_m) + | (e_orientation > orientation_threshold_rad) + ) + if n > 1: + joint_step = np.max( + np.abs(_wrapped_delta(slave[1:] - slave[:-1])), + axis=1, + ) + swivel_step = np.abs(_wrapped_delta(swivel[1:] - swivel[:-1])) + eligible = ( + accepted_array[1:] + & accepted_array[:-1] + & valid[1:] + & valid[:-1] + & (input_step[1:] <= input_step_threshold_rad) + & ~reset[1:] + & ~degeneracy[1:] + ) + discontinuity_mask = eligible & ( + (joint_step > joint_step_threshold_rad) + | (swivel_step > swivel_step_threshold_rad) + ) + else: + joint_step = np.empty(0, dtype=float) + swivel_step = np.empty(0, dtype=float) + eligible = np.empty(0, dtype=bool) + discontinuity_mask = np.empty(0, dtype=bool) + + F_r = int(np.any(failure_mask)) + D_r = int(np.any(discontinuity_mask)) + return { + "h1_F_r": F_r, + "h1_D_r": D_r, + "h1_C_r": max(F_r, D_r), + "h1_failure_sample_count": int(np.sum(failure_mask)), + "h1_discontinuity_sample_count": int(np.sum(discontinuity_mask)), + "h1_eligible_increment_count": int(np.sum(eligible)), + "h1_mapping_valid_fraction": float(np.mean(valid[accepted_array])) + if np.any(accepted_array) + else 0.0, + "h1_max_position_error_m": float(np.max(e_position[accepted_array])) + if np.any(accepted_array) + else 0.0, + "h1_max_orientation_error_rad": float(np.max(e_orientation[accepted_array])) + if np.any(accepted_array) + else 0.0, + "h1_max_joint_step_rad": float(np.max(joint_step)) + if joint_step.size + else 0.0, + "h1_max_swivel_step_rad": float(np.max(swivel_step)) + if swivel_step.size + else 0.0, + } + + +def compute_h2_wrench_metrics( + wrench_estimated: Any, + wrench_reference: Any, + *, + sample_mask: Any | None = None, +) -> dict[str, Any]: + estimate = _matrix(wrench_estimated, "wrench_estimated", columns=6) + reference = _matrix(wrench_reference, "wrench_reference", columns=6) + n = _same_rows({"wrench_estimated": estimate, "wrench_reference": reference}) + _finite(estimate, "wrench_estimated") + _finite(reference, "wrench_reference") + mask = ( + np.ones(n, dtype=bool) + if sample_mask is None + else _vector(sample_mask, "sample_mask", dtype=bool) + ) + if mask.shape[0] != n or not np.any(mask): + raise MetricError("H2 sample_mask must select at least one aligned sample") + error = estimate[mask] - reference[mask] + force_norm = np.linalg.norm(error[:, :3], axis=1) + moment_norm = np.linalg.norm(error[:, 3:], axis=1) + 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))), + "h2_force_mae_N": float(np.mean(force_norm)), + "h2_moment_mae_Nm": float(np.mean(moment_norm)), + "h2_force_bias_xyz_N": np.mean(error[:, :3], axis=0).tolist(), + "h2_moment_bias_xyz_Nm": np.mean(error[:, 3:], axis=0).tolist(), + } + + +def _dt_array(dt: Any, count: int) -> np.ndarray: + array = np.asarray(dt, dtype=float) + if array.ndim == 0: + array = np.full(count, float(array), dtype=float) + if array.shape != (count,): + raise MetricError(f"dt must be scalar or have shape ({count},)") + if not np.all(np.isfinite(array)) or np.any(array <= 0.0): + raise MetricError("dt must contain finite positive intervals") + return array + + +def compute_h3_power_mismatch( + *, + tau_master_raw: Any, + qd_master: Any, + tau_slave_source: Any, + qd_slave_source: Any, + dt: Any, + force_scale: float = 1.0, + epsilon_energy_J: float = 1e-12, + return_valid: Any | None = None, +) -> dict[str, Any]: + tau_m = _matrix(tau_master_raw, "tau_master_raw") + qd_m = _matrix(qd_master, "qd_master") + tau_s = _matrix(tau_slave_source, "tau_slave_source") + qd_s = _matrix(qd_slave_source, "qd_slave_source") + n = _same_rows( + { + "tau_master_raw": tau_m, + "qd_master": qd_m, + "tau_slave_source": tau_s, + "qd_slave_source": qd_s, + } + ) + if tau_m.shape != qd_m.shape or tau_s.shape != qd_s.shape: + raise MetricError("torque and velocity shapes must match at each port") + for array, name in ( + (tau_m, "tau_master_raw"), + (qd_m, "qd_master"), + (tau_s, "tau_slave_source"), + (qd_s, "qd_slave_source"), + ): + _finite(array, name) + intervals = _dt_array(dt, n) + chi = ( + np.ones(n, dtype=bool) + if return_valid is None + else _vector(return_valid, "return_valid", dtype=bool) + ) + if chi.shape[0] != n: + raise MetricError("return_valid length mismatch") + force_scale = float(force_scale) + epsilon_energy_J = float(epsilon_energy_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") + + 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)) + denominator = float( + 0.5 + * np.sum((np.abs(power_master) + np.abs(scaled_slave)) * intervals) + + epsilon_energy_J + ) + return { + "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)), + "h3_master_raw_work_J": float(np.sum(power_master * intervals)), + "h3_scaled_slave_work_J": float(np.sum(scaled_slave * intervals)), + } + + +def audit_h4_energy( + *, + energy_before_J: Any, + energy_after_J: Any, + tau_candidate: Any, + tau_applied: Any, + tau_accepted: Any | None = None, + qd_master: Any, + dt: Any, + energy_min_J: float, + energy_max_J: float, + epsilon_torque_impulse_Nms: float = 1e-12, + audit_tolerance_J: float = 1e-10, + software_preclip_J: Any | None = None, +) -> dict[str, Any]: + energy_before = _vector(energy_before_J, "energy_before_J") + energy_after = _vector(energy_after_J, "energy_after_J") + candidate = _matrix(tau_candidate, "tau_candidate") + applied = _matrix(tau_applied, "tau_applied") + accepted = ( + applied + if tau_accepted is None + else _matrix(tau_accepted, "tau_accepted") + ) + velocity = _matrix(qd_master, "qd_master") + n = _same_rows( + { + "energy_before_J": energy_before, + "energy_after_J": energy_after, + "tau_candidate": candidate, + "tau_applied": applied, + "tau_accepted": accepted, + "qd_master": velocity, + } + ) + if not ( + candidate.shape + == applied.shape + == accepted.shape + == velocity.shape + ): + raise MetricError( + "candidate, projected, accepted torque, and velocity must align" + ) + for array, name in ( + (energy_before, "energy_before_J"), + (energy_after, "energy_after_J"), + (candidate, "tau_candidate"), + (applied, "tau_applied"), + (accepted, "tau_accepted"), + (velocity, "qd_master"), + ): + _finite(array, name) + intervals = _dt_array(dt, n) + energy_min_J = float(energy_min_J) + energy_max_J = float(energy_max_J) + tolerance = float(audit_tolerance_J) + epsilon_tau = float(epsilon_torque_impulse_Nms) + if not ( + np.isfinite(energy_min_J) + and np.isfinite(energy_max_J) + and 0.0 <= energy_min_J <= energy_max_J + ): + raise MetricError("invalid energy bounds") + if not np.isfinite(tolerance) or tolerance < 0.0: + raise MetricError("audit_tolerance_J must be finite and non-negative") + if not np.isfinite(epsilon_tau) or epsilon_tau <= 0.0: + raise MetricError( + "epsilon_torque_impulse_Nms must be finite and positive" + ) + + # The deterministic gate is reconstructed at the actuator-accepted port. + # If no drive readback exists, callers may omit tau_accepted and explicitly + # declare that projected == accepted for that backend. + applied_power = np.einsum("ij,ij->i", accepted, velocity) + candidate_power = np.einsum("ij,ij->i", candidate, velocity) + reconstructed_preclip = energy_before - applied_power * intervals + reconstructed_after = np.clip( + reconstructed_preclip, + energy_min_J, + energy_max_J, + ) + deficit = np.maximum(0.0, energy_min_J - reconstructed_preclip) + accounting_error = np.abs(energy_after - reconstructed_after) + + shadow_energy = float(energy_before[0]) + shadow_min = shadow_energy + for power, interval in zip(candidate_power, intervals): + shadow_energy = min(energy_max_J, shadow_energy - float(power * interval)) + shadow_min = min(shadow_min, shadow_energy) + shadow_deficit = max(0.0, energy_min_J - shadow_min) + projected_deficit = float(np.max(deficit)) + + numerator = float( + np.sum(np.linalg.norm(accepted - candidate, axis=1) * intervals) + ) + denominator = float( + np.sum(np.linalg.norm(candidate, axis=1) * intervals) + epsilon_tau + ) + preclip_discrepancy = 0.0 + if software_preclip_J is not None: + software = _vector(software_preclip_J, "software_preclip_J") + if software.shape[0] != n: + raise MetricError("software_preclip_J length mismatch") + _finite(software, "software_preclip_J") + preclip_discrepancy = float( + np.max(np.abs(software - reconstructed_preclip)) + ) + audit_pass = ( + projected_deficit <= tolerance + and float(np.max(accounting_error)) <= tolerance + and preclip_discrepancy <= tolerance + ) + return { + "h4_energy_audit_pass": bool(audit_pass), + "h4_preclip_floor_deficit_max_J": projected_deficit, + "h4_energy_accounting_max_error_J": float(np.max(accounting_error)), + "h4_software_preclip_max_error_J": preclip_discrepancy, + "h4_shadow_floor_deficit_J": float(shadow_deficit), + "h4_projected_floor_deficit_J": projected_deficit, + "h4_delta_B_J": float(shadow_deficit - projected_deficit), + "h4_D_proj": numerator / denominator, + "h4_projection_distortion_numerator_Nms": numerator, + "h4_projection_distortion_normalizer_Nms": denominator, + "h4_shadow_energy_min_J": float(shadow_min), + "h4_downstream_modification_max_Nm": float( + np.max(np.linalg.norm(accepted - applied, axis=1)) + ), + } + + +def _field( + samples: Mapping[str, Any], + fields: Mapping[str, str], + logical_name: str, + default_name: str, + *, + optional: bool = False, +) -> Any: + stored_name = fields.get(logical_name, default_name) + if stored_name not in samples: + if optional: + return None + raise MetricError( + f"missing sample field {stored_name!r} for {logical_name!r}" + ) + return samples[stored_name] + + +def derive_trial_metrics( + samples: Mapping[str, Any], + configuration: Mapping[str, Any], +) -> dict[str, Any]: + """Derive configured endpoint families from one stored trial.""" + enabled = configuration.get("enabled") + if not isinstance(enabled, list) or not enabled: + raise MetricError("metric configuration needs a non-empty enabled list") + result: dict[str, Any] = {"metric_schema_version": METRIC_SCHEMA_VERSION} + for family in enabled: + family_config = configuration.get(family, {}) + fields = family_config.get("fields", {}) + if family == "h1": + thresholds = family_config.get("thresholds", {}) + required_thresholds = ( + "position_threshold_m", + "orientation_threshold_rad", + "joint_step_threshold_rad", + "swivel_step_threshold_rad", + "input_step_threshold_rad", + ) + missing = [name for name in required_thresholds if name not in thresholds] + if missing: + raise MetricError(f"missing H1 thresholds: {missing}") + pose_success = _field( + samples, fields, "pose_success", "map_pose_success" + ) + differential_valid = _field( + samples, + fields, + "differential_valid", + "map_differential_valid", + ) + mapping_valid = np.asarray(pose_success, dtype=bool) & np.asarray( + differential_valid, dtype=bool + ) + result.update( + compute_h1_composite( + mapping_valid=mapping_valid, + position_error_m=_field( + samples, + fields, + "position_error_m", + "map_position_error_m", + ), + orientation_error_rad=_field( + samples, + fields, + "orientation_error_rad", + "map_orientation_error_rad", + ), + q_slave=_field(samples, fields, "q_slave", "map_q_slave"), + swivel_angle_rad=_field( + samples, + fields, + "swivel_angle_rad", + "map_swivel_angle_rad", + ), + master_step_norm=_field( + samples, + fields, + "master_step_norm", + "map_master_step_norm", + ), + accepted=_field( + samples, + fields, + "accepted", + "map_accepted", + optional=True, + ), + commanded_reset=_field( + samples, + fields, + "commanded_reset", + "map_commanded_reset", + optional=True, + ), + degeneracy_transition=_field( + samples, + fields, + "degeneracy_transition", + "map_degeneracy_transition", + optional=True, + ), + **thresholds, + ) + ) + elif family == "h2": + result.update( + compute_h2_wrench_metrics( + _field( + samples, + fields, + "wrench_estimated", + "wrench_estimated", + ), + _field( + samples, + fields, + "wrench_reference", + "wrench_reference", + ), + sample_mask=_field( + samples, + fields, + "sample_mask", + "wrench_sample_mask", + optional=True, + ), + ) + ) + elif family == "h3": + result.update( + compute_h3_power_mismatch( + tau_master_raw=_field( + samples, fields, "tau_master_raw", "tau_master_raw" + ), + qd_master=_field(samples, fields, "qd_master", "qd_master"), + tau_slave_source=_field( + samples, + fields, + "tau_slave_source", + "tau_slave_source", + ), + qd_slave_source=_field( + samples, + fields, + "qd_slave_source", + "qd_slave_source", + ), + dt=_field(samples, fields, "dt", "dt"), + return_valid=_field( + samples, + fields, + "return_valid", + "return_valid", + optional=True, + ), + force_scale=family_config.get("force_scale", 1.0), + epsilon_energy_J=family_config.get( + "epsilon_energy_J", 1e-12 + ), + ) + ) + elif family == "h4": + result.update( + audit_h4_energy( + energy_before_J=_field( + samples, fields, "energy_before_J", "energy_before_J" + ), + energy_after_J=_field( + samples, fields, "energy_after_J", "energy_after_J" + ), + tau_candidate=_field( + samples, fields, "tau_candidate", "tau_master_candidate" + ), + tau_applied=_field( + samples, fields, "tau_applied", "tau_master_applied" + ), + tau_accepted=_field( + samples, + fields, + "tau_accepted", + "tau_master_accepted", + optional=True, + ), + qd_master=_field(samples, fields, "qd_master", "qd_master"), + dt=_field(samples, fields, "dt", "dt"), + software_preclip_J=_field( + samples, + fields, + "software_preclip_J", + "energy_preclip_J", + optional=True, + ), + 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 + ), + audit_tolerance_J=family_config.get( + "audit_tolerance_J", 1e-10 + ), + ) + ) + else: + raise MetricError(f"unknown metric family {family!r}") + return result diff --git a/code/config/config.yaml b/code/config/config.yaml new file mode 100644 index 0000000..c183327 --- /dev/null +++ b/code/config/config.yaml @@ -0,0 +1,110 @@ +# Paths are resolved relative to the code/ directory by legacy entry points. +master_urdf: "config/master_7dof.urdf" +slave_urdf: "config/real_slave_7dof.urdf" + +m_base_frame: master_base +m_shoulder_frame: master_shoulder +m_elbow_frame: master_forearm +m_wrist_frame: master_wrist +m_ee_frame: master_ee +s_base_frame: PELVIS_S +s_shoulder_frame: R_SHOULDER_R_S +s_elbow_frame: R_ELBOW_R_S +s_wrist_frame: R_WRIST_R_S +# The physical URDF has no calibrated TCP/FT frame yet. R_WRIST_R_S is the +# chain-end frame; the new simulator adds a documented R_EE_SIM frame at run +# time. Replace it with the measured TCP before hardware experiments. +s_ee_frame: R_WRIST_R_S + +master_joint_names: [ + "master_shoulder_pitch_joint", + "master_shoulder_yaw_joint", + "master_shoulder_roll_joint", + "master_elbow_flex_joint", + "master_wrist_roll_joint", + "master_wrist_yaw_joint", + "master_wrist_pitch_joint", +] + +sew_mapper: + slave_joint_names: [ + "R_SHOULDER_P", + "R_SHOULDER_R", + "R_SHOULDER_Y", + "R_ELBOW_R", + "R_WRIST_P", + "R_WRIST_Y", + "R_WRIST_R", + ] + shoulder_axis_order: "yxy" + shoulder_signs: [-1.0, 1.0, -1.0] + wrist_axis_order: "yzx" + wrist_signs: [-1.0, 1.0, 1.0] + elbow_axis_local: [1.0, 0.0, 0.0] + up_dir: [0, 0, 1.0] + eps_clip: 0.001 + fd_eps: 0.0001 + +interaction_est: + lambda_damp: 0.001 + +haptic_render: + feedback_strength: 1.2 + E_init: 1.0 + E_max: 8.0 + alpha_floor: 0.0 + alpha_ceil: 1.0 + E0: 1.0 + +simulation: + dt: 0.002 + duration: 4.0 + mapping_hz: 50.0 + seed: 7 + # Independent forward/return channels. G0c may override these with a + # frozen trace; the defaults reproduce the former fixed-return-delay case. + forward_delay_s: 0.0 + feedback_delay_s: 0.080 + forward_jitter_s: 0.0 + return_jitter_s: 0.0 + forward_packet_loss: 0.0 + return_packet_loss: 0.0 + forward_timeout_s: 0.20 + return_timeout_s: 0.20 + # A small smooth 3-cycle contact probe exposes delayed-channel activity + # without causing joint, torque, or position-limit events. + contact_probe_fraction: 0.03 + contact_probe_cycles: 3.0 + slave_tcp_frame: R_EE_SIM + slave_tcp_offset: [-0.01212, -0.17655, 0.07506] + wall_fraction: 0.55 + wall_stiffness: 800.0 + wall_damping: 45.0 + wall_force_limit: 80.0 + wall_transition_depth: 0.0001 + master_kp: [196.0, 196.0, 144.0, 256.0, 100.0, 100.0, 81.0] + master_kd: [28.0, 28.0, 24.0, 32.0, 20.0, 20.0, 18.0] + slave_kp: [900.0, 900.0, 676.0, 1156.0, 400.0, 324.0, 324.0] + slave_kd: [54.0, 54.0, 46.8, 61.2, 36.0, 32.4, 32.4] + master_acceleration_limits: [100.0, 100.0, 120.0, 120.0, 160.0, 160.0, 160.0] + slave_acceleration_limits: [120.0, 120.0, 150.0, 150.0, 180.0, 180.0, 180.0] + master_tracking_effort_fraction: 0.65 + slave_tracking_effort_fraction: 0.70 + velocity_limit_fraction: 0.80 + soft_limit_buffer: 0.12 + feedback_strength: 0.50 + haptic_filter_alpha: 0.222 + haptic_torque_limits: [6.0, 6.0, 4.0, 3.0, 1.0, 1.0, 1.0] + haptic_rate_limits: [150.0, 150.0, 100.0, 80.0, 30.0, 30.0, 30.0] + energy_min: 0.050 + energy_max: 0.055 + energy_initial: 0.050 + sensor_noise_std: 0.001 + # G0c numerical scaling only. Replace with a calibration-selected value + # before locked hardware experiments. + wrench_characteristic_length_m: 0.30 + wrench_scaled_damping: 0.001 + sensor_bias: [0.080, -0.050, 0.035, -0.025, 0.015, -0.010, 0.020] + bias_calibration_samples: 200 + joint_limit_margin: 0.00001 + differential_step: 0.0001 diff --git a/code/config/dual_arm.mjcf b/code/config/dual_arm.mjcf new file mode 100644 index 0000000..13cb17d --- /dev/null +++ b/code/config/dual_arm.mjcf @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/code/config/dual_arm.urdf b/code/config/dual_arm.urdf new file mode 100644 index 0000000..3bb4689 --- /dev/null +++ b/code/config/dual_arm.urdf @@ -0,0 +1,553 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/code/config/experiments/bilateral_calibration.json b/code/config/experiments/bilateral_calibration.json new file mode 100644 index 0000000..ba21f9f --- /dev/null +++ b/code/config/experiments/bilateral_calibration.json @@ -0,0 +1,50 @@ +{ + "study_id": "g0c_bilateral_calibration_v1", + "split": "calibration", + "root_seed": 2026072703, + "replicates": 2, + "methods": [ + "proposed_energy", + "proposed_no_energy", + "direct_energy", + "matched_wrench_energy", + "proposed_popc" + ], + "trajectories": [ + { + "id": "free_space_roundtrip", + "family": "free_space", + "duration_s": 1.2, + "contact_probe_fraction": 0.0 + }, + { + "id": "contact_roundtrip", + "family": "contact_roundtrip", + "duration_s": 1.2, + "contact_probe_fraction": 0.02 + } + ], + "factors": { + "map_policy": [ + "current", + "source_stamped" + ], + "return_delay_s": [ + 0.0, + 0.08 + ], + "return_jitter_s": [ + 0.0 + ], + "return_packet_loss": [ + 0.0 + ], + "wall_damping": [ + 30.0 + ], + "wall_stiffness": [ + 400.0, + 800.0 + ] + } +} diff --git a/code/config/experiments/bilateral_locked_template.json b/code/config/experiments/bilateral_locked_template.json new file mode 100644 index 0000000..469b0bb --- /dev/null +++ b/code/config/experiments/bilateral_locked_template.json @@ -0,0 +1,55 @@ +{ + "study_id": "g0c_bilateral_locked_template_v1", + "split": "locked", + "root_seed": 2026072713, + "replicates": 20, + "methods": [ + "proposed_energy", + "proposed_no_energy", + "direct_energy", + "matched_wrench_energy", + "proposed_popc" + ], + "trajectories": [ + { + "id": "free_space_roundtrip_locked", + "family": "free_space", + "duration_s": 4.0, + "contact_probe_fraction": 0.0 + }, + { + "id": "contact_roundtrip_locked", + "family": "contact_roundtrip", + "duration_s": 4.0, + "contact_probe_fraction": 0.03 + } + ], + "factors": { + "map_policy": [ + "current", + "source_stamped" + ], + "return_delay_s": [ + 0.0, + 0.04, + 0.08, + 0.12 + ], + "return_jitter_s": [ + 0.0, + 0.004 + ], + "return_packet_loss": [ + 0.0, + 0.02 + ], + "wall_damping": [ + 30.0 + ], + "wall_stiffness": [ + 300.0, + 600.0, + 900.0 + ] + } +} diff --git a/code/config/experiments/g0c_calibration_example.json b/code/config/experiments/g0c_calibration_example.json new file mode 100644 index 0000000..a8ee572 --- /dev/null +++ b/code/config/experiments/g0c_calibration_example.json @@ -0,0 +1,51 @@ +{ + "study_id": "g0c_bilateral_calibration_example", + "split": "calibration", + "root_seed": 20260727, + "replicates": 2, + "methods": [ + "proposed_energy", + "direct_energy", + "proposed_no_energy", + "matched_wrench_energy", + "popc" + ], + "trajectories": [ + { + "trajectory_id": "free_space_001", + "family": "free_space_workspace_sweep" + }, + { + "trajectory_id": "contact_001", + "family": "approach_contact_return" + }, + { + "trajectory_id": "multi_axis_001", + "family": "multi_axis_contact_probe" + }, + { + "trajectory_id": "boundary_001", + "family": "limit_singularity_degeneracy" + } + ], + "factors": { + "delay_ms": [0, 40, 80, 120], + "environment": [ + { + "stiffness_N_per_m": 200, + "damping_Ns_per_m": 15 + }, + { + "stiffness_N_per_m": 500, + "damping_Ns_per_m": 30 + }, + { + "stiffness_N_per_m": 800, + "damping_Ns_per_m": 45 + } + ] + }, + "metadata": { + "status": "example only; do not treat as a locked protocol" + } +} diff --git a/code/config/experiments/h1_calibration.json b/code/config/experiments/h1_calibration.json new file mode 100644 index 0000000..41f9261 --- /dev/null +++ b/code/config/experiments/h1_calibration.json @@ -0,0 +1,34 @@ +{ + "study_id": "g0c_sew_calibration_v1", + "split": "calibration", + "root_seed": 2026072701, + "replicates": 2, + "methods": [ + "sew", + "scaled_joint_space", + "bounded_dls_ik", + "task_priority_ik" + ], + "trajectories": [ + { + "id": "nominal_sweep", + "family": "nominal", + "sample_count": 81 + }, + { + "id": "reach_boundary", + "family": "reach_boundary", + "sample_count": 81 + }, + { + "id": "joint_limit_neighborhood", + "family": "joint_limit", + "sample_count": 81 + }, + { + "id": "low_manipulability", + "family": "low_manipulability", + "sample_count": 81 + } + ] +} diff --git a/code/config/experiments/h1_locked_template.json b/code/config/experiments/h1_locked_template.json new file mode 100644 index 0000000..735e584 --- /dev/null +++ b/code/config/experiments/h1_locked_template.json @@ -0,0 +1,34 @@ +{ + "study_id": "g0c_sew_locked_template_v1", + "split": "locked", + "root_seed": 2026072711, + "replicates": 20, + "methods": [ + "sew", + "scaled_joint_space", + "bounded_dls_ik", + "task_priority_ik" + ], + "trajectories": [ + { + "id": "nominal_sweep_locked", + "family": "nominal", + "sample_count": 161 + }, + { + "id": "reach_boundary_locked", + "family": "reach_boundary", + "sample_count": 161 + }, + { + "id": "joint_limit_neighborhood_locked", + "family": "joint_limit", + "sample_count": 161 + }, + { + "id": "low_manipulability_locked", + "family": "low_manipulability", + "sample_count": 161 + } + ] +} diff --git a/code/config/experiments/h1_smoke.json b/code/config/experiments/h1_smoke.json new file mode 100644 index 0000000..f3be27b --- /dev/null +++ b/code/config/experiments/h1_smoke.json @@ -0,0 +1,19 @@ +{ + "study_id": "g0c_sew_smoke_v1", + "split": "pilot", + "root_seed": 2026072797, + "replicates": 1, + "methods": [ + "sew", + "scaled_joint_space", + "bounded_dls_ik", + "task_priority_ik" + ], + "trajectories": [ + { + "id": "short_nominal", + "family": "nominal", + "sample_count": 9 + } + ] +} diff --git a/code/config/experiments/h2_calibration.json b/code/config/experiments/h2_calibration.json new file mode 100644 index 0000000..23bc1c1 --- /dev/null +++ b/code/config/experiments/h2_calibration.json @@ -0,0 +1,40 @@ +{ + "study_id": "g0c_estimator_sensitivity_calibration_v1", + "split": "calibration", + "root_seed": 2026072702, + "replicates": 2, + "methods": [ + "scaled_dls", + "undamped_svd", + "no_bias", + "no_friction" + ], + "trajectories": [ + { + "id": "dynamic_wrench_sweep", + "family": "synthetic_dynamic", + "sample_count": 256 + } + ], + "factors": { + "characteristic_length_m": [ + 0.3 + ], + "damping": [ + 0.01, + 0.03 + ], + "min_scaled_singular": [ + 0.02, + 0.1 + ], + "model_error_std": [ + 0.0, + 0.02 + ], + "torque_noise_std_Nm": [ + 0.002, + 0.02 + ] + } +} diff --git a/code/config/experiments/h2_locked_template.json b/code/config/experiments/h2_locked_template.json new file mode 100644 index 0000000..e6b6ecd --- /dev/null +++ b/code/config/experiments/h2_locked_template.json @@ -0,0 +1,41 @@ +{ + "study_id": "g0c_estimator_sensitivity_locked_template_v1", + "split": "locked", + "root_seed": 2026072712, + "replicates": 20, + "methods": [ + "scaled_dls", + "undamped_svd", + "no_bias", + "no_friction" + ], + "trajectories": [ + { + "id": "dynamic_wrench_sweep_locked", + "family": "synthetic_dynamic", + "sample_count": 512 + } + ], + "factors": { + "characteristic_length_m": [ + 0.3 + ], + "damping": [ + 0.01, + 0.03 + ], + "min_scaled_singular": [ + 0.01, + 0.05, + 0.2 + ], + "model_error_std": [ + 0.0, + 0.02 + ], + "torque_noise_std_Nm": [ + 0.002, + 0.02 + ] + } +} diff --git a/code/config/experiments/h2_smoke.json b/code/config/experiments/h2_smoke.json new file mode 100644 index 0000000..7d5cc10 --- /dev/null +++ b/code/config/experiments/h2_smoke.json @@ -0,0 +1,36 @@ +{ + "study_id": "g0c_estimator_smoke_v1", + "split": "pilot", + "root_seed": 2026072798, + "replicates": 1, + "methods": [ + "scaled_dls", + "undamped_svd", + "no_bias", + "no_friction" + ], + "trajectories": [ + { + "id": "short_wrench", + "family": "synthetic_dynamic", + "sample_count": 16 + } + ], + "factors": { + "characteristic_length_m": [ + 0.3 + ], + "damping": [ + 0.02 + ], + "min_scaled_singular": [ + 0.05 + ], + "model_error_std": [ + 0.01 + ], + "torque_noise_std_Nm": [ + 0.01 + ] + } +} diff --git a/code/config/experiments/metrics_bilateral.json b/code/config/experiments/metrics_bilateral.json new file mode 100644 index 0000000..dded1b1 --- /dev/null +++ b/code/config/experiments/metrics_bilateral.json @@ -0,0 +1,22 @@ +{ + "enabled": [ + "h3", + "h4" + ], + "h3": { + "force_scale": 1.0, + "epsilon_energy_J": 1e-12 + }, + "h4": { + "include_methods": [ + "proposed_energy", + "direct_energy", + "matched_wrench_energy" + ], + "energy_min_J": 0.05, + "energy_max_J": 0.055, + "epsilon_torque_impulse_Nms": 1e-12, + "audit_tolerance_J": 1e-10 + }, + "status": "H3 includes all bilateral methods; H4 includes tank-supervised methods only" +} diff --git a/code/config/experiments/metrics_h1_calibration.json b/code/config/experiments/metrics_h1_calibration.json new file mode 100644 index 0000000..8c2e769 --- /dev/null +++ b/code/config/experiments/metrics_h1_calibration.json @@ -0,0 +1,15 @@ +{ + "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 + } + }, + "status": "calibration-only thresholds; freeze a new hash before locked runs" +} diff --git a/code/config/experiments/metrics_h2.json b/code/config/experiments/metrics_h2.json new file mode 100644 index 0000000..e23f7f3 --- /dev/null +++ b/code/config/experiments/metrics_h2.json @@ -0,0 +1,6 @@ +{ + "enabled": [ + "h2" + ], + "h2": {} +} diff --git a/code/config/experiments/metrics_h3.json b/code/config/experiments/metrics_h3.json new file mode 100644 index 0000000..d0f7a44 --- /dev/null +++ b/code/config/experiments/metrics_h3.json @@ -0,0 +1,9 @@ +{ + "enabled": [ + "h3" + ], + "h3": { + "force_scale": 1.0, + "epsilon_energy_J": 1e-12 + } +} diff --git a/code/config/experiments/metrics_h3_h4_example.json b/code/config/experiments/metrics_h3_h4_example.json new file mode 100644 index 0000000..48f16ef --- /dev/null +++ b/code/config/experiments/metrics_h3_h4_example.json @@ -0,0 +1,30 @@ +{ + "enabled": ["h3", "h4"], + "h3": { + "force_scale": 1.0, + "epsilon_energy_J": 1e-12, + "fields": { + "tau_master_raw": "tau_master_raw", + "qd_master": "qd_master", + "tau_slave_source": "tau_slave_source", + "qd_slave_source": "qd_slave_source", + "dt": "dt", + "return_valid": "return_valid" + } + }, + "h4": { + "energy_min_J": 0.05, + "energy_max_J": 0.055, + "audit_tolerance_J": 1e-10, + "epsilon_torque_impulse_Nms": 1e-12, + "fields": { + "energy_before_J": "energy_before_J", + "energy_after_J": "energy_after_J", + "software_preclip_J": "energy_preclip_J", + "tau_candidate": "tau_master_candidate", + "tau_applied": "tau_master_applied", + "qd_master": "qd_master", + "dt": "dt" + } + } +} diff --git a/code/config/experiments/metrics_h4_tank.json b/code/config/experiments/metrics_h4_tank.json new file mode 100644 index 0000000..49717d2 --- /dev/null +++ b/code/config/experiments/metrics_h4_tank.json @@ -0,0 +1,17 @@ +{ + "enabled": [ + "h4" + ], + "h4": { + "include_methods": [ + "proposed_energy", + "direct_energy", + "matched_wrench_energy" + ], + "energy_min_J": 0.05, + "energy_max_J": 0.055, + "epsilon_torque_impulse_Nms": 1e-12, + "audit_tolerance_J": 1e-10 + }, + "status": "independent final-applied-port audit for tank-supervised trials" +} diff --git a/code/config/experiments/smoke.json b/code/config/experiments/smoke.json new file mode 100644 index 0000000..fcbe147 --- /dev/null +++ b/code/config/experiments/smoke.json @@ -0,0 +1,34 @@ +{ + "study_id": "g0c_bilateral_smoke_v1", + "split": "pilot", + "root_seed": 2026072799, + "replicates": 1, + "methods": [ + "proposed_energy", + "direct_energy", + "matched_wrench_energy", + "proposed_popc" + ], + "trajectories": [ + { + "id": "short_contact", + "family": "contact_roundtrip", + "duration_s": 0.6, + "contact_probe_fraction": 0.0 + } + ], + "factors": { + "map_policy": [ + "source_stamped" + ], + "return_delay_s": [ + 0.02 + ], + "wall_damping": [ + 45.0 + ], + "wall_stiffness": [ + 800.0 + ] + } +} diff --git a/code/config/hardware.yaml b/code/config/hardware.yaml new file mode 100644 index 0000000..152f46d --- /dev/null +++ b/code/config/hardware.yaml @@ -0,0 +1,17 @@ +bitrate: 1000000 +hz: 500 + +canbus: + - dev_idx: 0 + channel: + - ch_idx: 0 + mode: broadcast + - ch_idx: 1 + mode: singular + - dev_idx: 1 + channel: + - ch_idx: 0 + mode: broadcast + - ch_idx: 1 + mode: broadcast + diff --git a/code/config/master_7dof.mjcf b/code/config/master_7dof.mjcf new file mode 100644 index 0000000..91356d5 --- /dev/null +++ b/code/config/master_7dof.mjcf @@ -0,0 +1,84 @@ + + + \ No newline at end of file diff --git a/code/config/master_7dof.urdf b/code/config/master_7dof.urdf new file mode 100644 index 0000000..4fcf650 --- /dev/null +++ b/code/config/master_7dof.urdf @@ -0,0 +1,208 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/code/config/meshes/L_ELBOW_R_S.STL b/code/config/meshes/L_ELBOW_R_S.STL new file mode 100644 index 0000000..17e4796 Binary files /dev/null and b/code/config/meshes/L_ELBOW_R_S.STL differ diff --git a/code/config/meshes/L_SHOULDER_P_S.STL b/code/config/meshes/L_SHOULDER_P_S.STL new file mode 100644 index 0000000..855e54e Binary files /dev/null and b/code/config/meshes/L_SHOULDER_P_S.STL differ diff --git a/code/config/meshes/L_SHOULDER_R_S.STL b/code/config/meshes/L_SHOULDER_R_S.STL new file mode 100644 index 0000000..1652ea7 Binary files /dev/null and b/code/config/meshes/L_SHOULDER_R_S.STL differ diff --git a/code/config/meshes/L_SHOULDER_Y_S.STL b/code/config/meshes/L_SHOULDER_Y_S.STL new file mode 100644 index 0000000..a966726 Binary files /dev/null and b/code/config/meshes/L_SHOULDER_Y_S.STL differ diff --git a/code/config/meshes/L_WRIST_P_S.STL b/code/config/meshes/L_WRIST_P_S.STL new file mode 100644 index 0000000..10e6156 Binary files /dev/null and b/code/config/meshes/L_WRIST_P_S.STL differ diff --git a/code/config/meshes/L_WRIST_R_S.STL b/code/config/meshes/L_WRIST_R_S.STL new file mode 100644 index 0000000..4255742 Binary files /dev/null and b/code/config/meshes/L_WRIST_R_S.STL differ diff --git a/code/config/meshes/L_WRIST_Y_S.STL b/code/config/meshes/L_WRIST_Y_S.STL new file mode 100644 index 0000000..ae6a7f9 Binary files /dev/null and b/code/config/meshes/L_WRIST_Y_S.STL differ diff --git a/code/config/meshes/NECK_P_S.STL b/code/config/meshes/NECK_P_S.STL new file mode 100644 index 0000000..b294c7f Binary files /dev/null and b/code/config/meshes/NECK_P_S.STL differ diff --git a/code/config/meshes/NECK_R_S.STL b/code/config/meshes/NECK_R_S.STL new file mode 100644 index 0000000..f9e097e Binary files /dev/null and b/code/config/meshes/NECK_R_S.STL differ diff --git a/code/config/meshes/NECK_Y_S.STL b/code/config/meshes/NECK_Y_S.STL new file mode 100644 index 0000000..f1e1b9b Binary files /dev/null and b/code/config/meshes/NECK_Y_S.STL differ diff --git a/code/config/meshes/PELVIS_S.STL b/code/config/meshes/PELVIS_S.STL new file mode 100644 index 0000000..133cf46 Binary files /dev/null and b/code/config/meshes/PELVIS_S.STL differ diff --git a/code/config/meshes/R_ELBOW_R_S.STL b/code/config/meshes/R_ELBOW_R_S.STL new file mode 100644 index 0000000..2fb7b53 Binary files /dev/null and b/code/config/meshes/R_ELBOW_R_S.STL differ diff --git a/code/config/meshes/R_SHOULDER_P_S.STL b/code/config/meshes/R_SHOULDER_P_S.STL new file mode 100644 index 0000000..8bfafb1 Binary files /dev/null and b/code/config/meshes/R_SHOULDER_P_S.STL differ diff --git a/code/config/meshes/R_SHOULDER_R_S.STL b/code/config/meshes/R_SHOULDER_R_S.STL new file mode 100644 index 0000000..b14ad9b Binary files /dev/null and b/code/config/meshes/R_SHOULDER_R_S.STL differ diff --git a/code/config/meshes/R_SHOULDER_Y_S.STL b/code/config/meshes/R_SHOULDER_Y_S.STL new file mode 100644 index 0000000..aea9b41 Binary files /dev/null and b/code/config/meshes/R_SHOULDER_Y_S.STL differ diff --git a/code/config/meshes/R_WRIST_P_S.STL b/code/config/meshes/R_WRIST_P_S.STL new file mode 100644 index 0000000..b48aa44 Binary files /dev/null and b/code/config/meshes/R_WRIST_P_S.STL differ diff --git a/code/config/meshes/R_WRIST_R_S.STL b/code/config/meshes/R_WRIST_R_S.STL new file mode 100644 index 0000000..70a997d Binary files /dev/null and b/code/config/meshes/R_WRIST_R_S.STL differ diff --git a/code/config/meshes/R_WRIST_Y_S.STL b/code/config/meshes/R_WRIST_Y_S.STL new file mode 100644 index 0000000..ef15308 Binary files /dev/null and b/code/config/meshes/R_WRIST_Y_S.STL differ diff --git a/code/config/meshes/base_link.STL b/code/config/meshes/base_link.STL new file mode 100644 index 0000000..f7dae5c Binary files /dev/null and b/code/config/meshes/base_link.STL differ diff --git a/code/config/meshes/index_force_sensor_1.STL b/code/config/meshes/index_force_sensor_1.STL new file mode 100644 index 0000000..b271a2a Binary files /dev/null and b/code/config/meshes/index_force_sensor_1.STL differ diff --git a/code/config/meshes/index_force_sensor_2.STL b/code/config/meshes/index_force_sensor_2.STL new file mode 100644 index 0000000..f056699 Binary files /dev/null and b/code/config/meshes/index_force_sensor_2.STL differ diff --git a/code/config/meshes/index_force_sensor_3.STL b/code/config/meshes/index_force_sensor_3.STL new file mode 100644 index 0000000..7bf3a71 Binary files /dev/null and b/code/config/meshes/index_force_sensor_3.STL differ diff --git a/code/config/meshes/little_force_sensor_1.STL b/code/config/meshes/little_force_sensor_1.STL new file mode 100644 index 0000000..c4bffaf Binary files /dev/null and b/code/config/meshes/little_force_sensor_1.STL differ diff --git a/code/config/meshes/little_force_sensor_2.STL b/code/config/meshes/little_force_sensor_2.STL new file mode 100644 index 0000000..c1d599d Binary files /dev/null and b/code/config/meshes/little_force_sensor_2.STL differ diff --git a/code/config/meshes/little_force_sensor_3.STL b/code/config/meshes/little_force_sensor_3.STL new file mode 100644 index 0000000..f5d06b4 Binary files /dev/null and b/code/config/meshes/little_force_sensor_3.STL differ diff --git a/code/config/meshes/middle_force_sensor_1.STL b/code/config/meshes/middle_force_sensor_1.STL new file mode 100644 index 0000000..d42d830 Binary files /dev/null and b/code/config/meshes/middle_force_sensor_1.STL differ diff --git a/code/config/meshes/middle_force_sensor_2.STL b/code/config/meshes/middle_force_sensor_2.STL new file mode 100644 index 0000000..2feead5 Binary files /dev/null and b/code/config/meshes/middle_force_sensor_2.STL differ diff --git a/code/config/meshes/middle_force_sensor_3.STL b/code/config/meshes/middle_force_sensor_3.STL new file mode 100644 index 0000000..bb35da1 Binary files /dev/null and b/code/config/meshes/middle_force_sensor_3.STL differ diff --git a/code/config/meshes/palm_force_sensor.STL b/code/config/meshes/palm_force_sensor.STL new file mode 100644 index 0000000..60824bf Binary files /dev/null and b/code/config/meshes/palm_force_sensor.STL differ diff --git a/code/config/meshes/right_arm.mjcf b/code/config/meshes/right_arm.mjcf new file mode 100644 index 0000000..211249b --- /dev/null +++ b/code/config/meshes/right_arm.mjcf @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/code/config/meshes/right_arm.urdf b/code/config/meshes/right_arm.urdf new file mode 100644 index 0000000..24b4f9b --- /dev/null +++ b/code/config/meshes/right_arm.urdf @@ -0,0 +1,288 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/code/config/meshes/right_index_1.STL b/code/config/meshes/right_index_1.STL new file mode 100644 index 0000000..c17ff30 Binary files /dev/null and b/code/config/meshes/right_index_1.STL differ diff --git a/code/config/meshes/right_index_2.STL b/code/config/meshes/right_index_2.STL new file mode 100644 index 0000000..3f5e462 Binary files /dev/null and b/code/config/meshes/right_index_2.STL differ diff --git a/code/config/meshes/right_little_1.STL b/code/config/meshes/right_little_1.STL new file mode 100644 index 0000000..cc79788 Binary files /dev/null and b/code/config/meshes/right_little_1.STL differ diff --git a/code/config/meshes/right_little_2.STL b/code/config/meshes/right_little_2.STL new file mode 100644 index 0000000..c42f6d7 Binary files /dev/null and b/code/config/meshes/right_little_2.STL differ diff --git a/code/config/meshes/right_middle_1.STL b/code/config/meshes/right_middle_1.STL new file mode 100644 index 0000000..a6942b7 Binary files /dev/null and b/code/config/meshes/right_middle_1.STL differ diff --git a/code/config/meshes/right_middle_2.STL b/code/config/meshes/right_middle_2.STL new file mode 100644 index 0000000..0f3865f Binary files /dev/null and b/code/config/meshes/right_middle_2.STL differ diff --git a/code/config/meshes/right_ring_1.STL b/code/config/meshes/right_ring_1.STL new file mode 100644 index 0000000..6fc79ca Binary files /dev/null and b/code/config/meshes/right_ring_1.STL differ diff --git a/code/config/meshes/right_ring_2.STL b/code/config/meshes/right_ring_2.STL new file mode 100644 index 0000000..a4cf458 Binary files /dev/null and b/code/config/meshes/right_ring_2.STL differ diff --git a/code/config/meshes/right_thumb_1.STL b/code/config/meshes/right_thumb_1.STL new file mode 100644 index 0000000..a4f441c Binary files /dev/null and b/code/config/meshes/right_thumb_1.STL differ diff --git a/code/config/meshes/right_thumb_2.STL b/code/config/meshes/right_thumb_2.STL new file mode 100644 index 0000000..0b29f9b Binary files /dev/null and b/code/config/meshes/right_thumb_2.STL differ diff --git a/code/config/meshes/right_thumb_3.STL b/code/config/meshes/right_thumb_3.STL new file mode 100644 index 0000000..cf5cab1 Binary files /dev/null and b/code/config/meshes/right_thumb_3.STL differ diff --git a/code/config/meshes/right_thumb_4.STL b/code/config/meshes/right_thumb_4.STL new file mode 100644 index 0000000..bf147e0 Binary files /dev/null and b/code/config/meshes/right_thumb_4.STL differ diff --git a/code/config/meshes/ring_force_sensor_1.STL b/code/config/meshes/ring_force_sensor_1.STL new file mode 100644 index 0000000..40d42a1 Binary files /dev/null and b/code/config/meshes/ring_force_sensor_1.STL differ diff --git a/code/config/meshes/ring_force_sensor_2.STL b/code/config/meshes/ring_force_sensor_2.STL new file mode 100644 index 0000000..0e9e95b Binary files /dev/null and b/code/config/meshes/ring_force_sensor_2.STL differ diff --git a/code/config/meshes/ring_force_sensor_3.STL b/code/config/meshes/ring_force_sensor_3.STL new file mode 100644 index 0000000..32c799b Binary files /dev/null and b/code/config/meshes/ring_force_sensor_3.STL differ diff --git a/code/config/meshes/thumb_force_sensor_1.STL b/code/config/meshes/thumb_force_sensor_1.STL new file mode 100644 index 0000000..9993ba8 Binary files /dev/null and b/code/config/meshes/thumb_force_sensor_1.STL differ diff --git a/code/config/meshes/thumb_force_sensor_2.STL b/code/config/meshes/thumb_force_sensor_2.STL new file mode 100644 index 0000000..ee1fe22 Binary files /dev/null and b/code/config/meshes/thumb_force_sensor_2.STL differ diff --git a/code/config/meshes/thumb_force_sensor_3.STL b/code/config/meshes/thumb_force_sensor_3.STL new file mode 100644 index 0000000..57f9055 Binary files /dev/null and b/code/config/meshes/thumb_force_sensor_3.STL differ diff --git a/code/config/meshes/thumb_force_sensor_4.STL b/code/config/meshes/thumb_force_sensor_4.STL new file mode 100644 index 0000000..1842e2e Binary files /dev/null and b/code/config/meshes/thumb_force_sensor_4.STL differ diff --git a/code/config/real_slave_7dof.urdf b/code/config/real_slave_7dof.urdf new file mode 100644 index 0000000..b71e5ec --- /dev/null +++ b/code/config/real_slave_7dof.urdf @@ -0,0 +1,288 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/code/config/slave_7dof.urdf b/code/config/slave_7dof.urdf new file mode 100644 index 0000000..0fd8bd3 --- /dev/null +++ b/code/config/slave_7dof.urdf @@ -0,0 +1,208 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/code/core/__init__.py b/code/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/code/core/command_allocator.py b/code/core/command_allocator.py new file mode 100644 index 0000000..806fc11 --- /dev/null +++ b/code/core/command_allocator.py @@ -0,0 +1,182 @@ +"""Total-command allocation and accepted haptic-increment reconstruction.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + + +def _vector(value, size: int | None, name: str) -> np.ndarray: + array = np.asarray(value, dtype=float).reshape(-1) + if size is not None and array.shape != (size,): + raise ValueError(f"{name} must have shape ({size},), got {array.shape}") + if array.size == 0 or not np.all(np.isfinite(array)): + raise ValueError(f"{name} must be non-empty and finite") + return array + + +@dataclass(frozen=True) +class PreparedAllocation: + compensation: np.ndarray + haptic_raw: np.ndarray + haptic_candidate: np.ndarray + lower_haptic_bound: np.ndarray + upper_haptic_bound: np.ndarray + compensation_limited: bool + haptic_limited: bool + + +@dataclass(frozen=True) +class AcceptedAllocation: + compensation_requested: np.ndarray + haptic_projected: np.ndarray + total_requested: np.ndarray + total_quantized: np.ndarray + total_accepted: np.ndarray + compensation_accepted: np.ndarray + haptic_accepted: np.ndarray + downstream_modified: bool + quantization_active: bool + derating_active: bool + readback_used: bool + + +class CommandAllocator: + """Reserve total actuator headroom before the final energy projection. + + Call :meth:`prepare` before energy supervision. Pass its + ``haptic_candidate`` through the selected energy supervisor, then call + :meth:`finalize`. The latter never silently claims the projected torque was + accepted: quantization, derating, or drive readback are exposed and the + accepted haptic increment is reconstructed explicitly. + """ + + def __init__( + self, + total_torque_limit: float | np.ndarray, + *, + quantization_step: float | np.ndarray | None = None, + ): + limits = np.asarray(total_torque_limit, dtype=float) + if limits.ndim == 0: + limits = limits.reshape(1) + else: + limits = limits.reshape(-1) + if limits.size == 0 or np.any(~np.isfinite(limits)) or np.any(limits <= 0.0): + raise ValueError("total_torque_limit must contain finite positive values") + self.total_torque_limit = limits + if quantization_step is None: + self.quantization_step = np.zeros_like(limits) + else: + steps = np.asarray(quantization_step, dtype=float) + if steps.ndim == 0: + steps = np.full(limits.size, float(steps)) + else: + steps = steps.reshape(-1) + if steps.shape != limits.shape or np.any(~np.isfinite(steps)) or np.any(steps < 0.0): + raise ValueError( + "quantization_step must be scalar or match torque limits" + ) + self.quantization_step = steps + + def prepare( + self, + compensation: np.ndarray, + haptic_raw: np.ndarray, + ) -> PreparedAllocation: + size = self.total_torque_limit.size + comp_raw = _vector(compensation, size, "compensation") + haptic = _vector(haptic_raw, size, "haptic_raw") + comp = np.clip( + comp_raw, -self.total_torque_limit, self.total_torque_limit + ) + lower = -self.total_torque_limit - comp + upper = self.total_torque_limit - comp + candidate = np.clip(haptic, lower, upper) + return PreparedAllocation( + compensation=comp.copy(), + haptic_raw=haptic.copy(), + haptic_candidate=candidate, + lower_haptic_bound=lower, + upper_haptic_bound=upper, + compensation_limited=bool(np.any(np.abs(comp - comp_raw) > 1e-12)), + haptic_limited=bool(np.any(np.abs(candidate - haptic) > 1e-12)), + ) + + def finalize( + self, + prepared: PreparedAllocation, + haptic_projected: np.ndarray, + *, + derating: float | np.ndarray = 1.0, + accepted_total: np.ndarray | None = None, + accepted_compensation: np.ndarray | None = None, + ) -> AcceptedAllocation: + size = self.total_torque_limit.size + projected = _vector(haptic_projected, size, "haptic_projected") + tolerance = 1e-12 + if np.any(projected < prepared.lower_haptic_bound - tolerance) or np.any( + projected > prepared.upper_haptic_bound + tolerance + ): + raise ValueError( + "haptic_projected exceeds reserved headroom; projection must " + "not enlarge the prepared candidate" + ) + total_requested = prepared.compensation + projected + + quantized = total_requested.copy() + active_steps = self.quantization_step > 0.0 + quantized[active_steps] = ( + np.round(quantized[active_steps] / self.quantization_step[active_steps]) + * self.quantization_step[active_steps] + ) + + derating_vector = np.asarray(derating, dtype=float) + if derating_vector.ndim == 0: + derating_vector = np.full(size, float(derating_vector)) + else: + derating_vector = derating_vector.reshape(-1) + if ( + derating_vector.shape != (size,) + or np.any(~np.isfinite(derating_vector)) + or np.any(derating_vector < 0.0) + or np.any(derating_vector > 1.0) + ): + raise ValueError("derating must be scalar/vector in [0, 1]") + accepted_limits = derating_vector * self.total_torque_limit + expected_accepted = np.clip(quantized, -accepted_limits, accepted_limits) + readback_used = accepted_total is not None + accepted = ( + expected_accepted + if accepted_total is None + else _vector(accepted_total, size, "accepted_total") + ) + if np.any(np.abs(accepted) > accepted_limits + tolerance): + raise ValueError("accepted_total exceeds the declared derated limit") + + comp_accepted = ( + prepared.compensation + if accepted_compensation is None + else _vector( + accepted_compensation, size, "accepted_compensation" + ) + ) + haptic_accepted = accepted - comp_accepted + return AcceptedAllocation( + compensation_requested=prepared.compensation.copy(), + haptic_projected=projected.copy(), + total_requested=total_requested, + total_quantized=quantized, + total_accepted=accepted.copy(), + compensation_accepted=comp_accepted.copy(), + haptic_accepted=haptic_accepted, + downstream_modified=bool( + np.any(np.abs(haptic_accepted - projected) > tolerance) + ), + quantization_active=bool( + np.any(np.abs(quantized - total_requested) > tolerance) + ), + derating_active=bool(np.any(derating_vector < 1.0 - tolerance)), + readback_used=readback_used, + ) diff --git a/code/core/energy_audit.py b/code/core/energy_audit.py new file mode 100644 index 0000000..15614a5 --- /dev/null +++ b/code/core/energy_audit.py @@ -0,0 +1,152 @@ +"""Independent H3/H4 metrics reconstructed from immutable raw logs.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + + +@dataclass(frozen=True) +class EnergyAuditResult: + energy_before: np.ndarray + energy_preclip: np.ndarray + energy_after: np.ndarray + floor_deficit: np.ndarray + projected_shadow_energy: np.ndarray + candidate_shadow_energy: np.ndarray + candidate_power: np.ndarray + accepted_power: np.ndarray + max_floor_deficit: float + projected_floor_deficit: float + candidate_floor_deficit: float + delta_B: float + projection_distortion: float + released_energy: float + preclip_log_max_error: float | None + downstream_modification_max: float + + @property + def passed_floor_gate(self) -> bool: + return self.max_floor_deficit <= 1e-12 + + +def _sample_matrix(value, name: str) -> np.ndarray: + array = np.asarray(value, dtype=float) + if array.ndim != 2 or array.shape[0] == 0 or array.shape[1] == 0: + raise ValueError(f"{name} must be a non-empty 2-D array") + if not np.all(np.isfinite(array)): + raise ValueError(f"{name} must contain only finite values") + return array + + +def audit_haptic_energy( + *, + tau_candidate: np.ndarray, + tau_projected: np.ndarray, + tau_accepted: np.ndarray, + qd_master: np.ndarray, + dt: float | np.ndarray, + energy_initial: float, + energy_min: float, + energy_max: float, + epsilon_tau: float = 1e-12, + logged_preclip: np.ndarray | None = None, +) -> EnergyAuditResult: + """Recompute the H4 budget and transparency endpoints. + + The deterministic budget gate uses ``tau_accepted`` because this is the + actual actuator-port increment. The same-run shadow uses the logged + pre-projection candidate and never feeds the counterfactual back into the + simulated trajectory. + """ + candidate = _sample_matrix(tau_candidate, "tau_candidate") + projected = _sample_matrix(tau_projected, "tau_projected") + accepted = _sample_matrix(tau_accepted, "tau_accepted") + velocity = _sample_matrix(qd_master, "qd_master") + if not ( + candidate.shape == projected.shape == accepted.shape == velocity.shape + ): + raise ValueError("all torque and velocity arrays must have equal shapes") + count = candidate.shape[0] + delta = np.broadcast_to(np.asarray(dt, dtype=float), (count,)).copy() + if np.any(delta <= 0.0) or not np.all(np.isfinite(delta)): + raise ValueError("dt must contain finite positive values") + if not ( + np.isfinite(energy_initial) + and np.isfinite(energy_min) + and np.isfinite(energy_max) + and energy_min <= energy_initial <= energy_max + ): + raise ValueError("energy values must satisfy min <= initial <= max") + if epsilon_tau <= 0.0: + raise ValueError("epsilon_tau must be positive") + + candidate_power = np.einsum("ij,ij->i", candidate, velocity) + accepted_power = np.einsum("ij,ij->i", accepted, velocity) + energy_before = np.empty(count, dtype=float) + energy_preclip = np.empty(count, dtype=float) + energy_after = np.empty(count, dtype=float) + floor_deficit = np.empty(count, dtype=float) + energy = float(energy_initial) + for index in range(count): + energy_before[index] = energy + preclip = energy - accepted_power[index] * delta[index] + energy_preclip[index] = preclip + floor_deficit[index] = max(0.0, energy_min - preclip) + energy = float(np.clip(preclip, energy_min, energy_max)) + energy_after[index] = energy + + candidate_shadow = np.empty(count + 1, dtype=float) + projected_shadow = np.empty(count + 1, dtype=float) + candidate_shadow[0] = energy_initial + projected_shadow[0] = energy_initial + for index in range(count): + candidate_shadow[index + 1] = min( + energy_max, + candidate_shadow[index] - candidate_power[index] * delta[index], + ) + projected_shadow[index + 1] = min( + energy_max, + projected_shadow[index] - accepted_power[index] * delta[index], + ) + candidate_B = float( + np.max(np.maximum(0.0, energy_min - candidate_shadow)) + ) + projected_B = float( + np.max(np.maximum(0.0, energy_min - projected_shadow)) + ) + numerator = float( + np.sum(np.linalg.norm(accepted - candidate, axis=1) * delta) + ) + denominator = float( + np.sum(np.linalg.norm(candidate, axis=1) * delta) + epsilon_tau + ) + + preclip_error = None + if logged_preclip is not None: + logged = np.asarray(logged_preclip, dtype=float).reshape(-1) + if logged.shape != (count,) or not np.all(np.isfinite(logged)): + raise ValueError("logged_preclip must be finite with one value per sample") + preclip_error = float(np.max(np.abs(logged - energy_preclip))) + + return EnergyAuditResult( + energy_before=energy_before, + energy_preclip=energy_preclip, + energy_after=energy_after, + floor_deficit=floor_deficit, + projected_shadow_energy=projected_shadow, + candidate_shadow_energy=candidate_shadow, + candidate_power=candidate_power, + accepted_power=accepted_power, + max_floor_deficit=float(np.max(floor_deficit)), + projected_floor_deficit=projected_B, + candidate_floor_deficit=candidate_B, + delta_B=candidate_B - projected_B, + projection_distortion=numerator / denominator, + released_energy=float(np.sum(accepted_power * delta)), + preclip_log_max_error=preclip_error, + downstream_modification_max=float( + np.max(np.linalg.norm(accepted - projected, axis=1)) + ), + ) diff --git a/code/core/estimation_signals.py b/code/core/estimation_signals.py new file mode 100644 index 0000000..c64986c --- /dev/null +++ b/code/core/estimation_signals.py @@ -0,0 +1,425 @@ +"""Causal signal and calibration components for interaction estimation. + +These components are deliberately independent of the Pinocchio model. A +locked experiment can therefore replay the exact same measured signals through +the nominal, no-bias, and no-friction conditions without changing its dynamics +or sample selection. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Optional + +import numpy as np + +from .wrench_solver import ( + ScaledDLSSolver, + WrenchSolveResult, + WrenchSolver, +) + + +def _finite_vector(value, name: str, size: Optional[int] = None) -> np.ndarray: + vector = np.asarray(value, dtype=float).reshape(-1) + if size is not None and vector.shape != (size,): + raise ValueError(f"{name} must have shape ({size},), got {vector.shape}") + if vector.size == 0 or not np.all(np.isfinite(vector)): + raise ValueError(f"{name} must be a non-empty finite vector") + return vector + + +def _immutable_vector(value, name: str, size: Optional[int] = None) -> np.ndarray: + vector = _finite_vector(value, name, size).copy() + vector.setflags(write=False) + return vector + + +class AccelerationEstimateStatus(str, Enum): + INITIALIZING = "initializing" + VALID = "valid" + INVALID_DT = "invalid_dt" + + +@dataclass(frozen=True) +class AccelerationEstimatorConfig: + """Frozen one-pole differentiator configuration.""" + + cutoff_hz: float + minimum_dt_s: float + maximum_dt_s: float + + def __post_init__(self) -> None: + values = (self.cutoff_hz, self.minimum_dt_s, self.maximum_dt_s) + if any(not np.isfinite(value) or value <= 0.0 for value in values): + raise ValueError("acceleration settings must be finite and positive") + if self.minimum_dt_s >= self.maximum_dt_s: + raise ValueError("minimum_dt_s must be smaller than maximum_dt_s") + + @property + def nominal_filter_delay_s(self) -> float: + """Continuous one-pole time constant, logged as a delay descriptor.""" + return 1.0 / (2.0 * np.pi * self.cutoff_hz) + + +@dataclass(frozen=True) +class AccelerationEstimate: + acceleration: np.ndarray + status: AccelerationEstimateStatus + dt_s: float + raw_acceleration: np.ndarray + + def __post_init__(self) -> None: + acceleration = _immutable_vector(self.acceleration, "acceleration") + raw = _immutable_vector( + self.raw_acceleration, "raw_acceleration", acceleration.size + ) + if not np.isfinite(self.dt_s): + raise ValueError("dt_s must be finite") + if ( + self.status is not AccelerationEstimateStatus.INVALID_DT + and self.dt_s < 0.0 + ): + raise ValueError("dt_s must be non-negative for a valid estimate") + object.__setattr__(self, "acceleration", acceleration) + object.__setattr__(self, "raw_acceleration", raw) + + +class CausalAccelerationEstimator: + """Backward difference followed by a causal one-pole low-pass filter.""" + + def __init__( + self, + joint_count: int, + config: AccelerationEstimatorConfig, + ) -> None: + if int(joint_count) <= 0: + raise ValueError("joint_count must be positive") + self.joint_count = int(joint_count) + self.config = config + self.reset() + + def reset(self) -> None: + self._previous_velocity: Optional[np.ndarray] = None + self._previous_time_s: Optional[float] = None + self._filtered = np.zeros(self.joint_count, dtype=float) + + def update( + self, joint_velocity: np.ndarray, timestamp_s: float + ) -> AccelerationEstimate: + velocity = _finite_vector( + joint_velocity, "joint_velocity", self.joint_count + ) + timestamp = float(timestamp_s) + if not np.isfinite(timestamp): + raise ValueError("timestamp_s must be finite") + + if self._previous_velocity is None: + self._previous_velocity = velocity.copy() + self._previous_time_s = timestamp + zeros = np.zeros(self.joint_count, dtype=float) + return AccelerationEstimate( + acceleration=zeros, + raw_acceleration=zeros, + status=AccelerationEstimateStatus.INITIALIZING, + dt_s=0.0, + ) + + dt = timestamp - float(self._previous_time_s) + if dt < self.config.minimum_dt_s or dt > self.config.maximum_dt_s: + # Invalid timestamps are observable and do not contaminate state. + return AccelerationEstimate( + acceleration=self._filtered, + raw_acceleration=np.zeros(self.joint_count), + status=AccelerationEstimateStatus.INVALID_DT, + dt_s=dt, + ) + + raw = (velocity - self._previous_velocity) / dt + alpha = 1.0 - np.exp(-2.0 * np.pi * self.config.cutoff_hz * dt) + self._filtered = self._filtered + alpha * (raw - self._filtered) + self._previous_velocity = velocity.copy() + self._previous_time_s = timestamp + return AccelerationEstimate( + acceleration=self._filtered, + raw_acceleration=raw, + status=AccelerationEstimateStatus.VALID, + dt_s=dt, + ) + + +@dataclass(frozen=True) +class JointFrictionCalibration: + """Per-joint smooth Coulomb plus viscous friction coefficients.""" + + coulomb_nm: np.ndarray + viscous_nm_per_rad_s: np.ndarray + smoothing_velocity_rad_s: float = 0.02 + + def __post_init__(self) -> None: + coulomb = _immutable_vector(self.coulomb_nm, "coulomb_nm") + viscous = _immutable_vector( + self.viscous_nm_per_rad_s, + "viscous_nm_per_rad_s", + coulomb.size, + ) + if np.any(coulomb < 0.0) or np.any(viscous < 0.0): + raise ValueError("friction magnitudes must be non-negative") + smoothing = float(self.smoothing_velocity_rad_s) + if not np.isfinite(smoothing) or smoothing <= 0.0: + raise ValueError( + "smoothing_velocity_rad_s must be finite and positive" + ) + object.__setattr__(self, "coulomb_nm", coulomb) + object.__setattr__(self, "viscous_nm_per_rad_s", viscous) + object.__setattr__(self, "smoothing_velocity_rad_s", smoothing) + + @property + def joint_count(self) -> int: + return int(self.coulomb_nm.size) + + def torque(self, joint_velocity: np.ndarray) -> np.ndarray: + velocity = _finite_vector( + joint_velocity, "joint_velocity", self.joint_count + ) + return ( + self.coulomb_nm + * np.tanh(velocity / self.smoothing_velocity_rad_s) + + self.viscous_nm_per_rad_s * velocity + ) + + @classmethod + def zeros( + cls, + joint_count: int, + *, + smoothing_velocity_rad_s: float = 0.02, + ) -> "JointFrictionCalibration": + if int(joint_count) <= 0: + raise ValueError("joint_count must be positive") + zeros = np.zeros(int(joint_count), dtype=float) + return cls(zeros, zeros, smoothing_velocity_rad_s) + + @classmethod + def fit( + cls, + velocity_samples: np.ndarray, + friction_torque_samples: np.ndarray, + *, + smoothing_velocity_rad_s: float = 0.02, + ) -> "JointFrictionCalibration": + """Fit independent smooth-Coulomb/viscous models by least squares.""" + velocity = np.asarray(velocity_samples, dtype=float) + torque = np.asarray(friction_torque_samples, dtype=float) + if velocity.ndim != 2 or torque.shape != velocity.shape: + raise ValueError( + "velocity_samples and friction_torque_samples must share " + "shape (n_samples, n_joints)" + ) + if velocity.shape[0] < 2 or velocity.shape[1] < 1: + raise ValueError("at least two samples and one joint are required") + if not np.all(np.isfinite(velocity)) or not np.all(np.isfinite(torque)): + raise ValueError("friction calibration samples must be finite") + smoothing = float(smoothing_velocity_rad_s) + if not np.isfinite(smoothing) or smoothing <= 0.0: + raise ValueError( + "smoothing_velocity_rad_s must be finite and positive" + ) + + coulomb = np.zeros(velocity.shape[1], dtype=float) + viscous = np.zeros(velocity.shape[1], dtype=float) + for joint in range(velocity.shape[1]): + design = np.column_stack( + ( + np.tanh(velocity[:, joint] / smoothing), + velocity[:, joint], + ) + ) + coefficients, _, _, _ = np.linalg.lstsq( + design, torque[:, joint], rcond=None + ) + # Negative coefficients are non-physical and signal insufficient + # excitation; clipping makes that decision explicit and stable. + coulomb[joint], viscous[joint] = np.maximum(coefficients, 0.0) + return cls(coulomb, viscous, smoothing) + + +@dataclass(frozen=True) +class WrenchEstimatorCalibration: + """Immutable parameters selected only from the calibration split.""" + + joint_bias_nm: np.ndarray + friction: JointFrictionCalibration + characteristic_length_m: float + damping: float + relative_rank_tolerance: float = 1e-9 + calibration_id: str = "unversioned" + + def __post_init__(self) -> None: + bias = _immutable_vector( + self.joint_bias_nm, "joint_bias_nm", self.friction.joint_count + ) + positive = ( + self.characteristic_length_m, + self.damping, + self.relative_rank_tolerance, + ) + if any(not np.isfinite(value) or value <= 0.0 for value in positive): + raise ValueError("wrench calibration scalars must be positive") + if self.relative_rank_tolerance >= 1.0: + raise ValueError("relative_rank_tolerance must be smaller than one") + if not isinstance(self.calibration_id, str) or not self.calibration_id: + raise ValueError("calibration_id must be a non-empty string") + object.__setattr__(self, "joint_bias_nm", bias) + + def build_dls_solver(self) -> ScaledDLSSolver: + return ScaledDLSSolver( + self.characteristic_length_m, + self.damping, + relative_rank_tolerance=self.relative_rank_tolerance, + ) + + +@dataclass(frozen=True) +class ResidualAblation: + """Explicit H2 switches; both default to the nominal estimator.""" + + use_bias_correction: bool = True + use_friction_compensation: bool = True + + @classmethod + def no_bias(cls) -> "ResidualAblation": + return cls(use_bias_correction=False) + + @classmethod + def no_friction(cls) -> "ResidualAblation": + return cls(use_friction_compensation=False) + + +@dataclass(frozen=True) +class ResidualTorqueBreakdown: + residual_nm: np.ndarray + raw_residual_nm: np.ndarray + applied_bias_nm: np.ndarray + applied_friction_nm: np.ndarray + ablation: ResidualAblation + + def __post_init__(self) -> None: + residual = _immutable_vector(self.residual_nm, "residual_nm") + size = residual.size + object.__setattr__( + self, + "raw_residual_nm", + _immutable_vector(self.raw_residual_nm, "raw_residual_nm", size), + ) + object.__setattr__( + self, + "applied_bias_nm", + _immutable_vector(self.applied_bias_nm, "applied_bias_nm", size), + ) + object.__setattr__( + self, + "applied_friction_nm", + _immutable_vector( + self.applied_friction_nm, "applied_friction_nm", size + ), + ) + object.__setattr__(self, "residual_nm", residual) + + +class ResidualTorqueModel: + """Apply frozen bias/friction calibration with replayable ablations.""" + + def __init__(self, calibration: WrenchEstimatorCalibration) -> None: + self.calibration = calibration + + def compute( + self, + measured_torque_nm: np.ndarray, + rigid_body_torque_nm: np.ndarray, + joint_velocity_rad_s: np.ndarray, + *, + ablation: ResidualAblation = ResidualAblation(), + ) -> ResidualTorqueBreakdown: + size = self.calibration.friction.joint_count + measured = _finite_vector( + measured_torque_nm, "measured_torque_nm", size + ) + rigid = _finite_vector( + rigid_body_torque_nm, "rigid_body_torque_nm", size + ) + velocity = _finite_vector( + joint_velocity_rad_s, "joint_velocity_rad_s", size + ) + raw = measured - rigid + bias = ( + self.calibration.joint_bias_nm + if ablation.use_bias_correction + else np.zeros(size) + ) + friction = ( + self.calibration.friction.torque(velocity) + if ablation.use_friction_compensation + else np.zeros(size) + ) + return ResidualTorqueBreakdown( + residual_nm=raw - bias - friction, + raw_residual_nm=raw, + applied_bias_nm=bias, + applied_friction_nm=friction, + ablation=ablation, + ) + + +@dataclass(frozen=True) +class CalibratedWrenchEstimate: + residual: ResidualTorqueBreakdown + solve: WrenchSolveResult + + +class CalibratedResidualWrenchEstimator: + """Compose residual conditioning and a selectable H2 wrench solver.""" + + def __init__( + self, + calibration: WrenchEstimatorCalibration, + solver: Optional[WrenchSolver] = None, + ) -> None: + self.calibration = calibration + self.residual_model = ResidualTorqueModel(calibration) + self.solver: WrenchSolver = ( + calibration.build_dls_solver() if solver is None else solver + ) + if not np.isclose( + self.solver.characteristic_length_m, + calibration.characteristic_length_m, + ): + raise ValueError( + "solver and calibration must use the same characteristic length" + ) + if not np.isclose( + self.solver.relative_rank_tolerance, + calibration.relative_rank_tolerance, + ): + raise ValueError( + "solver and calibration must use the same rank tolerance" + ) + + def estimate( + self, + jacobian: np.ndarray, + measured_torque_nm: np.ndarray, + rigid_body_torque_nm: np.ndarray, + joint_velocity_rad_s: np.ndarray, + *, + ablation: ResidualAblation = ResidualAblation(), + ) -> CalibratedWrenchEstimate: + residual = self.residual_model.compute( + measured_torque_nm, + rigid_body_torque_nm, + joint_velocity_rad_s, + ablation=ablation, + ) + solve = self.solver.solve(jacobian, residual.residual_nm) + return CalibratedWrenchEstimate(residual=residual, solve=solve) diff --git a/code/core/feedback_protocol.py b/code/core/feedback_protocol.py new file mode 100644 index 0000000..178a124 --- /dev/null +++ b/code/core/feedback_protocol.py @@ -0,0 +1,364 @@ +"""Source-stamped bilateral-feedback contracts used by formal experiments. + +The current simulator historically delayed a residual and a wrench in separate +queues. That is sufficient for a visual demonstration but cannot guarantee a +matched H3 comparison. This module makes the comparison unit explicit: one +return packet carries the reconstructed wrench and ``J_s.T @ wrench`` together, +and both mapping conditions consume that immutable packet. + +All spatial vectors use the repository convention ``[linear; angular]`` for +twists and ``[force; moment]`` for wrenches. +""" + +from __future__ import annotations + +from collections import OrderedDict +from dataclasses import dataclass +from enum import Enum, IntEnum +import hashlib +from typing import Iterable + +import numpy as np + + +def _readonly_vector(value, size: int, name: str) -> np.ndarray: + array = np.asarray(value, dtype=float).reshape(-1).copy() + if array.shape != (size,): + raise ValueError(f"{name} must have shape ({size},), got {array.shape}") + if not np.all(np.isfinite(array)): + raise ValueError(f"{name} must contain only finite values") + array.setflags(write=False) + return array + + +def _readonly_matrix(value, shape: tuple[int, int], name: str) -> np.ndarray: + array = np.asarray(value, dtype=float).copy() + if array.shape != shape: + raise ValueError(f"{name} must have shape {shape}, got {array.shape}") + if not np.all(np.isfinite(array)): + raise ValueError(f"{name} must contain only finite values") + array.setflags(write=False) + return array + + +def _payload_hash(parts: Iterable[np.ndarray | int | float]) -> str: + digest = hashlib.sha256() + for part in parts: + if isinstance(part, np.ndarray): + contiguous = np.ascontiguousarray(part, dtype=np.float64) + digest.update(str(contiguous.shape).encode("ascii")) + digest.update(contiguous.tobytes()) + elif isinstance(part, (int, np.integer)): + digest.update(f"i:{int(part)}".encode("ascii")) + else: + digest.update(f"f:{float(part):.17g}".encode("ascii")) + return digest.hexdigest() + + +class MappingKind(str, Enum): + """Feedback mappings required by the H3/H4 comparison.""" + + DIFFERENTIAL_RESIDUAL = "differential_residual" + MATCHED_DIFFERENTIAL_WRENCH = "matched_differential_wrench" + DIRECT_MASTER_JACOBIAN = "direct_master_jacobian" + + +class MapPolicy(str, Enum): + """Which differential is used for a delayed return packet.""" + + CURRENT = "current" + SOURCE_STAMPED = "source_stamped" + + +class PacketState(IntEnum): + EMPTY = 0 + ACTIVE = 1 + HELD = 2 + TIMED_OUT = 3 + RECOVERING = 4 + + +class PacketRejectReason(IntEnum): + ACCEPTED = 0 + INVALID = 1 + CORRUPT = 2 + DUPLICATE_OR_STALE = 3 + + +@dataclass(frozen=True) +class MapSnapshot: + """One accepted 50-Hz retargeting differential.""" + + map_id: int + source_index: int + source_time: float + differential: np.ndarray + valid: bool = True + reason_code: int = 0 + + def __post_init__(self) -> None: + if self.map_id < 0 or self.source_index < 0: + raise ValueError("map_id and source_index must be non-negative") + if not np.isfinite(self.source_time): + raise ValueError("source_time must be finite") + matrix = np.asarray(self.differential, dtype=float) + if matrix.ndim != 2 or matrix.shape[0] == 0 or matrix.shape[1] == 0: + raise ValueError("differential must be a non-empty matrix") + object.__setattr__( + self, + "differential", + _readonly_matrix(matrix, matrix.shape, "differential"), + ) + + @property + def digest(self) -> str: + return _payload_hash( + ( + self.map_id, + self.source_index, + self.source_time, + self.differential, + ) + ) + + +class MapRegistry: + """Bounded map-id registry for source-stamped delayed feedback.""" + + def __init__(self, capacity: int = 512): + if capacity <= 0: + raise ValueError("capacity must be positive") + self.capacity = int(capacity) + self._maps: OrderedDict[int, MapSnapshot] = OrderedDict() + + def add(self, snapshot: MapSnapshot) -> None: + existing = self._maps.get(snapshot.map_id) + if existing is not None and existing.digest != snapshot.digest: + raise ValueError( + f"map_id {snapshot.map_id} cannot be reused for different data" + ) + self._maps[snapshot.map_id] = snapshot + self._maps.move_to_end(snapshot.map_id) + while len(self._maps) > self.capacity: + self._maps.popitem(last=False) + + def get(self, map_id: int) -> MapSnapshot: + try: + return self._maps[int(map_id)] + except KeyError as exc: + raise KeyError(f"map_id {map_id} is not available") from exc + + @property + def latest(self) -> MapSnapshot: + if not self._maps: + raise KeyError("map registry is empty") + return next(reversed(self._maps.values())) + + def __len__(self) -> int: + return len(self._maps) + + +@dataclass(frozen=True) +class ForwardPacket: + """Master-to-slave held reference packet.""" + + seq: int + source_index: int + source_time: float + map_id: int + q_slave_ref: np.ndarray + qd_slave_ctrl: np.ndarray + valid: bool = True + + def __post_init__(self) -> None: + if self.seq < 0 or self.source_index < 0 or self.map_id < 0: + raise ValueError("packet identifiers must be non-negative") + if not np.isfinite(self.source_time): + raise ValueError("source_time must be finite") + q = np.asarray(self.q_slave_ref, dtype=float).reshape(-1) + qd = np.asarray(self.qd_slave_ctrl, dtype=float).reshape(-1) + if q.size == 0 or q.shape != qd.shape: + raise ValueError("q_slave_ref and qd_slave_ctrl must have equal shapes") + object.__setattr__( + self, "q_slave_ref", _readonly_vector(q, q.size, "q_slave_ref") + ) + object.__setattr__( + self, "qd_slave_ctrl", _readonly_vector(qd, qd.size, "qd_slave_ctrl") + ) + + +@dataclass(frozen=True) +class ReturnPacket: + """Slave-to-master packet supporting every declared feedback condition.""" + + seq: int + source_index: int + source_time: float + echoed_map_id: int + residual: np.ndarray + wrench: np.ndarray + js_t_wrench: np.ndarray + qd_slave_actual: np.ndarray + valid: bool = True + + def __post_init__(self) -> None: + if self.seq < 0 or self.source_index < 0 or self.echoed_map_id < 0: + raise ValueError("packet identifiers must be non-negative") + if not np.isfinite(self.source_time): + raise ValueError("source_time must be finite") + residual = np.asarray(self.residual, dtype=float).reshape(-1) + js_t = np.asarray(self.js_t_wrench, dtype=float).reshape(-1) + qd = np.asarray(self.qd_slave_actual, dtype=float).reshape(-1) + if residual.size == 0 or residual.shape != js_t.shape or residual.shape != qd.shape: + raise ValueError( + "residual, js_t_wrench and qd_slave_actual must have equal shapes" + ) + object.__setattr__( + self, "residual", _readonly_vector(residual, residual.size, "residual") + ) + object.__setattr__( + self, "wrench", _readonly_vector(self.wrench, 6, "wrench") + ) + object.__setattr__( + self, + "js_t_wrench", + _readonly_vector(js_t, js_t.size, "js_t_wrench"), + ) + object.__setattr__( + self, + "qd_slave_actual", + _readonly_vector(qd, qd.size, "qd_slave_actual"), + ) + + @property + def matched_input_hash(self) -> str: + """Hash proving that H3 matched conditions consumed one packet.""" + return _payload_hash( + ( + self.seq, + self.source_index, + self.source_time, + self.echoed_map_id, + self.wrench, + self.js_t_wrench, + ) + ) + + +@dataclass(frozen=True) +class FeedbackResult: + tau_master_raw: np.ndarray + tau_slave_source: np.ndarray + selected_map_id: int | None + matched_input_hash: str + valid: bool + reason: str + + +def map_return_feedback( + *, + kind: MappingKind, + packet: ReturnPacket, + master_jacobian: np.ndarray, + maps: MapRegistry, + map_policy: MapPolicy = MapPolicy.CURRENT, +) -> FeedbackResult: + """Map one immutable return packet to a raw master generalized torque. + + ``MATCHED_DIFFERENTIAL_WRENCH`` and ``DIRECT_MASTER_JACOBIAN`` both use the + packet's one reconstructed wrench. The former consumes its stored + ``J_s.T @ wrench`` value; the latter consumes ``wrench`` directly. + """ + Jm = np.asarray(master_jacobian, dtype=float) + if Jm.ndim != 2 or Jm.shape[0] != 6: + raise ValueError("master_jacobian must have shape (6, nv_master)") + if not np.all(np.isfinite(Jm)): + raise ValueError("master_jacobian must contain only finite values") + + matched_hash = packet.matched_input_hash + if not packet.valid: + return FeedbackResult( + tau_master_raw=np.zeros(Jm.shape[1], dtype=float), + tau_slave_source=np.zeros_like(packet.residual), + selected_map_id=None, + matched_input_hash=matched_hash, + valid=False, + reason="invalid_return_packet", + ) + + if kind is MappingKind.DIRECT_MASTER_JACOBIAN: + return FeedbackResult( + tau_master_raw=np.asarray(Jm.T @ packet.wrench, dtype=float), + tau_slave_source=packet.js_t_wrench.copy(), + selected_map_id=None, + matched_input_hash=matched_hash, + valid=True, + reason="ok", + ) + + if map_policy is MapPolicy.SOURCE_STAMPED: + selected = maps.get(packet.echoed_map_id) + else: + selected = maps.latest + A = selected.differential + if A.shape[0] != packet.residual.size or A.shape[1] != Jm.shape[1]: + raise ValueError( + "differential shape is incompatible with slave/master dimensions" + ) + if not selected.valid: + return FeedbackResult( + tau_master_raw=np.zeros(Jm.shape[1], dtype=float), + tau_slave_source=np.zeros(A.shape[0], dtype=float), + selected_map_id=selected.map_id, + matched_input_hash=matched_hash, + valid=False, + reason="invalid_differential", + ) + + source = ( + packet.residual + if kind is MappingKind.DIFFERENTIAL_RESIDUAL + else packet.js_t_wrench + ) + return FeedbackResult( + tau_master_raw=np.asarray(A.T @ source, dtype=float), + tau_slave_source=source.copy(), + selected_map_id=selected.map_id, + matched_input_hash=matched_hash, + valid=True, + reason="ok", + ) + + +def normalized_actual_power_mismatch( + tau_master_raw: np.ndarray, + qd_master: np.ndarray, + tau_slave_source: np.ndarray, + qd_slave_actual: np.ndarray, + dt: float | np.ndarray, + *, + sigma_feedback: float = 1.0, + epsilon_energy: float = 1e-12, +) -> float: + """Compute the trajectory-level H3 endpoint from source-aligned samples.""" + tm = np.asarray(tau_master_raw, dtype=float) + qm = np.asarray(qd_master, dtype=float) + ts = np.asarray(tau_slave_source, dtype=float) + qs = np.asarray(qd_slave_actual, dtype=float) + if tm.ndim != 2 or tm.shape != qm.shape or ts.ndim != 2 or ts.shape != qs.shape: + raise ValueError("torque and velocity arrays must be paired 2-D arrays") + if tm.shape[0] != ts.shape[0]: + raise ValueError("master and slave arrays must have the same sample count") + delta = np.broadcast_to(np.asarray(dt, dtype=float), (tm.shape[0],)) + if np.any(delta <= 0.0) or not np.all(np.isfinite(delta)): + raise ValueError("dt must contain finite positive values") + if epsilon_energy <= 0.0: + raise ValueError("epsilon_energy must be positive") + + pm = np.einsum("ij,ij->i", tm, qm) + ps = float(sigma_feedback) * np.einsum("ij,ij->i", ts, qs) + numerator = float(np.sum(np.abs(pm - ps) * delta)) + denominator = float( + 0.5 * np.sum((np.abs(pm) + np.abs(ps)) * delta) + epsilon_energy + ) + return numerator / denominator diff --git a/code/core/haptic_render.py b/code/core/haptic_render.py new file mode 100644 index 0000000..e07c5d4 --- /dev/null +++ b/code/core/haptic_render.py @@ -0,0 +1,658 @@ +# haptic_render.py +# -*- coding: utf-8 -*- +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +try: + import pinocchio as pin +except ModuleNotFoundError: # Pure supervisor tests do not require Pinocchio. + pin = None + + +# ===== Final applied-port energy supervision ===== + +@dataclass +class TankParams: + E_min: float = 0.5 + E_max: float = 20.0 + alpha_floor: float = 0.0 + alpha_ceil: float = 1.0 + power_epsilon: float = 1e-12 + + # Legacy fields are kept so existing configuration/ablation code can load. + # They are deliberately not used for energy accounting: filtering, a power + # dead-zone, or alpha smoothing at this stage would make the tank account a + # different signal from the torque actually applied at the master port. + force_alpha: float = 0.2 + vel_alpha: float = 0.2 + alpha_smooth: float = 0.1 + power_deadzone: float = 0.0 + + +@dataclass(frozen=True) +class AppliedPortDiagnostics: + """Snapshot of one final-port projection/accounting step.""" + + tau_candidate: np.ndarray + tau_applied: np.ndarray + rho: float + E_before: float + E_preclip: float + E_after: float + candidate_power: float + power: float + fail_safe_active: bool + + +class AppliedPortEnergySupervisor: + """Passivity supervisor at the final master haptic-torque port. + + Positive ``tau_app @ qd_m`` is power delivered by the device to the + operator and therefore discharges the tank. Negative power charges the + tank. The candidate torque passed to :meth:`apply` must + already include all nominal scaling, filtering, rate limiting, and actuator + saturation. The returned torque is final: downstream code must not filter, + rescale, rate-limit, or saturate it. + + The projection is radial, ``tau_app = alpha * tau_candidate``. This keeps a + previously saturated candidate inside its actuator limits while imposing + the exact one-step energy budget. ``alpha_floor`` is never allowed to + override the energy-safe upper bound. + """ + + def __init__(self, tank: TankParams | None = None, E0: float = 2.0): + self.tp = TankParams() if tank is None else tank + if not np.isfinite(self.tp.E_min) or not np.isfinite(self.tp.E_max): + raise ValueError("E_min and E_max must be finite") + if self.tp.E_max < self.tp.E_min: + raise ValueError("E_max must be greater than or equal to E_min") + if not 0.0 <= self.tp.alpha_ceil <= 1.0: + raise ValueError("alpha_ceil must lie in [0, 1]") + if not 0.0 <= self.tp.alpha_floor <= self.tp.alpha_ceil: + raise ValueError("alpha_floor must lie in [0, alpha_ceil]") + if self.tp.power_epsilon < 0.0: + raise ValueError("power_epsilon must be non-negative") + + self.E = float(np.clip(E0, self.tp.E_min, self.tp.E_max)) + self.alpha = 1.0 + self.last_alpha = 1.0 + self.last_power = 0.0 + self.last_energy_before = self.E + self.last_energy_after = self.E + self.last_tau_candidate = np.empty(0, dtype=float) + self.last_tau_applied = np.empty(0, dtype=float) + self.fail_safe_active = False + self.last_diagnostics: AppliedPortDiagnostics | None = None + + def reset(self, E0: float | None = None): + if E0 is None: + E0 = self.tp.E_min + self.E = float(np.clip(E0, self.tp.E_min, self.tp.E_max)) + self.alpha = 1.0 + self.last_alpha = 1.0 + self.last_power = 0.0 + self.last_energy_before = self.E + self.last_energy_after = self.E + self.last_tau_candidate = np.empty(0, dtype=float) + self.last_tau_applied = np.empty(0, dtype=float) + self.fail_safe_active = False + self.last_diagnostics = None + + def _record(self, + tau_candidate: np.ndarray, + tau_applied: np.ndarray, + rho: float, + E_before: float, + E_preclip: float, + E_after: float, + candidate_power: float, + power: float, + fail_safe_active: bool) -> AppliedPortDiagnostics: + """Record one result while retaining legacy scalar/state attributes.""" + diagnostics = AppliedPortDiagnostics( + tau_candidate=tau_candidate.copy(), + tau_applied=tau_applied.copy(), + rho=float(rho), + E_before=float(E_before), + E_preclip=float(E_preclip), + E_after=float(E_after), + candidate_power=float(candidate_power), + power=float(power), + fail_safe_active=bool(fail_safe_active), + ) + self.alpha = diagnostics.rho + self.last_alpha = diagnostics.rho + self.last_power = diagnostics.power + self.last_energy_before = diagnostics.E_before + self.last_energy_after = diagnostics.E_after + self.last_tau_candidate = diagnostics.tau_candidate.copy() + self.last_tau_applied = diagnostics.tau_applied.copy() + self.fail_safe_active = diagnostics.fail_safe_active + self.last_diagnostics = diagnostics + return diagnostics + + def _zero_fail_safe( + self, + tau_candidate: np.ndarray, + ) -> tuple[np.ndarray, AppliedPortDiagnostics]: + tau_zero = np.zeros_like(tau_candidate, dtype=float) + diagnostics = self._record( + tau_candidate=tau_candidate, + tau_applied=tau_zero, + rho=0.0, + E_before=self.E, + E_preclip=self.E, + E_after=self.E, + candidate_power=0.0, + power=0.0, + fail_safe_active=True, + ) + return tau_zero, diagnostics + + def apply(self, + tau_raw: np.ndarray, + qd: np.ndarray, + dt: float) -> tuple[np.ndarray, AppliedPortDiagnostics]: + """Return the final safe torque and diagnostics, accounting exactly once. + + ``tau_raw`` is the already shaped and actuator-limited candidate at the + master joint port. It may come directly from a mapping such as + ``A.T @ tau_s`` or ``J_m.T @ F``. ``qd`` is the velocity at that same + port. The returned ``tau_app`` must be sent unchanged to the haptic + output (apart from an emergency zero-output fail-safe). + """ + tau_candidate = np.asarray(tau_raw, dtype=float).reshape(-1,) + qd_m = np.asarray(qd, dtype=float).reshape(-1,) + if tau_candidate.shape != qd_m.shape: + raise ValueError( + "tau_raw and qd must have identical one-dimensional shapes" + ) + if ( + tau_candidate.size == 0 + or not np.isfinite(dt) + or dt <= 0.0 + or not np.all(np.isfinite(tau_candidate)) + or not np.all(np.isfinite(qd_m)) + ): + return self._zero_fail_safe(tau_candidate) + + energy_before = self.E + nominal_power = float(np.dot(tau_candidate, qd_m)) + if not np.isfinite(nominal_power): + return self._zero_fail_safe(tau_candidate) + rho = self.tp.alpha_ceil + + # Only device-to-human power consumes stored energy. alpha_floor is a + # preference, not a safety constraint, and cannot force an unsafe gain. + if nominal_power > 0.0: + available = max(0.0, energy_before - self.tp.E_min) + rho_energy = available / (nominal_power * dt) + rho = min(rho, max(0.0, rho_energy)) + + tau_app = rho * tau_candidate + applied_power = float(np.dot(tau_app, qd_m)) + + # This is the sole tank update. It uses the exact torque returned to + # the caller and the exact master velocity supplied for this sample. + energy_preclip = energy_before - applied_power * dt + self.E = float(np.clip(energy_preclip, self.tp.E_min, self.tp.E_max)) + diagnostics = self._record( + tau_candidate=tau_candidate, + tau_applied=tau_app, + rho=rho, + E_before=energy_before, + E_preclip=energy_preclip, + E_after=self.E, + candidate_power=nominal_power, + power=applied_power, + fail_safe_active=False, + ) + return tau_app, diagnostics + + def project_and_account(self, + tau_candidate: np.ndarray, + qd_m: np.ndarray, + dt: float) -> tuple[float, np.ndarray]: + """Compatibility wrapper around :meth:`apply`. + + Returns ``(alpha, tau_app)``. The wrapper invokes ``apply`` exactly + once, so it cannot introduce a second tank update. + """ + tau_app, diagnostics = self.apply(tau_candidate, qd_m, dt) + return diagnostics.rho, tau_app + + def observe_master(self, *_args, **_kwargs): + """Reject the former second accounting path.""" + raise RuntimeError( + "observe_master() is disabled: use apply() exactly once " + "at the final applied master-torque port" + ) + + def enforce(self, *_args, **_kwargs): + """Reject wrench/twist-port enforcement from the former implementation.""" + raise RuntimeError( + "enforce(CF, VC, dt) is disabled: passivity is enforced on final " + "master tau_app and qd_m via apply()" + ) + + +# Backward-compatible class name for existing imports. +EnergyTankPOPC = AppliedPortEnergySupervisor + + +# ===== 主端胸腔雅可比 ===== + +class ChestJacobianMaster: + def __init__(self, model: pin.Model, + chest_frame_name: str, + ee_frame_name: str): + if pin is None: + raise ModuleNotFoundError( + "Pinocchio is required to construct ChestJacobianMaster" + ) + self.model = model + self.data = model.createData() + self.fid_C = model.getFrameId(chest_frame_name) + self.fid_EE = model.getFrameId(ee_frame_name) + + def chest_jacobian(self, q_m, qd_m): + pin.forwardKinematics(self.model, self.data, q_m, qd_m) + pin.updateFramePlacements(self.model, self.data) + + # LOCAL_WORLD_ALIGNED expresses the twist at the EE point in axes + # parallel to the world frame. The spatial-vector convention used by + # this project is [linear; angular]. + Jw = pin.computeFrameJacobian(self.model, self.data, + q_m, self.fid_EE, + pin.ReferenceFrame.LOCAL_WORLD_ALIGNED) + + # Rotate both vector blocks into chest axes, but do not translate the + # reference point: the wrench paired with this Jacobian still acts at + # the EE point. A full spatial adjoint here would introduce a spurious + # moment-arm term between the chest origin and EE point. + oTC = self.data.oMf[self.fid_C] # ^wT_C + R_cw = oTC.rotation.T + rotate_axes = np.zeros((6, 6)) + rotate_axes[:3, :3] = R_cw + rotate_axes[3:, 3:] = R_cw + CJ_m = rotate_axes @ Jw + return CJ_m + + +# ===== 主端力反馈渲染 ===== + +class HapticRenderer: + """Render interaction wrench as final, passivity-supervised master torque. + + The haptic path has one strict ordering: + + ``J_m.T @ F`` (direct baseline) + -> optional nominal low-pass + -> nominal sign/strength scaling + -> torque-rate limiting + -> actuator torque saturation + -> final applied-port energy projection and single accounting update. + + No filtering or limiting is performed after the energy projection. The + second returned torque is therefore the exact ``tau_app`` used in + ``tau_app @ qd_m`` for the tank update. + """ + + def __init__(self, + master_model: pin.Model, + chest_frame_name: str, + ee_frame_name: str, + feedback_strength: float, + E_init: float, + E_max: float, + alpha_floor: float, + alpha_ceil: float, + E0: float = 2.0, + torque_limit: float | np.ndarray | None = None, + torque_rate_limit: float | np.ndarray | None = None, + tau_filter_alpha: float = 0.2): + # 约定 feedback_strength > 0,反作用时乘上一个负号 + self.feedback_strength = float(feedback_strength) + if not np.isfinite(self.feedback_strength): + raise ValueError("feedback_strength must be finite") + + self.CJ_master = ChestJacobianMaster(master_model, + chest_frame_name, + ee_frame_name) + + tank_params = TankParams( + E_min=E_init, + E_max=E_max, + alpha_floor=alpha_floor, + alpha_ceil=alpha_ceil, + ) + self.tank = AppliedPortEnergySupervisor(tank_params, E0) + + # Nominal shaping state. All of this is upstream of the energy + # projection. Public tau_alpha is retained for existing scripts. + self.tau_alpha = float(tau_filter_alpha) + if not 0.0 <= self.tau_alpha <= 1.0: + raise ValueError("tau_filter_alpha must lie in [0, 1]") + self.torque_limit = torque_limit + self.torque_rate_limit = torque_rate_limit + self._tau_fb_state = np.zeros(master_model.nv) + self._tau_applied_prev = np.zeros(master_model.nv) + self.last_rate_limit_active = False + self.last_torque_saturation_active = False + + def reset_tank(self, E0: float = None): + self.tank.reset(E0) + self._tau_fb_state[:] = 0.0 + self._tau_applied_prev[:] = 0.0 + self.last_rate_limit_active = False + self.last_torque_saturation_active = False + + # ---------- 主端逆动力学:计算 tau_master_current ---------- + + def _inverse_dynamics(self, + q_m: np.ndarray, + qd_m: np.ndarray, + qdd_m: np.ndarray, + tau_ff_fric: np.ndarray | None = None) -> np.ndarray: + """ + 主端动力学模型: + τ = M(q) qdd + C(q,qd) qd + g(q) + τ_ff_fric + """ + q_m = np.asarray(q_m, dtype=float).reshape(-1,) + qd_m = np.asarray(qd_m, dtype=float).reshape(-1,) + qdd_m = np.asarray(qdd_m, dtype=float).reshape(-1,) + if tau_ff_fric is not None: + tau_ff_fric = np.asarray(tau_ff_fric, dtype=float).reshape(-1,) + + model = self.CJ_master.model + data = self.CJ_master.data + + # 惯量矩阵 M(q) + M = pin.crba(model, data, q_m) + M = (M + M.T) - np.diag(M.diagonal()) # 数值对称化 + + # 非线性项(科氏/离心 + 重力) + nle = pin.nonLinearEffects(model, data, q_m, qd_m) # = C(q,qd)qd + g(q) + g = pin.computeGeneralizedGravity(model, data, q_m) + Cqd = nle - g + + tau = M @ qdd_m + Cqd + g + if tau_ff_fric is not None: + tau = tau + tau_ff_fric + return tau + + def compute_tau_master_current(self, + q_m: np.ndarray, + qd_m: np.ndarray, + qdd_m: np.ndarray, + tau_ff_fric: np.ndarray | None = None) -> np.ndarray: + """ + 对外接口:给定主端 (q, qd, qdd),计算 + τ_master_current = M qdd + C qd + g + τ_ff_fric + """ + return self._inverse_dynamics(q_m, qd_m, qdd_m, tau_ff_fric) + + # ---------- 从端交互力 → 主端反馈扭矩 ---------- + + @staticmethod + def _symmetric_limit_vector(value: float | np.ndarray | None, + size: int, + name: str) -> np.ndarray: + if value is None: + return np.full(size, np.inf, dtype=float) + limit = np.asarray(value, dtype=float) + if limit.ndim == 0: + limit = np.full(size, float(limit), dtype=float) + else: + limit = limit.reshape(-1,) + if limit.size != size: + raise ValueError(f"{name} must be scalar or have {size} entries") + if np.any(np.isnan(limit)) or np.any(limit < 0.0): + raise ValueError(f"{name} must contain non-negative values") + return limit + + def direct_from_CF(self, + q_m: np.ndarray, + qd_m: np.ndarray, + CF_int_slave_C: np.ndarray) -> np.ndarray: + """Return the unmodified baseline mapping ``J_m.T @ F``. + + This interface intentionally applies no sign convention, nominal gain, + filter, rate limit, saturation, or energy supervision. + """ + CF = np.asarray(CF_int_slave_C, dtype=float).reshape(6,) + CJ_m = self.CJ_master.chest_jacobian(q_m, qd_m) + return np.asarray(CJ_m.T @ CF, dtype=float).reshape(-1,) + + # Explicitly named alias for experiment/baseline code. + render_direct_from_CF = direct_from_CF + + def _compute_feedback_tau(self, + q_m: np.ndarray, + qd_m: np.ndarray, + CF_int_slave_C: np.ndarray, + V_slave_C: np.ndarray | None, + dt: float): + """Legacy extension hook. + + The base implementation returns the direct mapping and ``None`` to + request final applied-port supervision. Existing no-tank subclasses + that override this method and return a numeric alpha continue to bypass + only the energy projection; all upstream nominal actuator shaping still + applies. ``V_slave_C`` is retained solely for call compatibility and is + never used for tank accounting. + """ + del V_slave_C, dt + return self.direct_from_CF(q_m, qd_m, CF_int_slave_C), None + + def _shape_and_supervise(self, + tau_direct: np.ndarray, + qd_m: np.ndarray, + dt: float, + bypass_energy_projection: bool = False + ) -> tuple[np.ndarray, float]: + """Apply the strictly ordered haptic output pipeline.""" + tau_saturated, valid = self._shape_candidate(tau_direct, qd_m, dt) + if not valid: + tau_zero, diagnostics = self.tank.apply(tau_direct, qd_m, dt) + self._tau_applied_prev = tau_zero.copy() + return tau_zero, diagnostics.rho + + # Final energy projection and the only accounting update. + if bypass_energy_projection: + tau_app = tau_saturated.copy() + alpha = 1.0 + else: + tau_app, diagnostics = self.tank.apply(tau_saturated, qd_m, dt) + alpha = diagnostics.rho + + # Nothing may modify tau_app after projection. Only state capture and + # addition of the independent inverse-dynamics command occur downstream. + self.commit_applied(tau_app) + return tau_app, float(alpha) + + def _shape_candidate( + self, + tau_direct: np.ndarray, + qd_m: np.ndarray, + dt: float, + ) -> tuple[np.ndarray, bool]: + """Return the final upstream candidate without applying a supervisor. + + This split is used by the formal PO/PC baseline. The caller must pass + the candidate through exactly one supervisor and then invoke + :meth:`commit_applied`; no additional filtering or limiting is allowed. + """ + tau_direct = np.asarray(tau_direct, dtype=float).reshape(-1,) + qd_m = np.asarray(qd_m, dtype=float).reshape(-1,) + if tau_direct.shape != qd_m.shape: + raise ValueError("direct haptic torque and qd_m must have equal shapes") + + if self._tau_fb_state.shape != tau_direct.shape: + self._tau_fb_state = np.zeros_like(tau_direct) + if self._tau_applied_prev.shape != tau_direct.shape: + self._tau_applied_prev = np.zeros_like(tau_direct) + + # Invalid samples must not contaminate nominal filter/rate-limit state. + if ( + not np.isfinite(dt) + or dt <= 0.0 + or not np.all(np.isfinite(tau_direct)) + or not np.all(np.isfinite(qd_m)) + ): + self.last_rate_limit_active = False + self.last_torque_saturation_active = False + return np.zeros_like(tau_direct), False + + # 1) Optional nominal torque filtering. + a = float(self.tau_alpha) + if not 0.0 <= a <= 1.0: + raise ValueError("tau_alpha must lie in [0, 1]") + self._tau_fb_state = (1.0 - a) * self._tau_fb_state + a * tau_direct + + # 2) Nominal sign and strength scaling. + tau_nominal = -self.feedback_strength * self._tau_fb_state + + # 3) Per-joint rate limiting relative to the previously applied output. + rate_limit = self._symmetric_limit_vector( + self.torque_rate_limit, tau_nominal.size, "torque_rate_limit" + ) + max_delta = rate_limit * dt + tau_rate_limited = np.clip( + tau_nominal, + self._tau_applied_prev - max_delta, + self._tau_applied_prev + max_delta, + ) + self.last_rate_limit_active = bool( + np.any(np.abs(tau_rate_limited - tau_nominal) > 1e-12) + ) + + # 4) Per-joint symmetric actuator saturation. + torque_limit = self._symmetric_limit_vector( + self.torque_limit, tau_nominal.size, "torque_limit" + ) + tau_saturated = np.clip(tau_rate_limited, -torque_limit, torque_limit) + self.last_torque_saturation_active = bool( + np.any(np.abs(tau_saturated - tau_rate_limited) > 1e-12) + ) + return tau_saturated, True + + def shape_mapped_reaction_candidate( + self, + tau_environment_on_master: np.ndarray, + qd_m: np.ndarray, + dt: float, + ) -> tuple[np.ndarray, bool]: + """Prepare the common upstream candidate for an external supervisor.""" + reaction = np.asarray( + tau_environment_on_master, + dtype=float, + ).reshape(-1) + return self._shape_candidate(-reaction, qd_m, dt) + + def commit_applied(self, tau_applied: np.ndarray) -> None: + """Record the exact supervisor output used at the master port.""" + applied = np.asarray(tau_applied, dtype=float).reshape(-1) + if applied.shape != self._tau_applied_prev.shape: + raise ValueError("tau_applied has an unexpected shape") + if not np.all(np.isfinite(applied)): + raise ValueError("tau_applied must contain only finite values") + self._tau_applied_prev = applied.copy() + + # ---------- 高层接口:算 tau_master_current + 反馈 ---------- + + def render_mapped_reaction( + self, + tau_environment_on_master: np.ndarray, + qd_m: np.ndarray, + dt: float, + *, + supervise_energy: bool = True, + ) -> tuple[np.ndarray, float]: + """Shape an arbitrary generalized reaction such as ``A.T @ tau_s``. + + ``tau_environment_on_master`` already has the desired physical sign: + it is the environment-on-device reaction and should oppose penetration. + The historical renderer pipeline stores robot-on-environment action + internally and inserts its own leading minus sign, so the conversion is + performed exactly once here. The returned torque is the final applied + haptic output; callers must not filter or limit it downstream. + """ + reaction = np.asarray( + tau_environment_on_master, + dtype=float, + ).reshape(-1,) + return self._shape_and_supervise( + -reaction, + qd_m, + dt, + bypass_energy_projection=not supervise_energy, + ) + + def render_tau(self, + q_m: np.ndarray, + qd_m: np.ndarray, + qdd_m: np.ndarray, + CF_int_slave_C: np.ndarray, + V_slave_C: np.ndarray | None, + dt: float, + tau_ff_fric: np.ndarray | None = None, + ): + """ + 高层接口:给定 (q, qd, qdd) 和从端交互力,返回最终主端总扭矩。 + + 输入: + - q_m, qd_m, qdd_m: 主端关节位置/速度/加速度(用于逆动力学) + - CF_int_slave_C: 从端 InteractionEstimator 估计出的 C F_int(6×1) + - V_slave_C: 仅为旧调用兼容而保留;能量只按主端 + tau_app^T qd_m 计算 + - tau_ff_fric: 主端摩擦前馈(可为 None) + - dt: 控制周期(秒) + + 输出: + - tau_cmd_m: 主端最终下发的总扭矩 = tau_master_current + tau_app + - tau_app: 最终反馈扭矩,也是能量记账使用的精确力矩 + - alpha: 最终能量投影的径向缩放系数 + """ + # 1) 主端逆动力学扭矩 + tau_master_current = self._inverse_dynamics(q_m, qd_m, qdd_m, tau_ff_fric) + tau_master_current = np.asarray(tau_master_current, dtype=float).reshape(-1,) + + # 2) Render and account the haptic contribution exactly once. + tau_app, alpha = self.render_from_CF( + q_m, qd_m, + CF_int_slave_C=CF_int_slave_C, + V_slave_C=V_slave_C, + dt=dt, + ) + + # 3) tau_app is not modified after projection. + tau_cmd_m = tau_master_current + tau_app + return tau_cmd_m, tau_app, alpha + + # 兼容旧接口:只关心反馈扭矩 + def render_from_CF(self, + q_m: np.ndarray, + qd_m: np.ndarray, + CF_int_slave_C: np.ndarray, + V_slave_C: np.ndarray | None, + dt: float): + """ + 兼容旧接口:返回最终施加的反馈扭矩与能量投影系数。 + """ + tau_direct, legacy_alpha = self._compute_feedback_tau( + q_m, + qd_m, + CF_int_slave_C, + V_slave_C, + dt, + ) + return self._shape_and_supervise( + tau_direct, + qd_m, + dt, + bypass_energy_projection=legacy_alpha is not None, + ) diff --git a/code/core/interaction_estimater.py b/code/core/interaction_estimater.py new file mode 100644 index 0000000..d9d72ae --- /dev/null +++ b/code/core/interaction_estimater.py @@ -0,0 +1,299 @@ +from __future__ import annotations + +from typing import Optional + +import numpy as np +import pinocchio as pin + +from .wrench_solver import WrenchSolveResult, WrenchSolver + + +def checked_frame_id(model: pin.Model, frame_name: str) -> int: + """Resolve a Pinocchio frame name and reject its not-found sentinel.""" + if not isinstance(frame_name, str) or not frame_name: + raise ValueError("frame_name must be a non-empty string") + + frame_id = int(model.getFrameId(frame_name)) + if frame_id < 0 or frame_id >= model.nframes: + available = ", ".join(frame.name for frame in model.frames) + raise ValueError( + f"Frame not found: {frame_name!r}. " + f"Expected an id in [0, {model.nframes}), got {frame_id}. " + f"Available frames: {available}" + ) + return frame_id + + +def checked_joint_id(model: pin.Model, joint_name: str) -> int: + """Resolve a movable joint name and reject universe/not-found sentinels.""" + if not isinstance(joint_name, str) or not joint_name: + raise ValueError("joint_name must be a non-empty string") + + joint_id = int(model.getJointId(joint_name)) + if joint_id <= 0 or joint_id >= model.njoints: + available = ", ".join(model.names[1:]) + raise ValueError( + f"Movable joint not found: {joint_name!r}. " + f"Expected an id in [1, {model.njoints}), got {joint_id}. " + f"Available movable joints: {available}" + ) + return joint_id + + +def _as_vector(value, size: int, name: str) -> np.ndarray: + vector = np.asarray(value, dtype=float).reshape(-1) + if vector.shape != (size,): + raise ValueError(f"{name} must have shape ({size},), got {vector.shape}") + if not np.all(np.isfinite(vector)): + raise ValueError(f"{name} must contain only finite values") + return vector + + +def _as_sample_matrix(value, width: int, name: str) -> np.ndarray: + samples = np.asarray(value, dtype=float) + if samples.ndim == 1: + samples = samples.reshape(1, -1) + if samples.ndim != 2 or samples.shape[1] != width: + raise ValueError( + f"{name} must have shape (n_samples, {width}), got {samples.shape}" + ) + if samples.shape[0] == 0: + raise ValueError(f"{name} must contain at least one sample") + if not np.all(np.isfinite(samples)): + raise ValueError(f"{name} must contain only finite values") + return samples + + +class InteractionEstimator: + """ + Estimate an external wrench at the end-effector point. + + Conventions are explicit throughout this class: + + * twists are ``[linear_velocity; angular_velocity]``; + * wrenches are ``[force; moment]``; + * both are expressed along the chest-frame axes, but at the EE point; + * ``tau_int = tau_meas - tau_model - tau_bias``. + + ``estimate`` keeps the original three-value return contract. Its first + result is the bias-corrected residual. The raw and corrected residuals are + also available through ``last_tau_residual_raw`` and + ``last_tau_residual_corrected``. + """ + + def __init__( + self, + model: pin.Model, + chest_frame_name: str, + ee_frame_name: str, + lambda_damp: float = 1e-3, + wrench_solver: Optional[WrenchSolver] = None, + ): + damping = float(lambda_damp) + if not np.isfinite(damping) or damping <= 0.0: + raise ValueError("lambda_damp must be a finite positive scalar") + + self.model = model + self.data = model.createData() + self.lambda_damp = damping + self.wrench_solver = wrench_solver + self.fid_C = checked_frame_id(model, chest_frame_name) + self.fid_EE = checked_frame_id(model, ee_frame_name) + + self._tau_bias = np.zeros(model.nv, dtype=float) + self._last_tau_residual_raw: Optional[np.ndarray] = None + self._last_tau_residual_corrected: Optional[np.ndarray] = None + self._last_wrench_solve: Optional[WrenchSolveResult] = None + + @staticmethod + def _rotation6(rotation: np.ndarray) -> np.ndarray: + """Apply one 3-D rotation to both linear and angular blocks.""" + rotation = np.asarray(rotation, dtype=float) + if rotation.shape != (3, 3): + raise ValueError(f"rotation must have shape (3, 3), got {rotation.shape}") + rotation6 = np.zeros((6, 6), dtype=float) + rotation6[:3, :3] = rotation + rotation6[3:, 3:] = rotation + return rotation6 + + @property + def tau_bias(self) -> np.ndarray: + """Current no-contact joint-torque bias (copy).""" + return self._tau_bias.copy() + + @property + def last_tau_residual_raw(self) -> Optional[np.ndarray]: + """Latest ``tau_meas - tau_model`` sample, before bias removal.""" + if self._last_tau_residual_raw is None: + return None + return self._last_tau_residual_raw.copy() + + @property + def last_tau_residual_corrected(self) -> Optional[np.ndarray]: + """Latest raw residual minus the calibrated bias.""" + if self._last_tau_residual_corrected is None: + return None + return self._last_tau_residual_corrected.copy() + + @property + def last_wrench_solve(self) -> Optional[WrenchSolveResult]: + """Latest formal scaled-solver result, if one was configured.""" + return self._last_wrench_solve + + def calibrate_bias(self, tau_residual_raw_samples) -> np.ndarray: + """ + Calibrate a constant joint-torque bias from offline no-contact samples. + + Parameters + ---------- + tau_residual_raw_samples: + One sample with shape ``(nv,)`` or a batch with shape + ``(n_samples, nv)``. Every row must already be the raw residual + ``tau_meas - tau_model`` collected under a no-contact condition. + + Returns + ------- + numpy.ndarray + A copy of the calibrated mean bias. + """ + samples = _as_sample_matrix( + tau_residual_raw_samples, self.model.nv, "tau_residual_raw_samples" + ) + self._tau_bias = np.mean(samples, axis=0) + return self.tau_bias + + def calibrate_bias_from_measurements( + self, + q_samples, + qd_samples, + qdd_samples, + tau_meas_samples, + tau_ff_fric_samples=None, + ) -> np.ndarray: + """ + Compute and calibrate bias from an offline no-contact measurement batch. + + Each argument is a row-major sample matrix. ``tau_ff_fric_samples`` is + optional and, when supplied, must have the same ``(n_samples, nv)`` + shape as the velocity/torque batches. + """ + q_batch = _as_sample_matrix(q_samples, self.model.nq, "q_samples") + qd_batch = _as_sample_matrix(qd_samples, self.model.nv, "qd_samples") + qdd_batch = _as_sample_matrix(qdd_samples, self.model.nv, "qdd_samples") + tau_batch = _as_sample_matrix( + tau_meas_samples, self.model.nv, "tau_meas_samples" + ) + + sample_count = q_batch.shape[0] + batches = (qd_batch, qdd_batch, tau_batch) + if any(batch.shape[0] != sample_count for batch in batches): + raise ValueError("all calibration batches must have the same sample count") + + friction_batch = None + if tau_ff_fric_samples is not None: + friction_batch = _as_sample_matrix( + tau_ff_fric_samples, self.model.nv, "tau_ff_fric_samples" + ) + if friction_batch.shape[0] != sample_count: + raise ValueError( + "tau_ff_fric_samples must match the calibration sample count" + ) + + residuals = np.empty((sample_count, self.model.nv), dtype=float) + for sample_index in range(sample_count): + friction = ( + None if friction_batch is None else friction_batch[sample_index] + ) + tau_model = self._tau_model( + q_batch[sample_index], + qd_batch[sample_index], + qdd_batch[sample_index], + friction, + ) + residuals[sample_index] = tau_batch[sample_index] - tau_model + return self.calibrate_bias(residuals) + + def clear_bias(self) -> None: + """Clear the calibrated joint-torque bias.""" + self._tau_bias.fill(0.0) + + def _chest_jacobian(self, q, qd): + """ + Return the EE-point Jacobian expressed along chest-frame axes. + + ``LOCAL_WORLD_ALIGNED`` is essential here: unlike ``WORLD``, its + translational rows are the linear velocity of the EE origin. Rotating + the two 3-D blocks changes only their coordinate axes; no translational + adjoint term is used, so the wrench/twist reference point stays at EE. + """ + q = _as_vector(q, self.model.nq, "q") + qd = _as_vector(qd, self.model.nv, "qd") + + pin.forwardKinematics(self.model, self.data, q, qd) + pin.updateFramePlacements(self.model, self.data) + + jacobian_lwa = pin.computeFrameJacobian( + self.model, + self.data, + q, + self.fid_EE, + pin.ReferenceFrame.LOCAL_WORLD_ALIGNED, + ) + + rotation_world_from_chest = self.data.oMf[self.fid_C].rotation + rotation_chest_from_world = rotation_world_from_chest.T + return self._rotation6(rotation_chest_from_world) @ jacobian_lwa + + def _tau_model(self, q, qd, qdd, tau_ff_fric=None): + """Return ``M qdd + C qd + g + tau_ff_fric``.""" + q = _as_vector(q, self.model.nq, "q") + qd = _as_vector(qd, self.model.nv, "qd") + qdd = _as_vector(qdd, self.model.nv, "qdd") + + mass_matrix = pin.crba(self.model, self.data, q) + mass_matrix = ( + mass_matrix + mass_matrix.T - np.diag(mass_matrix.diagonal()) + ) + nonlinear = pin.nonLinearEffects(self.model, self.data, q, qd) + tau_model = mass_matrix @ qdd + nonlinear + + if tau_ff_fric is not None: + tau_model = tau_model + _as_vector( + tau_ff_fric, self.model.nv, "tau_ff_fric" + ) + return tau_model + + def estimate(self, q, qd, qdd, tau_meas, tau_ff_fric=None): + """ + Estimate the bias-corrected joint residual and chest-axis EE wrench. + + A fixed damped least-squares solve is used: + + ``F = (J J.T + lambda**2 I)^-1 J tau_int``. + """ + tau_meas = _as_vector(tau_meas, self.model.nv, "tau_meas") + tau_model = self._tau_model(q, qd, qdd, tau_ff_fric) + + tau_residual_raw = tau_meas - tau_model + tau_residual_corrected = tau_residual_raw - self._tau_bias + self._last_tau_residual_raw = tau_residual_raw.copy() + self._last_tau_residual_corrected = tau_residual_corrected.copy() + + chest_jacobian = self._chest_jacobian(q, qd) + if self.wrench_solver is None: + normal_matrix = chest_jacobian @ chest_jacobian.T + normal_matrix = normal_matrix + ( + self.lambda_damp**2 + ) * np.eye(6, dtype=float) + wrench_chest = np.linalg.solve( + normal_matrix, chest_jacobian @ tau_residual_corrected + ) + self._last_wrench_solve = None + else: + solve = self.wrench_solver.solve( + chest_jacobian, tau_residual_corrected + ) + wrench_chest = solve.wrench.copy() + self._last_wrench_solve = solve + + return tau_residual_corrected, wrench_chest, chest_jacobian diff --git a/code/core/model_contract.py b/code/core/model_contract.py new file mode 100644 index 0000000..3835759 --- /dev/null +++ b/code/core/model_contract.py @@ -0,0 +1,233 @@ +"""Canonical model contract for the heterogeneous teleoperation prototype. + +The repository contains several historical URDF/config combinations. This +module defines the only combination used by the new simulation: + +* master: ``config/master_7dof.urdf`` +* slave: ``config/real_slave_7dof.urdf`` + +The physical slave URDF currently ends at the final wrist body and does not +contain a calibrated tool/force-sensor frame. For simulation only, an +operational frame named ``R_EE_SIM`` is added at a documented fixed offset. +Hardware experiments must replace that offset with the measured TCP transform. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, Sequence + +import numpy as np +import pinocchio as pin + + +CODE_ROOT = Path(__file__).resolve().parents[1] +CONFIG_ROOT = CODE_ROOT / "config" +MASTER_URDF = CONFIG_ROOT / "master_7dof.urdf" +SLAVE_URDF = CONFIG_ROOT / "real_slave_7dof.urdf" + +MASTER_JOINT_NAMES = ( + "master_shoulder_pitch_joint", + "master_shoulder_yaw_joint", + "master_shoulder_roll_joint", + "master_elbow_flex_joint", + "master_wrist_roll_joint", + "master_wrist_yaw_joint", + "master_wrist_pitch_joint", +) + +SLAVE_JOINT_NAMES = ( + "R_SHOULDER_P", + "R_SHOULDER_R", + "R_SHOULDER_Y", + "R_ELBOW_R", + "R_WRIST_P", + "R_WRIST_Y", + "R_WRIST_R", +) + +MASTER_FRAMES = { + "base": "master_base", + "shoulder": "master_shoulder", + "elbow": "master_forearm", + "wrist": "master_wrist", + "ee": "master_ee", +} + +SLAVE_FRAMES = { + "base": "PELVIS_S", + "shoulder": "R_SHOULDER_R_S", + "elbow": "R_ELBOW_R_S", + "wrist": "R_WRIST_R_S", + "ee": "R_EE_SIM", +} + +# Approximate simulated TCP taken from the terminal marker in right_arm.mjcf. +# This is deliberately not presented as a physical calibration. +SLAVE_SIM_TCP_OFFSET = np.array([-0.01212, -0.17655, 0.07506], dtype=float) + + +@dataclass(frozen=True) +class TeleoperationModels: + master: pin.Model + slave: pin.Model + + +def require_frame(model: pin.Model, name: str) -> int: + """Resolve a frame and reject Pinocchio's not-found sentinel.""" + fid = int(model.getFrameId(name)) + if fid < 0 or fid >= model.nframes: + raise ValueError( + f"Frame {name!r} not found in model {model.name!r}; " + f"available={[frame.name for frame in model.frames]}" + ) + return fid + + +def require_joint(model: pin.Model, name: str) -> int: + """Resolve an actuated joint and reject universe/not-found sentinels.""" + jid = int(model.getJointId(name)) + if jid <= 0 or jid >= model.njoints: + raise ValueError( + f"Joint {name!r} not found in model {model.name!r}; " + f"available={list(model.names)[1:]}" + ) + return jid + + +def joint_q_indices(model: pin.Model, names: Sequence[str]) -> np.ndarray: + indices = [] + for name in names: + jid = require_joint(model, name) + joint = model.joints[jid] + if joint.nq != 1 or joint.nv != 1: + raise ValueError(f"Expected a scalar revolute joint, got {name!r}") + indices.append(int(joint.idx_q)) + return np.asarray(indices, dtype=int) + + +def joint_v_indices(model: pin.Model, names: Sequence[str]) -> np.ndarray: + indices = [] + for name in names: + jid = require_joint(model, name) + joint = model.joints[jid] + if joint.nq != 1 or joint.nv != 1: + raise ValueError(f"Expected a scalar revolute joint, got {name!r}") + indices.append(int(joint.idx_v)) + return np.asarray(indices, dtype=int) + + +def validate_contract( + model: pin.Model, + joint_names: Sequence[str], + frame_names: Iterable[str], +) -> None: + if model.nq != 7 or model.nv != 7: + raise ValueError( + f"Expected a fixed-base 7-DoF model, got nq={model.nq}, nv={model.nv}" + ) + joint_q_indices(model, joint_names) + for frame_name in frame_names: + require_frame(model, frame_name) + + +def add_fixed_operational_frame( + model: pin.Model, + *, + name: str, + parent_frame_name: str, + translation: np.ndarray, + rotation: np.ndarray | None = None, +) -> int: + """Add an operational frame at a transform relative to an existing frame.""" + existing = int(model.getFrameId(name)) + if 0 <= existing < model.nframes: + return existing + + parent_fid = require_frame(model, parent_frame_name) + parent = model.frames[parent_fid] + relative = pin.SE3( + np.eye(3) if rotation is None else np.asarray(rotation, dtype=float), + np.asarray(translation, dtype=float).reshape(3), + ) + placement_in_parent_joint = parent.placement * relative + return int( + model.addFrame( + pin.Frame( + name, + parent.parentJoint, + parent_fid, + placement_in_parent_joint, + pin.FrameType.OP_FRAME, + ) + ) + ) + + +def load_models(*, add_simulated_tcp: bool = True) -> TeleoperationModels: + """Load and validate the canonical master/slave dynamics models.""" + master = pin.buildModelFromUrdf(str(MASTER_URDF)) + slave = pin.buildModelFromUrdf(str(SLAVE_URDF)) + + if add_simulated_tcp: + add_fixed_operational_frame( + slave, + name=SLAVE_FRAMES["ee"], + parent_frame_name=SLAVE_FRAMES["wrist"], + translation=SLAVE_SIM_TCP_OFFSET, + ) + + validate_contract(master, MASTER_JOINT_NAMES, MASTER_FRAMES.values()) + validate_contract(slave, SLAVE_JOINT_NAMES, SLAVE_FRAMES.values()) + return TeleoperationModels(master=master, slave=slave) + + +def finite_joint_limits( + model: pin.Model, + joint_names: Sequence[str], +) -> tuple[np.ndarray, np.ndarray]: + qidx = joint_q_indices(model, joint_names) + lower = np.asarray(model.lowerPositionLimit[qidx], dtype=float) + upper = np.asarray(model.upperPositionLimit[qidx], dtype=float) + if not np.all(np.isfinite(lower)) or not np.all(np.isfinite(upper)): + raise ValueError("All teleoperation joints must have finite position limits") + if np.any(lower >= upper): + raise ValueError("Invalid joint limits in teleoperation model") + return lower, upper + + +def safe_configuration( + model: pin.Model, + joint_names: Sequence[str], + *, + fraction: float = 0.5, + margin: float = 1e-3, +) -> np.ndarray: + """Return a configuration strictly inside all declared joint limits.""" + if not 0.0 <= fraction <= 1.0: + raise ValueError("fraction must lie in [0, 1]") + lower, upper = finite_joint_limits(model, joint_names) + span = upper - lower + q7 = lower + fraction * span + q7 = np.minimum(np.maximum(q7, lower + margin), upper - margin) + q = pin.neutral(model) + q[joint_q_indices(model, joint_names)] = q7 + return q + + +def clip_configuration( + model: pin.Model, + q: np.ndarray, + joint_names: Sequence[str], + *, + margin: float = 1e-6, +) -> tuple[np.ndarray, bool]: + """Clip scalar teleoperation joints and report whether clipping occurred.""" + q_clipped = np.asarray(q, dtype=float).copy() + qidx = joint_q_indices(model, joint_names) + lower, upper = finite_joint_limits(model, joint_names) + clipped7 = np.minimum(np.maximum(q_clipped[qidx], lower + margin), upper - margin) + changed = bool(np.any(np.abs(clipped7 - q_clipped[qidx]) > 1e-12)) + q_clipped[qidx] = clipped7 + return q_clipped, changed diff --git a/code/core/network_emulator.py b/code/core/network_emulator.py new file mode 100644 index 0000000..0262ac4 --- /dev/null +++ b/code/core/network_emulator.py @@ -0,0 +1,233 @@ +"""Deterministic packet channels and timeout semantics for G0c experiments.""" + +from __future__ import annotations + +from dataclasses import dataclass +import heapq +from typing import Generic, Iterable, TypeVar + +import numpy as np + +from core.feedback_protocol import ( + PacketRejectReason, + PacketState, +) + + +PacketT = TypeVar("PacketT") + + +@dataclass(frozen=True) +class NetworkTraceEntry: + """Transport decision for one sequence number.""" + + seq: int + delay_s: float + lost: bool = False + duplicate: bool = False + corrupt: bool = False + + def __post_init__(self) -> None: + if self.seq < 0: + raise ValueError("seq must be non-negative") + if not np.isfinite(self.delay_s) or self.delay_s < 0.0: + raise ValueError("delay_s must be finite and non-negative") + + +def generate_network_trace( + count: int, + *, + base_delay_s: float, + jitter_s: float = 0.0, + loss_probability: float = 0.0, + duplicate_probability: float = 0.0, + corrupt_probability: float = 0.0, + seed: int = 0, +) -> tuple[NetworkTraceEntry, ...]: + """Generate a reusable trace without coupling other random streams.""" + if count < 0: + raise ValueError("count must be non-negative") + probabilities = ( + loss_probability, + duplicate_probability, + corrupt_probability, + ) + if any(not 0.0 <= value <= 1.0 for value in probabilities): + raise ValueError("network probabilities must lie in [0, 1]") + if base_delay_s < 0.0 or jitter_s < 0.0: + raise ValueError("delay and jitter must be non-negative") + rng = np.random.default_rng(seed) + entries = [] + for seq in range(count): + jitter = rng.uniform(-jitter_s, jitter_s) if jitter_s else 0.0 + entries.append( + NetworkTraceEntry( + seq=seq, + delay_s=max(0.0, base_delay_s + jitter), + lost=bool(rng.random() < loss_probability), + duplicate=bool(rng.random() < duplicate_probability), + corrupt=bool(rng.random() < corrupt_probability), + ) + ) + return tuple(entries) + + +@dataclass(frozen=True) +class Delivery(Generic[PacketT]): + packet: PacketT + source_time: float + arrival_time: float + corrupt: bool + duplicate_copy: bool + + +class DeterministicChannel(Generic[PacketT]): + """One scheduled channel driven entirely by a frozen trace.""" + + def __init__(self, trace: Iterable[NetworkTraceEntry]): + self._trace = {entry.seq: entry for entry in trace} + self._pending: list[tuple[float, int, Delivery[PacketT]]] = [] + self._insertion_order = 0 + + def send(self, packet: PacketT, now: float) -> None: + seq = int(getattr(packet, "seq")) + entry = self._trace.get(seq, NetworkTraceEntry(seq=seq, delay_s=0.0)) + if entry.lost: + return + arrival = float(now) + entry.delay_s + delivery = Delivery( + packet=packet, + source_time=float(getattr(packet, "source_time")), + arrival_time=arrival, + corrupt=entry.corrupt, + duplicate_copy=False, + ) + heapq.heappush( + self._pending, (arrival, self._insertion_order, delivery) + ) + self._insertion_order += 1 + if entry.duplicate: + duplicate = Delivery( + packet=packet, + source_time=delivery.source_time, + arrival_time=arrival + 1e-12, + corrupt=entry.corrupt, + duplicate_copy=True, + ) + heapq.heappush( + self._pending, + (duplicate.arrival_time, self._insertion_order, duplicate), + ) + self._insertion_order += 1 + + def poll(self, now: float) -> list[Delivery[PacketT]]: + ready: list[Delivery[PacketT]] = [] + while self._pending and self._pending[0][0] <= float(now) + 1e-15: + _, _, delivery = heapq.heappop(self._pending) + ready.append(delivery) + return ready + + @property + def pending_count(self) -> int: + return len(self._pending) + + +@dataclass(frozen=True) +class Reception: + seq: int + arrival_time: float + age_s: float + accepted: bool + reason: PacketRejectReason + state: PacketState + + +@dataclass(frozen=True) +class HeldPacket(Generic[PacketT]): + packet: PacketT | None + state: PacketState + age_s: float | None + fresh: bool + + +class PacketReceiver(Generic[PacketT]): + """Newest-sequence hold, timeout, and recovery state machine.""" + + def __init__(self, timeout_s: float): + if not np.isfinite(timeout_s) or timeout_s <= 0.0: + raise ValueError("timeout_s must be finite and positive") + self.timeout_s = float(timeout_s) + self.last_seq = -1 + self.last_packet: PacketT | None = None + self.last_arrival_time: float | None = None + self.state = PacketState.EMPTY + self._fresh = False + self._recovering_sample_pending = False + + def accept(self, delivery: Delivery[PacketT]) -> Reception: + packet = delivery.packet + seq = int(getattr(packet, "seq")) + valid = bool(getattr(packet, "valid", True)) + source_time = float(getattr(packet, "source_time")) + age = max(0.0, delivery.arrival_time - source_time) + + if delivery.corrupt: + reason = PacketRejectReason.CORRUPT + elif not valid: + reason = PacketRejectReason.INVALID + elif seq <= self.last_seq: + reason = PacketRejectReason.DUPLICATE_OR_STALE + else: + previous = self.state + self.last_seq = seq + self.last_packet = packet + self.last_arrival_time = delivery.arrival_time + self._fresh = True + self._recovering_sample_pending = previous in ( + PacketState.EMPTY, + PacketState.TIMED_OUT, + ) + self.state = ( + PacketState.RECOVERING + if self._recovering_sample_pending + else PacketState.ACTIVE + ) + return Reception( + seq=seq, + arrival_time=delivery.arrival_time, + age_s=age, + accepted=True, + reason=PacketRejectReason.ACCEPTED, + state=self.state, + ) + + return Reception( + seq=seq, + arrival_time=delivery.arrival_time, + age_s=age, + accepted=False, + reason=reason, + state=self.state, + ) + + def sample(self, now: float) -> HeldPacket[PacketT]: + if self.last_packet is None or self.last_arrival_time is None: + self.state = PacketState.EMPTY + return HeldPacket(None, self.state, None, False) + age = max(0.0, float(now) - self.last_arrival_time) + if age > self.timeout_s: + self.state = PacketState.TIMED_OUT + self._fresh = False + return HeldPacket(None, self.state, age, False) + + if self._recovering_sample_pending: + state = PacketState.RECOVERING + self._recovering_sample_pending = False + elif self._fresh: + state = PacketState.ACTIVE + else: + state = PacketState.HELD + fresh = self._fresh + self._fresh = False + self.state = state + return HeldPacket(self.last_packet, state, age, fresh) diff --git a/code/core/retargeting_baselines.py b/code/core/retargeting_baselines.py new file mode 100644 index 0000000..36fd7ff --- /dev/null +++ b/code/core/retargeting_baselines.py @@ -0,0 +1,1123 @@ +"""Reproducible retargeting contracts and H1 baseline implementations. + +The historical retargeting scripts returned method-specific tuples and debug +dictionaries. Formal trajectory experiments need every method to expose the +same success/failure semantics and solver diagnostics. This module provides +that contract without changing :mod:`core.sew_mapper2`. + +The two Cartesian baselines intentionally share one :class:`PoseTargetBuilder` +so their comparison changes only the inverse-kinematics method. The builder +maps *changes* in the master shoulder-relative end-effector pose about frozen +reference configurations. This makes the reference pose exactly attainable +on the slave while keeping the mapping explicit and reproducible. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from time import perf_counter +from typing import Optional, Protocol, Sequence, runtime_checkable + +import numpy as np +import pinocchio as pin + +from .model_contract import ( + MASTER_FRAMES, + MASTER_JOINT_NAMES, + SLAVE_FRAMES, + SLAVE_JOINT_NAMES, + TeleoperationModels, + finite_joint_limits, + joint_q_indices, + load_models, + require_frame, + safe_configuration, +) +from .sew_mapper2 import BallJointConfig, SEWMapper + + +def _readonly_array(value: np.ndarray, shape: tuple[int, ...], name: str) -> np.ndarray: + array = np.asarray(value, dtype=float).copy() + if array.shape != shape: + raise ValueError(f"{name} must have shape {shape}, got {array.shape}") + if not np.all(np.isfinite(array)): + raise ValueError(f"{name} must contain only finite values") + array.setflags(write=False) + return array + + +def _pose_error( + current_position: np.ndarray, + current_rotation: np.ndarray, + target: "PoseTarget", +) -> tuple[np.ndarray, np.ndarray]: + """Return position and world-axis orientation errors.""" + position_error = target.position - current_position + orientation_error = pin.log3(target.rotation @ current_rotation.T) + return position_error, orientation_error + + +class RetargetingFailure(str, Enum): + """Stable trajectory-level failure categories used by H1.""" + + NONE = "none" + INVALID_INPUT = "invalid_input" + MASTER_LIMIT_VIOLATION = "master_limit_violation" + DEGENERATE_GEOMETRY = "degenerate_geometry" + JOINT_LIMIT_VIOLATION = "joint_limit_violation" + TASK_TOLERANCE_EXCEEDED = "task_tolerance_exceeded" + SOLVER_NOT_CONVERGED = "solver_not_converged" + NUMERICAL_FAILURE = "numerical_failure" + + +class RetargetingSolverStatus(str, Enum): + """Method-independent solver termination status.""" + + CLOSED_FORM = "closed_form" + CONVERGED = "converged" + MAX_ITERATIONS = "max_iterations" + INVALID_INPUT = "invalid_input" + FAILED = "failed" + + +@dataclass(frozen=True) +class PoseTarget: + """Slave end-effector target, expressed in the slave model world frame.""" + + position: np.ndarray + rotation: np.ndarray + valid: bool = True + smooth: bool = True + events: tuple[str, ...] = () + + def __post_init__(self) -> None: + object.__setattr__( + self, "position", _readonly_array(self.position, (3,), "position") + ) + object.__setattr__( + self, "rotation", _readonly_array(self.rotation, (3, 3), "rotation") + ) + if not np.allclose(self.rotation.T @ self.rotation, np.eye(3), atol=1e-8): + raise ValueError("rotation must be orthonormal") + if np.linalg.det(self.rotation) <= 0.0: + raise ValueError("rotation must be a proper rotation") + object.__setattr__( + self, "events", tuple(str(event) for event in self.events) + ) + if self.smooth and not self.valid: + raise ValueError("an invalid target cannot be smooth") + + +@dataclass(frozen=True) +class RetargetingDiagnostics: + """Diagnostics recorded once per retargeting sample.""" + + status: RetargetingSolverStatus + iterations: int + runtime_s: float + cost: float + position_error_m: float + orientation_error_rad: float + active_limit_indices: tuple[int, ...] = () + clipped: bool = False + message: str = "" + + def __post_init__(self) -> None: + if self.iterations < 0: + raise ValueError("iterations must be non-negative") + numeric = ( + self.runtime_s, + self.cost, + self.position_error_m, + self.orientation_error_rad, + ) + if any(not np.isfinite(item) or item < 0.0 for item in numeric): + raise ValueError("diagnostic scalars must be finite and non-negative") + + +@dataclass(frozen=True) +class RetargetingResult: + """Uniform output returned by every retargeting method.""" + + method: str + q_slave: np.ndarray + success: bool + smooth: bool + failure: RetargetingFailure + diagnostics: RetargetingDiagnostics + target: Optional[PoseTarget] = None + events: tuple[str, ...] = () + + def __post_init__(self) -> None: + q_slave = np.asarray(self.q_slave, dtype=float).copy() + if q_slave.ndim != 1: + raise ValueError("q_slave must be a one-dimensional configuration") + if not np.all(np.isfinite(q_slave)): + raise ValueError("q_slave must contain only finite values") + q_slave.setflags(write=False) + object.__setattr__(self, "q_slave", q_slave) + if not self.method: + raise ValueError("method must be non-empty") + if self.success != (self.failure is RetargetingFailure.NONE): + raise ValueError("success must be equivalent to failure == NONE") + if self.smooth and not self.success: + raise ValueError("a failed result cannot be smooth") + + +@runtime_checkable +class Retargeter(Protocol): + """Structural interface consumed by a future trajectory experiment runner.""" + + name: str + + def retarget( + self, + q_master: np.ndarray, + q_slave_seed: Optional[np.ndarray] = None, + ) -> RetargetingResult: + ... + + +class PoseTargetProvider(Protocol): + """Target policy shared by Cartesian retargeting baselines.""" + + q_slave_reference: np.ndarray + + def build(self, q_master: np.ndarray) -> PoseTarget: + ... + + +class PoseTargetBuilder: + """Build one frozen, cross-embodiment Cartesian target convention.""" + + def __init__( + self, + master_model: pin.Model, + slave_model: pin.Model, + *, + master_joint_names: Sequence[str], + slave_joint_names: Sequence[str], + master_shoulder_frame: str, + master_ee_frame: str, + slave_shoulder_frame: str, + slave_ee_frame: str, + q_master_reference: Optional[np.ndarray] = None, + q_slave_reference: Optional[np.ndarray] = None, + translation_scale: Optional[float] = None, + ) -> None: + self.master_model = master_model + self.slave_model = slave_model + self.master_data = master_model.createData() + self.slave_data = slave_model.createData() + self.master_q_indices = joint_q_indices(master_model, master_joint_names) + self.slave_q_indices = joint_q_indices(slave_model, slave_joint_names) + self.master_shoulder_id = require_frame(master_model, master_shoulder_frame) + self.master_ee_id = require_frame(master_model, master_ee_frame) + self.slave_shoulder_id = require_frame(slave_model, slave_shoulder_frame) + self.slave_ee_id = require_frame(slave_model, slave_ee_frame) + + q_m_ref = ( + safe_configuration(master_model, master_joint_names) + if q_master_reference is None + else np.asarray(q_master_reference, dtype=float) + ) + q_s_ref = ( + safe_configuration(slave_model, slave_joint_names) + if q_slave_reference is None + else np.asarray(q_slave_reference, dtype=float) + ) + self.q_master_reference = _readonly_array( + q_m_ref, (master_model.nq,), "q_master_reference" + ) + self.q_slave_reference = _readonly_array( + q_s_ref, (slave_model.nq,), "q_slave_reference" + ) + + master_reference = self._master_pose(self.q_master_reference) + slave_reference = self._slave_pose(self.q_slave_reference) + self._master_relative_reference = ( + master_reference[0] - master_reference[2] + ) + self._slave_position_reference = slave_reference[0] + self._orientation_alignment = ( + slave_reference[1] @ master_reference[1].T + ) + self._position_axis_alignment = ( + slave_reference[3] @ master_reference[3].T + ) + + master_reach = float(np.linalg.norm(self._master_relative_reference)) + slave_reach = float( + np.linalg.norm(slave_reference[0] - slave_reference[2]) + ) + if master_reach <= 1e-9 or slave_reach <= 1e-9: + raise ValueError("reference shoulder-to-EE distances must be non-zero") + scale = slave_reach / master_reach if translation_scale is None else float( + translation_scale + ) + if not np.isfinite(scale) or scale <= 0.0: + raise ValueError("translation_scale must be finite and positive") + self.translation_scale = scale + + def _master_pose( + self, q: np.ndarray + ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + pin.forwardKinematics(self.master_model, self.master_data, q) + pin.updateFramePlacements(self.master_model, self.master_data) + ee = self.master_data.oMf[self.master_ee_id] + shoulder = self.master_data.oMf[self.master_shoulder_id] + return ( + ee.translation.copy(), + ee.rotation.copy(), + shoulder.translation.copy(), + shoulder.rotation.copy(), + ) + + def _slave_pose( + self, q: np.ndarray + ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + pin.forwardKinematics(self.slave_model, self.slave_data, q) + pin.updateFramePlacements(self.slave_model, self.slave_data) + ee = self.slave_data.oMf[self.slave_ee_id] + shoulder = self.slave_data.oMf[self.slave_shoulder_id] + return ( + ee.translation.copy(), + ee.rotation.copy(), + shoulder.translation.copy(), + shoulder.rotation.copy(), + ) + + def build(self, q_master: np.ndarray) -> PoseTarget: + q_master = _readonly_array( + q_master, (self.master_model.nq,), "q_master" + ) + position, rotation, shoulder_position, _ = self._master_pose(q_master) + relative = position - shoulder_position + relative_delta = relative - self._master_relative_reference + target_position = self._slave_position_reference + ( + self.translation_scale + * self._position_axis_alignment + @ relative_delta + ) + target_rotation = self._orientation_alignment @ rotation + return PoseTarget(target_position, target_rotation) + + +class SEWFeasiblePoseTargetBuilder: + """Expose the SEW mapper's feasible wrist target to comparator IK methods. + + This builder supports the attribution block in which bounded DLS and + task-priority IK receive exactly the same wrist position/orientation target + as the proposed SEW recovery. It deliberately does not expose the SEW + elbow target to either comparator. + """ + + def __init__( + self, + mapper: SEWMapper, + q_slave_reference: Optional[np.ndarray] = None, + ) -> None: + self.mapper = mapper + reference = ( + safe_configuration( + mapper.s_model, + tuple( + mapper.s_model.names[joint_id] + for joint_id in mapper.s_joint_ids + ), + ) + if q_slave_reference is None + else np.asarray(q_slave_reference, dtype=float) + ) + self.q_slave_reference = _readonly_array( + reference, (mapper.s_model.nq,), "q_slave_reference" + ) + + def build(self, q_master: np.ndarray) -> PoseTarget: + q_master = np.asarray(q_master, dtype=float) + if q_master.shape == (self.mapper.m_model.nq,): + q_master7 = q_master[self.mapper.m_qidx7] + elif q_master.shape == (7,): + q_master7 = q_master + else: + raise ValueError( + "q_master must be a full master configuration or a 7-vector" + ) + target = self.mapper._target_from_master(q_master7) + nonsmooth_events = { + "reach_clipped_lower", + "reach_clipped_upper", + "reference_axis_fallback", + "master_shoulder_wrist_degenerate", + "master_arm_plane_degenerate", + } + events = tuple(str(event) for event in target["events"]) + valid = bool(target["hard_geometry_valid"]) + smooth = bool( + valid and not any(event in nonsmooth_events for event in events) + ) + return PoseTarget( + target["pW_s_ref"], + target["RmEE"], + valid=valid, + smooth=smooth, + events=events, + ) + + +class _RetargetingBase: + """Shared validation and kinematic diagnostics.""" + + name = "retargeting_base" + + def __init__( + self, + models: TeleoperationModels, + target_builder: PoseTargetProvider, + *, + master_joint_names: Sequence[str] = MASTER_JOINT_NAMES, + slave_joint_names: Sequence[str] = SLAVE_JOINT_NAMES, + slave_ee_frame: str = SLAVE_FRAMES["ee"], + joint_limit_margin: float = 1e-4, + ) -> None: + self.master_model = models.master + self.slave_model = models.slave + self.target_builder = target_builder + self.master_q_indices = joint_q_indices( + self.master_model, master_joint_names + ) + self.slave_q_indices = joint_q_indices(self.slave_model, slave_joint_names) + self.master_lower, self.master_upper = finite_joint_limits( + self.master_model, master_joint_names + ) + self.slave_lower, self.slave_upper = finite_joint_limits( + self.slave_model, slave_joint_names + ) + self.slave_ee_id = require_frame(self.slave_model, slave_ee_frame) + self.slave_data = self.slave_model.createData() + self.joint_limit_margin = float(joint_limit_margin) + if not np.isfinite(self.joint_limit_margin) or self.joint_limit_margin < 0.0: + raise ValueError("joint_limit_margin must be finite and non-negative") + + def _master_configuration( + self, q_master: np.ndarray + ) -> tuple[Optional[np.ndarray], Optional[RetargetingFailure]]: + q_input = np.asarray(q_master, dtype=float) + if q_input.shape == (self.master_model.nq,): + q = q_input.copy() + elif q_input.shape == (len(self.master_q_indices),): + q = pin.neutral(self.master_model) + q[self.master_q_indices] = q_input + else: + return None, RetargetingFailure.INVALID_INPUT + if not np.all(np.isfinite(q)): + return None, RetargetingFailure.INVALID_INPUT + q7 = q[self.master_q_indices] + if np.any(q7 < self.master_lower) or np.any(q7 > self.master_upper): + return None, RetargetingFailure.MASTER_LIMIT_VIOLATION + return q, None + + def _slave_seed(self, seed: Optional[np.ndarray]) -> Optional[np.ndarray]: + if seed is None: + return np.asarray( + self.target_builder.q_slave_reference, dtype=float + ).copy() + seed_array = np.asarray(seed, dtype=float) + if seed_array.shape == (self.slave_model.nq,): + q = seed_array.copy() + elif seed_array.shape == (len(self.slave_q_indices),): + q = pin.neutral(self.slave_model) + q[self.slave_q_indices] = seed_array + else: + return None + if not np.all(np.isfinite(q)): + return None + q7 = q[self.slave_q_indices] + if np.any(q7 < self.slave_lower) or np.any(q7 > self.slave_upper): + return None + return q + + def _kinematics( + self, q_slave: np.ndarray, target: PoseTarget + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + pin.forwardKinematics(self.slave_model, self.slave_data, q_slave) + pin.updateFramePlacements(self.slave_model, self.slave_data) + placement = self.slave_data.oMf[self.slave_ee_id] + position_error, orientation_error = _pose_error( + placement.translation, placement.rotation, target + ) + jacobian = pin.computeFrameJacobian( + self.slave_model, + self.slave_data, + q_slave, + self.slave_ee_id, + pin.ReferenceFrame.LOCAL_WORLD_ALIGNED, + ) + return position_error, orientation_error, jacobian[:, self.slave_q_indices] + + def _active_limits(self, q_slave: np.ndarray) -> tuple[int, ...]: + q7 = q_slave[self.slave_q_indices] + clearance = np.minimum(q7 - self.slave_lower, self.slave_upper - q7) + return tuple( + int(index) + for index in np.flatnonzero(clearance <= self.joint_limit_margin) + ) + + def _invalid_result( + self, + failure: RetargetingFailure, + started_at: float, + message: str, + ) -> RetargetingResult: + q = np.asarray(self.target_builder.q_slave_reference, dtype=float) + diagnostics = RetargetingDiagnostics( + status=RetargetingSolverStatus.INVALID_INPUT, + iterations=0, + runtime_s=max(0.0, perf_counter() - started_at), + cost=0.0, + position_error_m=0.0, + orientation_error_rad=0.0, + message=message, + ) + return RetargetingResult( + method=self.name, + q_slave=q, + success=False, + smooth=False, + failure=failure, + diagnostics=diagnostics, + events=(failure.value,), + ) + + def _invalid_target_result( + self, + target: PoseTarget, + started_at: float, + ) -> RetargetingResult: + diagnostics = RetargetingDiagnostics( + status=RetargetingSolverStatus.FAILED, + iterations=0, + runtime_s=max(0.0, perf_counter() - started_at), + cost=0.0, + position_error_m=0.0, + orientation_error_rad=0.0, + message="invalid Cartesian target", + ) + events = tuple( + dict.fromkeys( + (*target.events, RetargetingFailure.DEGENERATE_GEOMETRY.value) + ) + ) + return RetargetingResult( + method=self.name, + q_slave=self.target_builder.q_slave_reference, + success=False, + smooth=False, + failure=RetargetingFailure.DEGENERATE_GEOMETRY, + diagnostics=diagnostics, + target=target, + events=events, + ) + + +class ScaledJointSpaceRetargeter(_RetargetingBase): + """Anthropometrically scaled joint-range mapping baseline.""" + + name = "scaled_joint_space" + + def retarget( + self, + q_master: np.ndarray, + q_slave_seed: Optional[np.ndarray] = None, + ) -> RetargetingResult: + del q_slave_seed # Closed-form baseline has no branch state. + started_at = perf_counter() + q_m, failure = self._master_configuration(q_master) + if failure is not None: + return self._invalid_result(failure, started_at, failure.value) + + target = self.target_builder.build(q_m) + if not target.valid: + return self._invalid_target_result(target, started_at) + normalized = ( + q_m[self.master_q_indices] - self.master_lower + ) / (self.master_upper - self.master_lower) + q_slave = pin.neutral(self.slave_model) + q_slave[self.slave_q_indices] = self.slave_lower + normalized * ( + self.slave_upper - self.slave_lower + ) + position_error, orientation_error, _ = self._kinematics(q_slave, target) + active = self._active_limits(q_slave) + position_norm = float(np.linalg.norm(position_error)) + orientation_norm = float(np.linalg.norm(orientation_error)) + diagnostics = RetargetingDiagnostics( + status=RetargetingSolverStatus.CLOSED_FORM, + iterations=0, + runtime_s=perf_counter() - started_at, + cost=0.5 * (position_norm**2 + orientation_norm**2), + position_error_m=position_norm, + orientation_error_rad=orientation_norm, + active_limit_indices=active, + ) + return RetargetingResult( + method=self.name, + q_slave=q_slave, + success=True, + smooth=bool(target.smooth and not active), + failure=RetargetingFailure.NONE, + diagnostics=diagnostics, + target=target, + events=( + *target.events, + *(("joint_limit_active",) if active else ()), + ), + ) + + +class BoundedDLSIKRetargeter(_RetargetingBase): + """Bounded iterative damped-least-squares Cartesian IK baseline.""" + + name = "bounded_dls_ik" + + def __init__( + self, + models: TeleoperationModels, + target_builder: PoseTargetProvider, + *, + damping: float = 2e-3, + orientation_weight_m: float = 0.20, + max_iterations: int = 160, + step_limit_rad: float = 0.20, + position_tolerance_m: float = 2e-4, + orientation_tolerance_rad: float = 2e-3, + **kwargs, + ) -> None: + super().__init__(models, target_builder, **kwargs) + self.damping = float(damping) + self.orientation_weight_m = float(orientation_weight_m) + self.max_iterations = int(max_iterations) + self.step_limit_rad = float(step_limit_rad) + self.position_tolerance_m = float(position_tolerance_m) + self.orientation_tolerance_rad = float(orientation_tolerance_rad) + positive = ( + self.damping, + self.orientation_weight_m, + self.step_limit_rad, + self.position_tolerance_m, + self.orientation_tolerance_rad, + ) + if any(not np.isfinite(value) or value <= 0.0 for value in positive): + raise ValueError("DLS tuning parameters must be finite and positive") + if self.max_iterations <= 0: + raise ValueError("max_iterations must be positive") + + def _weighted_cost( + self, q_slave: np.ndarray, target: PoseTarget + ) -> tuple[float, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + ep, eo, jacobian = self._kinematics(q_slave, target) + error = np.concatenate((ep, self.orientation_weight_m * eo)) + weighted_jacobian = jacobian.copy() + weighted_jacobian[3:, :] *= self.orientation_weight_m + return 0.5 * float(error @ error), error, ep, eo, weighted_jacobian + + def retarget( + self, + q_master: np.ndarray, + q_slave_seed: Optional[np.ndarray] = None, + ) -> RetargetingResult: + started_at = perf_counter() + q_m, failure = self._master_configuration(q_master) + if failure is not None: + return self._invalid_result(failure, started_at, failure.value) + q_slave = self._slave_seed(q_slave_seed) + if q_slave is None: + return self._invalid_result( + RetargetingFailure.INVALID_INPUT, + started_at, + "invalid q_slave_seed", + ) + target = self.target_builder.build(q_m) + if not target.valid: + return self._invalid_target_result(target, started_at) + + status = RetargetingSolverStatus.MAX_ITERATIONS + iterations = 0 + numerical_failure = False + for iterations in range(1, self.max_iterations + 1): + cost, error, ep, eo, jacobian = self._weighted_cost(q_slave, target) + if ( + np.linalg.norm(ep) <= self.position_tolerance_m + and np.linalg.norm(eo) <= self.orientation_tolerance_rad + ): + status = RetargetingSolverStatus.CONVERGED + break + try: + normal = jacobian @ jacobian.T + ( + self.damping**2 + ) * np.eye(6) + step = jacobian.T @ np.linalg.solve(normal, error) + except np.linalg.LinAlgError: + numerical_failure = True + status = RetargetingSolverStatus.FAILED + break + step_norm = float(np.linalg.norm(step)) + if not np.isfinite(step_norm): + numerical_failure = True + status = RetargetingSolverStatus.FAILED + break + if step_norm > self.step_limit_rad: + step *= self.step_limit_rad / step_norm + + q7 = q_slave[self.slave_q_indices] + accepted = False + for scale in (1.0, 0.5, 0.25, 0.125, 0.0625): + candidate = q_slave.copy() + candidate[self.slave_q_indices] = np.clip( + q7 + scale * step, self.slave_lower, self.slave_upper + ) + candidate_cost = self._weighted_cost(candidate, target)[0] + if candidate_cost < cost: + q_slave = candidate + accepted = True + break + if not accepted: + # A deterministic small step lets the active set change while + # still bounding the solver near a stationary point. + candidate = q_slave.copy() + candidate[self.slave_q_indices] = np.clip( + q7 + 0.01 * step, self.slave_lower, self.slave_upper + ) + if self._weighted_cost(candidate, target)[0] < cost: + q_slave = candidate + else: + status = RetargetingSolverStatus.FAILED + break + + cost, _, ep, eo, _ = self._weighted_cost(q_slave, target) + position_norm = float(np.linalg.norm(ep)) + orientation_norm = float(np.linalg.norm(eo)) + converged = bool( + position_norm <= self.position_tolerance_m + and orientation_norm <= self.orientation_tolerance_rad + ) + if converged: + status = RetargetingSolverStatus.CONVERGED + failure = RetargetingFailure.NONE + elif numerical_failure: + failure = RetargetingFailure.NUMERICAL_FAILURE + elif iterations >= self.max_iterations: + failure = RetargetingFailure.SOLVER_NOT_CONVERGED + else: + failure = RetargetingFailure.TASK_TOLERANCE_EXCEEDED + active = self._active_limits(q_slave) + diagnostics = RetargetingDiagnostics( + status=status, + iterations=iterations, + runtime_s=perf_counter() - started_at, + cost=cost, + position_error_m=position_norm, + orientation_error_rad=orientation_norm, + active_limit_indices=active, + clipped=bool(active), + ) + events: list[str] = list(target.events) + if failure is not RetargetingFailure.NONE: + events.append(failure.value) + if active: + events.append("joint_limit_active") + return RetargetingResult( + method=self.name, + q_slave=q_slave, + success=converged, + smooth=bool(converged and target.smooth and not active), + failure=failure, + diagnostics=diagnostics, + target=target, + events=tuple(events), + ) + + +class TaskPriorityIKRetargeter(BoundedDLSIKRetargeter): + """Position-first IK with orientation, centering, and manipulability tasks.""" + + name = "task_priority_ik" + + def __init__( + self, + models: TeleoperationModels, + target_builder: PoseTargetProvider, + *, + joint_centering_gain: float = 0.05, + manipulability_gain: float = 0.005, + manipulability_fd_step_rad: float = 1e-4, + manipulability_regularization: float = 1e-8, + **kwargs, + ) -> None: + super().__init__(models, target_builder, **kwargs) + self.joint_centering_gain = float(joint_centering_gain) + self.manipulability_gain = float(manipulability_gain) + self.manipulability_fd_step_rad = float(manipulability_fd_step_rad) + self.manipulability_regularization = float( + manipulability_regularization + ) + if ( + not np.isfinite(self.joint_centering_gain) + or self.joint_centering_gain < 0.0 + ): + raise ValueError( + "joint_centering_gain must be finite and non-negative" + ) + nonnegative = ( + self.manipulability_gain, + self.manipulability_fd_step_rad, + self.manipulability_regularization, + ) + if any(not np.isfinite(value) or value < 0.0 for value in nonnegative): + raise ValueError( + "manipulability settings must be finite and non-negative" + ) + if self.manipulability_gain > 0.0 and ( + self.manipulability_fd_step_rad <= 0.0 + or self.manipulability_regularization <= 0.0 + ): + raise ValueError( + "enabled manipulability optimization needs positive FD and " + "regularization values" + ) + + def _damped_pseudoinverse(self, matrix: np.ndarray) -> np.ndarray: + rows = matrix.shape[0] + return matrix.T @ np.linalg.solve( + matrix @ matrix.T + (self.damping**2) * np.eye(rows), + np.eye(rows), + ) + + def _log_manipulability(self, q_slave: np.ndarray) -> float: + """Regularized log-volume of the six-dimensional velocity ellipsoid.""" + pin.forwardKinematics(self.slave_model, self.slave_data, q_slave) + pin.updateFramePlacements(self.slave_model, self.slave_data) + jacobian = pin.computeFrameJacobian( + self.slave_model, + self.slave_data, + q_slave, + self.slave_ee_id, + pin.ReferenceFrame.LOCAL_WORLD_ALIGNED, + )[:, self.slave_q_indices] + sign, log_determinant = np.linalg.slogdet( + jacobian @ jacobian.T + + self.manipulability_regularization * np.eye(6) + ) + return 0.5 * float(log_determinant) if sign > 0.0 else -np.inf + + def _manipulability_gradient(self, q_slave: np.ndarray) -> np.ndarray: + if self.manipulability_gain == 0.0: + return np.zeros(7, dtype=float) + gradient = np.zeros(7, dtype=float) + q7 = q_slave[self.slave_q_indices] + step = self.manipulability_fd_step_rad + for joint in range(7): + plus = q_slave.copy() + minus = q_slave.copy() + plus7 = q7.copy() + minus7 = q7.copy() + plus7[joint] = min(self.slave_upper[joint], plus7[joint] + step) + minus7[joint] = max(self.slave_lower[joint], minus7[joint] - step) + denominator = plus7[joint] - minus7[joint] + if denominator <= 0.0: + continue + plus[self.slave_q_indices] = plus7 + minus[self.slave_q_indices] = minus7 + gradient[joint] = ( + self._log_manipulability(plus) + - self._log_manipulability(minus) + ) / denominator + return gradient + + def retarget( + self, + q_master: np.ndarray, + q_slave_seed: Optional[np.ndarray] = None, + ) -> RetargetingResult: + started_at = perf_counter() + q_m, failure = self._master_configuration(q_master) + if failure is not None: + return self._invalid_result(failure, started_at, failure.value) + q_slave = self._slave_seed(q_slave_seed) + if q_slave is None: + return self._invalid_result( + RetargetingFailure.INVALID_INPUT, + started_at, + "invalid q_slave_seed", + ) + target = self.target_builder.build(q_m) + if not target.valid: + return self._invalid_target_result(target, started_at) + center = 0.5 * (self.slave_lower + self.slave_upper) + + status = RetargetingSolverStatus.MAX_ITERATIONS + numerical_failure = False + iterations = 0 + for iterations in range(1, self.max_iterations + 1): + cost, _, ep, eo, jacobian = self._weighted_cost(q_slave, target) + if ( + np.linalg.norm(ep) <= self.position_tolerance_m + and np.linalg.norm(eo) <= self.orientation_tolerance_rad + ): + status = RetargetingSolverStatus.CONVERGED + break + try: + jp = jacobian[:3, :] + jo = jacobian[3:, :] / self.orientation_weight_m + jp_inverse = self._damped_pseudoinverse(jp) + dq_position = jp_inverse @ ep + null_position = np.eye(7) - jp_inverse @ jp + + jo_null = jo @ null_position + jo_null_inverse = self._damped_pseudoinverse(jo_null) + dq_orientation = jo_null_inverse @ (eo - jo @ dq_position) + + full_jacobian = np.vstack((jp, jo)) + full_inverse = self._damped_pseudoinverse(full_jacobian) + null_full = np.eye(7) - full_inverse @ full_jacobian + q7 = q_slave[self.slave_q_indices] + dq_center = ( + self.joint_centering_gain * null_full @ (center - q7) + ) + dq_manipulability = ( + self.manipulability_gain + * null_full + @ self._manipulability_gradient(q_slave) + ) + step = ( + dq_position + + dq_orientation + + dq_center + + dq_manipulability + ) + except np.linalg.LinAlgError: + numerical_failure = True + status = RetargetingSolverStatus.FAILED + break + step_norm = float(np.linalg.norm(step)) + if not np.isfinite(step_norm): + numerical_failure = True + status = RetargetingSolverStatus.FAILED + break + if step_norm > self.step_limit_rad: + step *= self.step_limit_rad / step_norm + + q7 = q_slave[self.slave_q_indices] + accepted = False + for scale in (1.0, 0.5, 0.25, 0.125, 0.0625): + candidate = q_slave.copy() + candidate[self.slave_q_indices] = np.clip( + q7 + scale * step, self.slave_lower, self.slave_upper + ) + if self._weighted_cost(candidate, target)[0] < cost: + q_slave = candidate + accepted = True + break + if not accepted: + status = RetargetingSolverStatus.FAILED + break + + cost, _, ep, eo, _ = self._weighted_cost(q_slave, target) + position_norm = float(np.linalg.norm(ep)) + orientation_norm = float(np.linalg.norm(eo)) + converged = bool( + position_norm <= self.position_tolerance_m + and orientation_norm <= self.orientation_tolerance_rad + ) + if converged: + status = RetargetingSolverStatus.CONVERGED + failure = RetargetingFailure.NONE + elif numerical_failure: + failure = RetargetingFailure.NUMERICAL_FAILURE + elif iterations >= self.max_iterations: + failure = RetargetingFailure.SOLVER_NOT_CONVERGED + else: + failure = RetargetingFailure.TASK_TOLERANCE_EXCEEDED + active = self._active_limits(q_slave) + diagnostics = RetargetingDiagnostics( + status=status, + iterations=iterations, + runtime_s=perf_counter() - started_at, + cost=cost, + position_error_m=position_norm, + orientation_error_rad=orientation_norm, + active_limit_indices=active, + clipped=bool(active), + message="secondary_objective=log_manipulability", + ) + events: list[str] = list(target.events) + if failure is not RetargetingFailure.NONE: + events.append(failure.value) + if active: + events.append("joint_limit_active") + return RetargetingResult( + method=self.name, + q_slave=q_slave, + success=converged, + smooth=bool(converged and target.smooth and not active), + failure=failure, + diagnostics=diagnostics, + target=target, + events=tuple(events), + ) + + +class SEWRetargeterAdapter: + """Expose the existing bounded SEW mapper through :class:`Retargeter`.""" + + name = "sew" + + def __init__(self, mapper) -> None: + self.mapper = mapper + + def retarget( + self, + q_master: np.ndarray, + q_slave_seed: Optional[np.ndarray] = None, + ) -> RetargetingResult: + started_at = perf_counter() + try: + q_slave, debug = self.mapper.retarget( + q_master, q_s_init=q_slave_seed + ) + except (ValueError, FloatingPointError) as error: + diagnostics = RetargetingDiagnostics( + status=RetargetingSolverStatus.INVALID_INPUT, + iterations=0, + runtime_s=perf_counter() - started_at, + cost=0.0, + position_error_m=0.0, + orientation_error_rad=0.0, + message=str(error), + ) + return RetargetingResult( + method=self.name, + q_slave=pin.neutral(self.mapper.s_model), + success=False, + smooth=False, + failure=RetargetingFailure.INVALID_INPUT, + diagnostics=diagnostics, + events=(RetargetingFailure.INVALID_INPUT.value,), + ) + events = tuple(str(event) for event in debug.get("events", ())) + if debug.get("success", False): + failure = RetargetingFailure.NONE + elif "invalid_master_geometry" in events: + failure = RetargetingFailure.DEGENERATE_GEOMETRY + elif "joint_limit_violation" in events: + failure = RetargetingFailure.JOINT_LIMIT_VIOLATION + elif "task_tolerance_exceeded" in events: + failure = RetargetingFailure.TASK_TOLERANCE_EXCEEDED + elif "bounded_solver_not_converged" in events: + failure = RetargetingFailure.SOLVER_NOT_CONVERGED + else: + failure = RetargetingFailure.NUMERICAL_FAILURE + diagnostics = RetargetingDiagnostics( + status=( + RetargetingSolverStatus.CONVERGED + if failure is RetargetingFailure.NONE + else RetargetingSolverStatus.FAILED + ), + iterations=int(debug.get("solver_nfev", 0)), + runtime_s=perf_counter() - started_at, + cost=max(0.0, float(debug.get("solver_cost", 0.0))), + position_error_m=max(0.0, float(debug.get("position_error", 0.0))), + orientation_error_rad=max( + 0.0, float(debug.get("orientation_error", 0.0)) + ), + active_limit_indices=tuple( + int(index) + for index in debug.get("joint_limit_active_indices", ()) + ), + clipped=bool(debug.get("reach_clipped", False)), + ) + return RetargetingResult( + method=self.name, + q_slave=q_slave, + success=failure is RetargetingFailure.NONE, + smooth=bool(debug.get("smooth", False)), + failure=failure, + diagnostics=diagnostics, + events=events, + ) + + +def build_canonical_baselines( + models: Optional[TeleoperationModels] = None, +) -> dict[str, Retargeter]: + """Build the three frozen H1 baselines for the canonical URDF pair.""" + canonical_models = load_models() if models is None else models + target_builder = PoseTargetBuilder( + canonical_models.master, + canonical_models.slave, + master_joint_names=MASTER_JOINT_NAMES, + slave_joint_names=SLAVE_JOINT_NAMES, + master_shoulder_frame=MASTER_FRAMES["shoulder"], + master_ee_frame=MASTER_FRAMES["ee"], + slave_shoulder_frame=SLAVE_FRAMES["shoulder"], + slave_ee_frame=SLAVE_FRAMES["ee"], + ) + baselines: tuple[Retargeter, ...] = ( + ScaledJointSpaceRetargeter(canonical_models, target_builder), + BoundedDLSIKRetargeter(canonical_models, target_builder), + TaskPriorityIKRetargeter(canonical_models, target_builder), + ) + return {baseline.name: baseline for baseline in baselines} + + +def build_canonical_sew_target_baselines( + models: Optional[TeleoperationModels] = None, +) -> tuple[dict[str, Retargeter], SEWRetargeterAdapter]: + """Build the H1 common-target attribution baselines and proposed adapter.""" + canonical_models = load_models() if models is None else models + mapper = SEWMapper( + master_model=canonical_models.master, + slave_model=canonical_models.slave, + m_shoulder=MASTER_FRAMES["shoulder"], + m_elbow=MASTER_FRAMES["elbow"], + m_wrist=MASTER_FRAMES["wrist"], + m_ee=MASTER_FRAMES["ee"], + s_shoulder=SLAVE_FRAMES["shoulder"], + s_elbow=SLAVE_FRAMES["elbow"], + s_wrist=SLAVE_FRAMES["wrist"], + s_ee=SLAVE_FRAMES["wrist"], + master_joint_names=MASTER_JOINT_NAMES, + slave_joint_names=SLAVE_JOINT_NAMES, + slave_shoulder_cfg=BallJointConfig( + axis_order="yxy", + joint_names=SLAVE_JOINT_NAMES[:3], + signs=(-1.0, 1.0, -1.0), + ), + slave_wrist_cfg=BallJointConfig( + axis_order="yzx", + joint_names=SLAVE_JOINT_NAMES[4:], + signs=(-1.0, 1.0, 1.0), + ), + ) + target_builder = SEWFeasiblePoseTargetBuilder(mapper) + baselines: tuple[Retargeter, ...] = ( + ScaledJointSpaceRetargeter( + canonical_models, + target_builder, + slave_ee_frame=SLAVE_FRAMES["wrist"], + ), + BoundedDLSIKRetargeter( + canonical_models, + target_builder, + slave_ee_frame=SLAVE_FRAMES["wrist"], + ), + TaskPriorityIKRetargeter( + canonical_models, + target_builder, + slave_ee_frame=SLAVE_FRAMES["wrist"], + ), + ) + return ( + {baseline.name: baseline for baseline in baselines}, + SEWRetargeterAdapter(mapper), + ) diff --git a/code/core/sew_mapper.py b/code/core/sew_mapper.py new file mode 100644 index 0000000..1e9f004 --- /dev/null +++ b/code/core/sew_mapper.py @@ -0,0 +1,295 @@ +# -*- coding: utf-8 -*- +import numpy as np +import pinocchio as pin +from pathlib import Path + + +def hat(v): + x, y, z = v + return np.array([[0, -z, y], + [z, 0,-x], + [-y, x, 0]], dtype=float) + + +def normalize(v, eps=1e-12): + n = np.linalg.norm(v) + if n < eps: + return v * 0.0 + return v / n + + +def rodrigues(u, phi): + u = normalize(u) + K = hat(u) + return np.eye(3) + np.sin(phi)*K + (1-np.cos(phi))*(K@K) + + +def minimal_rotation_align(a, b): + a = normalize(a); b = normalize(b) + v = np.cross(a, b) + s = np.linalg.norm(v) + c = float(np.dot(a, b)) + if s < 1e-12: + # parallel or anti-parallel + if c > 0.0: + return np.eye(3) + # 180°: choose any axis orthogonal to a + axis = normalize(np.array([1.0,0,0]) if abs(a[0])<0.9 else np.array([0,1.0,0])) + axis = normalize(np.cross(a, axis)) + K = hat(axis) + return np.eye(3) + 2*(K@K) # R = I + 2[K]^2 for 180° + K = hat(v/s) + return np.eye(3) + K*s + (1-c)/(s*s) * (K@K) + + +def euler_zyx_from_R(R): + """Return Z-Y-X Euler angles (about z,y,x of the shoulder frame).""" + sy = -R[2,0] + cy = np.sqrt(max(0.0, 1.0 - sy*sy)) + if cy > 1e-9: + z = np.arctan2(R[1,0], R[0,0]) + y = np.arctan2(sy, cy) + x = np.arctan2(R[2,1], R[2,2]) + else: + # Gimbal case: cy ~ 0 + z = np.arctan2(-R[0,1], R[1,1]) + y = np.arctan2(sy, cy) + x = 0.0 + return np.array([z, y, x], dtype=float) + +def euler_xzy_from_R(R): + """ + Decompose R ≈ Rx(a) * Rz(b) * Ry(c) + Return (a, b, c) + """ + # from derivation: + # sb = -R[0,1]; cb = sqrt(R[0,0]^2 + R[0,2]^2) + sb = -R[0,1] + cb = np.sqrt(max(0.0, R[0,0]**2 + R[0,2]**2)) + b = np.arctan2(sb, cb) + # c = atan2(R[0,2], R[0,0]) + c = np.arctan2(R[0,2], R[0,0]) + # a from R[1,1] = ca*cb and R[2,1] = sa*cb + if cb < 1e-9: + # singular: cb≈0 => b≈±pi/2,退化时把 a=0,c 吸收残差(简化处理) + a = 0.0 + else: + ca = np.clip(R[1,1] / cb, -1.0, 1.0) + sa = np.clip(R[2,1] / cb, -1.0, 1.0) + a = np.arctan2(sa, ca) + return np.array([a, b, c], dtype=float) + +def euler_zy_from_R(R): + """Return Z-Y Euler angles for wrist (about z then y).""" + # R ≈ Rz(z) * Ry(y) + # y = asin(R[0,2])? We'll use standard decomposition. + # From R = Rz*Ry: + # R[2,0] = -sin(y) + y = np.arcsin(np.clip(R[0,2], -1.0, 1.0)) + cy = np.cos(y) + if abs(cy) < 1e-9: + z = 0.0 + else: + z = np.arctan2(-R[0,1]/cy, R[0,0]/cy) + return np.array([z, y], dtype=float) + + +def rot_error_deg(RA, RB): + # 旋转误差:以李群对数映射的范数(弧度),再转度 + R = RA.T @ RB + w = pin.log3(R) + return np.linalg.norm(w) * 180.0/np.pi + +def pose_of_frame(model, data, frame_name): + fid = model.getFrameId(frame_name) + oMf = data.oMf[fid] + return oMf.translation.copy(), oMf.rotation.copy() + +def fk_update(model, data, q): + pin.forwardKinematics(model, data, q) + pin.updateFramePlacements(model, data) + + + +class SEWMapper: + """ + SEW Mapper (closed-form, no numeric IK): + - Loads master & slave URDFs + - Computes heteromorphic retargeting from master EE pose to slave q_s using SEW geometry + Assumptions: + * Slave arm is 7-DoF in the order: 3 shoulder + 1 elbow + 3 wrist (axes orthogonal at shoulder/wrist frames). + * Provide correct link/frame names and joint name order for your model. + """ + + def __init__( + self, + master_model: pin.Model, + slave_model: pin.Model, + # Frame/link names + m_shoulder_frame: str, + m_elbow_frame: str, + m_wrist_frame: str, + m_ee_frame: str, + s_shoulder_frame: str, + s_elbow_frame: str, + s_wrist_frame: str, + s_ee_frame: str, + # Joint name order for slave 7-DoF: [S1,S2,S3, EL, W1, W2, W3] + slave_joint_names: list, + # world/up direction and safety margins + up_dir=np.array([0,0,1.0]), + eps_clip=1e-3, + ): + # Load master + self.m_model = master_model + self.m_data = self.m_model.createData() + + # Load slave + self.s_model = slave_model + self.s_data = self.s_model.createData() + + # Frame IDs + self.fid_mS = self._get_frame_id(self.m_model, m_shoulder_frame) + self.fid_mE = self._get_frame_id(self.m_model, m_elbow_frame) + self.fid_mW = self._get_frame_id(self.m_model, m_wrist_frame) + self.fid_mEE= self._get_frame_id(self.m_model, m_ee_frame) + + self.fid_sS = self._get_frame_id(self.s_model, s_shoulder_frame) + self.fid_sE = self._get_frame_id(self.s_model, s_elbow_frame) + self.fid_sW = self._get_frame_id(self.s_model, s_wrist_frame) + self.fid_sEE= self._get_frame_id(self.s_model, s_ee_frame) + + # Up direction and epsilon + self.up = normalize(up_dir) + self.eps_clip = float(eps_clip) + + # Query slave joint indices (order critical) + self.s_joint_ids = [self.s_model.getJointId(n) for n in slave_joint_names] + self.s_qidx = [self.s_model.joints[jid].idx_q for jid in self.s_joint_ids] + assert len(self.s_qidx) == 7, "Provide 7 slave joints in order [S1,S2,S3, EL, W1, W2, W3]" + + # Pre-compute slave segment lengths L1, L2 in reference (zero) config + self.qs0 = pin.neutral(self.s_model) + self._update_fk_slave(self.qs0) + pS = self._frame_pos(self.s_data, self.fid_sS) + pE = self._frame_pos(self.s_data, self.fid_sE) + pW = self._frame_pos(self.s_data, self.fid_sW) + self.L1 = float(np.linalg.norm(pE - pS)) + self.L2 = float(np.linalg.norm(pW - pE)) + + # Fixed slave shoulder location in world + self.pS_s_fixed = pS.copy() + + # ------------------ Utilities ------------------ # + def _get_frame_id(self, model, name): + try: + return model.getFrameId(name) + except: + # fallback: also try link->frame mapping via joint name + return model.getFrameId(name) + + def _update_fk_master(self, q_m): + pin.forwardKinematics(self.m_model, self.m_data, q_m) + pin.updateFramePlacements(self.m_model, self.m_data) + + def _update_fk_slave(self, q_s): + pin.forwardKinematics(self.s_model, self.s_data, q_s) + pin.updateFramePlacements(self.s_model, self.s_data) + + def _frame_pos(self, data, fid): + return data.oMf[fid].translation.copy() + + def _frame_rot(self, data, fid): + return data.oMf[fid].rotation.copy() + + # ------------------ Core Retarget ------------------ # + def retargetting(self, q_m, q_s_init=None): + """ + Input: + q_m : master joint configuration (np.ndarray, size = master nq) + q_s_init: optional slave initial seed (ignored for geometry, used only to keep continuity of angle unwrap if desired) + Output: + q_s : slave joint angles (np.ndarray, size = slave nq) + debug : dict with intermediate targets (pE_s, pW_s_ref, phi_m, theta) + """ + # 1) Master FK and SEW quantities + self._update_fk_master(q_m) + pS_m = self._frame_pos(self.m_data, self.fid_mS) + pE_m = self._frame_pos(self.m_data, self.fid_mE) + pW_m = self._frame_pos(self.m_data, self.fid_mW) + # RW_m = self._frame_rot(self.m_data, self.fid_mW) + RE_m = self._frame_rot(self.m_data, self.fid_mEE) + + r_m = pW_m - pS_m + d_m = float(np.linalg.norm(r_m)) + xhat_m = normalize(r_m) + + # Swivel (master) + nm_raw = np.cross(pE_m - pS_m, pW_m - pS_m) + nm = normalize(nm_raw) + nref_tilde = self.up - np.dot(self.up, xhat_m) * xhat_m + if np.linalg.norm(nref_tilde) < 1e-6: + ey = np.array([0,1.0,0]) + nref_tilde = ey - np.dot(ey, xhat_m) * xhat_m + nref = normalize(nref_tilde) + num = np.dot(xhat_m, np.cross(nref, nm)) + den = float(np.dot(nref, nm)) + phi_m = np.arctan2(num, den) + + # 2) Wrist pose and reach clipping on slave + d_min = abs(self.L1 - self.L2) + self.eps_clip + d_max = (self.L1 + self.L2) - self.eps_clip + d_s = np.clip(d_m, d_min, d_max) + xhat_s = xhat_m.copy() + pS_s = self.pS_s_fixed + pW_s_ref = pS_s + d_s * xhat_s + R_ee_ref = RE_m.copy() # preserve orientation + + # 3) Two-sphere elbow construction on slave + d = float(np.linalg.norm(pW_s_ref - pS_s)) + e3 = rodrigues(xhat_s, phi_m) @ nref + e3 = normalize(e3) + e2 = normalize(np.cross(xhat_s, e3)) + cos_th = (self.L1**2 + d**2 - self.L2**2) / (2*self.L1*d) + cos_th = float(np.clip(cos_th, -1.0, 1.0)) + th = np.arccos(cos_th) + sin_th = np.sqrt(max(0.0, 1.0 - cos_th*cos_th)) + pE_s = pS_s + self.L1*(cos_th * xhat_s + sin_th * e2) + + # 4) Joint reconstruction (assumes 3-1-3 structure with Z-Y-X at shoulder and Z-Y at wrist) + q_s = pin.neutral(self.s_model) if q_s_init is None else q_s_init.copy() + + # (a) Set shoulder (first 3 joints): align upper-arm vector + u = normalize(pE_s - pS_s) + y_axis = e3 # 让“肘轴=局部Y”严格对齐 SEW 的平面法向 + x_axis = u + z_axis = normalize(np.cross(x_axis, y_axis)) + RS_des = np.column_stack([x_axis, y_axis, z_axis]) + + + dz, dy, dx = euler_zyx_from_R(RS_des) # R ≈ Rz(dz)*Ry(dy)*Rx(dx) + q_s[self.s_qidx[0]] = dz + q_s[self.s_qidx[1]] = dy + q_s[self.s_qidx[2]] = dx + # (b) Elbow flex = pi - theta + fhat = normalize(pW_s_ref - pE_s) + u = normalize(pE_s - pS_s)# 上臂方向(从肩指向肘) + q_elbow = np.arctan2(np.dot(e3, np.cross(u, fhat)), np.dot(u, fhat))# 计算把 u 绕 e3 旋到 fhat 的有符号角:atan2( 轴·(u×f), u·f ) + q_s[self.s_qidx[3]] = q_elbow + self._update_fk_slave(q_s) + # (c) Wrist orientation match with X–Z–Y on wrist frame + RW_cur = self._frame_rot(self.s_data, self.fid_sW) + R_needed = RW_cur.T @ R_ee_ref + a_b_c = euler_xzy_from_R(R_needed) # (roll-X, yaw-Z, pitch-Y) + q_s[self.s_qidx[4]] = a_b_c[0] + q_s[self.s_qidx[5]] = a_b_c[1] + q_s[self.s_qidx[6]] = a_b_c[2] + + debug = dict( + phi_m=phi_m, + d_s=d_s, + pW_s_ref=pW_s_ref, + pE_s=pE_s, + theta=th + ) + return q_s, debug diff --git a/code/core/sew_mapper2.py b/code/core/sew_mapper2.py new file mode 100644 index 0000000..bcb80ea --- /dev/null +++ b/code/core/sew_mapper2.py @@ -0,0 +1,781 @@ + +# -*- coding: utf-8 -*- +""" +SEW retargeting for the real 7-DoF slave arm. + +The geometric SEW construction provides elbow/wrist targets. A bounded +least-squares recovery then enforces the joint limits from the slave URDF. +``compute_differential`` evaluates the local retargeting Jacobian with a +central finite difference on one fixed local branch and reports every event +that makes that differential invalid (failed recovery, branch jump, reach +clipping, or an active joint limit). + +Assumptions matching ``real_slave_7dof.urdf``: +- upper-arm / forearm links extend along local -Y; +- elbow hinge axis is +X in the elbow joint local frame; +- shoulder axes are y-x-y with signs (-1,+1,-1); +- wrist axes are y-z-x with signs (-1,+1,+1). +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, List, Optional, Sequence, Tuple + +import numpy as np +import pinocchio as pin +from scipy.optimize import least_squares + + +# -------------------------- small SO(3) helpers -------------------------- # + +def _hat(v: np.ndarray) -> np.ndarray: + x, y, z = v + return np.array([[0.0, -z, y], + [z, 0.0, -x], + [-y, x, 0.0]], dtype=float) + +def _normalize(v: np.ndarray, eps: float = 1e-12) -> np.ndarray: + n = float(np.linalg.norm(v)) + return v * 0.0 if n < eps else (v / n) + + +def _wrap_angle_delta(delta: np.ndarray) -> np.ndarray: + """Return revolute-joint differences on the principal interval [-pi, pi).""" + delta = np.asarray(delta, dtype=float) + return (delta + np.pi) % (2.0 * np.pi) - np.pi + +def _rodrigues(axis: np.ndarray, angle: float) -> np.ndarray: + a = _normalize(axis) + K = _hat(a) + return np.eye(3) + np.sin(angle) * K + (1.0 - np.cos(angle)) * (K @ K) + +def _ball_rot(axis_order: str, q3: np.ndarray) -> np.ndarray: + """Intrinsic chain: R = R(a1,q1) R(a2,q2) R(a3,q3) with a in {x,y,z} unit axes.""" + axes = { + "x": np.array([1.0, 0.0, 0.0]), + "y": np.array([0.0, 1.0, 0.0]), + "z": np.array([0.0, 0.0, 1.0]), + } + ao = axis_order.lower() + a1, a2, a3 = axes[ao[0]], axes[ao[1]], axes[ao[2]] + return _rodrigues(a1, q3[0]) @ _rodrigues(a2, q3[1]) @ _rodrigues(a3, q3[2]) + +def _solve_ball_gn( + R_des: np.ndarray, + axis_order: str, + q_seed: np.ndarray, + iters: int = 15, + damp: float = 1e-4, + fd_eps: float = 1e-6, +) -> np.ndarray: + """Gauss-Newton solve q s.t. R(q) ~= R_des. Works for repeated axes (e.g. yxy).""" + q = q_seed.astype(float).copy() + ao = axis_order.lower() + + for _ in range(iters): + R_cur = _ball_rot(ao, q) + err = pin.log3(R_cur.T @ R_des) + if float(np.linalg.norm(err)) < 1e-10: + break + + J = np.zeros((3, 3), dtype=float) + for i in range(3): + dq = np.zeros(3, dtype=float) + dq[i] = fd_eps + R_p = _ball_rot(ao, q + dq) + err_p = pin.log3(R_p.T @ R_des) + J[:, i] = (err_p - err) / fd_eps + + A = J @ J.T + damp * np.eye(3) + dq = - J.T @ np.linalg.solve(A, err) + q += dq + + q = (q + np.pi) % (2 * np.pi) - np.pi + return q + + +# -------------------------- joint config -------------------------- # + +@dataclass(frozen=True) +class BallJointConfig: + axis_order: str + joint_names: Tuple[str, str, str] + signs: Tuple[float, float, float] = (1.0, 1.0, 1.0) + + def resolve_qidx(self, model: pin.Model) -> Tuple[int, int, int]: + idxs = [] + for name in self.joint_names: + jid = int(model.getJointId(name)) + if jid <= 0 or jid >= model.njoints: + raise ValueError( + f"Joint not found: {name!r}; " + f"available={list(model.names)[1:]}" + ) + if model.joints[jid].nq != 1 or model.joints[jid].nv != 1: + raise ValueError(f"Expected scalar revolute joint: {name!r}") + idxs.append(model.joints[jid].idx_q) + return (idxs[0], idxs[1], idxs[2]) + + def eff_from_q(self, q: np.ndarray, qidx: Tuple[int, int, int]) -> np.ndarray: + s0, s1, s2 = self.signs + return np.array([s0 * q[qidx[0]], s1 * q[qidx[1]], s2 * q[qidx[2]]], dtype=float) + + def write_from_eff(self, q: np.ndarray, qidx: Tuple[int, int, int], q_eff: np.ndarray) -> None: + s0, s1, s2 = self.signs + q[qidx[0]] = s0 * float(q_eff[0]) + q[qidx[1]] = s1 * float(q_eff[1]) + q[qidx[2]] = s2 * float(q_eff[2]) + + +# -------------------------- mapper -------------------------- # + +class SEWMapper: + """ + Minimal SEW mapper. + You must provide correct frame names and 7 joint names order: + [S1,S2,S3, EL, W1,W2,W3] + """ + def __init__( + self, + master_model: pin.Model, + slave_model: pin.Model, + # frames + m_shoulder: str, m_elbow: str, m_wrist: str, m_ee: str, + s_shoulder: str, s_elbow: str, s_wrist: str, s_ee: str, + # joints order (7) + master_joint_names: Tuple[str, ...], + slave_joint_names: Tuple[str, ...], + # slave configs + slave_shoulder_cfg: BallJointConfig, + slave_wrist_cfg: BallJointConfig, + # elbow axis in elbow joint local frame + slave_elbow_axis_local: np.ndarray = np.array([1.0, 0.0, 0.0]), + up_dir: np.ndarray = np.array([0.0, 0.0, 1.0]), + eps_clip: float = 1e-3, + position_tolerance: float = 2e-4, + orientation_tolerance: float = 2e-3, + joint_limit_margin: float = 1e-4, + debug: bool = False, + ): + self.m_model = master_model + self.m_data = master_model.createData() + self.s_model = slave_model + self.s_data = slave_model.createData() + + # frames + def checked_frame_id(model: pin.Model, name: str) -> int: + fid = int(model.getFrameId(name)) + if fid < 0 or fid >= model.nframes: + raise ValueError( + f"Frame not found: {name!r}; " + f"available={[frame.name for frame in model.frames]}" + ) + return fid + + self.fid_mS = checked_frame_id(master_model, m_shoulder) + self.fid_mE = checked_frame_id(master_model, m_elbow) + self.fid_mW = checked_frame_id(master_model, m_wrist) + self.fid_mEE = checked_frame_id(master_model, m_ee) + + self.fid_sS = checked_frame_id(slave_model, s_shoulder) + self.fid_sE = checked_frame_id(slave_model, s_elbow) + self.fid_sW = checked_frame_id(slave_model, s_wrist) + self.fid_sEE = checked_frame_id(slave_model, s_ee) + + # joints mapping (7) + if len(master_joint_names) != 7 or len(slave_joint_names) != 7: + raise ValueError("master_joint_names and slave_joint_names must be length 7") + def checked_joint_id(model: pin.Model, name: str) -> int: + jid = int(model.getJointId(name)) + if jid <= 0 or jid >= model.njoints: + raise ValueError( + f"Joint not found: {name!r}; " + f"available={list(model.names)[1:]}" + ) + joint = model.joints[jid] + if joint.nq != 1 or joint.nv != 1: + raise ValueError(f"Expected scalar revolute joint: {name!r}") + return jid + + self.m_joint_ids = [ + checked_joint_id(master_model, name) for name in master_joint_names + ] + self.s_joint_ids = [ + checked_joint_id(slave_model, name) for name in slave_joint_names + ] + self.m_qidx7 = [master_model.joints[j].idx_q for j in self.m_joint_ids] + self.s_qidx7 = [slave_model.joints[j].idx_q for j in self.s_joint_ids] + self.jid_s_elbow = self.s_joint_ids[3] # joint id, not qidx + + # configs + self.sh_cfg = slave_shoulder_cfg + self.wr_cfg = slave_wrist_cfg + self.sh_qidx = self.sh_cfg.resolve_qidx(slave_model) + self.wr_qidx = self.wr_cfg.resolve_qidx(slave_model) + + self.elbow_axis_local = _normalize(slave_elbow_axis_local) + self.up = _normalize(up_dir) + self.eps_clip = float(eps_clip) + self.position_tolerance = float(position_tolerance) + self.orientation_tolerance = float(orientation_tolerance) + self.joint_limit_margin = float(joint_limit_margin) + self.debug = bool(debug) + + self.s_lower7 = np.array( + [slave_model.lowerPositionLimit[idx] for idx in self.s_qidx7], dtype=float + ) + self.s_upper7 = np.array( + [slave_model.upperPositionLimit[idx] for idx in self.s_qidx7], dtype=float + ) + if ( + not np.all(np.isfinite(self.s_lower7)) + or not np.all(np.isfinite(self.s_upper7)) + or np.any(self.s_lower7 >= self.s_upper7) + ): + raise ValueError("All seven slave joints must have finite, ordered URDF limits") + + # segment lengths from slave neutral + qs0 = pin.neutral(slave_model) + self._fk_slave(qs0) + pS = self._pos(self.s_data, self.fid_sS) + pE = self._pos(self.s_data, self.fid_sE) + pW = self._pos(self.s_data, self.fid_sW) + self.L1 = float(np.linalg.norm(pE - pS)) + self.L2 = float(np.linalg.norm(pW - pE)) + self.pS_s_fixed = pS.copy() + + # ---- FK helpers ---- + def _fk_master(self, q_m: np.ndarray) -> None: + pin.forwardKinematics(self.m_model, self.m_data, q_m) + pin.updateFramePlacements(self.m_model, self.m_data) + + def _fk_slave(self, q_s: np.ndarray) -> None: + pin.forwardKinematics(self.s_model, self.s_data, q_s) + pin.updateFramePlacements(self.s_model, self.s_data) + + @staticmethod + def _pos(data: pin.Data, fid: int) -> np.ndarray: + return data.oMf[fid].translation.copy() + + @staticmethod + def _rot(data: pin.Data, fid: int) -> np.ndarray: + return data.oMf[fid].rotation.copy() + + # ---- vector helpers ---- + def _build_q(self, model: pin.Model, qidx7: Sequence[int], q7: np.ndarray) -> np.ndarray: + q = pin.neutral(model) + for idx, value in zip(qidx7, q7): + q[idx] = float(value) + return q + + def _slave_q7(self, q_s: np.ndarray) -> np.ndarray: + return np.array([q_s[idx] for idx in self.s_qidx7], dtype=float) + + def _write_slave_q7(self, q_s: np.ndarray, q_s7: np.ndarray) -> None: + for idx, value in zip(self.s_qidx7, q_s7): + q_s[idx] = float(value) + + def _interior_clip(self, q_s7: np.ndarray) -> np.ndarray: + # scipy requires x0 to be feasible. Keeping it strictly inside also + # avoids declaring the seed itself to be the active-set solution. + pad = np.minimum(1e-9, 0.25 * (self.s_upper7 - self.s_lower7)) + return np.minimum(np.maximum(q_s7, self.s_lower7 + pad), self.s_upper7 - pad) + + # ---- target construction and bounded recovery ---- + def _target_from_master(self, q_m7: np.ndarray) -> Dict: + q_m7 = np.asarray(q_m7, dtype=float) + if q_m7.shape != (7,) or not np.all(np.isfinite(q_m7)): + raise ValueError("q_m7 must be a finite vector with shape (7,)") + + q_m = self._build_q(self.m_model, self.m_qidx7, q_m7) + self._fk_master(q_m) + pS_m = self._pos(self.m_data, self.fid_mS) + pE_m = self._pos(self.m_data, self.fid_mE) + pW_m = self._pos(self.m_data, self.fid_mW) + RmEE = self._rot(self.m_data, self.fid_mEE) + + events: List[str] = [] + r_m = pW_m - pS_m + d_m = float(np.linalg.norm(r_m)) + hard_geometry_valid = d_m > 1e-9 + if not hard_geometry_valid: + events.append("master_shoulder_wrist_degenerate") + xhat = np.array([1.0, 0.0, 0.0]) + else: + xhat = r_m / d_m + + arm_normal_raw = np.cross(pE_m - pS_m, pW_m - pS_m) + arm_normal_norm = float(np.linalg.norm(arm_normal_raw)) + if arm_normal_norm < 1e-8: + events.append("master_arm_plane_degenerate") + hard_geometry_valid = False + nm = self.up - float(np.dot(self.up, xhat)) * xhat + if float(np.linalg.norm(nm)) < 1e-8: + nm = np.array([0.0, 1.0, 0.0]) + nm = _normalize(nm) + else: + nm = arm_normal_raw / arm_normal_norm + + nref_tilde = self.up - float(np.dot(self.up, xhat)) * xhat + reference_fallback = float(np.linalg.norm(nref_tilde)) < 1e-6 + if reference_fallback: + events.append("reference_axis_fallback") + candidates = ( + np.array([0.0, 1.0, 0.0]), + np.array([1.0, 0.0, 0.0]), + ) + nref_tilde = max( + (axis - float(np.dot(axis, xhat)) * xhat for axis in candidates), + key=np.linalg.norm, + ) + nref = _normalize(nref_tilde) + phi = float( + np.arctan2( + np.dot(xhat, np.cross(nref, nm)), + np.dot(nref, nm), + ) + ) + + d_min = abs(self.L1 - self.L2) + self.eps_clip + d_max = (self.L1 + self.L2) - self.eps_clip + d_s = float(np.clip(d_m, d_min, d_max)) + if d_m < d_min: + clip_region = "lower" + events.append("reach_clipped_lower") + elif d_m > d_max: + clip_region = "upper" + events.append("reach_clipped_upper") + else: + clip_region = "none" + + pS_s = self.pS_s_fixed + pW_s_ref = pS_s + d_s * xhat + e3 = _normalize(_rodrigues(xhat, phi) @ nref) + e2 = _normalize(np.cross(xhat, e3)) + cos_th_raw = (self.L1**2 + d_s**2 - self.L2**2) / (2.0 * self.L1 * d_s) + cos_th = float(np.clip(cos_th_raw, -1.0, 1.0)) + sin_th = float(np.sqrt(max(0.0, 1.0 - cos_th * cos_th))) + pE_s_ref = pS_s + self.L1 * (cos_th * xhat + sin_th * e2) + + # Desired shoulder-link orientation used only to create a strong seed. + u = _normalize(pE_s_ref - pS_s) + x_axis = e3 + y_axis = -u + z_axis = _normalize(np.cross(x_axis, y_axis)) + y_axis = _normalize(np.cross(z_axis, x_axis)) + RS_des = np.column_stack([x_axis, y_axis, z_axis]) + + return { + "q_m": q_m, + "pE_s_ref": pE_s_ref, + "pW_s_ref": pW_s_ref, + "RmEE": RmEE, + "RS_des": RS_des, + "events": events, + "hard_geometry_valid": hard_geometry_valid, + "reference_fallback": reference_fallback, + "reach_clipped": clip_region != "none", + "clip_region": clip_region, + "master_reach": d_m, + "slave_reach": d_s, + "master_arm_normal_norm": arm_normal_norm, + } + + def _staged_seed(self, target: Dict, q_seed: np.ndarray) -> np.ndarray: + """Analytic/sequential solve used as a seed; it is never returned unchecked.""" + q_s = q_seed.copy() + + q_sh_seed = self.sh_cfg.eff_from_q(q_s, self.sh_qidx) + q_sh_eff = _solve_ball_gn( + target["RS_des"], self.sh_cfg.axis_order, q_sh_seed + ) + self.sh_cfg.write_from_eff(q_s, self.sh_qidx, q_sh_eff) + + # The one-dimensional search respects the actual elbow limits. + theta_lo = float(self.s_lower7[3]) + theta_hi = float(self.s_upper7[3]) + + def wrist_err(theta: float) -> float: + q_tmp = q_s.copy() + q_tmp[self.s_qidx7[3]] = theta + self._fk_slave(q_tmp) + return float( + np.linalg.norm( + self._pos(self.s_data, self.fid_sW) - target["pW_s_ref"] + ) + ) + + thetas = np.linspace(theta_lo, theta_hi, 181) + errs = np.array([wrist_err(theta) for theta in thetas]) + k = int(np.argmin(errs)) + lo = float(thetas[max(0, k - 1)]) + hi = float(thetas[min(len(thetas) - 1, k + 1)]) + gr = (np.sqrt(5.0) - 1.0) / 2.0 + x1 = hi - gr * (hi - lo) + x2 = lo + gr * (hi - lo) + f1, f2 = wrist_err(x1), wrist_err(x2) + for _ in range(20): + if f1 > f2: + lo, x1, f1 = x1, x2, f2 + x2 = lo + gr * (hi - lo) + f2 = wrist_err(x2) + else: + hi, x2, f2 = x2, x1, f1 + x1 = hi - gr * (hi - lo) + f1 = wrist_err(x1) + q_s[self.s_qidx7[3]] = float(x1 if f1 < f2 else x2) + self._fk_slave(q_s) + + RW = self._rot(self.s_data, self.fid_sW) + q_wr_seed = self.wr_cfg.eff_from_q(q_s, self.wr_qidx) + q_wr_eff = _solve_ball_gn( + RW.T @ target["RmEE"], self.wr_cfg.axis_order, q_wr_seed + ) + self.wr_cfg.write_from_eff(q_s, self.wr_qidx, q_wr_eff) + return q_s + + def _bounded_recovery( + self, + target: Dict, + q_template: np.ndarray, + q_seed7: np.ndarray, + ) -> Tuple[np.ndarray, object]: + q_seed7 = self._interior_clip(np.asarray(q_seed7, dtype=float)) + q_regularization_ref = q_seed7.copy() + + def residual(q_s7: np.ndarray) -> np.ndarray: + q_s = q_template.copy() + self._write_slave_q7(q_s, q_s7) + self._fk_slave(q_s) + pE = self._pos(self.s_data, self.fid_sE) + pW = self._pos(self.s_data, self.fid_sW) + RsEE = self._rot(self.s_data, self.fid_sEE) + return np.concatenate( + ( + 5.0 * (pE - target["pE_s_ref"]), + 5.0 * (pW - target["pW_s_ref"]), + pin.log3(RsEE.T @ target["RmEE"]), + 1e-5 * _wrap_angle_delta(q_s7 - q_regularization_ref), + ) + ) + + result = least_squares( + residual, + q_seed7, + bounds=(self.s_lower7, self.s_upper7), + method="trf", + ftol=1e-12, + xtol=1e-12, + gtol=1e-12, + max_nfev=400, + ) + q_s = q_template.copy() + self._write_slave_q7(q_s, result.x) + return q_s, result + + def _solution_metrics(self, q_s: np.ndarray, target: Dict) -> Dict: + self._fk_slave(q_s) + q_s7 = self._slave_q7(q_s) + elbow_error = float( + np.linalg.norm(self._pos(self.s_data, self.fid_sE) - target["pE_s_ref"]) + ) + wrist_error = float( + np.linalg.norm(self._pos(self.s_data, self.fid_sW) - target["pW_s_ref"]) + ) + orientation_error = float( + np.linalg.norm( + pin.log3( + self._rot(self.s_data, self.fid_sEE).T @ target["RmEE"] + ) + ) + ) + lower_clearance = q_s7 - self.s_lower7 + upper_clearance = self.s_upper7 - q_s7 + violation = np.flatnonzero( + (lower_clearance < -1e-9) | (upper_clearance < -1e-9) + ) + active = np.flatnonzero( + np.minimum(lower_clearance, upper_clearance) <= self.joint_limit_margin + ) + return { + "q_s7": q_s7, + "elbow_position_error": elbow_error, + "wrist_position_error": wrist_error, + "orientation_error": orientation_error, + "joint_limit_violation_indices": violation.tolist(), + "joint_limit_active_indices": active.tolist(), + "minimum_joint_limit_clearance": float( + np.min(np.minimum(lower_clearance, upper_clearance)) + ), + } + + # ---- public API ---- + def retarget( + self, + q_m7: np.ndarray, + q_s_init: Optional[np.ndarray] = None, + ) -> Tuple[np.ndarray, Dict]: + """ + Retarget one master pose with bounded recovery. + + ``success`` means the returned slave pose respects its URDF limits and + meets the configured elbow/wrist/orientation tolerances. ``smooth`` is + stricter: it is false at reach clipping, fallback geometry, or an active + joint limit. A differential may only be used when both are true. + """ + q_m7 = np.asarray(q_m7, dtype=float) + target = self._target_from_master(q_m7) + + if q_s_init is not None: + q_template = np.asarray(q_s_init, dtype=float).copy() + if q_template.shape != (self.s_model.nq,) or not np.all(np.isfinite(q_template)): + raise ValueError( + f"q_s_init must be finite with shape ({self.s_model.nq},)" + ) + # Supplying q_s_init explicitly requests this local branch. + seeds = [self._slave_q7(q_template)] + else: + q_template = pin.neutral(self.s_model) + staged = self._staged_seed(target, q_template) + midpoint = 0.5 * (self.s_lower7 + self.s_upper7) + seeds = [self._slave_q7(staged), midpoint] + + candidates = [ + self._bounded_recovery(target, q_template, seed) for seed in seeds + ] + candidate_metrics = [ + self._solution_metrics(q_s, target) for q_s, _ in candidates + ] + + def task_score(metrics: Dict) -> float: + return ( + 25.0 * metrics["elbow_position_error"] ** 2 + + 25.0 * metrics["wrist_position_error"] ** 2 + + metrics["orientation_error"] ** 2 + ) + + selected_index = int( + np.argmin([task_score(metrics) for metrics in candidate_metrics]) + ) + q_s, result = candidates[selected_index] + metrics = candidate_metrics[selected_index] + + events = list(target["events"]) + if metrics["joint_limit_violation_indices"]: + events.append("joint_limit_violation") + if metrics["joint_limit_active_indices"]: + events.append("joint_limit_active") + + finite = bool( + np.all(np.isfinite(metrics["q_s7"])) + and np.isfinite(metrics["elbow_position_error"]) + and np.isfinite(metrics["wrist_position_error"]) + and np.isfinite(metrics["orientation_error"]) + ) + within_tolerance = bool( + metrics["elbow_position_error"] <= self.position_tolerance + and metrics["wrist_position_error"] <= self.position_tolerance + and metrics["orientation_error"] <= self.orientation_tolerance + ) + success = bool( + finite + and result.success + and target["hard_geometry_valid"] + and not metrics["joint_limit_violation_indices"] + and within_tolerance + ) + if not result.success: + events.append("bounded_solver_not_converged") + if not within_tolerance: + events.append("task_tolerance_exceeded") + if not target["hard_geometry_valid"]: + events.append("invalid_master_geometry") + + nonsmooth_events = { + "reach_clipped_lower", + "reach_clipped_upper", + "reference_axis_fallback", + "master_shoulder_wrist_degenerate", + "master_arm_plane_degenerate", + "joint_limit_active", + "joint_limit_violation", + } + smooth = bool(success and not any(event in nonsmooth_events for event in events)) + position_error = max( + metrics["elbow_position_error"], metrics["wrist_position_error"] + ) + dbg = { + **metrics, + "success": success, + "valid": success, + "invalid": not success, + "smooth": smooth, + "events": tuple(dict.fromkeys(events)), + "pE_s_ref": target["pE_s_ref"].copy(), + "pW_s_ref": target["pW_s_ref"].copy(), + "reach_clipped": target["reach_clipped"], + "clipped": target["reach_clipped"], + "clip_region": target["clip_region"], + "near_limit": bool(metrics["joint_limit_active_indices"]), + "position_error": position_error, + "reference_fallback": target["reference_fallback"], + "master_reach": target["master_reach"], + "slave_reach": target["slave_reach"], + "master_arm_normal_norm": target["master_arm_normal_norm"], + "solver_success": bool(result.success), + "solver_status": int(result.status), + "solver_cost": float(result.cost), + "solver_nfev": int(result.nfev), + "selected_seed_index": selected_index, + } + self._fk_slave(q_s) + return q_s, dbg + + def compute_differential( + self, + q_m7: np.ndarray, + q_s_init: Optional[np.ndarray] = None, + fd_step: float = 1e-4, + branch_jump_threshold: float = 0.25, + consistency_tolerance: float = 5e-2, + ) -> Tuple[np.ndarray, Dict]: + """ + Compute A = d(q_slave)/d(q_master) by a central finite difference. + + Every +/- solve starts from the same bounded base solution. Slave + angle differences are wrapped before division. Invalid/nonsmooth + columns are filled with NaN; callers must also check ``info["valid"]``. + """ + q_m7 = np.asarray(q_m7, dtype=float) + if q_m7.shape != (7,) or not np.all(np.isfinite(q_m7)): + raise ValueError("q_m7 must be a finite vector with shape (7,)") + if not np.isfinite(fd_step) or fd_step <= 0.0: + raise ValueError("fd_step must be finite and positive") + if branch_jump_threshold <= 0.0 or consistency_tolerance <= 0.0: + raise ValueError("differential thresholds must be positive") + + base_q_s, base_dbg = self.retarget(q_m7, q_s_init=q_s_init) + base_q7 = self._slave_q7(base_q_s) + A = np.full((7, 7), np.nan, dtype=float) + events: List[str] = [] + columns: List[Dict] = [] + + if not base_dbg["success"]: + events.append("base_invalid") + if not base_dbg["smooth"]: + events.append("base_nonsmooth") + + if base_dbg["success"] and base_dbg["smooth"]: + for j in range(7): + q_plus = q_m7.copy() + q_minus = q_m7.copy() + q_plus[j] += fd_step + q_minus[j] -= fd_step + + plus_q_s, plus_dbg = self.retarget(q_plus, q_s_init=base_q_s) + minus_q_s, minus_dbg = self.retarget(q_minus, q_s_init=base_q_s) + plus_q7 = self._slave_q7(plus_q_s) + minus_q7 = self._slave_q7(minus_q_s) + + plus_jump = float( + np.max(np.abs(_wrap_angle_delta(plus_q7 - base_q7))) + ) + minus_jump = float( + np.max(np.abs(_wrap_angle_delta(minus_q7 - base_q7))) + ) + fwd = _wrap_angle_delta(plus_q7 - base_q7) / fd_step + bwd = _wrap_angle_delta(base_q7 - minus_q7) / fd_step + consistency = float( + np.linalg.norm(fwd - bwd) + / (1.0 + max(np.linalg.norm(fwd), np.linalg.norm(bwd))) + ) + + column_events: List[str] = [] + if not plus_dbg["success"] or not minus_dbg["success"]: + column_events.append("perturbation_invalid") + if not plus_dbg["smooth"] or not minus_dbg["smooth"]: + column_events.append("perturbation_nonsmooth") + if ( + plus_dbg["clip_region"] != base_dbg["clip_region"] + or minus_dbg["clip_region"] != base_dbg["clip_region"] + ): + column_events.append("clip_region_changed") + if ( + plus_jump > branch_jump_threshold + or minus_jump > branch_jump_threshold + ): + column_events.append("branch_jump") + if consistency > consistency_tolerance: + column_events.append("one_sided_derivative_mismatch") + + column_valid = not column_events + if column_valid: + A[:, j] = _wrap_angle_delta(plus_q7 - minus_q7) / ( + 2.0 * fd_step + ) + else: + events.extend(f"column_{j}:{event}" for event in column_events) + + columns.append( + { + "index": j, + "valid": column_valid, + "events": tuple(column_events), + "plus_success": plus_dbg["success"], + "minus_success": minus_dbg["success"], + "plus_jump": plus_jump, + "minus_jump": minus_jump, + "one_sided_consistency": consistency, + } + ) + + valid = bool( + base_dbg["success"] + and base_dbg["smooth"] + and len(columns) == 7 + and all(column["valid"] for column in columns) + and np.all(np.isfinite(A)) + ) + info = { + "valid": valid, + "invalid": not valid, + "smooth": valid, + "events": tuple(dict.fromkeys(events)), + "fd_step": float(fd_step), + "fixed_branch_seed": base_q7.copy(), + "base": base_dbg, + "columns": tuple(columns), + } + self._fk_slave(base_q_s) + return A, info + + def retarget_with_differential( + self, + q_m7: np.ndarray, + q_s_init: Optional[np.ndarray] = None, + fd_step: float = 1e-4, + branch_jump_threshold: float = 0.25, + consistency_tolerance: float = 5e-2, + ) -> Tuple[np.ndarray, np.ndarray, Dict]: + """ + Stable simulation-facing interface returning ``(q_s, A, debug)``. + + ``debug["success"]`` describes the bounded pose recovery; + ``debug["differential_valid"]`` must be checked independently before + using A for velocity/force mapping. + """ + q_s, pose_debug = self.retarget(q_m7, q_s_init=q_s_init) + A, differential_debug = self.compute_differential( + q_m7, + q_s_init=q_s, + fd_step=fd_step, + branch_jump_threshold=branch_jump_threshold, + consistency_tolerance=consistency_tolerance, + ) + debug = { + **pose_debug, + "differential_valid": bool(differential_debug["valid"]), + "differential": differential_debug, + } + self._fk_slave(q_s) + return q_s, A, debug diff --git a/code/core/time_domain_popc.py b/code/core/time_domain_popc.py new file mode 100644 index 0000000..b2cde2a --- /dev/null +++ b/code/core/time_domain_popc.py @@ -0,0 +1,132 @@ +"""Discrete time-domain passivity observer/controller at the master port.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + + +@dataclass(frozen=True) +class POPCDiagnostics: + tau_candidate: np.ndarray + tau_applied: np.ndarray + observer_before: float + observer_preclip: float + observer_after: float + candidate_power: float + applied_power: float + damping_gain: float + intervention_active: bool + fail_safe_active: bool + + +class TimeDomainPOPC: + """Causal PO/PC using dissipative velocity feedback. + + Positive ``tau @ qd`` is energy delivered by the device. When the + candidate would exhaust the observer balance, the controller injects + ``-beta * qd``. This is intentionally a separate baseline from the radial + tank projection implemented in :mod:`core.haptic_render`. + """ + + def __init__( + self, + *, + initial_energy: float = 0.0, + minimum_energy: float = 0.0, + maximum_energy: float = np.inf, + velocity_epsilon: float = 1e-12, + ): + if not np.isfinite(initial_energy) or not np.isfinite(minimum_energy): + raise ValueError("initial and minimum energy must be finite") + if maximum_energy <= minimum_energy: + raise ValueError("maximum_energy must exceed minimum_energy") + if not minimum_energy <= initial_energy <= maximum_energy: + raise ValueError("initial_energy must lie within observer bounds") + if velocity_epsilon <= 0.0: + raise ValueError("velocity_epsilon must be positive") + self.minimum_energy = float(minimum_energy) + self.maximum_energy = float(maximum_energy) + self.velocity_epsilon = float(velocity_epsilon) + self.energy = float(initial_energy) + self.last_diagnostics: POPCDiagnostics | None = None + + def reset(self, energy: float | None = None) -> None: + target = self.energy if energy is None else float(energy) + if not self.minimum_energy <= target <= self.maximum_energy: + raise ValueError("reset energy lies outside observer bounds") + self.energy = target + self.last_diagnostics = None + + def apply( + self, + tau_candidate: np.ndarray, + qd_master: np.ndarray, + dt: float, + ) -> tuple[np.ndarray, POPCDiagnostics]: + candidate = np.asarray(tau_candidate, dtype=float).reshape(-1) + velocity = np.asarray(qd_master, dtype=float).reshape(-1) + if candidate.shape != velocity.shape or candidate.size == 0: + raise ValueError("candidate and velocity must have equal non-empty shapes") + before = self.energy + if ( + not np.isfinite(dt) + or dt <= 0.0 + or not np.all(np.isfinite(candidate)) + or not np.all(np.isfinite(velocity)) + ): + applied = np.zeros_like(candidate) + diagnostics = POPCDiagnostics( + tau_candidate=candidate.copy(), + tau_applied=applied, + observer_before=before, + observer_preclip=before, + observer_after=before, + candidate_power=0.0, + applied_power=0.0, + damping_gain=0.0, + intervention_active=True, + fail_safe_active=True, + ) + self.last_diagnostics = diagnostics + return applied, diagnostics + + candidate_power = float(candidate @ velocity) + available = max(0.0, before - self.minimum_energy) + allowed_power = available / dt + damping_gain = 0.0 + applied = candidate.copy() + velocity_norm_sq = float(velocity @ velocity) + if candidate_power > allowed_power: + if velocity_norm_sq <= self.velocity_epsilon: + # With an almost-zero velocity, nonzero power is numerical + # contamination; zero output is the conservative response. + applied.fill(0.0) + else: + damping_gain = ( + candidate_power - allowed_power + ) / velocity_norm_sq + applied = candidate - damping_gain * velocity + + applied_power = float(applied @ velocity) + preclip = before - applied_power * dt + self.energy = float( + np.clip(preclip, self.minimum_energy, self.maximum_energy) + ) + diagnostics = POPCDiagnostics( + tau_candidate=candidate.copy(), + tau_applied=applied.copy(), + observer_before=before, + observer_preclip=preclip, + observer_after=self.energy, + candidate_power=candidate_power, + applied_power=applied_power, + damping_gain=damping_gain, + intervention_active=bool( + np.any(np.abs(applied - candidate) > 1e-12) + ), + fail_safe_active=False, + ) + self.last_diagnostics = diagnostics + return applied, diagnostics diff --git a/code/core/wrench_solver.py b/code/core/wrench_solver.py new file mode 100644 index 0000000..6ffd5d7 --- /dev/null +++ b/code/core/wrench_solver.py @@ -0,0 +1,306 @@ +"""Dimensionally scaled residual-to-wrench solvers for H2. + +Twists and Jacobians use ``[linear; angular]`` order and wrenches use +``[force; moment]`` order. For characteristic length ``ell`` this module +implements the manuscript convention + +``S = diag(1/ell I3, I3)``, ``J_tilde = S J`` and +``F_tilde = S**(-T) F``. + +Consequently ``J_tilde.T @ F_tilde == J.T @ F`` and every element of +``F_tilde`` has torque units. Both the damped and undamped methods use the +same scaling and frozen relative rank tolerance so their H2 comparison is +well-defined. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Protocol, runtime_checkable + +import numpy as np + + +def _vector(value, size: int, name: str) -> np.ndarray: + array = np.asarray(value, dtype=float).reshape(-1) + if array.shape != (size,): + raise ValueError(f"{name} must have shape ({size},), got {array.shape}") + if not np.all(np.isfinite(array)): + raise ValueError(f"{name} must contain only finite values") + return array + + +def _jacobian(value) -> np.ndarray: + array = np.asarray(value, dtype=float) + if array.ndim != 2 or array.shape[0] != 6 or array.shape[1] == 0: + raise ValueError( + f"jacobian must have shape (6, n_joints), got {array.shape}" + ) + if not np.all(np.isfinite(array)): + raise ValueError("jacobian must contain only finite values") + return array + + +def _readonly(value: np.ndarray) -> np.ndarray: + result = np.asarray(value, dtype=float).copy() + result.setflags(write=False) + return result + + +class WrenchSolveStatus(str, Enum): + """Numerical rank status after the frozen scaled-Jacobian test.""" + + FULL_RANK = "full_rank" + RANK_DEFICIENT = "rank_deficient" + + +@dataclass(frozen=True) +class WrenchSolveResult: + """Solver result and all quantities needed for H2 stratification.""" + + method: str + wrench: np.ndarray + scaled_wrench: np.ndarray + reconstructed_residual: np.ndarray + residual_error_norm: float + singular_values: np.ndarray + rank: int + rank_threshold: float + condition_number: float + status: WrenchSolveStatus + characteristic_length_m: float + damping: float + + def __post_init__(self) -> None: + wrench = _vector(self.wrench, 6, "wrench") + scaled_wrench = _vector(self.scaled_wrench, 6, "scaled_wrench") + reconstructed = np.asarray( + self.reconstructed_residual, dtype=float + ).reshape(-1) + singular_values = np.asarray(self.singular_values, dtype=float).reshape(-1) + if reconstructed.size == 0 or not np.all(np.isfinite(reconstructed)): + raise ValueError("reconstructed_residual must be a finite vector") + if singular_values.size == 0 or not np.all(np.isfinite(singular_values)): + raise ValueError("singular_values must be a non-empty finite vector") + if self.rank < 0 or self.rank > 6: + raise ValueError("rank must lie in [0, 6]") + finite_nonnegative = ( + self.residual_error_norm, + self.rank_threshold, + self.characteristic_length_m, + self.damping, + ) + if any(not np.isfinite(value) or value < 0.0 for value in finite_nonnegative): + raise ValueError( + "solver scalar diagnostics must be finite and non-negative" + ) + if not ( + np.isfinite(self.condition_number) + or np.isinf(self.condition_number) + ): + raise ValueError("condition_number must be finite or infinity") + object.__setattr__(self, "wrench", _readonly(wrench)) + object.__setattr__(self, "scaled_wrench", _readonly(scaled_wrench)) + object.__setattr__( + self, "reconstructed_residual", _readonly(reconstructed) + ) + object.__setattr__(self, "singular_values", _readonly(singular_values)) + + @property + def force(self) -> np.ndarray: + return self.wrench[:3].copy() + + @property + def moment(self) -> np.ndarray: + return self.wrench[3:].copy() + + +@runtime_checkable +class WrenchSolver(Protocol): + """Common H2 solver interface.""" + + name: str + characteristic_length_m: float + relative_rank_tolerance: float + + def solve( + self, jacobian: np.ndarray, joint_residual: np.ndarray + ) -> WrenchSolveResult: + ... + + +class _ScaledSolverBase: + def __init__( + self, + characteristic_length_m: float, + *, + relative_rank_tolerance: float = 1e-9, + ) -> None: + length = float(characteristic_length_m) + tolerance = float(relative_rank_tolerance) + if not np.isfinite(length) or length <= 0.0: + raise ValueError( + "characteristic_length_m must be finite and positive" + ) + if not np.isfinite(tolerance) or tolerance <= 0.0 or tolerance >= 1.0: + raise ValueError( + "relative_rank_tolerance must be finite and lie in (0, 1)" + ) + self.characteristic_length_m = length + self.relative_rank_tolerance = tolerance + self._scaling = np.diag( + np.array([1.0 / length] * 3 + [1.0] * 3, dtype=float) + ) + + def scaled_jacobian(self, jacobian: np.ndarray) -> np.ndarray: + """Return ``S_ell @ J`` using the frozen characteristic length.""" + return self._scaling @ _jacobian(jacobian) + + def _decompose( + self, jacobian: np.ndarray, joint_residual: np.ndarray + ) -> tuple[ + np.ndarray, + np.ndarray, + np.ndarray, + np.ndarray, + np.ndarray, + float, + int, + ]: + jacobian = _jacobian(jacobian) + residual = _vector( + joint_residual, jacobian.shape[1], "joint_residual" + ) + scaled_jacobian = self._scaling @ jacobian + u, singular_values, vt = np.linalg.svd( + scaled_jacobian, full_matrices=False + ) + largest = float(singular_values[0]) if singular_values.size else 0.0 + threshold = self.relative_rank_tolerance * largest + rank = int(np.count_nonzero(singular_values > threshold)) + return ( + jacobian, + residual, + u, + singular_values, + vt, + threshold, + rank, + ) + + def _result( + self, + *, + jacobian: np.ndarray, + residual: np.ndarray, + scaled_wrench: np.ndarray, + singular_values: np.ndarray, + threshold: float, + rank: int, + damping: float, + ) -> WrenchSolveResult: + wrench = self._scaling.T @ scaled_wrench + reconstructed = jacobian.T @ wrench + smallest = float(singular_values[-1]) + condition = ( + float(singular_values[0] / smallest) + if rank == 6 and smallest > 0.0 + else float("inf") + ) + return WrenchSolveResult( + method=self.name, + wrench=wrench, + scaled_wrench=scaled_wrench, + reconstructed_residual=reconstructed, + residual_error_norm=float(np.linalg.norm(reconstructed - residual)), + singular_values=singular_values, + rank=rank, + rank_threshold=threshold, + condition_number=condition, + status=( + WrenchSolveStatus.FULL_RANK + if rank == 6 + else WrenchSolveStatus.RANK_DEFICIENT + ), + characteristic_length_m=self.characteristic_length_m, + damping=damping, + ) + + +class ScaledDLSSolver(_ScaledSolverBase): + """Dimensionally scaled damped least-squares wrench reconstruction.""" + + name = "scaled_dls" + + def __init__( + self, + characteristic_length_m: float, + damping: float, + *, + relative_rank_tolerance: float = 1e-9, + ) -> None: + super().__init__( + characteristic_length_m, + relative_rank_tolerance=relative_rank_tolerance, + ) + damping_value = float(damping) + if not np.isfinite(damping_value) or damping_value <= 0.0: + raise ValueError("damping must be finite and positive") + self.damping = damping_value + + def solve( + self, jacobian: np.ndarray, joint_residual: np.ndarray + ) -> WrenchSolveResult: + ( + jacobian, + residual, + u, + singular_values, + vt, + threshold, + rank, + ) = self._decompose(jacobian, joint_residual) + gains = singular_values / (singular_values**2 + self.damping**2) + scaled_wrench = u @ (gains * (vt @ residual)) + return self._result( + jacobian=jacobian, + residual=residual, + scaled_wrench=scaled_wrench, + singular_values=singular_values, + threshold=threshold, + rank=rank, + damping=self.damping, + ) + + +class UndampedSVDSolver(_ScaledSolverBase): + """Frozen-tolerance, dimensionally scaled undamped pseudoinverse baseline.""" + + name = "undamped_svd" + + def solve( + self, jacobian: np.ndarray, joint_residual: np.ndarray + ) -> WrenchSolveResult: + ( + jacobian, + residual, + u, + singular_values, + vt, + threshold, + rank, + ) = self._decompose(jacobian, joint_residual) + inverse = np.zeros_like(singular_values) + retained = singular_values > threshold + inverse[retained] = 1.0 / singular_values[retained] + scaled_wrench = u @ (inverse * (vt @ residual)) + return self._result( + jacobian=jacobian, + residual=residual, + scaled_wrench=scaled_wrench, + singular_values=singular_values, + threshold=threshold, + rank=rank, + damping=0.0, + ) diff --git a/code/demo.py b/code/demo.py new file mode 100644 index 0000000..9d7dcd9 --- /dev/null +++ b/code/demo.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +"""Compatibility entry point for the closed-loop bilateral simulation. + +The former demo advanced the slave with ``q_des + random_noise`` and therefore +did not simulate robot dynamics or a dynamically coupled virtual wall. The +real implementation now lives in :mod:`simulate_closed_loop`; this file stays +as the short command users of the repository already know. +""" + +from simulate_closed_loop import main + + +if __name__ == "__main__": + main() diff --git a/code/diagrams/closed-loop_teleoperation_architecture.png b/code/diagrams/closed-loop_teleoperation_architecture.png new file mode 100644 index 0000000..cac7044 Binary files /dev/null and b/code/diagrams/closed-loop_teleoperation_architecture.png differ diff --git a/code/diagrams/closed_loop_control.py b/code/diagrams/closed_loop_control.py new file mode 100644 index 0000000..47e4579 --- /dev/null +++ b/code/diagrams/closed_loop_control.py @@ -0,0 +1,48 @@ +from diagrams import Diagram, Cluster +from diagrams.generic.compute import Rack +from diagrams.generic.blank import Blank +from diagrams.generic.network import Switch + +# We will use Rack as generic processing block +# Blank as hidden connectors + +with Diagram("Closed-loop Teleoperation Architecture", + show=False, direction="LR", + outformat="png"): + + # ---- Human operator ---- + human = Rack("Human\noperator") + + # ---- Master exoskeleton ---- + master = Rack("Master exoskeleton\nsensing & actuation") + + # ---- SEW retargeting ---- + sew = Rack("SEW-based posture\nretargeting (IK-free)") + + # ---- Slave controller ---- + ctrl = Rack("Slave joint\ncontroller") + + # ---- Slave arm ---- + slave = Rack("7-DoF slave arm\n+ environment") + + # ---- Bottom blocks ---- + est = Rack("Interaction\nforce estimator") + haptic = Rack("Haptic rendering\n+ energy tank") + + # ---- Connections (Main forward loop) ---- + human >> master + master >> sew + sew >> ctrl + ctrl >> slave + + # ---- Feedback loop ---- + slave >> est + est >> haptic + haptic >> master + + # Optional: chest-frame Jacobian dashed arrows (use Blank as endpoints) + Jm = Blank(" ") + Js = Blank(" ") + + master >> Jm >> haptic + slave >> Js >> est \ No newline at end of file diff --git a/code/experiments/README.md b/code/experiments/README.md new file mode 100644 index 0000000..7d0a3f3 --- /dev/null +++ b/code/experiments/README.md @@ -0,0 +1,81 @@ +# Evidence pipeline + +This package is the experiment/evidence layer. The pre-prototype executors in +`experiments.executors` adapt immutable trial records to H1 retargeting, H2 +synthetic sensitivity, and H3/H4 rigid-body simulation backends. + +## Minimal workflow + +From the repository root with `PYTHONPATH=code`: + +```bash +python -m experiments.cli plan \ + --spec code/config/experiments/h1_smoke.json \ + --output /tmp/h1-plan.json + +python -m experiments.cli run \ + --plan /tmp/h1-plan.json \ + --batch-dir output/experiments/h1-smoke \ + --executor experiments.executors:execute_h1_retargeting +``` + +Available executor/config pairs are: + +```text +execute_h1_retargeting h1_smoke.json / h1_calibration.json +execute_h2_synthetic h2_smoke.json / h2_calibration.json +execute_bilateral_simulation smoke.json / bilateral_calibration.json +``` + +An executor callable receives one immutable trial mapping and returns: + +```python +TrialPayload( + samples={"time": time_array, "...": sample_array}, + events=[{"sample_index": 10, "event": "contact"}], + metadata={"backend": "simulation"}, +) +``` + +Every sample array must have the same first dimension. Object arrays are +rejected. Successful trials are committed by one atomic directory rename; +failures are retained separately and may be retried with `--resume`. + +Validate a completed batch: + +```bash +python -m experiments.cli validate \ + --batch-dir output/experiments/h1-smoke +``` + +Recompute independent endpoints and paper source-data: + +```bash +python -m analysis.make_paper_artifacts \ + --batch-dir output/experiments/h1-smoke \ + --metric-config code/config/experiments/metrics_h1_calibration.json +``` + +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 +source-data. No Parquet dependency is required. + +## Pairing and random numbers + +`pair_id` excludes the method and therefore identifies common inputs. +`trial_id` includes the method. The trajectory, sensor, model, and network +streams are derived independently with NumPy `SeedSequence`; their serialized +states are identical across methods in the same pair. + +Calibration, pilot, and locked studies must use separate specifications. A +locked plan is immutable: changing a factor, method, trajectory, or seed +invalidates its hashes. + +Files named `*_locked_template.json` are deliberately not confirmatory plans. +Copy and freeze them only after calibration thresholds, safety limits, repeat +counts, the source commit, and the analysis configuration have been approved. diff --git a/code/experiments/__init__.py b/code/experiments/__init__.py new file mode 100644 index 0000000..a605f12 --- /dev/null +++ b/code/experiments/__init__.py @@ -0,0 +1,18 @@ +"""Reproducible experiment orchestration for the teleoperation study. + +The package is intentionally independent from the current simulation and core +controllers. It provides evidence-layer contracts (plans, manifests, storage, +validation, and random streams) that executors can adopt without changing the +algorithms they exercise. +""" + +from .plan import build_trial_plan, load_document, validate_trial_plan +from .runner import TrialPayload, run_trial_plan + +__all__ = [ + "TrialPayload", + "build_trial_plan", + "load_document", + "run_trial_plan", + "validate_trial_plan", +] diff --git a/code/experiments/cli.py b/code/experiments/cli.py new file mode 100644 index 0000000..ef2015a --- /dev/null +++ b/code/experiments/cli.py @@ -0,0 +1,90 @@ +"""Command-line interface for planning, running, and validating studies.""" + +from __future__ import annotations + +import argparse +import importlib +import json +from pathlib import Path +from typing import Any + +from .io import atomic_write_json +from .plan import build_trial_plan, load_document, validate_trial_plan +from .runner import run_trial_plan +from .validate import validate_batch + + +def _load_executor(specification: str): + if ":" not in specification: + raise ValueError("executor must use module:function syntax") + module_name, attribute_name = specification.split(":", 1) + module = importlib.import_module(module_name) + executor = getattr(module, attribute_name) + if not callable(executor): + raise TypeError(f"{specification} is not callable") + return executor + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Reproducible experiment runner") + subparsers = parser.add_subparsers(dest="command", required=True) + + plan_parser = subparsers.add_parser("plan", help="expand a study specification") + plan_parser.add_argument("--spec", type=Path, required=True) + plan_parser.add_argument("--output", type=Path, required=True) + + run_parser = subparsers.add_parser("run", help="execute an immutable plan") + run_parser.add_argument("--plan", type=Path, required=True) + run_parser.add_argument("--batch-dir", type=Path, required=True) + run_parser.add_argument("--executor", default=None) + run_parser.add_argument("--dry-run", action="store_true") + run_parser.add_argument("--no-resume", action="store_true") + run_parser.add_argument("--stop-on-error", action="store_true") + run_parser.add_argument("--max-trials", type=int, default=None) + + validate_parser = subparsers.add_parser("validate", help="validate a batch") + validate_parser.add_argument("--batch-dir", type=Path, required=True) + validate_parser.add_argument("--allow-incomplete", action="store_true") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + result: dict[str, Any] + if args.command == "plan": + plan = build_trial_plan(load_document(args.spec)) + atomic_write_json(args.output, plan) + result = { + "kind": "plan_summary", + "schema_version": plan["schema_version"], + "study_id": plan["study_id"], + "split": plan["split"], + "pair_count": plan["pair_count"], + "trial_count": plan["trial_count"], + "plan_hash": plan["plan_hash"], + "output": str(args.output.resolve()), + } + elif args.command == "run": + plan = load_document(args.plan) + validate_trial_plan(plan) + executor = None if args.executor is None else _load_executor(args.executor) + result = run_trial_plan( + plan, + args.batch_dir, + executor, + resume=not args.no_resume, + dry_run=args.dry_run, + continue_on_error=not args.stop_on_error, + max_trials=args.max_trials, + ) + else: + result = validate_batch( + args.batch_dir, + require_complete=not args.allow_incomplete, + ) + print(json.dumps(result, indent=2, ensure_ascii=False, sort_keys=True)) + return 0 if result.get("valid", True) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/code/experiments/executors.py b/code/experiments/executors.py new file mode 100644 index 0000000..8a90e1a --- /dev/null +++ b/code/experiments/executors.py @@ -0,0 +1,492 @@ +"""Executable pre-prototype G0c studies. + +Each function accepts one immutable trial record produced by +``experiments.plan`` and returns an atomic ``TrialPayload``. The methods in a +pair use the same recorded trajectory/model/sensor/network seeds. +""" + +from __future__ import annotations + +from dataclasses import replace +from enum import Enum +from typing import Any, Mapping + +import numpy as np +import pinocchio as pin + +from core.estimation_signals import ( + JointFrictionCalibration, + ResidualAblation, + WrenchEstimatorCalibration, + CalibratedResidualWrenchEstimator, +) +from core.model_contract import ( + MASTER_JOINT_NAMES, + SLAVE_FRAMES, + SLAVE_JOINT_NAMES, + finite_joint_limits, + load_models, + require_frame, +) +from core.retargeting_baselines import ( + RetargetingFailure, + build_canonical_sew_target_baselines, +) +from core.wrench_solver import ScaledDLSSolver, UndampedSVDSolver +from experiments.io import TrialPayload +from experiments.rng import generator_from_record +from simulate_closed_loop import ( + SCENARIOS, + SimulationConfig, + build_mapper, + make_wall, + simulate_scenario, +) + + +def _method_id(trial: Mapping[str, Any]) -> str: + method = trial.get("method") + if not isinstance(method, Mapping) or not isinstance( + method.get("method_id"), str + ): + raise ValueError("trial has no method.method_id") + return method["method_id"] + + +def _trajectory_spec(trial: Mapping[str, Any]) -> Mapping[str, Any]: + trajectory = trial.get("trajectory") + if not isinstance(trajectory, Mapping): + raise ValueError("trial has no trajectory mapping") + return trajectory + + +def _factor(trial: Mapping[str, Any], name: str, default: Any) -> Any: + factors = trial.get("factors", {}) + if not isinstance(factors, Mapping): + raise ValueError("trial factors must be a mapping") + return factors.get(name, default) + + +def _enum_code(member: Enum) -> int: + return list(type(member)).index(member) + + +def _master_trajectory( + trial: Mapping[str, Any], + *, + lower: np.ndarray, + upper: np.ndarray, +) -> np.ndarray: + """Generate a continuous, bounded master trajectory from a frozen spec.""" + specification = _trajectory_spec(trial) + sample_count = int(specification.get("sample_count", 81)) + if sample_count < 3: + raise ValueError("H1 trajectory sample_count must be at least three") + family = str(specification.get("family", "nominal")) + center = np.asarray( + specification.get( + "center", + [0.534, 0.314, -0.10, 2.14, 0.38, 0.38, -0.72], + ), + dtype=float, + ) + delta = np.asarray( + specification.get( + "delta", + [0.08, -0.06, 0.05, -0.12, 0.04, 0.05, -0.04], + ), + dtype=float, + ) + if center.shape != (7,) or delta.shape != (7,): + raise ValueError("H1 center and delta must have seven entries") + if family == "joint_limit": + center = center.copy() + center[0] = upper[0] - 0.03 + delta = np.zeros(7) + delta[0] = -0.22 + elif family == "low_manipulability": + center = center.copy() + center[3] = 0.08 + delta = np.array([0.04, 0.03, -0.04, 0.05, 0.02, -0.02, 0.02]) + elif family == "reach_boundary": + delta = 1.75 * delta + elif family == "sew_degeneracy": + center = np.array([0.0, 0.0, 0.0, 0.12, 0.0, 0.0, 0.0]) + delta = np.array([0.0, 0.18, 0.0, 0.08, 0.0, -0.08, 0.0]) + + phase = np.linspace(0.0, 1.0, sample_count) + # One cosine excursion starts and ends at the same configuration with zero + # endpoint velocity, making discontinuities attributable to the mapper. + excursion = 0.5 - 0.5 * np.cos(2.0 * np.pi * phase) + trajectory = center[None, :] + excursion[:, None] * delta[None, :] + margin = 1e-4 + if np.any(trajectory < lower + margin) or np.any(trajectory > upper - margin): + raise ValueError( + f"trajectory {specification.get('trajectory_id')} exceeds master limits" + ) + return trajectory + + +def _slave_swivel( + model: pin.Model, + data: pin.Data, + q_slave: np.ndarray, + shoulder_id: int, + elbow_id: int, + wrist_id: int, + previous: float, +) -> tuple[float, bool]: + """Evaluate a continuous diagnostic arm-plane angle from slave geometry.""" + pin.forwardKinematics(model, data, q_slave) + pin.updateFramePlacements(model, data) + shoulder = data.oMf[shoulder_id].translation + elbow = data.oMf[elbow_id].translation + wrist = data.oMf[wrist_id].translation + axis = wrist - shoulder + axis_norm = float(np.linalg.norm(axis)) + if axis_norm <= 1e-9: + return previous, True + axis /= axis_norm + radial = elbow - shoulder + radial -= float(radial @ axis) * axis + radial_norm = float(np.linalg.norm(radial)) + if radial_norm <= 1e-9: + return previous, True + radial /= radial_norm + reference = np.array([0.0, 0.0, 1.0]) + reference -= float(reference @ axis) * axis + if np.linalg.norm(reference) <= 1e-8: + reference = np.array([1.0, 0.0, 0.0]) + reference -= float(reference @ axis) * axis + reference /= np.linalg.norm(reference) + wrapped = float( + np.arctan2(axis @ np.cross(reference, radial), reference @ radial) + ) + # Unwrap only against the previous accepted diagnostic value. + delta = (wrapped - previous + np.pi) % (2.0 * np.pi) - np.pi + return previous + float(delta), False + + +def execute_h1_retargeting(trial: Mapping[str, Any]) -> TrialPayload: + """Run one paired H1 trajectory through SEW or one formal baseline.""" + models = load_models(add_simulated_tcp=True) + baselines, sew = build_canonical_sew_target_baselines(models) + methods = {**baselines, sew.name: sew} + method_id = _method_id(trial) + if method_id not in methods: + raise ValueError(f"unknown H1 method {method_id!r}") + method = methods[method_id] + + lower, upper = finite_joint_limits(models.master, MASTER_JOINT_NAMES) + q_master = _master_trajectory(trial, lower=lower, upper=upper) + sample_count = q_master.shape[0] + q_slave_log = np.empty((sample_count, models.slave.nq)) + position_error = np.empty(sample_count) + orientation_error = np.empty(sample_count) + success = np.empty(sample_count, dtype=np.int8) + smooth = np.empty(sample_count, dtype=np.int8) + failure_code = np.empty(sample_count, dtype=np.int16) + solver_status = np.empty(sample_count, dtype=np.int16) + iterations = np.empty(sample_count, dtype=np.int32) + runtime_s = np.empty(sample_count) + solver_cost = np.empty(sample_count) + swivel = np.empty(sample_count) + degeneracy = np.zeros(sample_count, dtype=np.int8) + events: list[dict[str, Any]] = [] + + slave_data = models.slave.createData() + shoulder_id = require_frame(models.slave, SLAVE_FRAMES["shoulder"]) + elbow_id = require_frame(models.slave, SLAVE_FRAMES["elbow"]) + wrist_id = require_frame(models.slave, SLAVE_FRAMES["wrist"]) + seed = None + previous_swivel = 0.0 + for index, q_m in enumerate(q_master): + result = method.retarget(q_m, q_slave_seed=seed) + q_slave_log[index] = result.q_slave + position_error[index] = result.diagnostics.position_error_m + orientation_error[index] = result.diagnostics.orientation_error_rad + success[index] = int(result.success) + smooth[index] = int(result.smooth) + failure_code[index] = _enum_code(result.failure) + solver_status[index] = _enum_code(result.diagnostics.status) + iterations[index] = result.diagnostics.iterations + runtime_s[index] = result.diagnostics.runtime_s + solver_cost[index] = result.diagnostics.cost + previous_swivel, is_degenerate = _slave_swivel( + models.slave, + slave_data, + result.q_slave, + shoulder_id, + elbow_id, + wrist_id, + previous_swivel, + ) + swivel[index] = previous_swivel + degeneracy[index] = int(is_degenerate) + if result.success: + seed = result.q_slave.copy() + for event in result.events: + events.append( + { + "sample_index": index, + "event": str(event), + "failure_code": int(failure_code[index]), + } + ) + + master_step = np.zeros(sample_count) + if sample_count > 1: + master_step[1:] = np.linalg.norm( + (q_master[1:] - q_master[:-1] + np.pi) % (2.0 * np.pi) - np.pi, + axis=1, + ) + samples = { + "sample_index": np.arange(sample_count, dtype=np.int64), + "q_master": q_master, + "map_q_slave": q_slave_log, + "map_pose_success": success, + # H1 requires a valid/smooth branch. Differential A is evaluated in a + # separate diagnostic study and is not silently imputed here. + "map_differential_valid": smooth, + "map_position_error_m": position_error, + "map_orientation_error_rad": orientation_error, + "map_swivel_angle_rad": swivel, + "map_master_step_norm": master_step, + "map_accepted": np.ones(sample_count, dtype=np.int8), + "map_commanded_reset": np.zeros(sample_count, dtype=np.int8), + "map_degeneracy_transition": degeneracy, + "map_failure_code": failure_code, + "map_solver_status": solver_status, + "map_solver_iterations": iterations, + "map_runtime_s": runtime_s, + "map_solver_cost": solver_cost, + } + return TrialPayload( + samples=samples, + events=events, + metadata={ + "evidence_scope": "pre-prototype numerical retargeting only", + "method_id": method_id, + "failure_enum": { + member.value: _enum_code(member) for member in RetargetingFailure + }, + "trajectory_family": _trajectory_spec(trial).get("family", "nominal"), + }, + ) + + +def _orthogonal(rng: np.random.Generator, size: int) -> np.ndarray: + q, r = np.linalg.qr(rng.normal(size=(size, size))) + signs = np.where(np.diag(r) >= 0.0, 1.0, -1.0) + return q * signs + + +def execute_h2_synthetic(trial: Mapping[str, Any]) -> TrialPayload: + """Run a paired, truth/estimator-separated H2 sensitivity trial.""" + method_id = _method_id(trial) + valid_methods = {"scaled_dls", "undamped_svd", "no_bias", "no_friction"} + if method_id not in valid_methods: + raise ValueError(f"unknown H2 method {method_id!r}") + trajectory = _trajectory_spec(trial) + count = int(trajectory.get("sample_count", 256)) + if count < 8: + raise ValueError("H2 synthetic trial needs at least eight samples") + characteristic_length = float( + _factor(trial, "characteristic_length_m", 0.30) + ) + damping = float(_factor(trial, "damping", 0.02)) + min_singular = float(_factor(trial, "min_scaled_singular", 0.05)) + noise_std = float(_factor(trial, "torque_noise_std_Nm", 0.01)) + model_error_std = float(_factor(trial, "model_error_std", 0.01)) + if min_singular < 0.0 or noise_std < 0.0 or model_error_std < 0.0: + raise ValueError("H2 perturbation factors must be non-negative") + + model_rng = generator_from_record(trial["seeds"], "model") + sensor_rng = generator_from_record(trial["seeds"], "sensor") + trajectory_rng = generator_from_record(trial["seeds"], "trajectory") + u = _orthogonal(model_rng, 6) + v = _orthogonal(model_rng, 7) + singular = np.array([1.6, 1.25, 0.95, 0.65, 0.35, min_singular]) + base_scaled_truth = u @ np.diag(singular) @ v[:6, :] + inverse_scaling = np.diag( + [characteristic_length] * 3 + [1.0] * 3 + ) + + phase = np.linspace(0.0, 2.0 * np.pi, count, endpoint=False) + amplitudes = np.array([18.0, 12.0, 9.0, 1.8, 1.2, 0.8]) + offsets = trajectory_rng.uniform(-np.pi, np.pi, 6) + wrench_reference = amplitudes[None, :] * np.sin( + phase[:, None] * np.arange(1, 7)[None, :] + offsets[None, :] + ) + qd = 0.6 * np.sin( + phase[:, None] * np.arange(1, 8)[None, :] + + trajectory_rng.uniform(-np.pi, np.pi, (1, 7)) + ) + bias = np.array([0.08, -0.05, 0.035, -0.025, 0.015, -0.01, 0.02]) + friction = JointFrictionCalibration( + coulomb_nm=np.array([0.06, 0.05, 0.045, 0.04, 0.02, 0.02, 0.015]), + viscous_nm_per_rad_s=np.array( + [0.018, 0.017, 0.015, 0.014, 0.009, 0.008, 0.007] + ), + ) + calibration = WrenchEstimatorCalibration( + joint_bias_nm=bias, + friction=friction, + characteristic_length_m=characteristic_length, + damping=damping, + calibration_id="g0c-synthetic-frozen-v1", + ) + solver = ( + UndampedSVDSolver(characteristic_length) + if method_id == "undamped_svd" + else ScaledDLSSolver(characteristic_length, damping) + ) + estimator = CalibratedResidualWrenchEstimator(calibration, solver) + ablation = ( + ResidualAblation.no_bias() + if method_id == "no_bias" + else ResidualAblation.no_friction() + if method_id == "no_friction" + else ResidualAblation() + ) + + wrench_estimated = np.empty((count, 6)) + singular_log = np.empty((count, 6)) + rank = np.empty(count, dtype=np.int16) + status = np.empty(count, dtype=np.int16) + truth_jacobian = np.empty((count, 42)) + estimator_jacobian = np.empty((count, 42)) + residual_raw = np.empty((count, 7)) + residual_corrected = np.empty((count, 7)) + for index in range(count): + smooth_change = 0.015 * np.sin(phase[index]) + J_truth = inverse_scaling @ ( + base_scaled_truth + + smooth_change * model_rng.normal(size=(6, 7)) + ) + J_estimator = J_truth + inverse_scaling @ ( + model_error_std * model_rng.normal(size=(6, 7)) + ) + interaction = J_truth.T @ wrench_reference[index] + measured = ( + interaction + + bias + + friction.torque(qd[index]) + + sensor_rng.normal(0.0, noise_std, 7) + ) + estimate = estimator.estimate( + J_estimator, + measured_torque_nm=measured, + rigid_body_torque_nm=np.zeros(7), + joint_velocity_rad_s=qd[index], + ablation=ablation, + ) + wrench_estimated[index] = estimate.solve.wrench + singular_log[index] = estimate.solve.singular_values + rank[index] = estimate.solve.rank + status[index] = _enum_code(estimate.solve.status) + truth_jacobian[index] = J_truth.reshape(-1) + estimator_jacobian[index] = J_estimator.reshape(-1) + residual_raw[index] = estimate.residual.raw_residual_nm + residual_corrected[index] = estimate.residual.residual_nm + + return TrialPayload( + samples={ + "sample_index": np.arange(count, dtype=np.int64), + "wrench_reference": wrench_reference, + "wrench_estimated": wrench_estimated, + "wrench_sample_mask": np.ones(count, dtype=np.int8), + "qd_slave": qd, + "jacobian_truth": truth_jacobian, + "jacobian_estimator": estimator_jacobian, + "scaled_singular_values": singular_log, + "solver_rank": rank, + "solver_status": status, + "tau_residual_raw": residual_raw, + "tau_residual_corrected": residual_corrected, + }, + metadata={ + "evidence_scope": ( + "synthetic sensitivity only; not independent physical F/T evidence" + ), + "method_id": method_id, + "truth_estimator_models_separated": True, + "calibration_id": calibration.calibration_id, + }, + ) + + +def execute_bilateral_simulation(trial: Mapping[str, Any]) -> TrialPayload: + """Run one paired H3/H4 rigid-body trial with a frozen network trace.""" + method_id = _method_id(trial) + scenarios = {scenario.key: scenario for scenario in SCENARIOS} + if method_id not in scenarios: + raise ValueError(f"unknown bilateral method {method_id!r}") + scenario = scenarios[method_id] + map_policy = str(_factor(trial, "map_policy", scenario.map_policy)) + scenario = replace(scenario, map_policy=map_policy) + + trajectory = _trajectory_spec(trial) + trajectory_rng = generator_from_record(trial["seeds"], "trajectory") + seed = int(trajectory_rng.integers(0, np.iinfo(np.int32).max)) + config = replace( + SimulationConfig(), + seed=seed, + duration=float(trajectory.get("duration_s", 1.2)), + contact_probe_fraction=float( + trajectory.get("contact_probe_fraction", 0.0) + ), + feedback_delay_s=float(_factor(trial, "return_delay_s", 0.04)), + forward_delay_s=float(_factor(trial, "forward_delay_s", 0.0)), + return_jitter_s=float(_factor(trial, "return_jitter_s", 0.0)), + forward_jitter_s=float(_factor(trial, "forward_jitter_s", 0.0)), + return_packet_loss=float(_factor(trial, "return_packet_loss", 0.0)), + forward_packet_loss=float(_factor(trial, "forward_packet_loss", 0.0)), + wall_stiffness=float(_factor(trial, "wall_stiffness", 800.0)), + wall_damping=float(_factor(trial, "wall_damping", 45.0)), + ) + models = load_models(add_simulated_tcp=True) + mapper = build_mapper(models) + wall, wall_metadata, q_slave_start = make_wall(config, models, mapper) + if trajectory.get("family") == "free_space": + travel = float(wall_metadata["free_space_travel_m"]) + wall = replace( + wall, + point=wall.point + 2.0 * travel * wall.normal, + ) + wall_metadata = { + **wall_metadata, + "condition": "free_space", + "point_world_m": wall.point.tolist(), + } + result = simulate_scenario( + scenario, config, models, wall, q_slave_start + ) + samples = {name: value.copy() for name, value in result.logs.items()} + samples["tau_master_raw"] = samples["tau_master_mapped"].copy() + samples["tau_slave_source"] = ( + samples["tau_slave_residual_source"].copy() + if scenario.mapping == "differential_residual" + else samples["tau_slave_matched_wrench"].copy() + ) + samples["return_valid"] = samples["return_packet_active"].astype(np.int8) + samples["energy_before_J"] = samples["energy_before"].copy() + samples["energy_after_J"] = samples["tank_energy"].copy() + samples["energy_preclip_J"] = samples["energy_preclip"].copy() + return TrialPayload( + samples=samples, + events=(), + metadata={ + "evidence_scope": ( + "pre-prototype rigid-body simulation; no physical or human claim" + ), + "scenario": { + "key": scenario.key, + "mapping": scenario.mapping, + "supervisor": scenario.supervisor, + "map_policy": scenario.map_policy, + }, + "wall": wall_metadata, + "online_metrics_are_diagnostic_only": result.metrics, + }, + ) diff --git a/code/experiments/hashing.py b/code/experiments/hashing.py new file mode 100644 index 0000000..16f019c --- /dev/null +++ b/code/experiments/hashing.py @@ -0,0 +1,62 @@ +"""Canonical serialization and stable SHA-256 helpers.""" + +from __future__ import annotations + +import dataclasses +import hashlib +import json +from pathlib import Path +from typing import Any, Mapping + +import numpy as np + + +def to_jsonable(value: Any) -> Any: + """Convert supported scientific-Python values to strict JSON values.""" + if dataclasses.is_dataclass(value): + return to_jsonable(dataclasses.asdict(value)) + if isinstance(value, Path): + return str(value) + if isinstance(value, np.ndarray): + return to_jsonable(value.tolist()) + if isinstance(value, np.generic): + return to_jsonable(value.item()) + if isinstance(value, Mapping): + return {str(key): to_jsonable(item) for key, item in value.items()} + if isinstance(value, (tuple, list)): + return [to_jsonable(item) for item in value] + if isinstance(value, (str, int, float, bool)) or value is None: + return value + raise TypeError(f"Unsupported value for canonical JSON: {type(value).__name__}") + + +def canonical_json_bytes(value: Any) -> bytes: + """Return deterministic UTF-8 JSON bytes, rejecting NaN and infinity.""" + return json.dumps( + to_jsonable(value), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ).encode("utf-8") + + +def stable_hash(value: Any, *, prefix: str = "") -> str: + """Hash a value using canonical JSON and an optional domain prefix.""" + digest = hashlib.sha256() + if prefix: + digest.update(prefix.encode("utf-8")) + digest.update(b"\0") + digest.update(canonical_json_bytes(value)) + return digest.hexdigest() + + +def file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + while True: + block = stream.read(1024 * 1024) + if not block: + break + digest.update(block) + return digest.hexdigest() diff --git a/code/experiments/io.py b/code/experiments/io.py new file mode 100644 index 0000000..c35d515 --- /dev/null +++ b/code/experiments/io.py @@ -0,0 +1,209 @@ +"""Atomic evidence writers using only the standard library and NumPy.""" + +from __future__ import annotations + +import json +import os +import shutil +import tempfile +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Mapping, Sequence + +import numpy as np + +from .hashing import file_sha256, to_jsonable +from .schema import SCHEMA_VERSION, STORAGE_FORMATS, ContractError + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _flush_and_sync(stream) -> None: + stream.flush() + os.fsync(stream.fileno()) + + +def atomic_write_json(path: Path, document: Mapping[str, Any]) -> None: + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + fd, temporary_name = tempfile.mkstemp( + prefix=f".{path.name}.", suffix=".tmp", dir=path.parent + ) + temporary = Path(temporary_name) + try: + with os.fdopen(fd, "w", encoding="utf-8") as stream: + json.dump( + to_jsonable(document), + stream, + indent=2, + sort_keys=True, + ensure_ascii=False, + allow_nan=False, + ) + stream.write("\n") + _flush_and_sync(stream) + os.replace(temporary, path) + except Exception: + temporary.unlink(missing_ok=True) + raise + + +def atomic_write_jsonl(path: Path, rows: Sequence[Mapping[str, Any]]) -> None: + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + fd, temporary_name = tempfile.mkstemp( + prefix=f".{path.name}.", suffix=".tmp", dir=path.parent + ) + temporary = Path(temporary_name) + try: + with os.fdopen(fd, "w", encoding="utf-8") as stream: + for row in rows: + stream.write( + json.dumps( + to_jsonable(row), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ) + ) + stream.write("\n") + _flush_and_sync(stream) + os.replace(temporary, path) + except Exception: + temporary.unlink(missing_ok=True) + raise + + +def atomic_write_npz(path: Path, arrays: Mapping[str, np.ndarray]) -> None: + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.parent / f".{path.stem}.{uuid.uuid4().hex}.tmp.npz" + try: + np.savez_compressed(temporary, **arrays) + with temporary.open("rb") as stream: + os.fsync(stream.fileno()) + os.replace(temporary, path) + except Exception: + temporary.unlink(missing_ok=True) + raise + + +def normalize_sample_arrays( + samples: Mapping[str, Any], +) -> tuple[dict[str, np.ndarray], int]: + if not isinstance(samples, Mapping) or not samples: + raise ContractError("trial samples must be a non-empty mapping") + arrays: dict[str, np.ndarray] = {} + sample_count: int | None = None + for name, value in samples.items(): + if not isinstance(name, str) or not name: + raise ContractError("sample field names must be non-empty strings") + array = np.asarray(value) + if array.dtype == object: + raise ContractError(f"sample field {name!r} cannot have object dtype") + if array.ndim == 0: + raise ContractError(f"sample field {name!r} must have a sample axis") + if sample_count is None: + sample_count = int(array.shape[0]) + elif array.shape[0] != sample_count: + raise ContractError( + f"sample field {name!r} has {array.shape[0]} rows, " + f"expected {sample_count}" + ) + arrays[name] = array + assert sample_count is not None + if sample_count <= 0: + raise ContractError("trial samples must contain at least one row") + return arrays, sample_count + + +@dataclass(frozen=True) +class TrialPayload: + samples: Mapping[str, Any] + events: Sequence[Mapping[str, Any]] = field(default_factory=tuple) + metadata: Mapping[str, Any] = field(default_factory=dict) + + +class AtomicTrialWriter: + """Commit one successful trial directory using a final atomic rename.""" + + def __init__(self, batch_dir: Path): + self.batch_dir = Path(batch_dir) + self.raw_dir = self.batch_dir / "raw" + self.raw_dir.mkdir(parents=True, exist_ok=True) + + def trial_dir(self, trial_id: str) -> Path: + return self.raw_dir / trial_id + + def write(self, trial: Mapping[str, Any], payload: TrialPayload) -> Path: + trial_id = str(trial["trial_id"]) + destination = self.trial_dir(trial_id) + if destination.exists(): + raise FileExistsError(f"trial artifact already exists: {destination}") + arrays, sample_count = normalize_sample_arrays(payload.samples) + temporary = self.raw_dir / f".{trial_id}.tmp.{uuid.uuid4().hex}" + temporary.mkdir(parents=False, exist_ok=False) + try: + sample_path = temporary / "samples.npz" + event_path = temporary / "events.jsonl" + atomic_write_npz(sample_path, arrays) + atomic_write_jsonl(event_path, list(payload.events)) + fields = { + name: {"dtype": str(array.dtype), "shape": list(array.shape)} + for name, array in sorted(arrays.items()) + } + manifest = { + "kind": "trial_manifest", + "schema_version": SCHEMA_VERSION, + "trial_id": trial_id, + "pair_id": trial["pair_id"], + "trial_spec_hash": trial["trial_spec_hash"], + "status": "completed", + "completed_utc": utc_now(), + "sample_count": sample_count, + "sample_fields": fields, + "storage_formats": { + "samples": STORAGE_FORMATS["samples"], + "events": STORAGE_FORMATS["events"], + }, + "files": { + "samples.npz": file_sha256(sample_path), + "events.jsonl": file_sha256(event_path), + }, + "metadata": to_jsonable(payload.metadata), + } + atomic_write_json(temporary / "trial_manifest.json", manifest) + os.replace(temporary, destination) + except Exception: + shutil.rmtree(temporary, ignore_errors=True) + raise + return destination + + +def record_trial_failure( + batch_dir: Path, + trial: Mapping[str, Any], + error: BaseException, +) -> Path: + failure_dir = Path(batch_dir) / "failures" / str(trial["trial_id"]) + failure_dir.mkdir(parents=True, exist_ok=True) + path = failure_dir / f"attempt-{utc_now().replace(':', '')}-{uuid.uuid4().hex}.json" + atomic_write_json( + path, + { + "kind": "trial_failure", + "schema_version": SCHEMA_VERSION, + "trial_id": trial["trial_id"], + "pair_id": trial["pair_id"], + "trial_spec_hash": trial["trial_spec_hash"], + "recorded_utc": utc_now(), + "error_type": type(error).__name__, + "error_message": str(error), + }, + ) + return path diff --git a/code/experiments/manifest.py b/code/experiments/manifest.py new file mode 100644 index 0000000..b044f7b --- /dev/null +++ b/code/experiments/manifest.py @@ -0,0 +1,227 @@ +"""Batch manifest creation and provenance capture.""" + +from __future__ import annotations + +import importlib +import importlib.metadata +import platform +import subprocess +import sys +from pathlib import Path +from typing import Any, Mapping + +import numpy as np + +from .hashing import file_sha256, stable_hash +from .io import atomic_write_json, utc_now +from .plan import validate_trial_plan +from .schema import SCHEMA_VERSION, STORAGE_FORMATS, ContractError, require_schema + + +_SOURCE_FILES = ( + Path("pyproject.toml"), + Path("uv.lock"), + Path("code/config/master_7dof.urdf"), + Path("code/config/real_slave_7dof.urdf"), +) + + +def _git_provenance(cwd: Path) -> dict[str, Any]: + try: + commit = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=cwd, + check=True, + capture_output=True, + text=True, + timeout=5, + ).stdout.strip() + status = subprocess.run( + ["git", "status", "--porcelain"], + cwd=cwd, + check=True, + capture_output=True, + text=True, + timeout=5, + ).stdout + return {"available": True, "commit": commit, "dirty": bool(status.strip())} + except (OSError, subprocess.SubprocessError): + return {"available": False, "commit": None, "dirty": None} + + +def _source_file_hashes(source_root: Path) -> dict[str, dict[str, str]]: + """Hash reproducibility-critical files without requiring every file.""" + hashes: dict[str, dict[str, str]] = {} + for relative_path in _SOURCE_FILES: + path = source_root / relative_path + if path.is_file(): + hashes[relative_path.as_posix()] = {"sha256": file_sha256(path)} + return hashes + + +def _optional_module_version(module_name: str) -> str | None: + """Return an importable module's version, including distribution fallback.""" + try: + module = importlib.import_module(module_name) + except Exception: + return None + + version = getattr(module, "__version__", None) + if version is not None: + return str(version) + + try: + distributions = importlib.metadata.packages_distributions().get( + module_name, () + ) + for distribution in distributions: + try: + return importlib.metadata.version(distribution) + except importlib.metadata.PackageNotFoundError: + continue + except (ImportError, OSError): + pass + return "unknown" + + +def _runtime_provenance() -> dict[str, str]: + runtime = { + "python": sys.version.split()[0], + "numpy": np.__version__, + "platform": platform.platform(), + "processor": platform.processor(), + } + for module_name in ("scipy", "pinocchio"): + version = _optional_module_version(module_name) + if version is not None: + runtime[module_name] = version + return runtime + + +def _require_locked_git( + plan: Mapping[str, Any], + provenance: Mapping[str, Any], + source_root: Path, +) -> None: + """Reject evidence-locked runs that cannot identify immutable source.""" + if plan.get("split") != "locked": + return + if not provenance.get("available"): + raise ContractError( + "locked split requires available Git metadata; " + f"source_root={source_root} is not a usable Git worktree" + ) + if provenance.get("dirty"): + raise ContractError( + "locked split requires a clean Git worktree; " + "commit or stash all source changes before running" + ) + + +def _require_locked_resume_match( + stored_manifest: Mapping[str, Any], + current_provenance: Mapping[str, Any], + source_root: Path, +) -> None: + """Require a resumed locked batch to use its original immutable source.""" + stored_source = stored_manifest.get("source") + if not isinstance(stored_source, Mapping): + raise ContractError("locked batch manifest is missing source provenance") + + stored_commit = stored_source.get("commit") + current_commit = current_provenance.get("commit") + if not stored_commit or current_commit != stored_commit: + raise ContractError( + "locked batch Git commit mismatch; " + f"stored={stored_commit!r}, current={current_commit!r}" + ) + + stored_files = stored_source.get("files") + if not isinstance(stored_files, Mapping): + raise ContractError( + "locked batch manifest is missing reproducibility file hashes" + ) + current_files = _source_file_hashes(source_root) + if dict(stored_files) != current_files: + changed_paths = sorted( + relative_path + for relative_path in set(stored_files) | set(current_files) + if stored_files.get(relative_path) != current_files.get(relative_path) + ) + raise ContractError( + "locked batch source file hash mismatch for: " + + ", ".join(changed_paths) + ) + + +def build_batch_manifest( + plan: Mapping[str, Any], + *, + source_root: Path | None = None, +) -> dict[str, Any]: + validate_trial_plan(plan) + source_root = Path.cwd() if source_root is None else Path(source_root) + source = _git_provenance(source_root) + _require_locked_git(plan, source, source_root) + source["files"] = _source_file_hashes(source_root) + batch_id = f"batch-{str(plan['plan_hash'])[:16]}" + manifest = { + "kind": "batch_manifest", + "schema_version": SCHEMA_VERSION, + "batch_id": batch_id, + "study_id": plan["study_id"], + "split": plan["split"], + "plan_hash": plan["plan_hash"], + "created_utc": utc_now(), + "expected_pair_count": plan["pair_count"], + "expected_trial_count": plan["trial_count"], + "storage_formats": dict(STORAGE_FORMATS), + "runtime": _runtime_provenance(), + "source": source, + } + hash_basis = dict(manifest) + hash_basis.pop("created_utc") + manifest["manifest_hash"] = stable_hash(hash_basis, prefix="batch-manifest") + return manifest + + +def ensure_batch( + batch_dir: Path, + plan: Mapping[str, Any], + *, + source_root: Path | None = None, +) -> dict[str, Any]: + """Create or verify the immutable plan and batch manifest.""" + validate_trial_plan(plan) + batch_dir = Path(batch_dir) + source_root = Path.cwd() if source_root is None else Path(source_root) + manifest_path = batch_dir / "batch_manifest.json" + plan_path = batch_dir / "plan.json" + if manifest_path.exists() or plan_path.exists(): + if not manifest_path.exists() or not plan_path.exists(): + raise ContractError("batch has only one of plan.json/batch_manifest.json") + current_source = _git_provenance(source_root) + _require_locked_git(plan, current_source, source_root) + from .plan import load_document + + stored_plan = load_document(plan_path) + stored_manifest = load_document(manifest_path) + validate_trial_plan(stored_plan) + require_schema(stored_manifest, "batch_manifest") + if plan.get("split") == "locked": + _require_locked_resume_match( + stored_manifest, + current_source, + source_root, + ) + if stored_plan["plan_hash"] != plan["plan_hash"]: + raise ContractError("existing batch was created from a different plan") + if stored_manifest.get("plan_hash") != plan["plan_hash"]: + raise ContractError("batch manifest plan_hash mismatch") + return stored_manifest + + batch_dir.mkdir(parents=True, exist_ok=True) + manifest = build_batch_manifest(plan, source_root=source_root) + atomic_write_json(plan_path, plan) + atomic_write_json(manifest_path, manifest) + return manifest diff --git a/code/experiments/plan.py b/code/experiments/plan.py new file mode 100644 index 0000000..44a5eb3 --- /dev/null +++ b/code/experiments/plan.py @@ -0,0 +1,212 @@ +"""Deterministic paired-trial plan expansion.""" + +from __future__ import annotations + +import itertools +import json +from pathlib import Path +from typing import Any, Mapping + +from .hashing import stable_hash, to_jsonable +from .rng import named_seed_record +from .schema import ( + SCHEMA_VERSION, + STORAGE_FORMATS, + ContractError, + require_nonempty_string, + require_schema, + require_sequence, +) + + +def load_document(path: Path) -> dict[str, Any]: + """Load JSON, with optional YAML support when PyYAML is available.""" + path = Path(path) + suffix = path.suffix.lower() + with path.open("r", encoding="utf-8") as stream: + if suffix == ".json": + document = json.load(stream) + elif suffix in {".yaml", ".yml"}: + try: + import yaml + except ImportError as exc: + raise RuntimeError( + "YAML input requires PyYAML; use JSON in minimal environments" + ) from exc + document = yaml.safe_load(stream) + else: + raise ValueError("experiment documents must use .json, .yaml, or .yml") + if not isinstance(document, dict): + raise ContractError(f"{path} must contain a mapping") + return document + + +def _normalize_methods(methods: Any) -> list[dict[str, Any]]: + normalized: list[dict[str, Any]] = [] + for entry in require_sequence(methods, "methods"): + if isinstance(entry, str): + method = {"method_id": require_nonempty_string(entry, "method")} + elif isinstance(entry, Mapping): + method = dict(to_jsonable(entry)) + source_id = method.pop("id", method.get("method_id")) + method["method_id"] = require_nonempty_string( + source_id, "method.method_id" + ) + else: + raise ContractError("each method must be a string or mapping") + normalized.append(method) + identifiers = [method["method_id"] for method in normalized] + if len(set(identifiers)) != len(identifiers): + raise ContractError("method identifiers must be unique") + return normalized + + +def _normalize_trajectories(trajectories: Any) -> list[dict[str, Any]]: + normalized: list[dict[str, Any]] = [] + for entry in require_sequence(trajectories, "trajectories"): + if isinstance(entry, str): + trajectory = { + "trajectory_id": require_nonempty_string(entry, "trajectory") + } + elif isinstance(entry, Mapping): + trajectory = dict(to_jsonable(entry)) + source_id = trajectory.pop("id", trajectory.get("trajectory_id")) + trajectory["trajectory_id"] = require_nonempty_string( + source_id, "trajectory.trajectory_id" + ) + else: + raise ContractError("each trajectory must be a string or mapping") + normalized.append(trajectory) + identifiers = [trajectory["trajectory_id"] for trajectory in normalized] + if len(set(identifiers)) != len(identifiers): + raise ContractError("trajectory identifiers must be unique") + return normalized + + +def _factor_cells(factors: Any) -> list[dict[str, Any]]: + if factors is None: + return [{}] + if not isinstance(factors, Mapping): + raise ContractError("factors must be a mapping from name to levels") + names = sorted(factors) + levels = [list(require_sequence(factors[name], f"factors.{name}")) for name in names] + return [ + dict(zip(names, to_jsonable(combination))) + for combination in itertools.product(*levels) + ] + + +def build_trial_plan(specification: Mapping[str, Any]) -> dict[str, Any]: + """Expand a study specification into immutable paired trial records.""" + if not isinstance(specification, Mapping): + raise ContractError("study specification must be a mapping") + spec = dict(to_jsonable(specification)) + study_id = require_nonempty_string(spec.get("study_id"), "study_id") + split = require_nonempty_string(spec.get("split"), "split") + if split not in {"calibration", "pilot", "locked"}: + raise ContractError("split must be calibration, pilot, or locked") + root_seed = int(spec.get("root_seed", 0)) + if root_seed < 0: + raise ContractError("root_seed must be non-negative") + replicates = int(spec.get("replicates", 1)) + if replicates <= 0: + raise ContractError("replicates must be positive") + + methods = _normalize_methods(spec.get("methods")) + trajectories = _normalize_trajectories(spec.get("trajectories")) + factor_cells = _factor_cells(spec.get("factors", {})) + + trials: list[dict[str, Any]] = [] + pair_ids: set[str] = set() + for trajectory, factors, replicate in itertools.product( + trajectories, factor_cells, range(replicates) + ): + pair_basis = { + "study_id": study_id, + "split": split, + "trajectory": trajectory, + "factors": factors, + "replicate": replicate, + "root_seed": root_seed, + } + pair_id = f"pair-{stable_hash(pair_basis, prefix='pair')[:16]}" + seeds = named_seed_record(root_seed, pair_basis) + pair_ids.add(pair_id) + for method in methods: + trial_basis = { + "pair_id": pair_id, + "method": method, + "seeds": seeds, + } + trial_id = f"trial-{stable_hash(trial_basis, prefix='trial')[:16]}" + trial = { + "trial_index": len(trials), + "trial_id": trial_id, + "pair_id": pair_id, + "study_id": study_id, + "split": split, + "method": method, + "trajectory": trajectory, + "factors": factors, + "replicate": replicate, + "seeds": seeds, + } + trial["trial_spec_hash"] = stable_hash(trial, prefix="trial-spec") + trials.append(trial) + + spec_hash = stable_hash(spec, prefix="study-spec") + plan = { + "kind": "trial_plan", + "schema_version": SCHEMA_VERSION, + "study_id": study_id, + "split": split, + "specification": spec, + "spec_hash": spec_hash, + "storage_formats": dict(STORAGE_FORMATS), + "pair_count": len(pair_ids), + "trial_count": len(trials), + "trials": trials, + } + plan["plan_hash"] = stable_hash(plan, prefix="trial-plan") + validate_trial_plan(plan) + return plan + + +def validate_trial_plan(plan: Mapping[str, Any]) -> None: + require_schema(plan, "trial_plan") + trials = require_sequence(plan.get("trials"), "trials") + if int(plan.get("trial_count", -1)) != len(trials): + raise ContractError("trial_count does not match trials") + trial_ids: set[str] = set() + pairs: dict[str, dict[str, Any]] = {} + for trial in trials: + if not isinstance(trial, Mapping): + raise ContractError("each trial must be a mapping") + trial_id = require_nonempty_string(trial.get("trial_id"), "trial_id") + pair_id = require_nonempty_string(trial.get("pair_id"), "pair_id") + if trial_id in trial_ids: + raise ContractError(f"duplicate trial_id {trial_id}") + trial_ids.add(trial_id) + trial_without_hash = dict(trial) + recorded_trial_hash = trial_without_hash.pop("trial_spec_hash", None) + expected_trial_hash = stable_hash(trial_without_hash, prefix="trial-spec") + if recorded_trial_hash != expected_trial_hash: + raise ContractError(f"trial_spec_hash mismatch for {trial_id}") + pair_signature = { + "trajectory": trial.get("trajectory"), + "factors": trial.get("factors"), + "replicate": trial.get("replicate"), + "seeds": trial.get("seeds"), + "study_id": trial.get("study_id"), + "split": trial.get("split"), + } + previous = pairs.setdefault(pair_id, pair_signature) + if previous != pair_signature: + raise ContractError(f"paired inputs differ inside {pair_id}") + if int(plan.get("pair_count", -1)) != len(pairs): + raise ContractError("pair_count does not match unique pair identifiers") + plan_without_hash = dict(plan) + recorded_hash = plan_without_hash.pop("plan_hash", None) + expected_hash = stable_hash(plan_without_hash, prefix="trial-plan") + if recorded_hash != expected_hash: + raise ContractError("plan_hash mismatch") diff --git a/code/experiments/rng.py b/code/experiments/rng.py new file mode 100644 index 0000000..1305a2b --- /dev/null +++ b/code/experiments/rng.py @@ -0,0 +1,53 @@ +"""Order-independent named NumPy ``SeedSequence`` substreams.""" + +from __future__ import annotations + +import hashlib +from typing import Iterable, Mapping + +import numpy as np + +from .hashing import canonical_json_bytes + + +DEFAULT_STREAMS = ("trajectory", "sensor", "model", "network") + + +def _label_words(*labels: object) -> list[int]: + digest = hashlib.sha256(canonical_json_bytes(labels)).digest() + return [ + int.from_bytes(digest[offset : offset + 4], "little") + for offset in range(0, 16, 4) + ] + + +def named_seed_record( + root_seed: int, + namespace: object, + stream_names: Iterable[str] = DEFAULT_STREAMS, +) -> dict[str, list[int]]: + """Create stable named seed states independent of request order.""" + root_seed = int(root_seed) + if root_seed < 0: + raise ValueError("root_seed must be non-negative") + record: dict[str, list[int]] = {} + for name in sorted(set(stream_names)): + if not name: + raise ValueError("stream names must be non-empty") + entropy = [root_seed & 0xFFFFFFFF, (root_seed >> 32) & 0xFFFFFFFF] + entropy.extend(_label_words(namespace, name)) + state = np.random.SeedSequence(entropy).generate_state(4, dtype=np.uint32) + record[name] = [int(word) for word in state] + return record + + +def generator_from_record( + record: Mapping[str, list[int]], + stream_name: str, +) -> np.random.Generator: + if stream_name not in record: + raise KeyError(f"Unknown random stream {stream_name!r}") + words = [int(word) for word in record[stream_name]] + if len(words) != 4 or any(word < 0 or word > 0xFFFFFFFF for word in words): + raise ValueError(f"Invalid seed state for stream {stream_name!r}") + return np.random.default_rng(np.random.SeedSequence(words)) diff --git a/code/experiments/runner.py b/code/experiments/runner.py new file mode 100644 index 0000000..1d40f2a --- /dev/null +++ b/code/experiments/runner.py @@ -0,0 +1,113 @@ +"""Sequential, resumable execution of an immutable paired trial plan.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Callable, Mapping + +from .io import ( + AtomicTrialWriter, + TrialPayload, + atomic_write_json, + record_trial_failure, + utc_now, +) +from .manifest import ensure_batch +from .plan import validate_trial_plan +from .validate import validate_trial_directory + + +TrialExecutor = Callable[[Mapping[str, Any]], TrialPayload | Mapping[str, Any]] + + +def _normalize_payload(value: TrialPayload | Mapping[str, Any]) -> TrialPayload: + if isinstance(value, TrialPayload): + return value + if not isinstance(value, Mapping) or "samples" not in value: + raise TypeError("executor must return TrialPayload or a mapping with samples") + return TrialPayload( + samples=value["samples"], + events=value.get("events", ()), + metadata=value.get("metadata", {}), + ) + + +def run_trial_plan( + plan: Mapping[str, Any], + batch_dir: Path, + executor: TrialExecutor | None = None, + *, + resume: bool = True, + dry_run: bool = False, + continue_on_error: bool = True, + max_trials: int | None = None, + source_root: Path | None = None, +) -> dict[str, Any]: + """Run trials in plan order and atomically commit successful artifacts.""" + validate_trial_plan(plan) + selected = list(plan["trials"]) + if max_trials is not None: + if max_trials < 0: + raise ValueError("max_trials must be non-negative") + selected = selected[:max_trials] + if dry_run: + return { + "dry_run": True, + "study_id": plan["study_id"], + "split": plan["split"], + "pair_count": len({trial["pair_id"] for trial in selected}), + "trial_count": len(selected), + "plan_hash": plan["plan_hash"], + } + if executor is None: + raise ValueError("executor is required unless dry_run=True") + + batch_dir = Path(batch_dir) + ensure_batch(batch_dir, plan, source_root=source_root) + writer = AtomicTrialWriter(batch_dir) + completed = 0 + skipped = 0 + failed = 0 + failures: list[dict[str, str]] = [] + for trial in selected: + destination = writer.trial_dir(trial["trial_id"]) + if destination.exists(): + validation_errors = validate_trial_directory(destination, trial) + if resume and not validation_errors: + skipped += 1 + continue + if validation_errors: + raise RuntimeError( + f"existing trial {trial['trial_id']} is invalid: " + + "; ".join(validation_errors) + ) + raise FileExistsError(f"trial already completed: {trial['trial_id']}") + try: + payload = _normalize_payload(executor(trial)) + writer.write(trial, payload) + completed += 1 + except Exception as exc: + failed += 1 + record_trial_failure(batch_dir, trial, exc) + failures.append( + { + "trial_id": trial["trial_id"], + "error_type": type(exc).__name__, + "error_message": str(exc), + } + ) + if not continue_on_error: + raise + summary = { + "kind": "run_summary", + "schema_version": plan["schema_version"], + "plan_hash": plan["plan_hash"], + "recorded_utc": utc_now(), + "selected_trials": len(selected), + "completed_trials": completed, + "skipped_trials": skipped, + "failed_trials": failed, + "failures": failures, + } + atomic_write_json(batch_dir / "run_summary.json", summary) + return summary diff --git a/code/experiments/schema.py b/code/experiments/schema.py new file mode 100644 index 0000000..eb19066 --- /dev/null +++ b/code/experiments/schema.py @@ -0,0 +1,58 @@ +"""Versioned contracts shared by plans, trial artifacts, and analysis.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any + + +SCHEMA_VERSION = "1.0.0" +SCHEMA_MAJOR = 1 + +STORAGE_FORMATS = { + "plan": "json", + "batch_manifest": "json", + "trial_manifest": "json", + "samples": "numpy-npz", + "events": "json-lines", + "trial_metrics": "json-lines", + "paper_source_data": "csv", +} + + +class ContractError(ValueError): + """Raised when an evidence artifact violates its declared contract.""" + + +def require_schema(document: Mapping[str, Any], expected_kind: str) -> None: + if not isinstance(document, Mapping): + raise ContractError("document must be a mapping") + if document.get("kind") != expected_kind: + raise ContractError( + f"expected kind {expected_kind!r}, got {document.get('kind')!r}" + ) + version = document.get("schema_version") + if not isinstance(version, str): + raise ContractError("schema_version must be a string") + try: + major = int(version.split(".", 1)[0]) + except (TypeError, ValueError) as exc: + raise ContractError(f"invalid schema_version {version!r}") from exc + if major != SCHEMA_MAJOR: + raise ContractError( + f"unsupported schema major {major}; expected {SCHEMA_MAJOR}" + ) + + +def require_nonempty_string(value: Any, name: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ContractError(f"{name} must be a non-empty string") + return value + + +def require_sequence(value: Any, name: str) -> Sequence[Any]: + if isinstance(value, (str, bytes)) or not isinstance(value, Sequence): + raise ContractError(f"{name} must be a sequence") + if not value: + raise ContractError(f"{name} must not be empty") + return value diff --git a/code/experiments/validate.py b/code/experiments/validate.py new file mode 100644 index 0000000..c742595 --- /dev/null +++ b/code/experiments/validate.py @@ -0,0 +1,150 @@ +"""Independent validation of plans, manifests, and per-trial artifacts.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Mapping + +import numpy as np + +from .hashing import file_sha256, stable_hash +from .plan import load_document, validate_trial_plan +from .schema import ContractError, require_schema + + +def validate_trial_directory( + trial_dir: Path, + trial: Mapping[str, Any], +) -> list[str]: + errors: list[str] = [] + trial_dir = Path(trial_dir) + manifest_path = trial_dir / "trial_manifest.json" + if not manifest_path.exists(): + return [f"{trial_dir}: missing trial_manifest.json"] + try: + manifest = load_document(manifest_path) + require_schema(manifest, "trial_manifest") + except Exception as exc: + return [f"{trial_dir}: invalid manifest: {exc}"] + if manifest.get("trial_id") != trial.get("trial_id"): + errors.append(f"{trial_dir}: trial_id mismatch") + if manifest.get("pair_id") != trial.get("pair_id"): + errors.append(f"{trial_dir}: pair_id mismatch") + if manifest.get("trial_spec_hash") != trial.get("trial_spec_hash"): + errors.append(f"{trial_dir}: trial_spec_hash mismatch") + if manifest.get("status") != "completed": + errors.append(f"{trial_dir}: status is not completed") + + declared_files = manifest.get("files", {}) + for relative, expected_hash in declared_files.items(): + path = trial_dir / relative + if not path.is_file(): + errors.append(f"{trial_dir}: missing {relative}") + elif file_sha256(path) != expected_hash: + errors.append(f"{trial_dir}: hash mismatch for {relative}") + + sample_path = trial_dir / "samples.npz" + if sample_path.exists(): + try: + with np.load(sample_path, allow_pickle=False) as archive: + expected_count = int(manifest.get("sample_count", -1)) + declared_fields = manifest.get("sample_fields", {}) + if set(archive.files) != set(declared_fields): + errors.append(f"{trial_dir}: NPZ fields differ from manifest") + for name in archive.files: + array = archive[name] + if array.ndim == 0 or array.shape[0] != expected_count: + errors.append( + f"{trial_dir}: invalid sample axis for field {name}" + ) + expected = declared_fields.get(name, {}) + if list(array.shape) != expected.get("shape"): + errors.append(f"{trial_dir}: shape mismatch for field {name}") + if str(array.dtype) != expected.get("dtype"): + errors.append(f"{trial_dir}: dtype mismatch for field {name}") + except Exception as exc: + errors.append(f"{trial_dir}: cannot read samples.npz: {exc}") + + event_path = trial_dir / "events.jsonl" + if event_path.exists(): + try: + with event_path.open("r", encoding="utf-8") as stream: + for line_number, line in enumerate(stream, start=1): + if line.strip(): + value = json.loads(line) + if not isinstance(value, dict): + raise ValueError(f"line {line_number} is not an object") + except Exception as exc: + errors.append(f"{trial_dir}: invalid events.jsonl: {exc}") + return errors + + +def validate_batch( + batch_dir: Path, + *, + require_complete: bool = True, +) -> dict[str, Any]: + batch_dir = Path(batch_dir) + errors: list[str] = [] + warnings: list[str] = [] + try: + plan = load_document(batch_dir / "plan.json") + validate_trial_plan(plan) + except Exception as exc: + return { + "valid": False, + "errors": [f"invalid plan: {exc}"], + "warnings": [], + "expected_trials": 0, + "completed_trials": 0, + } + try: + manifest = load_document(batch_dir / "batch_manifest.json") + require_schema(manifest, "batch_manifest") + if manifest.get("plan_hash") != plan.get("plan_hash"): + errors.append("batch manifest plan_hash mismatch") + hash_basis = dict(manifest) + recorded_manifest_hash = hash_basis.pop("manifest_hash", None) + hash_basis.pop("created_utc", None) + expected_manifest_hash = stable_hash( + hash_basis, + prefix="batch-manifest", + ) + if recorded_manifest_hash != expected_manifest_hash: + errors.append("batch manifest_hash mismatch") + except Exception as exc: + errors.append(f"invalid batch manifest: {exc}") + + completed = 0 + raw_dir = batch_dir / "raw" + for trial in plan["trials"]: + trial_dir = raw_dir / trial["trial_id"] + if not trial_dir.exists(): + if require_complete: + errors.append(f"missing trial {trial['trial_id']}") + continue + trial_errors = validate_trial_directory(trial_dir, trial) + errors.extend(trial_errors) + if not trial_errors: + completed += 1 + if raw_dir.exists(): + expected = {trial["trial_id"] for trial in plan["trials"]} + for entry in raw_dir.iterdir(): + if entry.name.startswith("."): + warnings.append(f"temporary trial artifact present: {entry.name}") + elif entry.is_dir() and entry.name not in expected: + warnings.append(f"unexpected trial directory: {entry.name}") + return { + "valid": not errors, + "errors": errors, + "warnings": warnings, + "expected_trials": plan["trial_count"], + "completed_trials": completed, + } + + +def require_valid_batch(batch_dir: Path, *, require_complete: bool = True) -> None: + report = validate_batch(batch_dir, require_complete=require_complete) + if not report["valid"]: + raise ContractError("; ".join(report["errors"])) diff --git a/code/hardware/can_msg_builder.py b/code/hardware/can_msg_builder.py new file mode 100644 index 0000000..c18de58 --- /dev/null +++ b/code/hardware/can_msg_builder.py @@ -0,0 +1,257 @@ +import can +import math +import struct +from typing import Dict, List, Tuple, Optional + +# 广播控制 +BROADCAST_TORQUE_CMD_ID = 0x280 +BROADCAST_VELOCITY_CMD_ID = 0x281 +BROADCAST_POSITION_CMD_ID = 0x282 +BROADCAST_MIX_CMD_ID = 0x288 +BROADCAST_CLEAR_ERR_CMD_ID = 0x9B +BROADCAST_TURN_ON_CMD_ID = 0x88 +BROADCAST_TURN_OFF_CMD_ID = 0x80 +BROADCAST_STOP_CMD_ID = 0x81 +BROADCAST_GET_STATE_CMD_ID = 0x9C +# 单电机控制 +SINGLE_MS_TORQUE_CMD_ID = 0xA0 +SINGLE_MG_TORQUE_CMD_ID = 0xA1 +SINGLE_VELOCITY_CMD_1_ID = 0xA2 +SINGLE_VELOCITY_CMD_2_ID = 0xAD +SINGLE_MULTITURN_POS_CMD_1_ID = 0xA3 +SINGLE_MULTITURN_POS_CMD_2_ID = 0xA4 +SINGLE_ONETURN_POS_CMD_1_ID = 0xA5 +SINGLE_ONETURN_POS_CMD_2_ID = 0xA6 +READ_MULTITURN_POS_CMD_ID = 0x92 +SET_MULTITURN_POS_CMD_ID = 0x95 +SINGLE_TURN_ON_CMD_ID = 0x88 +SINGLE_TURN_OFF_CMD_ID = 0x80 +SINGLE_STOP_CMD_ID = 0x81 +SINGLE_CLEAR_ERR_CMD_ID = 0x9B + + +def _clamp_int16(x: int) -> int: + return max(-32768, min(32767, int(x))) + +def _can_id_single_motor(motor_id: int) -> int: + """ + 单电机命令:标识符 = 0x140 + ID(1~32) + """ + assert 1 <= motor_id <= 32 + return 0x140 + motor_id + +def _pack_i16_le(x: int) -> Tuple[int, int]: + x = _clamp_int16(x) + u = x & 0xFFFF + return u & 0xFF, (u >> 8) & 0xFF + +def build_torque_broadcast(torques: List[int]) -> can.Message: + """ + 广播控制模式速度控制can帧构建 + torques: 电机电流控制量 + """ + # TODO: 不同电机型号的力矩与电流int16数值的换算 + t = [0, 0, 0, 0] + for i, torque in enumerate(torques): + t[i] = torque + + d0, d1 = _pack_i16_le(t[0]) + d2, d3 = _pack_i16_le(t[1]) + d4, d5 = _pack_i16_le(t[2]) + d6, d7 = _pack_i16_le(t[3]) + return can.Message( + arbitration_id=BROADCAST_TORQUE_CMD_ID, + is_extended_id=False, + data=[d0, d1, d2, d3, d4, d5, d6, d7], + ) + +def build_velocity_broadcast(vels: List[float]) -> can.Message: + """ + 广播控制模式速度控制can帧构建 + vels: 电机速度,单位:弧度/秒 + """ + v = [0, 0, 0, 0] + for i, vel in enumerate(vels): + v[i] = int(round(math.degrees(vel))) + d0, d1 = _pack_i16_le(v[0]) + d2, d3 = _pack_i16_le(v[1]) + d4, d5 = _pack_i16_le(v[2]) + d6, d7 = _pack_i16_le(v[3]) + return can.Message( + arbitration_id=BROADCAST_VELOCITY_CMD_ID, + is_extended_id=False, + data=[d0, d1, d2, d3, d4, d5, d6, d7], + ) + +def build_position_broadcast(rads: List[float]) -> can.Message: + """ + 广播控制模式位置控制can帧构建 + rad1: 电机1弧度 + rad2:电机2弧度 + rad2:电机3弧度 + rad2:电机4弧度 + """ + p = [0, 0, 0, 0] + for i, rad in enumerate(rads): + p[i] = int(round(math.degrees(rad) / 0.01)) + d0, d1 = _pack_i16_le(p[0]) + d2, d3 = _pack_i16_le(p[1]) + d4, d5 = _pack_i16_le(p[2]) + d6, d7 = _pack_i16_le(p[3]) + return can.Message( + arbitration_id=BROADCAST_POSITION_CMD_ID, + is_extended_id=False, + data=[d0, d1, d2, d3, d4, d5, d6, d7], + ) + +def build_turn_on_broadcast() -> can.Message: + return can.Message( + arbitration_id=BROADCAST_MIX_CMD_ID, + is_extended_id=False, + data=[ + BROADCAST_TURN_ON_CMD_ID, 0x00, + BROADCAST_TURN_ON_CMD_ID, 0x00, + BROADCAST_TURN_ON_CMD_ID, 0x00, + BROADCAST_TURN_ON_CMD_ID, 0x00] + ) + +def build_turn_off_broadcast() -> can.Message: + return can.Message( + arbitration_id=BROADCAST_MIX_CMD_ID, + is_extended_id=False, + data=[ + BROADCAST_TURN_OFF_CMD_ID, 0x00, + BROADCAST_TURN_OFF_CMD_ID, 0x00, + BROADCAST_TURN_OFF_CMD_ID, 0x00, + BROADCAST_TURN_OFF_CMD_ID, 0x00] + ) + +def build_stop_broadcast() -> can.Message: + return can.Message( + arbitration_id=BROADCAST_MIX_CMD_ID, + is_extended_id=False, + data=[ + BROADCAST_STOP_CMD_ID, 0x00, + BROADCAST_STOP_CMD_ID, 0x00, + BROADCAST_STOP_CMD_ID, 0x00, + BROADCAST_STOP_CMD_ID, 0x00 + ] + ) + +def build_clear_error_broadcast() -> can.Message: + return can.Message( + arbitration_id=BROADCAST_MIX_CMD_ID, + is_extended_id=False, + data=[ + BROADCAST_CLEAR_ERR_CMD_ID, 0x00, + BROADCAST_CLEAR_ERR_CMD_ID, 0x00, + BROADCAST_CLEAR_ERR_CMD_ID, 0x00, + BROADCAST_CLEAR_ERR_CMD_ID, 0x00 + ] + ) + +def build_get_state_broadcast() -> can.Message: + return can.Message( + arbitration_id=BROADCAST_MIX_CMD_ID, + is_extended_id=False, + data=[ + BROADCAST_GET_STATE_CMD_ID, 0x00, + BROADCAST_GET_STATE_CMD_ID, 0x00, + BROADCAST_GET_STATE_CMD_ID, 0x00, + BROADCAST_GET_STATE_CMD_ID, 0x00 + ] + ) + +def build_torque_single(motor_id: int, iq: int, is_MS: bool) -> can.Message: + """ + 单电机控制模式力矩控制can帧构建 + motor_id: 电机id + iq: 电流 + """ + # TODO: 不同电机型号的力矩与电流int16数值的换算 + d4, d5 = _pack_i16_le(iq) + if is_MS: + return can.Message( + arbitration_id=_can_id_single_motor(motor_id), + is_extended_id=False, + data=[SINGLE_MS_TORQUE_CMD_ID, 0x00, 0x00, 0x00, d4, d5, 0x00, 0x00], + ) + else: + return can.Message( + arbitration_id=_can_id_single_motor(motor_id), + is_extended_id=False, + data=[SINGLE_MG_TORQUE_CMD_ID, 0x00, 0x00, 0x00, d4, d5, 0x00, 0x00], + ) + +def build_velocity_single(motor_id: int, speed_rps: float) -> can.Message: + """ + 单电机控制模式速度控制can帧构建 + motor_id: 电机id + speed_rps: 电机转速,单位 弧度/秒 + """ + speed_control = int(round(math.degrees(speed_rps) / 0.01)) # 0.01 dps/LSB + data = bytearray(8) + data[0] = 0xA2 + data[1] = 0x00 + data[2] = 0x00 + data[3] = 0x00 + data[4:8] = struct.pack(" can.Message: + """ + 单电机控制模式多圈位置控制can帧构建 + motor_id: 电机id + rad: 电机多圈角度,单位 弧度 + """ + multiturn_pos = int(round(math.degrees(rad) / 0.01)) + data = bytearray(8) + data[0] = SET_MULTITURN_POS_CMD_ID + data[1] = 0x00 + data[2] = 0x00 + data[3] = 0x00 + data[4:8] = struct.pack(" can.Message: + return can.Message( + arbitration_id=_can_id_single_motor(motor_id), + is_extended_id=False, + data=[READ_MULTITURN_POS_CMD_ID, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], + ) + +def build_turn_on_single(motor_id: int) -> can.Message: + return can.Message( + arbitration_id=_can_id_single_motor(motor_id), + is_extended_id=False, + data=[SINGLE_TURN_ON_CMD_ID, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], + ) + +def build_turn_off_single(motor_id: int) -> can.Message: + return can.Message( + arbitration_id=_can_id_single_motor(motor_id), + is_extended_id=False, + data=[SINGLE_TURN_OFF_CMD_ID, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], + ) + +def build_stop_single(motor_id: int) -> can.Message: + return can.Message( + arbitration_id=_can_id_single_motor(motor_id), + is_extended_id=False, + data=[SINGLE_STOP_CMD_ID, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], + ) + +def build_clear_error_single(motor_id: int) -> can.Message: + return can.Message( + arbitration_id=_can_id_single_motor(motor_id), + is_extended_id=False, + data=[SINGLE_CLEAR_ERR_CMD_ID, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], + ) \ No newline at end of file diff --git a/code/hardware/can_msg_parser.py b/code/hardware/can_msg_parser.py new file mode 100644 index 0000000..ac8da9e --- /dev/null +++ b/code/hardware/can_msg_parser.py @@ -0,0 +1,43 @@ +import math +import struct +from typing import Dict, List, Tuple, Optional +from can_msg_builder import BROADCAST_MIX_CMD_ID, BROADCAST_TORQUE_CMD_ID + + +def parse_single_turn_state(data8: bytes) -> Dict: + """ + 解析电机状态2回复can帧 + """ + assert len(data8)==8 + temperature = struct.unpack(" Dict: + """ + 解析“读取多圈角度(0x92)”回复帧 + - DATA[0] = 0x92 + - DATA[1..7] = int64 motorAngle 的低 7 字节(little-endian) + - 单位:0.01 deg / LSB + """ + assert len(data8) == 8 + # 取低 7 字节 + low7 = data8[1:8] # bytes length = 7 + # 符号扩展:看第7字节(低7字节中的最高字节)的最高位是否为1 + # 如果为1,说明负数,需要补 0xFF;否则补 0x00 + sign_ext = 0xFF if (low7[6] & 0x80) else 0x00 + raw8 = low7 + bytes([sign_ext]) # 补成 8 字节 little-endian 的 int64 + + motor_angle_raw = struct.unpack("= self.idle_timeout_s: + if now >= self._next_idle_ts: + try: + self._thread_idle() + except usb.core.USBTimeoutError: + time.sleep(0.005) + except can.CanError: + pass + # 维持均匀节拍 + self._next_idle_ts += self.idle_period + else: + # 睡到下一次 idle + time.sleep(min(0.001, self._next_idle_ts - now)) + else: + # 刚停止控制不久:短睡,等进入 idle + time.sleep(0.001) + + + +class BroadcastSenderThread(SenderThread): + def __init__( + self, + name: str, + bus: can.BusABC, + bus_lock: threading.Lock, + channel_index: int, # 0 or 1 + tick_event: threading.Event, + barrier: threading.Barrier, + stop_event: threading.Event, + ): + super().__init__(name, bus, bus_lock, channel_index, tick_event, barrier, stop_event) + + def turn_on(self): + self.queue.put(build_turn_on_broadcast()) + + def turn_off(self): + self.queue.put(build_turn_off_broadcast()) + + def stop(self): + self.queue.put(build_stop_broadcast()) + + def clear_error(self): + self.queue.put(build_clear_error_broadcast()) + + def set_zero(self): + pass + + def set_torq(self, torques): + assert len(torques) <= 4 + self.queue.put(build_torque_broadcast(torques)) + + def set_vel(self, vel: List[float]): + assert len(vel) <= 4 + self.queue.put(build_velocity_broadcast(vel)) + + def set_pos(self, pos): + assert len(pos) <= 4 + self.queue.put(build_position_broadcast(pos)) + + def _thread_idle(self): + msg = build_get_state_broadcast() + msg.channel = self.channel_index + try: + with self.bus_lock: + self.bus.send(msg) + except can.CanError: + pass + +class SingleSenderThread(SenderThread): + def __init__( + self, + name: str, + bus: can.BusABC, + bus_lock: threading.Lock, + channel_index: int, # 0 or 1 + tick_event: threading.Event, + barrier: threading.Barrier, + stop_event: threading.Event, + ms_index: List[int] + ): + super().__init__(name, bus, bus_lock, channel_index, tick_event, barrier, stop_event) + self.ms_index = ms_index + + def turn_on(self): + for i in range(1, 5): + self.queue.put(build_turn_on_single(i)) + + def turn_off(self): + for i in range(1, 5): + self.queue.put(build_turn_off_single(i)) + + def stop(self): + for i in range(1, 5): + self.queue.put(build_stop_single(i)) + + def clear_error(self): + for i in range(1, 5): + self.queue.put(build_clear_error_single(i)) + + def set_zero(self): + pass + + def set_torq(self, torques): + for i in range(1, 5): + if i in self.ms_index: + self.queue.put(build_torque_single(i, torques[i-1], True)) + else: + self.queue.put(build_torque_single(i, torques[i-1], False)) + + def set_vel(self, vel: List[float]): + for i in range(1, 5): + self.queue.put(build_velocity_single(i, vel[i - 1])) + + def set_pos(self, pos): + for i in range(1, 5): + self.queue.put(build_set_multiturn_position_single(i, pos[i - 1])) + + def _thread_idle(self): + for i in range(1, 5): + msg = build_get_multiturn_position_single(i) + msg.channel = self.channel_index + try: + with self.bus_lock: + self.bus.send(msg) + except can.CanError: + pass + except usb.core.USBTimeoutError: + time.sleep(0.001) + + + +@dataclass +class RxFrame: + t: float = 0.0 + arb: int = 0 + data: bytes = b"" + + +@dataclass +class MotorState: + stamp: float = 0.0 + temp: float = 0.0 + single_turn_rad: float = 0.0 + multi_turn_rad: float = 0.0 + vel: float = 0.0 + power_raw: float = 0.0 + + +class RxCache4Ch(can.Listener): + """ + 一个 Notifier 监听一个 “设备Bus(包含两个channel)”,按 msg.channel 分流到 ch0/ch1 的缓存。 + """ + def __init__(self, joint_map: Dict[int, List[str]]) -> None: + super().__init__() + self._lock = threading.Lock() + self.joint_state = {} + self.ch_mid_idx = {} + for ch, joint_names in joint_map.items(): + for i, joint_name in enumerate(joint_names): + self.ch_mid_idx[(ch, i+1)] = joint_name + self.joint_state[joint_name] = MotorState() + + def on_message_received(self, msg: can.Message): + # python-can Message.channel:对 canalystii 多通道会填 0/1 + ch = getattr(msg, "channel", None) + if ch is None: + # 没有 channel 信息就无法分流,直接忽略或当作 ch0 + ch = 0 + try: + ch = int(ch) + except Exception: + ch = 0 + + arb = msg.arbitration_id + if arb < 0x141 or arb > 0x160: + return + mid = arb - 0x140 + + joint_name = self.ch_mid_idx[(ch, mid)] + if joint_name is None: + return + + if msg.data[0] in [0xA0, 0xA1, 0x9C]: + res = parse_single_turn_state(msg.data) + # print(res) + with self._lock: + self.joint_state[joint_name].stamp = time.time() + self.joint_state[joint_name].temp = res["temperature_C"] + self.joint_state[joint_name].power_raw = res["iq_or_power_raw"] + self.joint_state[joint_name].vel = res["speed_dps"] + self.joint_state[joint_name].single_turn_rad = res["rad"] + elif msg.data[0] in [0x92]: + res = parse_multi_turn_state(msg.data) + with self._lock: + self.joint_state[joint_name].multi_turn_rad =res["rad"] + else: + print(msg.data[0]) + + def snapshot(self): + with self._lock: + return self.joint_state + + \ No newline at end of file diff --git a/code/hardware/exoskeleton.py b/code/hardware/exoskeleton.py new file mode 100644 index 0000000..a2335ce --- /dev/null +++ b/code/hardware/exoskeleton.py @@ -0,0 +1,172 @@ +import time + +import can +import threading +from typing import Dict, List, Tuple, Optional +from canbus import RxCache4Ch, BroadcastSenderThread, SingleSenderThread +from usb_can import resolve_canalyst_device_index +from can_msg_builder import * + + +class Exoskeleton: + def __init__(self): + self.bitrate = 1000000 + self.motor_map = { + ("dev0", 0): [1, 2, 3, 4], + ("dev0", 1): [1, 2, 3, 4], + ("dev1", 0): [1, 2, 3, 4], + ("dev1", 1): [1, 2, 3, 4], + } + self.joint_map = { + "sender-dev0-ch0": ["LJ1", "LJ2", "RJ1", "RJ2"], + "sender-dev0-ch1": ["LJ5", "LJ3", "RJ5", "RJ3"], + "sender-dev1-ch0": ["LJ7", "LJ6", "LJ4"], + "sender-dev1-ch1": ["RJ7", "RJ6", "RJ4"], + } + + idx0 = resolve_canalyst_device_index("/dev/canalystii_1") + idx1 = resolve_canalyst_device_index("/dev/canalystii_0") + self.bus0 = can.Bus(interface="canalystii", channel=[0, 1], bitrate=self.bitrate, device=idx0) + self.bus1 = can.Bus(interface="canalystii", channel=[0, 1], bitrate=self.bitrate, device=idx1) + + # ========= 接收缓存:每个设备一个 Notifier,一个 cache(按 channel 分流) ========= + self.cache0 = RxCache4Ch({0: ["LJ1", "LJ2", "RJ1", "RJ2"], 1: ["LJ5", "LJ3", "RJ5", "RJ3"]}) + self.cache1 = RxCache4Ch({0: ["LJ7", "LJ6", "LJ4"], 1: ["RJ7", "RJ6", "RJ4"]}) + self.notifier0 = can.Notifier(self.bus0, [self.cache0], timeout=0.01) + self.notifier1 = can.Notifier(self.bus1, [self.cache1], timeout=0.01) + + # ========= 发送线程:4个逻辑channel ========= + self.stop_event = threading.Event() + self.cmd_event = threading.Event() + self.barrier = threading.Barrier(4 + 1) # 4个sender + 主线程 + + self.bus0_lock = threading.Lock() + self.bus1_lock = threading.Lock() + + sender_dev0_ch0 = BroadcastSenderThread( + "sender-dev0-ch0", self.bus0, self.bus0_lock, 0, self.cmd_event, self.barrier, self.stop_event) + sender_dev0_ch1 = SingleSenderThread( + "sender-dev0-ch1", self.bus0, self.bus0_lock, 1, self.cmd_event, self.barrier, self.stop_event, [1, 3]) + sender_dev1_ch0 = BroadcastSenderThread( + "sender-dev1-ch0", self.bus1, self.bus1_lock, 0, self.cmd_event, self.barrier, self.stop_event) + sender_dev1_ch1 = BroadcastSenderThread( + "sender-dev1-ch1", self.bus1, self.bus1_lock, 1, self.cmd_event, self.barrier, self.stop_event) + self.senders = [sender_dev0_ch0, sender_dev0_ch1, sender_dev1_ch0, sender_dev1_ch1] + for sender in self.senders: + sender.start() + + def turn_on(self): + for sender in self.senders: + sender.turn_on() + self.cmd_event.set() + try: + self.barrier.wait(timeout=0.05) + except threading.BrokenBarrierError: + pass + self.cmd_event.clear() + + def turn_off(self): + for sender in self.senders: + sender.turn_off() + self.cmd_event.set() + try: + self.barrier.wait(timeout=0.05) + except threading.BrokenBarrierError: + pass + self.cmd_event.clear() + + def clear_error(self): + for sender in self.senders: + sender.clear_error() + self.cmd_event.set() + try: + self.barrier.wait(timeout=0.05) + except threading.BrokenBarrierError: + pass + self.cmd_event.clear() + + def stop_all(self): + for sender in self.senders: + sender.stop() + self.cmd_event.set() + try: + self.barrier.wait(timeout=0.05) + except threading.BrokenBarrierError: + pass + self.cmd_event.clear() + + def set_zero(self): + for sender in self.senders: + sender.set_zero() + self.cmd_event.set() + try: + self.barrier.wait(timeout=0.05) + except threading.BrokenBarrierError: + pass + self.cmd_event.clear() + + def set_torq(self, torq: Dict[str, float]): + torq_cmd = {} + for sender in self.senders: + torq_cmd[sender.name] = [torq[x] for x in self.joint_map[sender.name]] + for sender in self.senders: + sender.set_torq(torq_cmd[sender.name]) + self.cmd_event.set() + try: + self.barrier.wait(timeout=0.05) + except threading.BrokenBarrierError: + pass + self.cmd_event.clear() + + def set_pos(self, pos: Dict[str, float]): + pos_cmd = {} + for sender in self.senders: + pos_cmd[sender.name] = [pos[x] for x in self.joint_map[sender.name]] + for sender in self.senders: + sender.set_pos(pos_cmd[sender.name]) + self.cmd_event.set() + try: + self.barrier.wait(timeout=0.05) + except threading.BrokenBarrierError: + pass + self.cmd_event.clear() + + def get_states(self): + c0 = self.cache0.snapshot() + c1 = self.cache1.snapshot() + print({"c0": c0, "c1": c1}) + + @staticmethod + def _arb_id_single(motor_id: int) -> int: + return 0x140 + int(motor_id) + + +if __name__ == "__main__": + exoskeleton = Exoskeleton() + exoskeleton.turn_on() + count = 0 + try: + while True: + time.sleep(0.02) + exoskeleton.set_torq({ + "LJ1": 30, "LJ2": 30, "LJ3": 30, "LJ4": 30, "LJ5": 100, "LJ6": 100, "LJ7": 100, + "RJ1": 30, "RJ2": 30, "RJ3": 30, "RJ4": 30, "RJ5": 100, "RJ6": 100, "RJ7": 100, + }) + count += 1 + if count % 10 == 0: + exoskeleton.get_states() + except KeyboardInterrupt: + exoskeleton.set_torq({ + "LJ1": 0, "LJ2": 0, "LJ3": 0, "LJ4": 0, "LJ5": 0, "LJ6": 0, "LJ7": 0, + "RJ1": 0, "RJ2": 0, "RJ3": 0, "RJ4": 0, "RJ5": 0, "RJ6": 0, "RJ7": 0, + }) + exoskeleton.stop_all() + exoskeleton.turn_off() + exoskeleton.stop_event.set() + exoskeleton.cmd_event.set() + for th in exoskeleton.senders: + th.join(timeout=0.5) + exoskeleton.notifier0.stop() + exoskeleton.notifier1.stop() + exoskeleton.bus0.shutdown() + exoskeleton.bus1.shutdown() diff --git a/code/hardware/usb_can.py b/code/hardware/usb_can.py new file mode 100644 index 0000000..4693cb7 --- /dev/null +++ b/code/hardware/usb_can.py @@ -0,0 +1,54 @@ +import usb.core +import usb.util +import os +import re + +CANALYST_VID = 0x04d8 +CANALYST_PID = 0x0053 + + +def _bus_dev_from_symlink(symlink: str): + """ + /dev/canalyst_left -> /dev/bus/usb/003/004 + return (busnum, devnum) + """ + real = os.path.realpath(symlink) + m = re.search(r"/usb/(\d+)/(\d+)$", real) + if not m: + raise RuntimeError(f"Cannot parse bus/dev from {real}") + return int(m.group(1)), int(m.group(2)) + + +def resolve_canalyst_device_index(symlink: str) -> int: + """ + Resolve canalystii logical device index from udev symlink. + """ + target_bus, target_dev = _bus_dev_from_symlink(symlink) + + devices = list( + usb.core.find( + find_all=True, + idVendor=CANALYST_VID, + idProduct=CANALYST_PID, + ) + ) + + if not devices: + raise RuntimeError("No CANalyst-II device found") + + for idx, dev in enumerate(devices): + # pyusb 的 bus / address 就是 BUSNUM / DEVNUM + if dev.bus == target_bus and dev.address == target_dev: + return idx + + raise RuntimeError( + f"CANalyst-II {symlink} (bus={target_bus}, dev={target_dev}) not found in pyusb list" + ) + + +if __name__ == "__main__": + id0 = resolve_canalyst_device_index("/dev/canalystii_0") + id1 = resolve_canalyst_device_index("/dev/canalystii_1") + + print(id0) + print(id1) diff --git a/code/script/99-canalystii.rules b/code/script/99-canalystii.rules new file mode 100644 index 0000000..26447e8 --- /dev/null +++ b/code/script/99-canalystii.rules @@ -0,0 +1,2 @@ +SUBSYSTEM=="usb", ATTR{idVendor}=="04d8", ATTR{idProduct}=="0053", KERNELS=="3-7", SYMLINK+="canalystii_0", MODE="0666" +SUBSYSTEM=="usb", ATTR{idVendor}=="04d8", ATTR{idProduct}=="0053", KERNELS=="3-8", SYMLINK+="canalystii_1", MODE="0666" diff --git a/code/script/init_can.sh b/code/script/init_can.sh new file mode 100644 index 0000000..9bc7eda --- /dev/null +++ b/code/script/init_can.sh @@ -0,0 +1,4 @@ +sudo ip link set can0 down +sudo ip link set can0 type can bitrate 1000000 restart-ms 100 +sudo ip link set can0 up +ip -details link show can0 \ No newline at end of file diff --git a/code/script/probe_canalystii_ports.sh b/code/script/probe_canalystii_ports.sh new file mode 100755 index 0000000..edd4024 --- /dev/null +++ b/code/script/probe_canalystii_ports.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "=== USB devices (candidate CAN adapters) ===" +# 先列出所有USB设备,方便你看到Vendor/Product +lsusb + +echo +echo "=== Finding candidate USB devices by vendor/product (you may need to adjust filters) ===" +echo "Tip: if you know VID:PID, set VIDPID like: VIDPID=1d50:606f ./probe_canalystii_ports.sh" +VIDPID="${VIDPID:-}" +if [[ -n "${VIDPID}" ]]; then + lsusb | grep -i "${VIDPID}" || true +else + echo "(No VIDPID specified. We'll show details for all USB devices; you can narrow it later.)" +fi + +echo +echo "=== Enumerate /dev/bus/usb/*/* and print stable attributes ===" + +# 遍历所有 USB device node,提取关键属性:idVendor/idProduct + DEVPATH + KERNELS(物理端口链) +for devnode in /dev/bus/usb/*/*; do + # 过滤:如果设置了VIDPID,则只看匹配的 + if [[ -n "${VIDPID}" ]]; then + info=$(udevadm info -a -n "$devnode" 2>/dev/null | head -n 80 || true) + echo "$info" | grep -qi "ATTR{idVendor}==\"${VIDPID%%:*}\"" || continue + echo "$info" | grep -qi "ATTR{idProduct}==\"${VIDPID##*:}\"" || continue + fi + + # udev属性输出 + path=$(udevadm info -q path -n "$devnode" 2>/dev/null || true) + [[ -z "$path" ]] && continue + + props=$(udevadm info -q property -n "$devnode" 2>/dev/null || true) + vid=$(echo "$props" | awk -F= '/^ID_VENDOR_ID=/{print $2}') + pid=$(echo "$props" | awk -F= '/^ID_MODEL_ID=/{print $2}') + busnum=$(echo "$props" | awk -F= '/^BUSNUM=/{print $2}') + devnum=$(echo "$props" | awk -F= '/^DEVNUM=/{print $2}') + + [[ -z "$vid" || -z "$pid" ]] && continue + + # 取 KERNELS 链:包含类似 2-3 / 1-1.2 这种物理端口信息 + kernels=$(udevadm info -a -n "$devnode" 2>/dev/null | awk -F'==' '/KERNELS==/{print $2}' | head -n 8 | tr -d '"' | tr '\n' ' ') + + echo "----" + echo "DEVNODE : $devnode" + echo "VID:PID : ${vid}:${pid}" + echo "BUS/DEV : ${busnum:-?}/${devnum:-?}" + echo "DEVPATH : $path" + echo "KERNELS : $kernels" +done + +echo +echo "=== Next steps ===" +echo "1) Identify the two CANalyst-II devices (same VID:PID) but different KERNELS like 2-3 and 2-4" +echo "2) Use those KERNELS values to write udev rules mapping them to /dev/canalyst_left and /dev/canalyst_right" diff --git a/code/simulate_closed_loop.py b/code/simulate_closed_loop.py new file mode 100644 index 0000000..3689378 --- /dev/null +++ b/code/simulate_closed_loop.py @@ -0,0 +1,1974 @@ +#!/usr/bin/env python3 +"""Closed-loop bilateral simulation for the heterogeneous 7-DoF arms. + +This program is deliberately a *simulation validation*, not a replacement for +the future prototype experiment. Both arms are integrated with their URDF +rigid-body dynamics. A computed-torque human proxy drives the master, a +computed-torque controller drives the slave, and a unilateral spring-damper +wall acts at a documented simulation-only TCP attached to the terminal wrist. + +Three otherwise identical cases are run: + +``proposed_energy`` + ``tau_m = A(q_m).T @ tau_s`` plus final applied-port energy supervision. +``direct_energy`` + The former direct baseline ``tau_m = J_m.T @ F_s`` with the same output + shaping and energy supervision. +``proposed_no_energy`` + The proposed differential mapping with the energy projection bypassed. + +The comparison separates the two implementation questions: virtual-work +consistency of the retargeting map, and the final-port energy safety layer. +""" + +from __future__ import annotations + +import argparse +import csv +import json +import math +import time +from collections import deque +from dataclasses import asdict, dataclass, fields, replace +from pathlib import Path +from typing import Any, Iterable + +import numpy as np +import pinocchio as pin +import yaml + +from core.haptic_render import HapticRenderer +from core.interaction_estimater import InteractionEstimator +from core.feedback_protocol import ( + ForwardPacket, + MapPolicy, + MapRegistry, + MapSnapshot, + MappingKind, + PacketState, + ReturnPacket, + map_return_feedback, +) +from core.network_emulator import ( + DeterministicChannel, + PacketReceiver, + generate_network_trace, +) +from core.model_contract import ( + MASTER_FRAMES, + MASTER_JOINT_NAMES, + MASTER_URDF, + SLAVE_FRAMES, + SLAVE_JOINT_NAMES, + SLAVE_URDF, + TeleoperationModels, + clip_configuration, + load_models, + require_frame, +) +from core.sew_mapper2 import BallJointConfig, SEWMapper +from core.time_domain_popc import TimeDomainPOPC +from core.wrench_solver import ScaledDLSSolver + + +DEFAULT_OUTPUT_DIR = Path(__file__).resolve().parents[1] / "output" / "simulation" +DEFAULT_CONFIG_PATH = Path(__file__).resolve().parent / "config" / "config.yaml" + + +@dataclass(frozen=True) +class SimulationConfig: + """Numerical and controller parameters for the reproducible comparison.""" + + dt: float = 0.002 + duration: float = 4.0 + mapping_hz: float = 50.0 + seed: int = 7 + slave_contact_frame: str = SLAVE_FRAMES["ee"] + feedback_delay_s: float = 0.080 + forward_delay_s: float = 0.0 + forward_jitter_s: float = 0.0 + return_jitter_s: float = 0.0 + forward_packet_loss: float = 0.0 + return_packet_loss: float = 0.0 + forward_timeout_s: float = 0.20 + return_timeout_s: float = 0.20 + contact_probe_fraction: float = 0.03 + contact_probe_cycles: float = 3.0 + + wall_fraction: float = 0.55 + wall_stiffness: float = 800.0 + wall_damping: float = 45.0 + wall_force_limit: float = 80.0 + wall_transition_depth: float = 0.0001 + + master_kp: tuple[float, ...] = ( + 196.0, + 196.0, + 144.0, + 256.0, + 100.0, + 100.0, + 81.0, + ) + master_kd: tuple[float, ...] = ( + 28.0, + 28.0, + 24.0, + 32.0, + 20.0, + 20.0, + 18.0, + ) + slave_kp: tuple[float, ...] = ( + 900.0, + 900.0, + 676.0, + 1156.0, + 400.0, + 324.0, + 324.0, + ) + slave_kd: tuple[float, ...] = ( + 54.0, + 54.0, + 46.8, + 61.2, + 36.0, + 32.4, + 32.4, + ) + master_acceleration_limits: tuple[float, ...] = ( + 100.0, + 100.0, + 120.0, + 120.0, + 160.0, + 160.0, + 160.0, + ) + slave_acceleration_limits: tuple[float, ...] = ( + 120.0, + 120.0, + 150.0, + 150.0, + 180.0, + 180.0, + 180.0, + ) + master_tracking_effort_fraction: float = 0.65 + slave_tracking_effort_fraction: float = 0.70 + velocity_limit_fraction: float = 0.80 + soft_limit_buffer: float = 0.12 + + feedback_strength: float = 0.50 + haptic_filter_alpha: float = 0.222 + haptic_torque_limits: tuple[float, ...] = ( + 6.0, + 6.0, + 4.0, + 3.0, + 1.0, + 1.0, + 1.0, + ) + haptic_rate_limits: tuple[float, ...] = ( + 150.0, + 150.0, + 100.0, + 80.0, + 30.0, + 30.0, + 30.0, + ) + + energy_min: float = 0.05 + energy_max: float = 0.055 + energy_initial: float = 0.05 + + sensor_noise_std: float = 0.001 + wrench_characteristic_length_m: float = 0.30 + wrench_scaled_damping: float = 1e-3 + sensor_bias: tuple[float, ...] = ( + 0.080, + -0.050, + 0.035, + -0.025, + 0.015, + -0.010, + 0.020, + ) + bias_calibration_samples: int = 200 + + joint_limit_margin: float = 1e-5 + differential_step: float = 1e-4 + + def validate(self) -> None: + if not np.isfinite(self.dt) or self.dt <= 0.0: + raise ValueError("dt must be finite and positive") + if not np.isfinite(self.duration) or self.duration <= 0.0: + raise ValueError("duration must be finite and positive") + if not np.isfinite(self.mapping_hz) or self.mapping_hz <= 0.0: + raise ValueError("mapping_hz must be finite and positive") + if self.mapping_hz > 1.0 / self.dt: + raise ValueError("mapping_hz cannot exceed the dynamics rate") + if not 0.0 < self.wall_fraction < 1.0: + raise ValueError("wall_fraction must lie strictly inside (0, 1)") + if ( + self.wall_stiffness <= 0.0 + or self.wall_damping < 0.0 + or self.wall_force_limit <= 0.0 + or self.wall_transition_depth < 0.0 + ): + raise ValueError("wall parameters must be non-negative") + network_times = ( + self.feedback_delay_s, + self.forward_delay_s, + self.forward_jitter_s, + self.return_jitter_s, + ) + if any(value < 0.0 or not np.isfinite(value) for value in network_times): + raise ValueError("network delays and jitter must be finite/non-negative") + if self.forward_timeout_s <= 0.0 or self.return_timeout_s <= 0.0: + raise ValueError("network timeouts must be positive") + if not 0.0 <= self.forward_packet_loss <= 1.0: + raise ValueError("forward_packet_loss must lie in [0, 1]") + if not 0.0 <= self.return_packet_loss <= 1.0: + raise ValueError("return_packet_loss must lie in [0, 1]") + if self.contact_probe_fraction < 0.0 or self.contact_probe_cycles < 0.0: + raise ValueError("contact probe parameters cannot be negative") + if not ( + 0.0 <= self.energy_min <= self.energy_initial <= self.energy_max + ): + raise ValueError( + "energy values must satisfy 0 <= min <= initial <= max" + ) + if len(self.haptic_torque_limits) != 7: + raise ValueError("haptic_torque_limits must contain seven entries") + if len(self.haptic_rate_limits) != 7: + raise ValueError("haptic_rate_limits must contain seven entries") + if len(self.sensor_bias) != 7: + raise ValueError("sensor_bias must contain seven entries") + if ( + self.wrench_characteristic_length_m <= 0.0 + or self.wrench_scaled_damping <= 0.0 + ): + raise ValueError("scaled wrench solver parameters must be positive") + vector_parameters = ( + self.master_kp, + self.master_kd, + self.slave_kp, + self.slave_kd, + self.master_acceleration_limits, + self.slave_acceleration_limits, + ) + if any(len(values) != 7 for values in vector_parameters): + raise ValueError("all gain and acceleration-limit vectors need 7 entries") + if not 0.0 < self.velocity_limit_fraction <= 1.0: + raise ValueError("velocity_limit_fraction must lie in (0, 1]") + if not 0.0 < self.master_tracking_effort_fraction <= 1.0: + raise ValueError("master_tracking_effort_fraction must lie in (0, 1]") + if not 0.0 < self.slave_tracking_effort_fraction <= 1.0: + raise ValueError("slave_tracking_effort_fraction must lie in (0, 1]") + + +@dataclass(frozen=True) +class Scenario: + key: str + mapping: str + supervise_energy: bool + description: str + supervisor: str = "tank" + map_policy: str = "current" + + +SCENARIOS = ( + Scenario( + key="proposed_energy", + mapping=MappingKind.DIFFERENTIAL_RESIDUAL.value, + supervise_energy=True, + description="A(q_m)^T tau_s with final applied-port supervision", + supervisor="tank", + map_policy=MapPolicy.CURRENT.value, + ), + Scenario( + key="direct_energy", + mapping=MappingKind.DIRECT_MASTER_JACOBIAN.value, + supervise_energy=True, + description="J_m^T F_s matched-wrench baseline with tank supervision", + supervisor="tank", + map_policy=MapPolicy.CURRENT.value, + ), + Scenario( + key="proposed_no_energy", + mapping=MappingKind.DIFFERENTIAL_RESIDUAL.value, + supervise_energy=False, + description="A(q_m)^T tau_s with final projection bypassed", + supervisor="bypass", + map_policy=MapPolicy.CURRENT.value, + ), + Scenario( + key="matched_wrench_energy", + mapping=MappingKind.MATCHED_DIFFERENTIAL_WRENCH.value, + supervise_energy=True, + description=( + "A(source)^T J_s^T F_s matched to direct J_m^T F_s input" + ), + supervisor="tank", + map_policy=MapPolicy.SOURCE_STAMPED.value, + ), + Scenario( + key="proposed_popc", + mapping=MappingKind.DIFFERENTIAL_RESIDUAL.value, + supervise_energy=False, + description="A(q_m)^T tau_s with time-domain PO/PC", + supervisor="popc", + map_policy=MapPolicy.CURRENT.value, + ), +) + + +def load_simulation_config(path: Path) -> SimulationConfig: + """Load the ``simulation`` block while rejecting silent key drift.""" + with path.open("r", encoding="utf-8") as stream: + document = yaml.safe_load(stream) + if not isinstance(document, dict) or not isinstance( + document.get("simulation"), dict + ): + raise ValueError(f"{path} has no mapping-valued 'simulation' block") + + raw = dict(document["simulation"]) + if "slave_tcp_frame" in raw: + raw["slave_contact_frame"] = raw.pop("slave_tcp_frame") + # The offset is consumed by model_contract.py; it is retained in YAML as + # provenance, not duplicated as a simulator constructor parameter. + raw.pop("slave_tcp_offset", None) + + field_map = {field.name: field for field in fields(SimulationConfig)} + unknown = sorted(set(raw) - set(field_map)) + if unknown: + raise ValueError(f"Unknown simulation config keys: {unknown}") + tuple_fields = { + field.name + for field in fields(SimulationConfig) + if isinstance(field.default, tuple) + } + for name in tuple_fields & raw.keys(): + raw[name] = tuple(raw[name]) + config = SimulationConfig(**raw) + config.validate() + return config + + +@dataclass(frozen=True) +class Wall: + point: np.ndarray + normal: np.ndarray + stiffness: float + damping: float + force_limit: float + transition_depth: float = 0.0 + + def wrench( + self, + position_world: np.ndarray, + linear_velocity_world: np.ndarray, + ) -> tuple[np.ndarray, float]: + """Return environment-on-robot wrench and unilateral penetration.""" + penetration = max( + 0.0, + float(np.dot(self.normal, position_world - self.point)), + ) + if penetration <= 0.0: + return np.zeros(6, dtype=float), 0.0 + + normal_velocity = float(np.dot(self.normal, linear_velocity_world)) + if self.transition_depth > 0.0: + damping_activation = min(1.0, penetration / self.transition_depth) + else: + damping_activation = 1.0 + force_magnitude = min( + self.force_limit, + max( + 0.0, + self.stiffness * penetration + + damping_activation * self.damping * normal_velocity, + ), + ) + force = -force_magnitude * self.normal + return np.concatenate((force, np.zeros(3, dtype=float))), penetration + + +@dataclass +class ScenarioResult: + scenario: Scenario + metrics: dict[str, Any] + logs: dict[str, np.ndarray] + + +def build_mapper(models: TeleoperationModels) -> SEWMapper: + """Construct the bounded mapper with the axes/signs in the real slave URDF.""" + return SEWMapper( + master_model=models.master, + slave_model=models.slave, + m_shoulder=MASTER_FRAMES["shoulder"], + m_elbow=MASTER_FRAMES["elbow"], + m_wrist=MASTER_FRAMES["wrist"], + m_ee=MASTER_FRAMES["ee"], + s_shoulder=SLAVE_FRAMES["shoulder"], + s_elbow=SLAVE_FRAMES["elbow"], + s_wrist=SLAVE_FRAMES["wrist"], + # Retarget the physical wrist orientation; the added TCP is only used + # for simulated contact and wrench estimation. + s_ee=SLAVE_FRAMES["wrist"], + master_joint_names=MASTER_JOINT_NAMES, + slave_joint_names=SLAVE_JOINT_NAMES, + slave_shoulder_cfg=BallJointConfig( + axis_order="yxy", + joint_names=SLAVE_JOINT_NAMES[:3], + signs=(-1.0, 1.0, -1.0), + ), + slave_wrist_cfg=BallJointConfig( + axis_order="yzx", + joint_names=SLAVE_JOINT_NAMES[4:], + signs=(-1.0, 1.0, 1.0), + ), + slave_elbow_axis_local=np.array([1.0, 0.0, 0.0]), + up_dir=np.array([0.0, 0.0, 1.0]), + ) + + +def master_endpoint_configurations() -> tuple[np.ndarray, np.ndarray]: + """Return an interior, smooth, exactly recoverable 4.6 cm wrist reach.""" + q_start = np.array( + [0.25, 0.25, -0.20, 1.90, -0.10, 0.10, -0.10], + dtype=float, + ) + q_end = q_start.copy() + q_end[3] = 1.80 + return q_start, q_end + + +def _smooth_transition( + t: float, + t0: float, + t1: float, +) -> tuple[float, float, float]: + if t <= t0: + return 0.0, 0.0, 0.0 + if t >= t1: + return 1.0, 0.0, 0.0 + duration = t1 - t0 + u = (t - t0) / duration + position = 10.0 * u**3 - 15.0 * u**4 + 6.0 * u**5 + velocity = (30.0 * u**2 - 60.0 * u**3 + 30.0 * u**4) / duration + acceleration = ( + 60.0 * u - 180.0 * u**2 + 120.0 * u**3 + ) / duration**2 + return position, velocity, acceleration + + +def master_reference( + t: float, + duration: float, + q_start: np.ndarray, + q_end: np.ndarray, + contact_probe_fraction: float = 0.0, + contact_probe_cycles: float = 0.0, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Approach, probe the delayed contact channel, return, then settle.""" + approach_start = 0.10 * duration + approach_end = 0.38 * duration + return_start = 0.53 * duration + return_end = 0.81 * duration + + delta = q_end - q_start + if t < return_start: + s, sd, sdd = _smooth_transition(t, approach_start, approach_end) + if ( + approach_end < t < return_start + and contact_probe_fraction > 0.0 + and contact_probe_cycles > 0.0 + ): + probe_duration = return_start - approach_end + u = (t - approach_end) / probe_duration + angular_frequency_u = 2.0 * np.pi * contact_probe_cycles + envelope = np.sin(np.pi * u) ** 2 + envelope_du = np.pi * np.sin(2.0 * np.pi * u) + envelope_du2 = 2.0 * np.pi**2 * np.cos(2.0 * np.pi * u) + carrier = np.sin(angular_frequency_u * u) + carrier_du = angular_frequency_u * np.cos( + angular_frequency_u * u + ) + carrier_du2 = -(angular_frequency_u**2) * carrier + probe = contact_probe_fraction * envelope * carrier + probe_du = contact_probe_fraction * ( + envelope_du * carrier + envelope * carrier_du + ) + probe_du2 = contact_probe_fraction * ( + envelope_du2 * carrier + + 2.0 * envelope_du * carrier_du + + envelope * carrier_du2 + ) + s += probe + sd += probe_du / probe_duration + sdd += probe_du2 / probe_duration**2 + else: + sr, srd, srdd = _smooth_transition(t, return_start, return_end) + s, sd, sdd = 1.0 - sr, -srd, -srdd + return q_start + s * delta, sd * delta, sdd * delta + + +def _mass_matrix(model: pin.Model, data: pin.Data, q: np.ndarray) -> np.ndarray: + upper = np.asarray(pin.crba(model, data, q), dtype=float) + return np.triu(upper) + np.triu(upper, 1).T + + +def computed_torque( + model: pin.Model, + data: pin.Data, + q: np.ndarray, + qd: np.ndarray, + q_ref: np.ndarray, + qd_ref: np.ndarray, + qdd_ref: np.ndarray, + kp: float | np.ndarray, + kd: float | np.ndarray, + soft_limit_acceleration: np.ndarray | None = None, +) -> np.ndarray: + """Model-based tracking command for a fixed-base revolute chain.""" + position_error = pin.difference(model, q, q_ref) + kp_vector = np.broadcast_to(np.asarray(kp, dtype=float), (model.nv,)) + kd_vector = np.broadcast_to(np.asarray(kd, dtype=float), (model.nv,)) + acceleration_command = ( + qdd_ref + + kp_vector * position_error + + kd_vector * (qd_ref - qd) + ) + if soft_limit_acceleration is not None: + acceleration_command = ( + acceleration_command + + np.asarray(soft_limit_acceleration, dtype=float) + ) + nonlinear = pin.nonLinearEffects(model, data, q, qd) + return nonlinear + _mass_matrix(model, data, q) @ acceleration_command + + +def _clip_actuator_torque( + model: pin.Model, + torque: np.ndarray, + effort_fraction: float = 1.0, +) -> tuple[np.ndarray, bool]: + limits = effort_fraction * np.asarray(model.effortLimit, dtype=float) + finite_limits = np.where(np.isfinite(limits), limits, np.inf) + clipped = np.clip(np.asarray(torque, dtype=float), -finite_limits, finite_limits) + return clipped, bool(np.any(np.abs(clipped - torque) > 1e-12)) + + +def soft_limit_acceleration( + model: pin.Model, + q: np.ndarray, + qd: np.ndarray, + buffer: float, +) -> np.ndarray: + """Continuous acceleration-domain guard before the last-resort projection.""" + if buffer <= 0.0: + return np.zeros(model.nv, dtype=float) + lower_zone = np.asarray(model.lowerPositionLimit, dtype=float) + buffer + upper_zone = np.asarray(model.upperPositionLimit, dtype=float) - buffer + acceleration = np.zeros(model.nv, dtype=float) + below = q < lower_zone + above = q > upper_zone + acceleration[below] += 400.0 * (lower_zone[below] - q[below]) + acceleration[below] += 36.0 * np.maximum(-qd[below], 0.0) + acceleration[above] -= 400.0 * (q[above] - upper_zone[above]) + acceleration[above] -= 36.0 * np.maximum(qd[above], 0.0) + return acceleration + + +def integrate_state( + model: pin.Model, + q: np.ndarray, + qd: np.ndarray, + qdd: np.ndarray, + dt: float, + joint_names: Iterable[str], + margin: float, + velocity_limit_fraction: float, +) -> tuple[np.ndarray, np.ndarray, bool, bool]: + """Semi-implicit integration with URDF speed and position guards.""" + velocity_limit = ( + velocity_limit_fraction * np.asarray(model.velocityLimit, dtype=float) + ) + qd_unlimited = qd + qdd * dt + qd_next = np.clip(qd_unlimited, -velocity_limit, velocity_limit) + velocity_limited = bool( + np.any(np.abs(qd_next - qd_unlimited) > 1e-12) + ) + q_next = pin.integrate(model, q, qd_next * dt) + q_next, clipped = clip_configuration( + model, + q_next, + tuple(joint_names), + margin=margin, + ) + + if clipped: + # All models in this experiment have nq == nv == 7 scalar revolute + # joints. At a clipped boundary, cancel only outward velocity. + lower = np.asarray(model.lowerPositionLimit, dtype=float) + margin + upper = np.asarray(model.upperPositionLimit, dtype=float) - margin + at_lower = q_next <= lower + 1e-12 + at_upper = q_next >= upper - 1e-12 + qd_next = qd_next.copy() + qd_next[at_lower & (qd_next < 0.0)] = 0.0 + qd_next[at_upper & (qd_next > 0.0)] = 0.0 + return q_next, qd_next, clipped, velocity_limited + + +def frame_kinematics( + model: pin.Model, + data: pin.Data, + q: np.ndarray, + qd: np.ndarray, + frame_id: int, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + pin.forwardKinematics(model, data, q, qd) + pin.updateFramePlacements(model, data) + jacobian = pin.computeFrameJacobian( + model, + data, + q, + frame_id, + pin.ReferenceFrame.LOCAL_WORLD_ALIGNED, + ) + position = np.asarray(data.oMf[frame_id].translation, dtype=float).copy() + linear_velocity = np.asarray(jacobian[:3] @ qd, dtype=float) + return position, linear_velocity, np.asarray(jacobian, dtype=float) + + +def make_wall( + config: SimulationConfig, + models: TeleoperationModels, + mapper: SEWMapper, +) -> tuple[Wall, dict[str, Any], np.ndarray]: + q_start, q_end = master_endpoint_configurations() + q_slave_start, start_debug = mapper.retarget(q_start) + q_slave_end, end_debug = mapper.retarget(q_end, q_s_init=q_slave_start) + if not start_debug["success"] or not end_debug["success"]: + raise RuntimeError( + "Cannot place the wall because endpoint retargeting failed: " + f"start={start_debug['events']}, end={end_debug['events']}" + ) + + frame_id = require_frame(models.slave, config.slave_contact_frame) + data = models.slave.createData() + p_start, _, _ = frame_kinematics( + models.slave, + data, + q_slave_start, + np.zeros(models.slave.nv), + frame_id, + ) + p_end, _, _ = frame_kinematics( + models.slave, + data, + q_slave_end, + np.zeros(models.slave.nv), + frame_id, + ) + travel = p_end - p_start + travel_norm = float(np.linalg.norm(travel)) + if travel_norm < 1e-6: + raise RuntimeError("Retargeted TCP motion is too small to define a wall") + normal = travel / travel_norm + point = p_start + config.wall_fraction * travel + wall = Wall( + point=point, + normal=normal, + stiffness=config.wall_stiffness, + damping=config.wall_damping, + force_limit=config.wall_force_limit, + transition_depth=config.wall_transition_depth, + ) + metadata = { + "start_tcp_world_m": p_start.tolist(), + "end_tcp_world_m": p_end.tolist(), + "free_space_travel_m": travel_norm, + "point_world_m": point.tolist(), + "normal_world": normal.tolist(), + "fraction_of_free_space_travel": config.wall_fraction, + "stiffness_N_per_m": config.wall_stiffness, + "damping_Ns_per_m": config.wall_damping, + "force_limit_N": config.wall_force_limit, + "transition_depth_m": config.wall_transition_depth, + } + return wall, metadata, q_slave_start + + +def make_renderer( + model: pin.Model, + config: SimulationConfig, +) -> HapticRenderer: + return HapticRenderer( + model, + chest_frame_name=MASTER_FRAMES["base"], + ee_frame_name=MASTER_FRAMES["ee"], + feedback_strength=config.feedback_strength, + E_init=config.energy_min, + E_max=config.energy_max, + alpha_floor=0.0, + alpha_ceil=1.0, + E0=config.energy_initial, + torque_limit=np.asarray(config.haptic_torque_limits, dtype=float), + torque_rate_limit=np.asarray(config.haptic_rate_limits, dtype=float), + tau_filter_alpha=config.haptic_filter_alpha, + ) + + +def _delay_line( + size: int, + sample_shape: tuple[int, ...], +) -> deque[np.ndarray]: + return deque( + [np.zeros(sample_shape, dtype=float) for _ in range(size)], + maxlen=size, + ) + + +def _push_delayed( + queue: deque[np.ndarray], + sample: np.ndarray, +) -> np.ndarray: + if queue.maxlen == 0: + return np.asarray(sample, dtype=float).copy() + delayed = queue.popleft() + queue.append(np.asarray(sample, dtype=float).copy()) + return delayed + + +def _rms(values: np.ndarray) -> float: + values = np.asarray(values, dtype=float) + if values.size == 0: + return 0.0 + return float(np.sqrt(np.mean(np.square(values)))) + + +def _finite_or_none(value: float) -> float | None: + return float(value) if np.isfinite(value) else None + + +def simulate_scenario( + scenario: Scenario, + config: SimulationConfig, + models: TeleoperationModels, + wall: Wall, + q_slave_start: np.ndarray, +) -> ScenarioResult: + """Run one closed-loop case with an independent but identically seeded state.""" + config.validate() + rng = np.random.default_rng(config.seed) + mapper = build_mapper(models) + renderer = make_renderer(models.master, config) + estimator = InteractionEstimator( + models.slave, + chest_frame_name=SLAVE_FRAMES["base"], + ee_frame_name=config.slave_contact_frame, + lambda_damp=1e-3, + wrench_solver=ScaledDLSSolver( + config.wrench_characteristic_length_m, + config.wrench_scaled_damping, + ), + ) + + sensor_bias = np.asarray(config.sensor_bias, dtype=float) + calibration = sensor_bias + rng.normal( + 0.0, + config.sensor_noise_std, + size=(config.bias_calibration_samples, models.slave.nv), + ) + estimator.calibrate_bias(calibration) + + q_start, q_end = master_endpoint_configurations() + q_m = q_start.copy() + qd_m = np.zeros(models.master.nv, dtype=float) + q_s = q_slave_start.copy() + qd_s = np.zeros(models.slave.nv, dtype=float) + + q_s_ref, A, map_debug = mapper.retarget_with_differential( + q_m, + q_s_init=q_s, + fd_step=config.differential_step, + ) + if not map_debug["success"] or not map_debug["differential_valid"]: + raise RuntimeError( + "Initial retargeting differential is invalid: " + f"pose={map_debug['events']}, " + f"A={map_debug['differential']['events']}" + ) + differential_feedback_valid = True + qd_s_ref_hold = np.zeros(models.slave.nv, dtype=float) + map_registry = MapRegistry(capacity=2048) + map_registry.add( + MapSnapshot( + map_id=0, + source_index=0, + source_time=0.0, + differential=A, + valid=True, + ) + ) + active_forward_map_id = 0 + next_map_id = 1 + forward_seq = 0 + + master_data_control = models.master.createData() + master_data_dynamics = models.master.createData() + slave_data_control = models.slave.createData() + slave_data_dynamics = models.slave.createData() + slave_data_contact = models.slave.createData() + slave_tcp_id = require_frame(models.slave, config.slave_contact_frame) + + step_count = int(round(config.duration / config.dt)) + mapping_stride = max( + 1, + int(round(1.0 / (config.mapping_hz * config.dt))), + ) + forward_trace = generate_network_trace( + step_count // mapping_stride + 2, + base_delay_s=config.forward_delay_s, + jitter_s=config.forward_jitter_s, + loss_probability=config.forward_packet_loss, + seed=config.seed + 1001, + ) + return_trace = generate_network_trace( + step_count, + base_delay_s=config.feedback_delay_s, + jitter_s=config.return_jitter_s, + loss_probability=config.return_packet_loss, + seed=config.seed + 1002, + ) + forward_channel: DeterministicChannel[ForwardPacket] = ( + DeterministicChannel(forward_trace) + ) + return_channel: DeterministicChannel[ReturnPacket] = ( + DeterministicChannel(return_trace) + ) + forward_receiver: PacketReceiver[ForwardPacket] = PacketReceiver( + config.forward_timeout_s + ) + return_receiver: PacketReceiver[ReturnPacket] = PacketReceiver( + config.return_timeout_s + ) + forward_channel.send( + ForwardPacket( + seq=forward_seq, + source_index=0, + source_time=0.0, + map_id=0, + q_slave_ref=q_s_ref, + qd_slave_ctrl=qd_s_ref_hold, + ), + now=0.0, + ) + forward_seq += 1 + + scalar_keys = ( + "time", + "sample_index", + "dt", + "missed_deadline", + "contact_force_norm", + "penetration", + "force_estimation_error_norm", + "moment_estimation_error_norm", + "raw_master_power", + "a_defined_slave_power", + "a_port_identity_error", + "source_slave_power", + "actual_power_mismatch_abs", + "actual_slave_environment_power", + "candidate_power", + "applied_power", + "rho", + "energy_before", + "tank_energy", + "energy_preclip", + "shadow_energy", + "popc_damping_gain", + "map_id", + "source_map_id", + "return_source_index", + "return_packet_age", + "forward_packet_state", + "return_packet_state", + "return_packet_active", + "master_tracking_error", + "slave_tracking_error", + "feedback_torque_norm", + "mapped_torque_norm", + ) + vector_keys = ( + "q_master", + "qd_master", + "q_master_ref", + "q_slave", + "qd_slave", + "q_slave_ref", + "tau_slave_external", + "tau_slave_estimated", + "tau_slave_residual_source", + "tau_slave_matched_wrench", + "qd_slave_source", + "tau_master_mapped", + "tau_master_candidate", + "tau_master_applied", + "tau_master_accepted", + "wrench_external", + "wrench_estimated", + "wrench_feedback_source", + "map_differential", + "tcp_position", + ) + log_lists: dict[str, list[np.ndarray | float]] = { + key: [] for key in scalar_keys + vector_keys + } + + map_update_count = 0 + map_pose_success_count = 0 + differential_valid_count = 0 + differential_fallback_count = 0 + mapping_runtimes_ms: list[float] = [] + master_limit_events = 0 + slave_limit_events = 0 + master_velocity_limit_events = 0 + slave_velocity_limit_events = 0 + master_acceleration_limit_events = 0 + slave_acceleration_limit_events = 0 + master_torque_saturation_events = 0 + slave_torque_saturation_events = 0 + haptic_rate_limit_events = 0 + haptic_torque_saturation_events = 0 + energy_identity_errors: list[float] = [] + shadow_energy = config.energy_initial + contact_steps = 0 + forward_packets_accepted = 0 + forward_packets_rejected = 0 + return_packets_accepted = 0 + return_packets_rejected = 0 + forward_timeout_steps = 0 + return_timeout_steps = 0 + popc = TimeDomainPOPC( + initial_energy=config.energy_initial, + minimum_energy=config.energy_min, + maximum_energy=config.energy_max, + ) + + run_start = time.perf_counter() + for step in range(step_count): + t = step * config.dt + q_m_ref, qd_m_ref, qdd_m_ref = master_reference( + t, + config.duration, + q_start, + q_end, + config.contact_probe_fraction, + config.contact_probe_cycles, + ) + + if step > 0 and step % mapping_stride == 0: + tic = time.perf_counter() + q_s_candidate, A_candidate, update_debug = ( + mapper.retarget_with_differential( + q_m, + q_s_init=q_s_ref, + fd_step=config.differential_step, + ) + ) + mapping_runtimes_ms.append( + 1e3 * (time.perf_counter() - tic) + ) + map_update_count += 1 + pose_valid = bool(update_debug["success"]) + differential_valid = bool(update_debug["differential_valid"]) + if pose_valid: + map_pose_success_count += 1 + if update_debug["differential_valid"]: + A = A_candidate + differential_valid_count += 1 + qd_s_candidate = np.clip( + A @ qd_m, + -config.velocity_limit_fraction + * np.asarray(models.slave.velocityLimit, dtype=float), + config.velocity_limit_fraction + * np.asarray(models.slave.velocityLimit, dtype=float), + ) + else: + differential_fallback_count += 1 + qd_s_candidate = np.zeros(models.slave.nv, dtype=float) + + snapshot_differential = ( + A_candidate + if np.all(np.isfinite(A_candidate)) + else np.zeros_like(A) + ) + map_registry.add( + MapSnapshot( + map_id=next_map_id, + source_index=step, + source_time=t, + differential=snapshot_differential, + valid=differential_valid, + reason_code=0 if differential_valid else 1, + ) + ) + forward_channel.send( + ForwardPacket( + seq=forward_seq, + source_index=step, + source_time=t, + map_id=next_map_id, + q_slave_ref=( + q_s_candidate if pose_valid else q_s_ref + ), + qd_slave_ctrl=qd_s_candidate, + valid=pose_valid, + ), + now=t, + ) + next_map_id += 1 + forward_seq += 1 + + for delivery in forward_channel.poll(t): + reception = forward_receiver.accept(delivery) + if reception.accepted: + forward_packets_accepted += 1 + else: + forward_packets_rejected += 1 + held_forward = forward_receiver.sample(t) + if held_forward.packet is not None: + q_s_ref = held_forward.packet.q_slave_ref.copy() + qd_s_ref_hold = held_forward.packet.qd_slave_ctrl.copy() + active_forward_map_id = held_forward.packet.map_id + elif held_forward.state is PacketState.TIMED_OUT: + # The position target is held while commanded velocity goes to zero. + qd_s_ref_hold.fill(0.0) + forward_timeout_steps += 1 + qd_s_ref = qd_s_ref_hold + try: + differential_feedback_valid = map_registry.get( + active_forward_map_id + ).valid + except KeyError: + differential_feedback_valid = False + + tcp_position, tcp_linear_velocity, J_slave_world = frame_kinematics( + models.slave, + slave_data_contact, + q_s, + qd_s, + slave_tcp_id, + ) + wrench_external, penetration = wall.wrench( + tcp_position, + tcp_linear_velocity, + ) + if penetration > 0.0: + contact_steps += 1 + tau_slave_external = J_slave_world.T @ wrench_external + + tau_slave_control = computed_torque( + models.slave, + slave_data_control, + q_s, + qd_s, + q_s_ref, + qd_s_ref, + np.zeros(models.slave.nv), + config.slave_kp, + config.slave_kd, + soft_limit_acceleration( + models.slave, + q_s, + qd_s, + config.soft_limit_buffer, + ), + ) + tau_slave_control, torque_clipped = _clip_actuator_torque( + models.slave, + tau_slave_control, + config.slave_tracking_effort_fraction, + ) + slave_torque_saturation_events += int(torque_clipped) + qdd_s_raw = pin.aba( + models.slave, + slave_data_dynamics, + q_s, + qd_s, + tau_slave_control + tau_slave_external, + ) + slave_acceleration_limits = np.asarray( + config.slave_acceleration_limits, + dtype=float, + ) + qdd_s = np.clip( + qdd_s_raw, + -slave_acceleration_limits, + slave_acceleration_limits, + ) + slave_acceleration_limit_events += int( + np.any(np.abs(qdd_s_raw) > slave_acceleration_limits) + ) + + # This is an explicitly simulated load-side equivalent measurement. + # It satisfies the estimator's declared residual convention: + # tau_int = tau_meas - (M qdd + h) - calibrated_bias. + tau_slave_model = estimator._tau_model(q_s, qd_s, qdd_s) + measurement_noise = rng.normal( + 0.0, + config.sensor_noise_std, + models.slave.nv, + ) + tau_slave_measured = ( + tau_slave_model + + tau_slave_external + + sensor_bias + + measurement_noise + ) + tau_slave_estimated, wrench_estimated, J_slave_chest = estimator.estimate( + q_s, + qd_s, + qdd_s, + tau_slave_measured, + ) + tau_slave_matched_wrench = J_slave_chest.T @ wrench_estimated + return_channel.send( + ReturnPacket( + seq=step, + source_index=step, + source_time=t, + echoed_map_id=active_forward_map_id, + residual=tau_slave_estimated, + wrench=wrench_estimated, + js_t_wrench=tau_slave_matched_wrench, + qd_slave_actual=qd_s, + ), + now=t, + ) + for delivery in return_channel.poll(t): + reception = return_receiver.accept(delivery) + if reception.accepted: + return_packets_accepted += 1 + else: + return_packets_rejected += 1 + held_return = return_receiver.sample(t) + delayed_packet = held_return.packet + return_packet_active = delayed_packet is not None + if held_return.state is PacketState.TIMED_OUT: + return_timeout_steps += 1 + + master_jacobian = renderer.CJ_master.chest_jacobian(q_m, qd_m) + if delayed_packet is None: + tau_master_mapped = np.zeros(models.master.nv, dtype=float) + delayed_tau_slave = np.zeros(models.slave.nv, dtype=float) + delayed_wrench = np.zeros(6, dtype=float) + delayed_tau_matched = np.zeros(models.slave.nv, dtype=float) + tau_slave_source_for_mapping = np.zeros( + models.slave.nv, dtype=float + ) + qd_slave_source = np.zeros(models.slave.nv, dtype=float) + selected_map_id = -1 + source_map_id = -1 + return_source_index = -1 + return_packet_age = math.nan + else: + feedback = map_return_feedback( + kind=MappingKind(scenario.mapping), + packet=delayed_packet, + master_jacobian=master_jacobian, + maps=map_registry, + map_policy=MapPolicy(scenario.map_policy), + ) + tau_master_mapped = feedback.tau_master_raw + delayed_tau_slave = delayed_packet.residual.copy() + delayed_wrench = delayed_packet.wrench.copy() + delayed_tau_matched = delayed_packet.js_t_wrench.copy() + tau_slave_source_for_mapping = feedback.tau_slave_source.copy() + qd_slave_source = delayed_packet.qd_slave_actual.copy() + selected_map_id = ( + -1 + if feedback.selected_map_id is None + else feedback.selected_map_id + ) + source_map_id = delayed_packet.echoed_map_id + return_source_index = delayed_packet.source_index + return_packet_age = max(0.0, t - delayed_packet.source_time) + if not feedback.valid: + tau_master_mapped = np.zeros(models.master.nv, dtype=float) + + popc_damping_gain = 0.0 + if scenario.supervisor == "tank": + tau_master_applied, rho = renderer.render_mapped_reaction( + tau_master_mapped, + qd_m, + config.dt, + supervise_energy=True, + ) + elif scenario.supervisor == "bypass": + tau_master_applied, rho = renderer.render_mapped_reaction( + tau_master_mapped, + qd_m, + config.dt, + supervise_energy=False, + ) + elif scenario.supervisor == "popc": + tau_master_candidate, candidate_valid = ( + renderer.shape_mapped_reaction_candidate( + tau_master_mapped, + qd_m, + config.dt, + ) + ) + if candidate_valid: + tau_master_applied, popc_diagnostics = popc.apply( + tau_master_candidate, + qd_m, + config.dt, + ) + else: + tau_master_applied = np.zeros_like(tau_master_candidate) + _, popc_diagnostics = popc.apply( + tau_master_applied, + np.zeros_like(qd_m), + config.dt, + ) + renderer.commit_applied(tau_master_applied) + rho = math.nan + popc_damping_gain = popc_diagnostics.damping_gain + else: + raise ValueError(f"Unknown supervisor: {scenario.supervisor}") + haptic_rate_limit_events += int(renderer.last_rate_limit_active) + haptic_torque_saturation_events += int( + renderer.last_torque_saturation_active + ) + if scenario.supervisor == "tank": + diagnostics = renderer.tank.last_diagnostics + assert diagnostics is not None + tau_master_candidate = diagnostics.tau_candidate.copy() + reconstructed_energy_preclip = float( + diagnostics.E_before - diagnostics.power * config.dt + ) + energy_identity_errors.append( + abs(diagnostics.E_preclip - reconstructed_energy_preclip) + ) + tank_energy = diagnostics.E_after + energy_before = diagnostics.E_before + energy_preclip = diagnostics.E_preclip + candidate_power = diagnostics.candidate_power + elif scenario.supervisor == "popc": + tank_energy = popc_diagnostics.observer_after + energy_before = popc_diagnostics.observer_before + energy_preclip = popc_diagnostics.observer_preclip + candidate_power = popc_diagnostics.candidate_power + else: + tau_master_candidate = tau_master_applied.copy() + tank_energy = math.nan + energy_before = math.nan + energy_preclip = math.nan + candidate_power = float(np.dot(tau_master_candidate, qd_m)) + + applied_power = float(np.dot(tau_master_applied, qd_m)) + tau_master_accepted = tau_master_applied.copy() + # Counterfactual storage obeys the same upper capacity but deliberately + # has no lower projection. Falling below E_min demonstrates the exact + # sample at which unsupervised output violates the configured budget. + shadow_energy = min( + config.energy_max, + shadow_energy - candidate_power * config.dt, + ) + + tau_human = computed_torque( + models.master, + master_data_control, + q_m, + qd_m, + q_m_ref, + qd_m_ref, + qdd_m_ref, + config.master_kp, + config.master_kd, + soft_limit_acceleration( + models.master, + q_m, + qd_m, + config.soft_limit_buffer, + ), + ) + tau_human, master_torque_clipped = _clip_actuator_torque( + models.master, + tau_human, + config.master_tracking_effort_fraction, + ) + master_torque_saturation_events += int(master_torque_clipped) + qdd_m_raw = pin.aba( + models.master, + master_data_dynamics, + q_m, + qd_m, + tau_human + tau_master_applied, + ) + master_acceleration_limits = np.asarray( + config.master_acceleration_limits, + dtype=float, + ) + qdd_m = np.clip( + qdd_m_raw, + -master_acceleration_limits, + master_acceleration_limits, + ) + master_acceleration_limit_events += int( + np.any(np.abs(qdd_m_raw) > master_acceleration_limits) + ) + + try: + differential_for_power = ( + map_registry.get(selected_map_id).differential + if selected_map_id >= 0 + else map_registry.latest.differential + ) + except KeyError: + differential_for_power = np.zeros( + (models.slave.nv, models.master.nv), dtype=float + ) + reference_slave_velocity = differential_for_power @ qd_m + raw_master_power = float(np.dot(tau_master_mapped, qd_m)) + a_defined_slave_power = float( + np.dot(tau_slave_source_for_mapping, reference_slave_velocity) + ) + a_port_identity_error = abs( + raw_master_power - a_defined_slave_power + ) + source_slave_power = float( + np.dot(tau_slave_source_for_mapping, qd_slave_source) + ) + actual_power_mismatch_abs = abs( + raw_master_power - source_slave_power + ) + + scalar_values = { + "time": t, + "sample_index": step, + "dt": config.dt, + "missed_deadline": 0, + "contact_force_norm": float(np.linalg.norm(wrench_external[:3])), + "penetration": penetration, + "force_estimation_error_norm": float( + np.linalg.norm(wrench_estimated[:3] - wrench_external[:3]) + ), + "moment_estimation_error_norm": float( + np.linalg.norm(wrench_estimated[3:] - wrench_external[3:]) + ), + "raw_master_power": raw_master_power, + "a_defined_slave_power": a_defined_slave_power, + "a_port_identity_error": a_port_identity_error, + "source_slave_power": source_slave_power, + "actual_power_mismatch_abs": actual_power_mismatch_abs, + "actual_slave_environment_power": float( + np.dot(tau_slave_external, qd_s) + ), + "candidate_power": candidate_power, + "applied_power": applied_power, + "rho": rho, + "energy_before": energy_before, + "tank_energy": tank_energy, + "energy_preclip": energy_preclip, + "shadow_energy": shadow_energy, + "popc_damping_gain": popc_damping_gain, + "map_id": selected_map_id, + "source_map_id": source_map_id, + "return_source_index": return_source_index, + "return_packet_age": return_packet_age, + "forward_packet_state": int(held_forward.state), + "return_packet_state": int(held_return.state), + "return_packet_active": int(return_packet_active), + "master_tracking_error": float( + np.linalg.norm(pin.difference(models.master, q_m, q_m_ref)) + ), + "slave_tracking_error": float( + np.linalg.norm(pin.difference(models.slave, q_s, q_s_ref)) + ), + "feedback_torque_norm": float(np.linalg.norm(tau_master_applied)), + "mapped_torque_norm": float(np.linalg.norm(tau_master_mapped)), + } + vector_values = { + "q_master": q_m.copy(), + "qd_master": qd_m.copy(), + "q_master_ref": q_m_ref.copy(), + "q_slave": q_s.copy(), + "qd_slave": qd_s.copy(), + "q_slave_ref": q_s_ref.copy(), + "tau_slave_external": tau_slave_external.copy(), + "tau_slave_estimated": tau_slave_estimated.copy(), + "tau_slave_residual_source": delayed_tau_slave.copy(), + "tau_slave_matched_wrench": delayed_tau_matched.copy(), + "qd_slave_source": qd_slave_source.copy(), + "tau_master_mapped": tau_master_mapped.copy(), + "tau_master_candidate": tau_master_candidate.copy(), + "tau_master_applied": tau_master_applied.copy(), + "tau_master_accepted": tau_master_accepted.copy(), + "wrench_external": wrench_external.copy(), + "wrench_estimated": wrench_estimated.copy(), + "wrench_feedback_source": delayed_wrench.copy(), + "map_differential": differential_for_power.reshape(-1).copy(), + "tcp_position": tcp_position.copy(), + } + for key, value in scalar_values.items(): + log_lists[key].append(float(value)) + for key, value in vector_values.items(): + log_lists[key].append(value) + + q_m, qd_m, master_clipped, master_velocity_limited = integrate_state( + models.master, + q_m, + qd_m, + qdd_m, + config.dt, + MASTER_JOINT_NAMES, + config.joint_limit_margin, + config.velocity_limit_fraction, + ) + q_s, qd_s, slave_clipped, slave_velocity_limited = integrate_state( + models.slave, + q_s, + qd_s, + qdd_s, + config.dt, + SLAVE_JOINT_NAMES, + config.joint_limit_margin, + config.velocity_limit_fraction, + ) + master_limit_events += int(master_clipped) + slave_limit_events += int(slave_clipped) + master_velocity_limit_events += int(master_velocity_limited) + slave_velocity_limit_events += int(slave_velocity_limited) + + if not ( + np.all(np.isfinite(q_m)) + and np.all(np.isfinite(qd_m)) + and np.all(np.isfinite(q_s)) + and np.all(np.isfinite(qd_s)) + ): + raise FloatingPointError( + f"{scenario.key}: non-finite state at t={t:.6f} s" + ) + + runtime = time.perf_counter() - run_start + logs = { + key: np.asarray(values, dtype=float) for key, values in log_lists.items() + } + contact_mask = logs["penetration"] > 0.0 + force_active_mask = logs["contact_force_norm"] > 1e-6 + power_scale = _rms(logs["a_defined_slave_power"][contact_mask]) + projection_mask = ( + logs["rho"] < (1.0 - 1e-12) + if scenario.supervisor == "tank" + else logs["popc_damping_gain"] > 0.0 + ) + positive_power = np.maximum(logs["applied_power"], 0.0) + absorbed_power = np.maximum(-logs["applied_power"], 0.0) + actual_mismatch_numerator = float( + np.sum(logs["actual_power_mismatch_abs"]) * config.dt + ) + actual_mismatch_denominator = float( + 0.5 + * np.sum( + np.abs(logs["raw_master_power"]) + + np.abs(logs["source_slave_power"]) + ) + * config.dt + + 1e-12 + ) + finite_rho = logs["rho"][np.isfinite(logs["rho"])] + + metrics = { + "completed": True, + "finite_state": True, + "simulated_duration_s": config.duration, + "wall_contact_fraction": float(np.mean(contact_mask)), + "wall_contact_duration_s": float(np.sum(contact_mask) * config.dt), + "wall_force_active_fraction": float(np.mean(force_active_mask)), + "peak_contact_force_N": float(np.max(logs["contact_force_norm"])), + "max_penetration_mm": float(1e3 * np.max(logs["penetration"])), + "force_estimation_rmse_N": _rms( + logs["force_estimation_error_norm"] + ), + "moment_estimation_rmse_Nm": _rms( + logs["moment_estimation_error_norm"] + ), + "master_tracking_rmse_rad": _rms(logs["master_tracking_error"]), + "slave_tracking_rmse_rad": _rms(logs["slave_tracking_error"]), + "feedback_torque_rms_Nm": _rms(logs["feedback_torque_norm"]), + "feedback_torque_peak_Nm": float( + np.max(logs["feedback_torque_norm"]) + ), + "a_port_identity_error_rms_W": _rms( + logs["a_port_identity_error"] + ), + "a_port_identity_error_max_W": float( + np.max(logs["a_port_identity_error"]) + ), + "a_port_identity_relative_rms": float( + _rms(logs["a_port_identity_error"][contact_mask]) + / max(power_scale, 1e-12) + ), + "actual_power_mismatch_normalized": ( + actual_mismatch_numerator / actual_mismatch_denominator + ), + "slave_environment_net_work_J": float( + np.sum(logs["actual_slave_environment_power"]) * config.dt + ), + "positive_energy_delivered_J": float( + np.sum(positive_power) * config.dt + ), + "energy_absorbed_J": float(np.sum(absorbed_power) * config.dt), + "supervisor_intervention_fraction": float(np.mean(projection_mask)), + "energy_projection_fraction": ( + float(np.mean(projection_mask)) + if scenario.supervisor == "tank" + else None + ), + "energy_projection_contact_fraction": float( + np.mean(projection_mask[contact_mask]) + if scenario.supervisor == "tank" and np.any(contact_mask) + else 0.0 + ), + "rho_min": ( + float(np.min(finite_rho)) if finite_rho.size else None + ), + "tank_energy_min_J": _finite_or_none( + float(np.nanmin(logs["tank_energy"])) + if scenario.supervisor in ("tank", "popc") + else math.nan + ), + "tank_energy_final_J": _finite_or_none( + float(logs["tank_energy"][-1]) + if scenario.supervisor in ("tank", "popc") + else math.nan + ), + "shadow_energy_min_J": float(np.min(logs["shadow_energy"])), + "shadow_energy_floor_violation_J": float( + max(0.0, config.energy_min - np.min(logs["shadow_energy"])) + ), + "energy_accounting_max_error_J": ( + float(max(energy_identity_errors, default=0.0)) + if scenario.supervisor == "tank" + else None + ), + "mapping_updates": map_update_count, + "mapping_pose_success_rate": float( + map_pose_success_count / max(map_update_count, 1) + ), + "differential_valid_rate": float( + differential_valid_count / max(map_update_count, 1) + ), + "differential_fallback_count": differential_fallback_count, + "mapping_runtime_median_ms": float( + np.median(mapping_runtimes_ms) if mapping_runtimes_ms else 0.0 + ), + "mapping_runtime_p95_ms": float( + np.percentile(mapping_runtimes_ms, 95.0) + if mapping_runtimes_ms + else 0.0 + ), + "mapping_runtime_max_ms": float( + max(mapping_runtimes_ms, default=0.0) + ), + "master_joint_limit_events": master_limit_events, + "slave_joint_limit_events": slave_limit_events, + "master_velocity_limit_events": master_velocity_limit_events, + "slave_velocity_limit_events": slave_velocity_limit_events, + "master_acceleration_limit_events": master_acceleration_limit_events, + "slave_acceleration_limit_events": slave_acceleration_limit_events, + "master_torque_saturation_events": master_torque_saturation_events, + "slave_torque_saturation_events": slave_torque_saturation_events, + "haptic_rate_limit_events": haptic_rate_limit_events, + "haptic_torque_saturation_events": haptic_torque_saturation_events, + "forward_packets_accepted": forward_packets_accepted, + "forward_packets_rejected": forward_packets_rejected, + "return_packets_accepted": return_packets_accepted, + "return_packets_rejected": return_packets_rejected, + "forward_timeout_steps": forward_timeout_steps, + "return_timeout_steps": return_timeout_steps, + "wall_contact_steps": contact_steps, + "wall_time_s": runtime, + } + return ScenarioResult(scenario=scenario, metrics=metrics, logs=logs) + + +def _write_npz(path: Path, logs: dict[str, np.ndarray]) -> None: + np.savez_compressed(path, **logs) + + +def _write_csv(path: Path, logs: dict[str, np.ndarray]) -> None: + scalar_keys = [ + key for key, value in logs.items() if value.ndim == 1 + ] + vector_keys = [ + key for key, value in logs.items() if value.ndim == 2 + ] + fieldnames = scalar_keys + [ + f"{key}_{index}" + for key in vector_keys + for index in range(logs[key].shape[1]) + ] + with path.open("w", encoding="utf-8", newline="") as stream: + writer = csv.DictWriter(stream, fieldnames=fieldnames) + writer.writeheader() + row_count = logs["time"].shape[0] + for row_index in range(row_count): + row: dict[str, float] = { + key: float(logs[key][row_index]) for key in scalar_keys + } + for key in vector_keys: + for column_index, value in enumerate(logs[key][row_index]): + row[f"{key}_{column_index}"] = float(value) + writer.writerow(row) + + +def make_comparison_plot( + results: list[ScenarioResult], + config: SimulationConfig, + output_path: Path, +) -> None: + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + colors = { + "proposed_energy": "#0072B2", + "direct_energy": "#D55E00", + "proposed_no_energy": "#009E73", + "matched_wrench_energy": "#CC79A7", + "proposed_popc": "#E69F00", + } + labels = { + "proposed_energy": r"$A^\mathsf{T}$ + final energy", + "direct_energy": r"$J_m^\mathsf{T}F$ + final energy", + "proposed_no_energy": r"$A^\mathsf{T}$, no energy projection", + "matched_wrench_energy": ( + r"$A_\mathrm{src}^\mathsf{T}J_s^\mathsf{T}F$ + final energy" + ), + "proposed_popc": r"$A^\mathsf{T}$ + time-domain PO/PC", + } + fig, axes = plt.subplots(3, 2, figsize=(13.0, 10.0), sharex=True) + + for result in results: + key = result.scenario.key + color = colors[key] + label = labels[key] + time_axis = result.logs["time"] + axes[0, 0].plot( + time_axis, + result.logs["contact_force_norm"], + color=color, + label=label, + ) + axes[0, 1].semilogy( + time_axis, + np.maximum(result.logs["a_port_identity_error"], 1e-14), + color=color, + label=label, + ) + axes[1, 0].plot( + time_axis, + result.logs["feedback_torque_norm"], + color=color, + label=label, + ) + supervisor_trace = ( + (result.logs["popc_damping_gain"] > 0.0).astype(float) + if result.scenario.supervisor == "popc" + else result.logs["rho"] + ) + axes[1, 1].plot( + time_axis, + supervisor_trace, + color=color, + label=label, + ) + if result.scenario.supervisor in ("tank", "popc"): + axes[2, 0].plot( + time_axis, + result.logs["tank_energy"], + color=color, + label=label, + ) + else: + axes[2, 0].plot( + time_axis, + result.logs["shadow_energy"], + color=color, + linestyle="--", + label="unsupervised counterfactual reserve", + ) + axes[2, 1].plot( + time_axis, + 1e3 * result.logs["penetration"], + color=color, + label=label, + ) + + axes[0, 0].set_ylabel("contact force [N]") + axes[0, 0].set_title("Closed-loop wall interaction") + axes[0, 1].set_ylabel("A-port identity error [W]") + axes[0, 1].set_title( + "Raw A-port power consistency (plot floor $10^{-14}$ W)" + ) + axes[1, 0].set_ylabel(r"$\|\tau_{m,app}\|$ [N m]") + axes[1, 0].set_title("Applied master haptic torque") + axes[1, 1].set_ylabel(r"tank $\rho$ / PO-PC active") + axes[1, 1].set_ylim(-0.04, 1.04) + axes[1, 1].set_title("Final-port supervisor activity") + axes[2, 0].axhline( + config.energy_min, + color="black", + linestyle="--", + linewidth=1.0, + label="configured E_min", + ) + axes[2, 0].set_ylabel("tank energy [J]") + axes[2, 0].set_title("Accounted / counterfactual energy reserve") + axes[2, 1].set_ylabel("wall penetration [mm]") + axes[2, 1].set_title("Slave TCP penetration") + for axis in axes[-1, :]: + axis.set_xlabel("time [s]") + for axis in axes.flat: + axis.grid(True, alpha=0.3) + axes[0, 0].legend(loc="best", fontsize=8) + axes[2, 0].legend(loc="best", fontsize=8) + fig.suptitle( + "Simulation only — master_7dof.urdf / real_slave_7dof.urdf", + fontsize=13, + ) + fig.tight_layout(rect=(0.0, 0.0, 1.0, 0.97)) + fig.savefig(output_path, dpi=180) + plt.close(fig) + + +def _json_ready(value: Any) -> Any: + if isinstance(value, np.ndarray): + return value.tolist() + if isinstance(value, (np.floating, np.integer)): + return value.item() + if isinstance(value, dict): + return {key: _json_ready(item) for key, item in value.items()} + if isinstance(value, (tuple, list)): + return [_json_ready(item) for item in value] + return value + + +def run_comparison( + config: SimulationConfig, + output_dir: Path, + scenarios: tuple[Scenario, ...] = SCENARIOS, +) -> dict[str, Any]: + config.validate() + output_dir.mkdir(parents=True, exist_ok=True) + models = load_models(add_simulated_tcp=True) + wall_mapper = build_mapper(models) + wall, wall_metadata, q_slave_start = make_wall( + config, + models, + wall_mapper, + ) + + results: list[ScenarioResult] = [] + for scenario in scenarios: + print(f"[simulation] running {scenario.key}: {scenario.description}") + result = simulate_scenario( + scenario, + config, + models, + wall, + q_slave_start, + ) + results.append(result) + _write_npz(output_dir / f"{scenario.key}.npz", result.logs) + _write_csv(output_dir / f"{scenario.key}.csv", result.logs) + + make_comparison_plot( + results, + config, + output_dir / "closed_loop_comparison.png", + ) + summary = { + "evidence_scope": ( + "Rigid-body closed-loop simulation only; no prototype or human " + "subject result is claimed." + ), + "metric_definitions_and_limits": { + "a_port_identity": ( + "Compares tau_mapped^T qd_m with delayed_tau_s^T " + "(A qd_m). It is an algebraic implementation-consistency " + "metric, not equality of the actual master/slave port powers." + ), + "contact": ( + "wall_contact_fraction/duration/steps use penetration > 0; " + "wall_force_active_fraction separately uses force norm > 1e-6." + ), + "energy_stress_protocol": ( + "The tank starts at E_min with only " + f"{config.energy_max - config.energy_min:.6g} J capacity above " + "the floor. This deliberately tight budget exercises the " + "projection and is not a hardware tuning recommendation." + ), + "shadow_energy": ( + "For no-energy, this reconstructs that scenario's actual " + "unprojected candidate sequence. For supervised scenarios, " + "it is a stepwise witness on the supervised closed-loop " + "candidate sequence, not a second counterfactual simulation." + ), + "plotting_floor_W": 1e-14, + "estimator_scope": ( + "Torque bias/noise and load-side measurements are synthetic, " + "and plant/estimator share one URDF model; hardware robustness " + "is therefore not established." + ), + "claims_not_supported": ( + "No claim of prototype performance, human-in-the-loop " + "stability, global passivity, delay robustness, transparency, " + "or workspace-wide/statistical robustness." + ), + "ablation_scope": ( + "proposed_no_energy bypasses only the final energy projection. " + "It retains feedback gain/filtering, haptic rate and torque " + "limits, tracking effort limits, acceleration/velocity/position " + "guards, soft joint-limit guards, and the wall force cap." + ), + "feedback_chain_comparison": ( + "A.T receives the estimated joint residual, whereas the direct " + "baseline receives its DLS wrench projection. This is an " + "end-to-end feedback-chain ablation, not a same-input pure " + "matrix comparison." + ), + "slave_reference_sampling": ( + "q_s_ref and qd_s_ref are both sampled and held at the 50 Hz " + "mapping update rate; qdd_s_ref is zero." + ), + }, + "models": { + "master_urdf": str(MASTER_URDF), + "slave_urdf": str(SLAVE_URDF), + "slave_contact_frame": config.slave_contact_frame, + "contact_frame_status": ( + "R_EE_SIM is a simulation-only fixed frame from the repository " + "MJCF marker; a measured TCP/FT transform is required for hardware" + if config.slave_contact_frame == SLAVE_FRAMES["ee"] + else "terminal frame present in real_slave_7dof.urdf" + ), + }, + "config": asdict(config), + "wall": wall_metadata, + "scenarios": { + result.scenario.key: { + "mapping": result.scenario.mapping, + "energy_supervision": result.scenario.supervise_energy, + "supervisor": result.scenario.supervisor, + "map_policy": result.scenario.map_policy, + "description": result.scenario.description, + "metrics": result.metrics, + } + for result in results + }, + "artifacts": { + "plot": "closed_loop_comparison.png", + "machine_readable_logs": [ + f"{result.scenario.key}.npz" for result in results + ], + "tabular_logs": [ + f"{result.scenario.key}.csv" for result in results + ], + }, + } + with (output_dir / "summary.json").open("w", encoding="utf-8") as stream: + json.dump(_json_ready(summary), stream, indent=2, ensure_ascii=False) + + print("\nSimulation-only comparison") + print( + "scenario contact[N] A-port err[W] " + "rho_min E_min[J] map p95[ms]" + ) + for result in results: + metrics = result.metrics + tank_min = metrics["tank_energy_min_J"] + tank_text = " n/a" if tank_min is None else f"{tank_min:8.4f}" + rho_min = metrics["rho_min"] + rho_text = " n/a" if rho_min is None else f"{rho_min:9.3f}" + print( + f"{result.scenario.key:24s}" + f"{metrics['peak_contact_force_N']:10.3f}" + f"{metrics['a_port_identity_error_rms_W']:15.3e}" + f"{rho_text}" + f"{tank_text}" + f"{metrics['mapping_runtime_p95_ms']:13.3f}" + ) + print(f"\nArtifacts: {output_dir}") + return summary + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run the closed-loop bilateral simulation comparison." + ) + parser.add_argument( + "--config", + type=Path, + default=DEFAULT_CONFIG_PATH, + help=f"YAML configuration (default: {DEFAULT_CONFIG_PATH})", + ) + parser.add_argument( + "--output-dir", + type=Path, + default=DEFAULT_OUTPUT_DIR, + help=f"artifact directory (default: {DEFAULT_OUTPUT_DIR})", + ) + parser.add_argument( + "--duration", + type=float, + default=None, + help="override simulated duration per scenario in seconds", + ) + parser.add_argument( + "--dt", + type=float, + default=None, + help="override dynamics integration period in seconds", + ) + parser.add_argument( + "--mapping-hz", + type=float, + default=None, + help="override retargeting/differential update rate", + ) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + config = load_simulation_config(args.config.resolve()) + overrides = { + key: value + for key, value in { + "dt": args.dt, + "duration": args.duration, + "mapping_hz": args.mapping_hz, + }.items() + if value is not None + } + if overrides: + config = replace(config, **overrides) + run_comparison(config, args.output_dir.resolve()) + + +if __name__ == "__main__": + main() diff --git a/code/test/ablation_haptic.py b/code/test/ablation_haptic.py new file mode 100644 index 0000000..3a5a49a --- /dev/null +++ b/code/test/ablation_haptic.py @@ -0,0 +1,511 @@ +# -*- coding: utf-8 -*- +""" +ablation_haptic.py + +Quantitative ablation for the haptic rendering module. + +Modes: +- baseline : full method (energy tank + F/V filtering + alpha smoothing + torque LPF) +- no_tank : energy tank disabled (no passivity enforcement) +- no_filter : tank enabled but F/V filtering + alpha smoothing + torque LPF disabled + +Run: + python test/ablation_haptic.py +from the project root, with config/config.yaml following the same +structure as demo.py and test_sew.py. +""" + +import os +import sys +import time +from typing import Dict + +import numpy as np +import pinocchio as pin + +import matplotlib.pyplot as plt +from omegaconf import OmegaConf + +# Make sure project root is on sys.path +THIS_DIR = os.path.dirname(__file__) +PROJECT_ROOT = os.path.abspath(os.path.join(THIS_DIR, "..")) +if PROJECT_ROOT not in sys.path: + sys.path.append(PROJECT_ROOT) + +from core.sew_mapper import SEWMapper +from core.interaction_estimater import InteractionEstimator +from core.haptic_render import HapticRenderer + + +# -------------------- Utilities -------------------- + +def dummy_master_measure(step: int, model: pin.Model) -> np.ndarray: + """ + Synthetic master joint measurement used for offline ablation. + Start from neutral and add small sinusoidal motions on the first few joints. + """ + q = pin.neutral(model) + n_use = min(model.nq, 6) + for i in range(n_use): + q[i] = 0.5 * np.sin(0.01 * step + 0.7 * i) + return q + + +def dummy_slave_dynamics(q_cmd: np.ndarray, model: pin.Model) -> np.ndarray: + """ + Simple slave "execution" model: follow the commanded joint positions + with a small Gaussian disturbance, to emulate interaction / noise. + """ + q_cmd = np.asarray(q_cmd, dtype=float).reshape(-1,) + noise = 0.001 * np.random.randn(model.nq) + return q_cmd + noise + + +def load_config(): + """ + Load config/config.yaml in the project root (same style as demo.py / test_sew.py). + """ + cand = os.path.join(PROJECT_ROOT, "config", "config.yaml") + if not os.path.exists(cand): + raise FileNotFoundError(f"config.yaml not found at: {cand}") + conf = OmegaConf.load(cand) + print(f"[INFO] Loaded config from: {cand}") + return conf + + +def build_modules(conf): + # Build models from URDF + master_model = pin.buildModelFromUrdf(str(conf.master_urdf)) + slave_model = pin.buildModelFromUrdf(str(conf.slave_urdf)) + + # SEW mapper + sew = SEWMapper( + master_model=master_model, + slave_model=slave_model, + m_shoulder_frame=conf.m_shoulder_frame, + m_elbow_frame=conf.m_elbow_frame, + m_wrist_frame=conf.m_wrist_frame, + m_ee_frame=conf.m_ee_frame, + s_shoulder_frame=conf.s_shoulder_frame, + s_elbow_frame=conf.s_elbow_frame, + s_wrist_frame=conf.s_wrist_frame, + s_ee_frame=conf.s_ee_frame, + slave_joint_names=conf.sew_mapper.slave_joint_names, + up_dir=np.array(conf.sew_mapper.up_dir, dtype=float), + eps_clip=float(conf.sew_mapper.eps_clip), + ) + + # Interaction estimator on slave side + estimator = InteractionEstimator( + model=slave_model, + chest_frame_name=conf.s_base_frame, + ee_frame_name=conf.s_ee_frame, + lambda_damp=float(conf.interaction_est.lambda_damp), + ) + + return master_model, slave_model, sew, estimator + + + +# -------------------- Haptic renderer variants -------------------- + +class HapticRendererNoTank(HapticRenderer): + """ + Variant that completely disables the energy tank: + - directly maps interaction wrench to master joint torques through CJ_m^T + - no passivity enforcement + """ + + def _compute_feedback_tau(self, + q_m: np.ndarray, + qd_m: np.ndarray, + CF_int_slave_C: np.ndarray, + V_slave_C: np.ndarray | None, + dt: float): + CF = np.asarray(CF_int_slave_C, dtype=float).reshape(6,) + # Chest Jacobian on master side + CJ_m = self.CJ_master.chest_jacobian(q_m, qd_m) + tau_fb_m_raw = CJ_m.T @ CF + alpha = 1.0 + return tau_fb_m_raw, alpha + + +def make_renderer(conf, master_model: pin.Model, mode: str) -> HapticRenderer: + """ + Construct different haptic renderers for ablation. + """ + common_kwargs = dict( + master_model=master_model, + chest_frame_name=conf.m_base_frame, + ee_frame_name=conf.m_ee_frame, + feedback_strength=float(conf.haptic_render.feedback_strength), + E_init=float(conf.haptic_render.E_init), + E_max=float(conf.haptic_render.E_max), + alpha_floor=float(conf.haptic_render.alpha_floor), + alpha_ceil=float(conf.haptic_render.alpha_ceil), + E0=float(conf.haptic_render.E0), + ) + + if mode == "baseline": + renderer = HapticRenderer(**common_kwargs) + + # baseline:能量罐 + 强滤波 + 平滑 + 扭矩低通(“管得很严”) + renderer.tank.tp.force_alpha = 0.05 + renderer.tank.tp.vel_alpha = 0.05 + renderer.tank.tp.alpha_smooth = 0.02 + renderer.tank.tp.power_deadzone = 0.0 + renderer.tau_alpha = 0.05 + + elif mode == "no_tank": + renderer = HapticRendererNoTank(**common_kwargs) + + # no_tank:完全不做能量控制,也不做扭矩低通,暴露最差情况 + renderer.tau_alpha = 1.0 + + elif mode == "no_filter": + renderer = HapticRenderer(**common_kwargs) + + # no_filter:保留能量罐,但不给它任何滤波/平滑能力 + renderer.tank.tp.force_alpha = 1.0 + renderer.tank.tp.vel_alpha = 1.0 + renderer.tank.tp.alpha_smooth = 1.0 + renderer.tank.tp.power_deadzone = 0.0 + renderer.tau_alpha = 1.0 + + else: + raise ValueError(f"Unknown ablation mode: {mode}") + + return renderer + + +# -------------------- Teleoperation simulation (offline) -------------------- + +def simulate_teleop(conf, + master_model: pin.Model, + slave_model: pin.Model, + sew: SEWMapper, + estimator: InteractionEstimator, + renderer: HapticRenderer, + Ts: float = 0.002, + steps: int = 800) -> Dict[str, np.ndarray]: + """ + Offline simulation similar to demo.py, but without real-time delays. + Returns logs for computing quantitative metrics. + """ + + # States + q_slave = pin.neutral(slave_model) + dq_slave = np.zeros(slave_model.nv) + q_slave_prev = q_slave.copy() + + q_m_init = dummy_master_measure(0, master_model) + pre_q_m = q_m_init.copy() + pre_qd_m = np.zeros_like(q_m_init) + + # Logs + log_tau_fb = [] + log_alpha = [] + log_E = [] + log_tau_int_norm = [] + log_qd_m = [] + ee_traj_world = [] + + # Harder virtual wall to highlight differences + d_wall = 0.35 + k_wall = 20000.0 + + for k in range(steps): + # --- master state --- + q_m = dummy_master_measure(k, master_model) + if k == 0: + qd_m = np.zeros_like(q_m) + qdd_m = np.zeros_like(q_m) + else: + qd_m = (q_m - pre_q_m) / Ts + qdd_m = (qd_m - pre_qd_m) / Ts + + pre_q_m = q_m + pre_qd_m = qd_m + log_qd_m.append(qd_m.copy()) + + # --- SEW retargetting: master -> desired slave --- + q_des_slave, _ = sew.retargetting(q_m, q_slave) + + # --- slave motion (with small noise) --- + q_slave = dummy_slave_dynamics(q_des_slave, slave_model) + dq_slave = (q_slave - q_slave_prev) / Ts + q_slave_prev = q_slave.copy() + qdd_slave = np.zeros(slave_model.nv) + + # --- virtual wall on slave side --- + CJ_env = estimator._chest_jacobian(q_slave, dq_slave) + + oTC = estimator.data.oMf[estimator.fid_C] # ^wT_C + oTEE = estimator.data.oMf[estimator.fid_EE] # ^wT_EE + + R_wc = oTC.rotation + p_wc = oTC.translation + R_cw = R_wc.T + p_cw = -R_cw @ p_wc + + p_we = oTEE.translation + ee_traj_world.append(p_we.copy()) + p_ce = R_cw @ p_we + p_cw + x_ce = float(p_ce[0]) + + if x_ce > d_wall: + delta = x_ce - d_wall + Fx = -k_wall * delta + CF_env = np.array([Fx, 0, 0, 0, 0, 0], dtype=float) + else: + CF_env = np.zeros(6, dtype=float) + + tau_env = CJ_env.T @ CF_env + + # --- interaction estimation on slave --- + tau_model = estimator._tau_model(q_slave, dq_slave, qdd_slave) + tau_meas = tau_model + tau_env + tau_int, CF_int, CJ = estimator.estimate(q_slave, dq_slave, qdd_slave, tau_meas) + + log_tau_int_norm.append(np.linalg.norm(tau_int)) + + V_slave_C = CJ @ dq_slave # 6 x 1 + + # --- haptic rendering on master side --- + tau_cmd_m, tau_fb_m, alpha = renderer.render_tau( + q_m=q_m, + qd_m=qd_m, + qdd_m=qdd_m, + CF_int_slave_C=CF_int, + V_slave_C=V_slave_C, + tau_ff_fric=None, + dt=Ts, + ) + + log_tau_fb.append(tau_fb_m.copy()) + log_alpha.append(alpha) + # not all variants really use the tank, but querying E is safe + log_E.append(getattr(renderer.tank, "E", 0.0)) + + # stack + log_tau_fb = np.vstack(log_tau_fb) + log_alpha = np.asarray(log_alpha) + log_E = np.asarray(log_E) + log_tau_int_norm = np.asarray(log_tau_int_norm) + log_qd_m = np.vstack(log_qd_m) + ee_traj_world = np.vstack(ee_traj_world) + + return dict( + tau_fb=log_tau_fb, + alpha=log_alpha, + E=log_E, + tau_int_norm=log_tau_int_norm, + qd_m=log_qd_m, + ee_traj_world=ee_traj_world, + ) + + +# -------------------- Metric computation -------------------- + +def compute_metrics(tau_fb: np.ndarray, + qd_m: np.ndarray, + E: np.ndarray, + Ts: float) -> Dict[str, float]: + """ + Compute quantitative metrics for ablation: + - rms / peak torque + - torque spike count / rate + - positive power injection ratio + - (optional) energy range if tank is used + """ + # torque norms + tau_norm = np.linalg.norm(tau_fb, axis=1) + rms_tau = float(np.sqrt(np.mean(tau_norm ** 2))) + peak_tau = float(np.max(tau_norm)) + + # torque spikes: ||Δτ||_∞ > threshold + dtau = np.diff(tau_fb, axis=0) + dtau_inf = np.max(np.abs(dtau), axis=1) + spike_th = 5.0 # Nm, can be adjusted to your system scale + n_spikes = int(np.sum(dtau_inf > spike_th)) + spike_rate = n_spikes / (len(dtau_inf) * Ts) + + # positive power injection ratio: tau_fb^T * qd_m > 0 + P = np.sum(tau_fb * qd_m, axis=1) + P_inject_ratio = float(np.mean(P > 0.0)) + + metrics = dict( + rms_tau=rms_tau, + peak_tau=peak_tau, + n_spikes=n_spikes, + spike_rate=spike_rate, + P_inject_ratio=P_inject_ratio, + ) + + if np.any(E != 0.0): + metrics["E_min"] = float(np.min(E)) + metrics["E_max"] = float(np.max(E)) + + return metrics + + +def plot_ablation_results(all_logs, all_metrics, Ts): + + modes = ["baseline", "no_tank", "no_filter"] + colors = { + "baseline": "tab:blue", + "no_tank": "tab:red", + "no_filter": "tab:green", + } + + # ------------------------------------------- + # 1) Torque feedback trajectories (norm vs time) + # ------------------------------------------- + plt.figure(figsize=(8,4)) + for mode in modes: + tau_norm = np.linalg.norm(all_logs[mode]["tau_fb"], axis=1) + t = np.arange(len(tau_norm)) * Ts + plt.plot(t, tau_norm, label=mode, color=colors[mode]) + plt.xlabel("Time [s]") + plt.ylabel(r"$\|\tau_{\mathrm{fb}}(t)\|\ \mathrm{[N\cdot m]}$") + plt.title("Feedback Torque Norm Over Time") + plt.legend() + plt.grid(True, alpha=0.3) + plt.tight_layout() + plt.savefig("logs_ablation/fig_tau_fb_norm.png", dpi=300) + + # ------------------------------------------- + # 2) Alpha(t) (energy tank scaling) + # ------------------------------------------- + plt.figure(figsize=(8,4)) + for mode in modes: + alpha = all_logs[mode]["alpha"] + t = np.arange(len(alpha)) * Ts + plt.plot(t, alpha, label=mode, color=colors[mode]) + plt.xlabel("Time [s]") + plt.ylabel(r"$\alpha(t)$") + plt.title("Energy Tank Scaling Factor") + plt.ylim([-0.1, 1.1]) + plt.legend() + plt.grid(True, alpha=0.3) + plt.tight_layout() + plt.savefig("logs_ablation/fig_alpha.png", dpi=300) + + # ------------------------------------------- + # 3) Tank Energy E(t) + # ------------------------------------------- + plt.figure(figsize=(8,4)) + for mode in modes: + E = all_logs[mode]["E"] + t = np.arange(len(E)) * Ts + plt.plot(t, E, label=mode, color=colors[mode]) + plt.xlabel("Time [s]") + plt.ylabel(r"$E(t)$") + plt.title("Energy Tank Level") + plt.legend() + plt.grid(True, alpha=0.3) + plt.tight_layout() + plt.savefig("logs_ablation/fig_energy.png", dpi=300) + + # ------------------------------------------- + # 4) Bar charts: RMS / Peak / Spikes / P>0 + # ------------------------------------------- + metrics_list = ["rms_tau", "peak_tau", "n_spikes", "P_inject_ratio"] + metric_names = [ + "RMS Torque", + "Peak Torque", + "# Torque Spikes", + "Positive Power Ratio", + ] + + plt.figure(figsize=(9,4)) + for i, key in enumerate(metrics_list): + plt.subplot(1,4,i+1) + vals = [all_metrics[m][key] for m in modes] + plt.bar(modes, vals, color=[colors[m] for m in modes]) + plt.title(metric_names[i]) + plt.xticks(rotation=45) + plt.grid(True, alpha=0.3) + plt.tight_layout() + plt.savefig("logs_ablation/fig_metrics_barchart.png", dpi=300) + + print("[INFO] All plots saved to logs_ablation/") + + + +# -------------------- Main -------------------- + +def main(): + conf = load_config() + master_model, slave_model, sew, estimator = build_modules(conf) + + Ts = 0.002 + steps = 800 + + modes = ["baseline", "no_tank", "no_filter"] + all_logs = {} + all_metrics = {} + + print(f"dt = {Ts:.4f} s, steps = {steps}") + + for mode in modes: + print(f"\n========== Running haptic ablation: {mode} ==========") + renderer = make_renderer(conf, master_model, mode) + renderer.reset_tank(float(conf.haptic_render.E0)) + + # fix random seed so that each mode sees the same slave noise + np.random.seed(0) + + t0 = time.time() + logs = simulate_teleop( + conf, + master_model, + slave_model, + sew, + estimator, + renderer, + Ts=Ts, + steps=steps, + ) + elapsed = time.time() - t0 + + all_logs[mode] = logs + + metrics = compute_metrics( + tau_fb=logs["tau_fb"], + qd_m=logs["qd_m"], + E=logs["E"], + Ts=Ts, + ) + all_metrics[mode] = metrics + + print( + f"[{mode}] done in {elapsed:.3f} s | " + f"rms_tau={metrics['rms_tau']:.3f}, " + f"peak_tau={metrics['peak_tau']:.3f}, " + f"spikes={metrics['n_spikes']}, " + f"spike_rate={metrics['spike_rate']:.3f} 1/s, " + f"P>0={metrics['P_inject_ratio']:.3f}, " + f"E_range=" + f"{'[{:.2f},{:.2f}]'.format(metrics['E_min'], metrics['E_max']) if 'E_min' in metrics else 'N/A'}" + ) + + # Save logs for further plotting if needed + save_dir = os.path.join(PROJECT_ROOT, "logs_ablation") + os.makedirs(save_dir, exist_ok=True) + save_path = os.path.join(save_dir, "haptics_ablation_results.npz") + + # pack as a single dict and rely on pickle when loading + np.savez( + save_path, + all_logs=all_logs, + all_metrics=all_metrics, + Ts=Ts, + ) + + print(f"\n[INFO] Ablation logs and metrics saved to:\n {save_path}") + plot_ablation_results(all_logs, all_metrics, Ts) + + +if __name__ == "__main__": + main() diff --git a/code/test/broadcast_control.py b/code/test/broadcast_control.py new file mode 100644 index 0000000..44a877e --- /dev/null +++ b/code/test/broadcast_control.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +瓴控电机 CAN 广播控制(V2.35): +- 力矩/开环控制:帧ID 0x280,data[0..7] 为 4 个电机的 int16 torqueValue(低字节在前,小端) +- 混合命令:帧ID 0x288,data[0,2,4,6] 为 4 个电机 motorCmd(如 0x88 开启 / 0x81 停止 / 0x80 关闭) + +依赖: + pip install python-can + +运行前准备(Linux 推荐 SocketCAN): + sudo ip link set can0 up type can bitrate 500000 + 或者 1000000(看你驱动与设备设置) +""" + +from __future__ import annotations +import time +from typing import Iterable, Tuple + +import can + + +TORQUE_CMD_ID = 0x280 +MIXED_CMD_ID = 0x288 + +# 混合命令 motorCmd(来自文档) +CMD_READ_STATUS1 = 0x9A +CMD_CLR_ERROR = 0x9B +CMD_READ_STATUS2 = 0x9C +CMD_MOTOR_OFF = 0x80 +CMD_MOTOR_ON = 0x88 +CMD_MOTOR_STOP = 0x81 + + +def _clamp_int16(x: int) -> int: + """限制到 int16 范围(-32768..32767),随后按补码打包。""" + return max(-32768, min(32767, int(x))) + + +def _pack_i16_le(x: int) -> Tuple[int, int]: + """int16 -> (low_byte, high_byte) 小端。""" + x = _clamp_int16(x) + u = x & 0xFFFF + return (u & 0xFF, (u >> 8) & 0xFF) + + +def build_torque_broadcast(t1: int, t2: int, t3: int, t4: int = 0) -> can.Message: + """ + 帧ID 0x280: + data[0..1]=#1 torqueValue (low,high) + data[2..3]=#2 + data[4..5]=#3 + data[6..7]=#4 + """ + d0, d1 = _pack_i16_le(t1) + d2, d3 = _pack_i16_le(t2) + d4, d5 = _pack_i16_le(t3) + d6, d7 = _pack_i16_le(t4) + data = [d0, d1, d2, d3, d4, d5, d6, d7] + return can.Message(arbitration_id=TORQUE_CMD_ID, is_extended_id=False, data=data) + + +def build_mixed_cmd(cmd1: int, cmd2: int, cmd3: int, cmd4: int = 0x00) -> can.Message: + """ + 帧ID 0x288: + data[0]=#1 motorCmd, data[1]=0 + data[2]=#2 motorCmd, data[3]=0 + data[4]=#3 motorCmd, data[5]=0 + data[6]=#4 motorCmd, data[7]=0 + """ + data = [cmd1 & 0xFF, 0x00, cmd2 & 0xFF, 0x00, cmd3 & 0xFF, 0x00, cmd4 & 0xFF, 0x00] + return can.Message(arbitration_id=MIXED_CMD_ID, is_extended_id=False, data=data) + + +def send_many(bus: can.BusABC, msgs: Iterable[can.Message], gap_s: float = 0.005) -> None: + """连续发几帧(给驱动一点处理间隔)。""" + for m in msgs: + bus.send(m) + print(m) + time.sleep(gap_s) + + +def main(): + # 1) 选择 CAN 接口 + # - Linux SocketCAN: channel="can0", bustype="socketcan" + # - 如果你用的是 USB-CAN 且已有 python-can 对应驱动,也可改 bustype/channel + bus = can.Bus(interface="canalystii", channel=0, bitrate=1000000) + + # 2) 可选:先广播“电机开启” + send_many(bus, [build_mixed_cmd(CMD_MOTOR_ON, CMD_MOTOR_ON, CMD_MOTOR_ON, CMD_MOTOR_ON)], gap_s=0.02) + + # 3) 力矩模式:控制 3 个电机一起转(示例:#1=+T, #2=-T, #3=+T,第4路=0) + # 注意 torqueValue 的具体量程与含义:文档写 MF/MG 为 -2000..2000(电流力矩),MS 为 -850..850(开环电压) + # 发送频率:文档提示 500kbps 下 4 电机广播最大约 600Hz;1Mbps 下约 1.2kHz。这里示例用 200Hz 更稳妥。 + freq_hz = 500.0 + dt = 1.0 / freq_hz + + T = 200 # 示例力矩指令(根据你的电机系列调整范围) + + count = 0 + + try: + while True: + # now = time.time() + # if now - t0 > duration_s: + # break + + msg = build_torque_broadcast( + t1=50, + t2=50, + t3=40, + t4=50 + ) + bus.send(msg) + + count += 1 + if count % 100 == 0: + print(msg) + + time.sleep(dt) + + except KeyboardInterrupt: + pass + finally: + # 4) 停止(建议退出前先 stop,再按需 off) + msg = build_torque_broadcast( + t1=0, + t2=0, + t3=0, + t4=0 + ) + bus.send(msg) + send_many(bus, [build_mixed_cmd(CMD_MOTOR_STOP, CMD_MOTOR_STOP, CMD_MOTOR_STOP, CMD_MOTOR_STOP)], gap_s=0.02) + send_many(bus, [build_mixed_cmd(CMD_MOTOR_OFF, CMD_MOTOR_OFF, CMD_MOTOR_OFF, CMD_MOTOR_OFF)], gap_s=0.02) + bus.shutdown() + + +if __name__ == "__main__": + main() diff --git a/code/test/broadcast_control2.py b/code/test/broadcast_control2.py new file mode 100644 index 0000000..52361a2 --- /dev/null +++ b/code/test/broadcast_control2.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import time +import struct +import threading +from dataclasses import dataclass, field +from typing import Dict, List, Tuple +import can + + +TORQUE_CMD_ID = 0x280 + + +def _clamp_int16(x: int) -> int: + return max(-32768, min(32767, int(x))) + + +def _pack_i16_le(x: int) -> Tuple[int, int]: + x = _clamp_int16(x) + u = x & 0xFFFF + return (u & 0xFF, (u >> 8) & 0xFF) + + +def build_torque_broadcast(t1: int, t2: int, t3: int, t4: int) -> can.Message: + d0, d1 = _pack_i16_le(t1) + d2, d3 = _pack_i16_le(t2) + d4, d5 = _pack_i16_le(t3) + d6, d7 = _pack_i16_le(t4) + return can.Message(arbitration_id=TORQUE_CMD_ID, is_extended_id=False, + data=[d0, d1, d2, d3, d4, d5, d6, d7]) + + +def _arb_id_single(motor_id: int) -> int: + return 0x140 + int(motor_id) + + +def dump_frame(mid: int, fr): + if fr.t <= 0: + print(f"M{mid}: (no reply yet)") + return + + d = fr.data + arb = fr.arb + cmd = d[0] + + if cmd == 0xA1: + temperature = struct.unpack(" 0x160: + return + mid = arb - 0x140 + if mid not in self.motor_ids: + return + with self._lock: + self.latest[mid] = RxFrame(time.time(), arb, bytes(msg.data)) + + def snapshot(self) -> Dict[int, RxFrame]: + with self._lock: + return {k: v for k, v in self.latest.items()} + + +def main(): + motor_ids = [1,2,3,4] + bus = can.Bus(interface="canalystii", channel=0, bitrate=1000000) + + # 只收 1~4 的回包(0x141~0x144) + try: + bus.set_filters([{"can_id": _arb_id_single(i), "can_mask": 0x7FF, "extended": False} for i in motor_ids]) + except Exception: + pass + + cache = RxCache(motor_ids) + notifier = can.Notifier(bus, [cache], timeout=0.01) + + hz = 500 + dt = 1.0 / hz + k = 0 + next_tick = time.perf_counter() + + try: + while True: + # 500Hz 广播力矩(示例:全 0,你可替换成控制输出) + bus.send(build_torque_broadcast(30, 30, 30, 30)) + + # 每 100ms 打印一次收到的自动回包 + if k % int(hz * 0.1) == 0: + snap = cache.snapshot() + print("---- RX snapshot ----") + for mid in motor_ids: + fr = snap[mid] + if fr.t <= 0: + print(f"M{mid}: (no reply yet)") + else: + dump_frame(mid, fr) + + k += 1 + + # 固定周期 + next_tick += dt + now = time.perf_counter() + if next_tick > now: + time.sleep(next_tick - now) + else: + next_tick = now + + except KeyboardInterrupt: + pass + finally: + notifier.stop() + bus.send(build_torque_broadcast(0, 0, 0, 0)) + bus.shutdown() + +if __name__ == "__main__": + main() diff --git a/code/test/broadcast_control3.py b/code/test/broadcast_control3.py new file mode 100644 index 0000000..81af19d --- /dev/null +++ b/code/test/broadcast_control3.py @@ -0,0 +1,330 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import time +import struct +import threading +from dataclasses import dataclass +from typing import Dict, List, Tuple, Optional + +import can + +TORQUE_CMD_ID = 0x280 +CMD_A1 = 0xA1 + + +def _clamp_int16(x: int) -> int: + return max(-32768, min(32767, int(x))) + + +def _pack_i16_le(x: int) -> Tuple[int, int]: + x = _clamp_int16(x) + u = x & 0xFFFF + return (u & 0xFF, (u >> 8) & 0xFF) + + +def build_torque_broadcast(t1: int, t2: int, t3: int, t4: int) -> can.Message: + d0, d1 = _pack_i16_le(t1) + d2, d3 = _pack_i16_le(t2) + d4, d5 = _pack_i16_le(t3) + d6, d7 = _pack_i16_le(t4) + return can.Message( + arbitration_id=TORQUE_CMD_ID, + is_extended_id=False, + data=[d0, d1, d2, d3, d4, d5, d6, d7], + ) + + +def _arb_id_single(motor_id: int) -> int: + return 0x140 + int(motor_id) + + +def parse_a1(data8: bytes) -> Dict: + # A1 回包(你实测) + # [0]=0xA1 [1]=temp(int8) [2..3]=iq/power(int16 LE) [4..5]=speed(int16 LE, dps) [6..7]=encoder(uint16 LE) + if len(data8) != 8 or data8[0] != CMD_A1: + raise ValueError("not A1") + temperature = struct.unpack(" str: + if fr.t <= 0: + return f"{tag} M{mid}: (no reply yet)" + d = fr.data + arb = fr.arb + cmd = d[0] + if cmd == CMD_A1: + s = parse_a1(d) + return (f"{tag} M{mid}: arb=0x{arb:X} " + f"T={s['temperature_C']}C iq/p={s['iq_or_power_raw']} " + f"spd={s['speed_dps']}dps enc={s['encoder']}") + return f"{tag} M{mid}: arb=0x{arb:X} cmd=0x{cmd:02X} raw={[hex(x) for x in d]}" + + +@dataclass +class RxFrame: + t: float = 0.0 + arb: int = 0 + data: bytes = b"" + + +class RxCache4Ch(can.Listener): + """ + 一个 Notifier 监听一个 “设备Bus(包含两个channel)”,按 msg.channel 分流到 ch0/ch1 的缓存。 + """ + def __init__(self, ch_to_motor_ids: Dict[int, List[int]]): + super().__init__() + self.ch_to_motor_ids = {int(ch): set(mids) for ch, mids in ch_to_motor_ids.items()} + self._lock = threading.Lock() + self.latest: Dict[Tuple[int, int], RxFrame] = {} + for ch, mids in self.ch_to_motor_ids.items(): + for mid in mids: + self.latest[(ch, mid)] = RxFrame() + + def on_message_received(self, msg: can.Message): + # python-can Message.channel:对 canalystii 多通道会填 0/1 + ch = getattr(msg, "channel", None) + if ch is None: + # 没有 channel 信息就无法分流,直接忽略或当作 ch0 + ch = 0 + try: + ch = int(ch) + except Exception: + ch = 0 + + if ch not in self.ch_to_motor_ids: + return + + arb = msg.arbitration_id + if arb < 0x141 or arb > 0x160: + return + mid = arb - 0x140 + if mid not in self.ch_to_motor_ids[ch]: + return + + with self._lock: + self.latest[(ch, mid)] = RxFrame(time.time(), arb, bytes(msg.data)) + + def snapshot(self) -> Dict[Tuple[int, int], RxFrame]: + with self._lock: + return dict(self.latest) + + +class SenderThread(threading.Thread): + """ + 每个“逻辑channel(共4个)”一个发送线程: + - 等主线程 tick_event + - barrier 同步放行 + - 加 bus_lock 后 send(同一个设备的两个channel共享一个bus对象,必须加锁) + """ + def __init__( + self, + name: str, + bus: can.BusABC, + bus_lock: threading.Lock, + channel_index: int, # 0 or 1 + tick_event: threading.Event, + barrier: threading.Barrier, + stop_event: threading.Event, + ): + super().__init__(name=name) + self.bus = bus + self.bus_lock = bus_lock + self.channel_index = int(channel_index) + self.tick_event = tick_event + self.barrier = barrier + self.stop_event = stop_event + self.torque = (0, 0, 0, 0) + + def run(self): + while not self.stop_event.is_set(): + if not self.tick_event.wait(timeout=0.5): + continue + if self.stop_event.is_set(): + break + + try: + self.barrier.wait(timeout=0.05) + except threading.BrokenBarrierError: + continue + + t1, t2, t3, t4 = self.torque + msg = build_torque_broadcast(t1, t2, t3, t4) + msg.channel = self.channel_index # ★ 指定发到该设备的哪个channel + + try: + with self.bus_lock: + self.bus.send(msg) + except can.CanError: + pass + + # 等主线程清 tick_event,避免同tick重复发 + while self.tick_event.is_set() and not self.stop_event.is_set(): + time.sleep(0) + + +def open_device_bus(device_idx: int, bitrate: int) -> can.BusABC: + """ + 每块 CANalyst-II 只打开一次 Bus,channel=[0,1] + """ + # canalystii 后端一般支持 device=0/1;如果你环境不支持会 TypeError,这里做兼容 + try: + return can.Bus(interface="canalystii", channel=[0, 1], bitrate=bitrate, device=device_idx) + except TypeError: + # 某些版本 canalystii 不暴露 device 参数(只能看到一块设备) + return can.Bus(interface="canalystii", channel=[0, 1], bitrate=bitrate) + + +def main(): + bitrate = 1000000 + hz = 500 + dt = 1.0 / hz + + # ========= 你需要按实际接线修改:每个“设备-channel”上有哪些电机ID ========= + # 逻辑4路:dev0-ch0, dev0-ch1, dev1-ch0, dev1-ch1 + motor_map = { + ("dev0", 0): [1, 2, 3, 4], + ("dev0", 1): [1, 2, 3, 4], + ("dev1", 0): [1, 2, 3, 4], + ("dev1", 1): [1, 2, 3, 4], + } + + # ========= 打开两块设备(每块一次性打开2路channel) ========= + bus0 = open_device_bus(device_idx=0, bitrate=bitrate) + bus1 = open_device_bus(device_idx=1, bitrate=bitrate) + + # 过滤:只收我们关心的 arb_id(两设备相同过滤即可) + def set_filters_for(bus: can.BusABC, all_motor_ids: List[int]): + try: + bus.set_filters([{"can_id": _arb_id_single(i), "can_mask": 0x7FF, "extended": False} for i in all_motor_ids]) + except Exception: + pass + + all_ids_dev0 = sorted(set(motor_map[("dev0", 0)] + motor_map[("dev0", 1)])) + all_ids_dev1 = sorted(set(motor_map[("dev1", 0)] + motor_map[("dev1", 1)])) + set_filters_for(bus0, all_ids_dev0) + set_filters_for(bus1, all_ids_dev1) + + # ========= 接收缓存:每个设备一个 Notifier,一个 cache(按 channel 分流) ========= + cache0 = RxCache4Ch({0: motor_map[("dev0", 0)], 1: motor_map[("dev0", 1)]}) + cache1 = RxCache4Ch({0: motor_map[("dev1", 0)], 1: motor_map[("dev1", 1)]}) + notifier0 = can.Notifier(bus0, [cache0], timeout=0.01) + notifier1 = can.Notifier(bus1, [cache1], timeout=0.01) + + # ========= 发送线程:4个逻辑channel ========= + stop_event = threading.Event() + tick_event = threading.Event() + barrier = threading.Barrier(4 + 1) # 4个sender + 主线程 + + bus0_lock = threading.Lock() + bus1_lock = threading.Lock() + + sender_dev0_ch0 = SenderThread("sender-dev0-ch0", bus0, bus0_lock, 0, tick_event, barrier, stop_event) + sender_dev0_ch1 = SenderThread("sender-dev0-ch1", bus0, bus0_lock, 1, tick_event, barrier, stop_event) + sender_dev1_ch0 = SenderThread("sender-dev1-ch0", bus1, bus1_lock, 0, tick_event, barrier, stop_event) + sender_dev1_ch1 = SenderThread("sender-dev1-ch1", bus1, bus1_lock, 1, tick_event, barrier, stop_event) + + senders = [sender_dev0_ch0, sender_dev0_ch1, sender_dev1_ch0, sender_dev1_ch1] + for th in senders: + th.start() + + # ========= 你的控制输出:这里演示固定 torque ========= + def torque_for(tag: str, ch: int) -> Tuple[int, int, int, int]: + # TODO:替换为你的控制算法输出 + return (30, 30, 30, 30) + + k = 0 + next_tick = time.perf_counter() + + try: + while True: + # 1) 写入4路 torque(主线程写共享变量) + sender_dev0_ch0.torque = torque_for("dev0", 0) + sender_dev0_ch1.torque = torque_for("dev0", 1) + sender_dev1_ch0.torque = torque_for("dev1", 0) + sender_dev1_ch1.torque = torque_for("dev1", 1) + + # 2) 同步tick发布 + barrier放行(尽量同时发送) + tick_event.set() + try: + barrier.wait(timeout=0.05) + except threading.BrokenBarrierError: + pass + tick_event.clear() + + # 3) 打印(每100ms一次) + if k % int(hz * 0.1) == 0: + print(f"\n==== tick {k} t={time.time():.3f} ====") + + snap0 = cache0.snapshot() + print("[dev0-ch0]") + for mid in motor_map[("dev0", 0)]: + print(" " + dump_frame("[dev0-ch0]", mid, snap0[(0, mid)])) + print("[dev0-ch1]") + for mid in motor_map[("dev0", 1)]: + print(" " + dump_frame("[dev0-ch1]", mid, snap0[(1, mid)])) + + snap1 = cache1.snapshot() + print("[dev1-ch0]") + for mid in motor_map[("dev1", 0)]: + print(" " + dump_frame("[dev1-ch0]", mid, snap1[(0, mid)])) + print("[dev1-ch1]") + for mid in motor_map[("dev1", 1)]: + print(" " + dump_frame("[dev1-ch1]", mid, snap1[(1, mid)])) + + k += 1 + + # 4) 500Hz 固定周期(绝对时间推进) + next_tick += dt + now = time.perf_counter() + if next_tick > now: + time.sleep(next_tick - now) + else: + next_tick = now + + except KeyboardInterrupt: + pass + finally: + # 停止发送线程 + stop_event.set() + tick_event.set() + for th in senders: + th.join(timeout=0.5) + + # 停止 Notifier(避免你之前那种 interpreter shutdown 崩溃) + try: + notifier0.stop() + notifier1.stop() + except Exception: + pass + + # 安全清零力矩(给每个设备两个channel都发一次) + try: + for ch in [0, 1]: + m = build_torque_broadcast(0, 0, 0, 0) + m.channel = ch + with bus0_lock: + bus0.send(m) + with bus1_lock: + bus1.send(m) + except Exception: + pass + + try: + bus0.shutdown() + bus1.shutdown() + except Exception: + pass + + +if __name__ == "__main__": + main() diff --git a/code/test/multi_turn_encode.py b/code/test/multi_turn_encode.py new file mode 100644 index 0000000..40c1271 --- /dev/null +++ b/code/test/multi_turn_encode.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import argparse +import math +import struct +import can + + +CMD_READ_MULTITURN_ANGLE = 0x92 + + +def arb_id_single(motor_id: int) -> int: + # 单电机命令/回包 arbitration id + return 0x140 + int(motor_id) + + +def build_read_multiturn_angle(motor_id: int) -> can.Message: + """ + 读取多圈角度命令: + data[0]=0x92,其余填0 + """ + data = [CMD_READ_MULTITURN_ANGLE, 0, 0, 0, 0, 0, 0, 0] + return can.Message(arbitration_id=arb_id_single(motor_id), is_extended_id=False, data=data) + + +def sign_extend(value: int, bits: int) -> int: + """ + 对 value 做符号扩展,使其成为 Python int(有符号) + """ + sign_bit = 1 << (bits - 1) + mask = (1 << bits) - 1 + value &= mask + return (value ^ sign_bit) - sign_bit + + +def parse_multiturn_angle_reply_int56(msg: can.Message) -> int: + """ + 兼容你贴的文档:DATA[1..7] 是角度的低->高字节(共7字节) + => 解析为有符号 int56 + 单位:0.01 deg / LSB + """ + d = bytes(msg.data) + if len(d) != 8 or d[0] != CMD_READ_MULTITURN_ANGLE: + raise ValueError("not 0x92 reply") + raw_u56 = int.from_bytes(d[1:8], byteorder="little", signed=False) # 7 bytes + raw_s56 = sign_extend(raw_u56, 56) + return raw_s56 + + +def parse_multiturn_angle_reply_int64(msg: can.Message) -> int: + """ + 如果你确认实际回的是 8 字节 motorAngle(int64): + 常见做法是 DATA[0..7] 都是 motorAngle 的 8 字节(但会与“命令字节=0x92”冲突) + 或者命令字节在别处。 + 这个函数留作你确认后切换用。 + """ + d = bytes(msg.data) + # 这里假设 d[0] 仍是 0x92,则 motorAngle 应该在 d[1:9] 不存在 + # 所以只有当你的实际报文不是“d[0]=0x92”时才用它 + raise NotImplementedError("Need exact 8-byte layout confirmation.") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--interface", default="canalystii", help="python-can interface: canalystii / socketcan / ...") + ap.add_argument("--channel", default="0", help="channel: canalystii 0/1, socketcan can0/can1 ...") + ap.add_argument("--bitrate", type=int, default=1000000) + ap.add_argument("--motor-id", type=int, default=2) + ap.add_argument("--timeout", type=float, default=0.2, help="seconds") + ap.add_argument("--repeat", type=int, default=3, help="query times") + args = ap.parse_args() + + motor_id = args.motor_id + rx_arb = arb_id_single(motor_id) + + bus = can.Bus(interface=args.interface, channel=args.channel, bitrate=args.bitrate) + + # 只收这个电机的回包(降低噪声) + try: + bus.set_filters([{"can_id": rx_arb, "can_mask": 0x7FF, "extended": False}]) + except Exception: + pass + + try: + for i in range(args.repeat): + # 发送 0x92 + bus.send(build_read_multiturn_angle(motor_id)) + + # 等回包 + msg = bus.recv(timeout=args.timeout) + if msg is None: + print(f"[M{motor_id}] timeout: no reply") + continue + if msg.arbitration_id != rx_arb or len(msg.data) != 8: + print(f"[M{motor_id}] unexpected frame: arb=0x{msg.arbitration_id:X} data={list(msg.data)}") + continue + + # 解析(按你贴的文档:int56 in DATA[1..7]) + motor_angle_raw = parse_multiturn_angle_reply_int56(msg) + + # 单位换算:0.01°/LSB + angle_deg = motor_angle_raw * 0.01 + angle_rad = angle_deg * math.pi / 180.0 + + print( + f"[M{motor_id}] motorAngle_raw={motor_angle_raw} " + f"angle_deg={angle_deg:+.4f}° angle_rad={angle_rad:+.6f} rad" + ) + + finally: + bus.shutdown() + + +if __name__ == "__main__": + main() diff --git a/code/test/plot_sew.py b/code/test/plot_sew.py new file mode 100644 index 0000000..7142a38 --- /dev/null +++ b/code/test/plot_sew.py @@ -0,0 +1,436 @@ +import os +import sys +import numpy as np +import pinocchio as pin +import matplotlib.pyplot as plt +from mpl_toolkits.mplot3d import Axes3D +from mpl_toolkits.mplot3d.art3d import Poly3DCollection +from omegaconf import OmegaConf + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +from core.sew_mapper import SEWMapper, fk_update, pose_of_frame, rot_error_deg + + +def safe_normalize(v, eps=1e-8): + """Return v / ||v||, or None if the norm is too small.""" + n = np.linalg.norm(v) + if n < eps: + return None + return v / n + + +def check_once(mapper, q_m, names, visualize=True): + import numpy as np + import open3d as o3d + from utils.drwa import o3d_frame, o3d_lineset, o3d_sphere + + m_model, m_data = mapper.m_model, mapper.m_data + s_model, s_data = mapper.s_model, mapper.s_data + + q_s, dbg = mapper.retargetting(q_m) + + # 主端 FK + fk_update(m_model, m_data, q_m) + pS_m, RS_m = pose_of_frame(m_model, m_data, names["m_shoulder"]) + pE_m, REf_m = pose_of_frame(m_model, m_data, names["m_elbow"]) + pW_m, RW_m = pose_of_frame(m_model, m_data, names["m_wrist"]) + p_m, R_m = pose_of_frame(m_model, m_data, names["m_ee"]) + + # 从端 FK + fk_update(s_model, s_data, q_s) + pS_s, RS_s = pose_of_frame(s_model, s_data, names["s_shoulder"]) + pE_s, REf_s = pose_of_frame(s_model, s_data, names["s_elbow"]) + pW_s, RW_s = pose_of_frame(s_model, s_data, names["s_wrist"]) + p_s, R_s = pose_of_frame(s_model, s_data, names["s_ee"]) + + # 误差 + pos_err = np.linalg.norm(p_m - p_s) * 1000.0 # mm + rot_err = rot_error_deg(R_m, R_s) # degree + print(f"rot_err: {rot_err}, rot_err: {rot_err}") + + # 是否发生 reach 裁剪 + clipped = dbg["d_s"] < np.linalg.norm( + m_data.oMf[m_model.getFrameId(names["m_wrist"])].translation + - m_data.oMf[m_model.getFrameId(names["m_shoulder"])].translation) - 1e-9 \ + or dbg["d_s"] > mapper.L1 + mapper.L2 - mapper.eps_clip + 1e-9 + + if visualize: + geoms = [] + geoms.append(o3d_frame(np.eye(3), np.zeros(3), size=0.1)) + + pts_m = np.vstack([pS_m, pE_m, pW_m, p_m]) + geoms.append(o3d_lineset(pts_m, color=(1.0, 0.0, 0.0))) + geoms += [ + o3d_sphere(pS_m, 0.016, (1.0,0.4,0.4)), + o3d_sphere(pE_m, 0.014, (1.0,0.3,0.3)), + o3d_sphere(pW_m, 0.012, (1.0,0.2,0.2)), + o3d_sphere(p_m, 0.012, (1.0,0.0,0.0)), + o3d_frame(R_m, p_m, size=0.07) + ] + # 从臂(蓝) + pts_s = np.vstack([pS_s, pE_s, pW_s, p_s]) + geoms.append(o3d_lineset(pts_s, color=(0.0, 0.4, 1.0))) + geoms += [ + o3d_sphere(pS_s, 0.016, (0.4,0.6,1.0)), + o3d_sphere(pE_s, 0.014, (0.3,0.5,1.0)), + o3d_sphere(pW_s, 0.012, (0.2,0.4,1.0)), + o3d_sphere(p_s, 0.012, (0.0,0.2,1.0)), + o3d_frame(R_s, p_s, size=0.07) + ] + + geoms.append(o3d_sphere(dbg["pW_s_ref"], 0.008, (0.2, 1.0, 0.2))) + o3d.visualization.draw_geometries(geoms, window_name="SEW Retargeting (Master=Red, Slave=Blue)") + return dict(pos_mm=pos_err, rot_deg=rot_err, clipped=clipped, dbg=dbg, q_s=q_s) + + +def random_qm(mapper, N=50, margin=0.2): + lb = []; ub = [] + for j in mapper.m_model.joints[1:]: # skip universe + if j.nq == 1: # 1-DoF revolute + jid = j.id + iq = j.idx_q + lb.append(mapper.m_model.lowerPositionLimit[iq]) + ub.append(mapper.m_model.upperPositionLimit[iq]) + lb = np.array(lb); ub = np.array(ub) + rng = (ub - lb) + lb2 = lb + margin * rng + ub2 = ub - margin * rng + + qs = [] + for _ in range(N): + v = lb2 + np.random.rand(len(lb2)) * (ub2 - lb2) + q_full = pin.neutral(mapper.m_model) + k = 0 + for j in mapper.m_model.joints[1:]: + if j.nq == 1: + q_full[j.idx_q] = v[k]; k += 1 + qs.append(q_full) + return qs + + +def validation(mapper): + q_m0 = pin.neutral(mapper.m_model) + names = { + "m_shoulder": "master_shoulder", + "m_elbow": "master_forearm", + "m_wrist": "master_wrist", + "m_ee": "master_ee", + "s_shoulder": "slave_shoulder", + "s_elbow": "slave_forearm", + "s_wrist": "slave_wrist", + "s_ee": "slave_ee" + } + res0 = check_once(mapper, q_m0, names) + print("[Neutral] pos_err = %.3f mm, rot_err = %.3f deg, clipped=%s" % + (res0["pos_mm"], res0["rot_deg"], res0["clipped"])) + + # 2) 随机多组验证 + N = 100 + samples = random_qm(mapper, N=N, margin=0.1) + pos_errs = []; rot_errs = []; n_clip = 0 + for q_m in samples: + r = check_once(mapper, q_m, names) + pos_errs.append(r["pos_mm"]) + rot_errs.append(r["rot_deg"]) + n_clip += int(r["clipped"]) + + pos_errs = np.array(pos_errs) + rot_errs = np.array(rot_errs) + + +# ======================================================= +# 辅助函数:让 3D 轴等比例,图更“几何化” +# ======================================================= +def set_equal_aspect_3d(ax, pts): + """pts: (N,3) numpy array.""" + pts = np.asarray(pts) + + # 如果有 NaN 或 Inf,直接返回,不调坐标轴 + if not np.all(np.isfinite(pts)): + return + + x_min, y_min, z_min = pts.min(axis=0) + x_max, y_max, z_max = pts.max(axis=0) + max_range = max(x_max - x_min, y_max - y_min, z_max - z_min) + if max_range == 0: + max_range = 1.0 # 防止除零 + + x_mid = 0.5 * (x_max + x_min) + y_mid = 0.5 * (y_max + y_min) + z_mid = 0.5 * (z_max + z_min) + r = 0.6 * max_range + ax.set_xlim(x_mid - r, x_mid + r) + ax.set_ylim(y_mid - r, y_mid + r) + ax.set_zlim(z_mid - r, z_mid + r) + + + +# ======================================================= +# 绘图(论文插图专用) +# ======================================================= + +def plot_sew_geometry(pS_m, pE_m, pW_m, + pS_s, pE_s, pW_s_ref, + xhat_m, n_m, n_ref, phi_m): + """ + Fig. SEW Geometry: + - Master/Slave S-E-W + - Shoulder-Wrist 方向 x̂_m + - 上臂平面法向 n_m + - 参考法向 n_ref + """ + + fig = plt.figure(figsize=(6, 6)) + ax = fig.add_subplot(111, projection='3d') + + # Master skeleton (粗实体+虚线前臂) + ax.plot([pS_m[0], pE_m[0]], [pS_m[1], pE_m[1]], [pS_m[2], pE_m[2]], + 'r-', lw=2.5, label='Master upper arm') + ax.plot([pE_m[0], pW_m[0]], [pE_m[1], pW_m[1]], [pE_m[2], pW_m[2]], + 'r--', lw=2.0, label='Master forearm') + + # Slave skeleton (蓝色) + ax.plot([pS_s[0], pE_s[0]], [pS_s[1], pE_s[1]], [pS_s[2], pE_s[2]], + 'b-', lw=2.5, label='Slave upper arm') + ax.plot([pE_s[0], pW_s_ref[0]], [pE_s[1], pW_s_ref[1]], [pE_s[2], pW_s_ref[2]], + 'b--', lw=2.0, label='Slave forearm') + + # --- Master 上臂平面 patch(S_m, E_m, W_m) --- + tri = np.vstack([pS_m, pE_m, pW_m]) + area = np.linalg.norm(np.cross(pE_m - pS_m, pW_m - pS_m)) + if area > 1e-8: + # 使用 Poly3DCollection 画简单三角面片,避免 qhull + verts = [tri] + poly = Poly3DCollection(verts, alpha=0.08, + facecolor='r', edgecolor='none') + ax.add_collection3d(poly) + ax.plot_trisurf(tri[:, 0], tri[:, 1], tri[:, 2], + color='r', alpha=0.08, edgecolor='none') + + # Vectors at S_m + arrow_len = 0.12 + ax.quiver(*pS_m, *(arrow_len * xhat_m), + color='g', lw=2.0, normalize=False, label=r'$\hat{x}_m$') + ax.quiver(*pS_m, *(arrow_len * n_m), + color='m', lw=2.0, normalize=False, label=r'$n_m$') + ax.quiver(*pS_m, *(arrow_len * n_ref), + color='c', lw=2.0, normalize=False, label=r'$n_{\mathrm{ref}}$') + + # Labels + ax.text(*pS_m, r'$S_m$', fontsize=10) + ax.text(*pE_m, r'$E_m$', fontsize=10) + ax.text(*pW_m, r'$W_m$', fontsize=10) + ax.text(*pS_s, r'$S_s$', fontsize=10) + ax.text(*pE_s, r'$E_s$', fontsize=10) + ax.text(*pW_s_ref, r'$W_s$', fontsize=10) + + # 视角和轴 + pts_all = np.vstack([pS_m, pE_m, pW_m, pS_s, pE_s, pW_s_ref]) + set_equal_aspect_3d(ax, pts_all) + ax.view_init(elev=22, azim=52) + ax.set_xlabel("X"); ax.set_ylabel("Y"); ax.set_zlabel("Z") + + title_deg = phi_m * 180.0 / np.pi + ax.set_title(r"SEW geometry (swivel $\phi_m = %.1f^\circ$)" % title_deg) + ax.legend(loc='upper right', fontsize=8) + plt.tight_layout() + plt.show() + + +def plot_swivel_angle(pS_m, n_ref, n_m, xhat_m): + """ + Fig. Swivel Angle: + 可视化 n_ref → n_m 绕 x̂_m 的旋转,并画出圆弧 φ_m。 + """ + + fig = plt.figure(figsize=(6, 6)) + ax = fig.add_subplot(111, projection='3d') + + # 以 S_m 为圆心,在垂直于 xhat_m 的平面内画单位圆 + # 构造平面正交基 b1, b2 + tmp = np.array([1.0, 0.0, 0.0]) + if abs(np.dot(tmp, xhat_m)) > 0.9: + tmp = np.array([0.0, 1.0, 0.0]) + b1 = tmp - np.dot(tmp, xhat_m) * xhat_m + b1 = b1 / np.linalg.norm(b1) + b2 = np.cross(xhat_m, b1) + + # 用 n_ref, n_m 在该平面上算出对应角度 + # 这里假设 n_ref 已经在平面内 + r = 0.12 + # Swivel angle + cos_phi = np.dot(n_ref, n_m) + sin_phi = np.dot(xhat_m, np.cross(n_ref, n_m)) + phi = np.arctan2(sin_phi, cos_phi) + + # 圆弧 + ts = np.linspace(0.0, phi, 80) + arc_pts = [] + for t in ts: + v = np.cos(t) * n_ref + np.sin(t) * np.cross(xhat_m, n_ref) + v = v / np.linalg.norm(v) + arc_pts.append(pS_m + r * v) + arc_pts = np.asarray(arc_pts) + ax.plot(arc_pts[:, 0], arc_pts[:, 1], arc_pts[:, 2], 'k-', lw=2.0, label=r'$\phi_m$') + + # 向量 + ax.quiver(*pS_m, *(r * n_ref), color='c', lw=2.0, normalize=False, label=r'$n_{\mathrm{ref}}$') + ax.quiver(*pS_m, *(r * n_m), color='m', lw=2.0, normalize=False, label=r'$n_m$') + ax.quiver(*pS_m, *(r * xhat_m), color='g', lw=2.0, normalize=False, label=r'$\hat{x}_m$') + + ax.text(*pS_m, r'$S_m$', fontsize=10) + + pts_all = np.vstack([arc_pts, pS_m]) + set_equal_aspect_3d(ax, pts_all) + ax.view_init(elev=20, azim=45) + ax.set_xlabel("X"); ax.set_ylabel("Y"); ax.set_zlabel("Z") + ax.set_title(r"Swivel angle definition") + ax.legend(loc='upper right', fontsize=8) + plt.tight_layout() + plt.show() + + +def plot_two_sphere_sew(pS_s, pW_s_ref, L1, L2, pE_s): + """ + Fig. Two-sphere intersection: + - 球心 S_s, W_s,半径 L1, L2 + - 交点 E_s + - 高亮三角形 S-E-W + """ + + fig = plt.figure(figsize=(6, 6)) + ax = fig.add_subplot(111, projection='3d') + + # 球面 + u, v = np.mgrid[0:2*np.pi:40j, 0:np.pi:20j] + sphere1 = pS_s.reshape(3,1,1) + L1 * np.array([ + np.cos(u)*np.sin(v), + np.sin(u)*np.sin(v), + np.cos(v) + ]) + sphere2 = pW_s_ref.reshape(3,1,1) + L2 * np.array([ + np.cos(u)*np.sin(v), + np.sin(u)*np.sin(v), + np.cos(v) + ]) + ax.plot_surface(sphere1[0], sphere1[1], sphere1[2], + alpha=0.15, color='r', edgecolor='none') + ax.plot_surface(sphere2[0], sphere2[1], sphere2[2], + alpha=0.15, color='b', edgecolor='none') + + # S-E-W 三角形 + tri = np.vstack([pS_s, pE_s, pW_s_ref]) + ax.plot_trisurf(tri[:, 0], tri[:, 1], tri[:, 2], + color='k', alpha=0.08, edgecolor='none') + ax.plot([pS_s[0], pE_s[0], pW_s_ref[0], pS_s[0]], + [pS_s[1], pE_s[1], pW_s_ref[1], pS_s[1]], + [pS_s[2], pE_s[2], pW_s_ref[2], pS_s[2]], + 'k-', lw=2.0) + + # 关键点 + ax.scatter(*pE_s, color='k', s=50, label=r'$E_s$') + ax.scatter(*pS_s, color='r', s=40, label=r'$S_s$') + ax.scatter(*pW_s_ref, color='b', s=40, label=r'$W_s$') + + ax.set_xlabel("X"); ax.set_ylabel("Y"); ax.set_zlabel("Z") + ax.set_title("Two-sphere intersection for SEW elbow") + + pts_all = np.vstack([pS_s, pE_s, pW_s_ref]) + set_equal_aspect_3d(ax, pts_all) + ax.view_init(elev=20, azim=40) + ax.legend(loc='upper right', fontsize=8) + plt.tight_layout() + plt.show() + + +# ======================================================= +# 主测试逻辑 +# ======================================================= + +def main(): + conf = OmegaConf.load("../config/config.yaml") + + master_model, _, _ = pin.buildModelsFromUrdf(str(conf.master_urdf)) + slave_model, _, _ = pin.buildModelsFromUrdf(str(conf.slave_urdf)) + + mapper = SEWMapper( + master_model=master_model, + slave_model=slave_model, + m_shoulder_frame=conf.m_shoulder_frame, + m_elbow_frame=conf.m_elbow_frame, + m_wrist_frame=conf.m_wrist_frame, + m_ee_frame=conf.m_ee_frame, + s_shoulder_frame=conf.s_shoulder_frame, + s_elbow_frame=conf.s_elbow_frame, + s_wrist_frame=conf.s_wrist_frame, + s_ee_frame=conf.s_ee_frame, + slave_joint_names=conf.sew_mapper.slave_joint_names, + up_dir=np.array(conf.sew_mapper.up_dir), + eps_clip=conf.sew_mapper.eps_clip, + ) + + # 选一组姿态示例(这里用 neutral) + # q_m = pin.neutral(mapper.m_model) + samples = random_qm(mapper, N=500, margin=0.1) + for q_m in samples: + q_s, dbg = mapper.retargetting(q_m) + + # 主端点 + fk_update(mapper.m_model, mapper.m_data, q_m) + pS_m, _ = pose_of_frame(mapper.m_model, mapper.m_data, conf.m_shoulder_frame) + pE_m, _ = pose_of_frame(mapper.m_model, mapper.m_data, conf.m_elbow_frame) + pW_m, _ = pose_of_frame(mapper.m_model, mapper.m_data, conf.m_wrist_frame) + + # 从端(SEW 参考) + pS_s = mapper.pS_s_fixed + pE_s = dbg["pE_s"] + pW_s_ref = dbg["pW_s_ref"] + + # 几何向量 + r_m = pW_m - pS_m + xhat_m = safe_normalize(r_m) + if xhat_m is None: + raise RuntimeError("Shoulder–wrist vector is zero; check FK / frames.") + + # master 上臂平面法向 + n_m_raw = np.cross(pE_m - pS_m, pW_m - pS_m) + n_m = safe_normalize(n_m_raw) + + # 参考法向(胸部 up 向量投影到垂直于 xhat_m 的平面) + nref_tilde = mapper.up - (mapper.up @ xhat_m) * xhat_m + n_ref = safe_normalize(nref_tilde) + + phi_m = dbg["phi_m"] + + degenerate_plane = (n_m is None or n_ref is None) + if degenerate_plane: + print("[Warn] S–E–W nearly collinear, swivel angle undefined for this posture.") + # 用一个合理的默认法向,方便画 SEW overall 图;swivel 角度取 0 + if n_ref is None: + # 如果连 n_ref 都 degenerate,就随便取一个与 xhat_m 垂直的向量 + tmp = np.array([1.0, 0.0, 0.0]) + if abs(np.dot(tmp, xhat_m)) > 0.9: + tmp = np.array([0.0, 1.0, 0.0]) + n_ref = safe_normalize(tmp - np.dot(tmp, xhat_m) * xhat_m) + if n_m is None: + n_m = n_ref.copy() + phi_m = 0.0 # 退化情形下设成 0 仅用于示意 + + # 绘制三类图 + print("Plotting SEW Geometry...") + plot_sew_geometry(pS_m, pE_m, pW_m, + pS_s, pE_s, pW_s_ref, + xhat_m, n_m, n_ref, phi_m) + + if not degenerate_plane: + print("Plotting Swivel Angle Geometry...") + plot_swivel_angle(pS_m, n_ref, n_m, xhat_m) + else: + print("Skip swivel-angle figure for this degenerate posture (S–E–W nearly collinear).") + + print("Plotting Two-Sphere Intersection...") + plot_two_sphere_sew(pS_s, pW_s_ref, mapper.L1, mapper.L2, pE_s) + + +if __name__ == '__main__': + main() diff --git a/code/test/speed_control.py b/code/test/speed_control.py new file mode 100644 index 0000000..0a88566 --- /dev/null +++ b/code/test/speed_control.py @@ -0,0 +1,169 @@ +import time +import struct +import can + + +def can_id_single_motor(motor_id: int) -> int: + """ + 单电机命令:标识符 = 0x140 + ID(1~32) + """ + assert 1 <= motor_id <= 32 + return 0x140 + motor_id + + +def send_run(bus: can.Bus, motor_id: int): + """ + 电机运行命令:DATA[0]=0x88,其余 0 + """ + arb_id = can_id_single_motor(motor_id) + data = bytes([0x88, 0, 0, 0, 0, 0, 0, 0]) + msg = can.Message(arbitration_id=arb_id, data=data, is_extended_id=False) + bus.send(msg) + + +def send_stop(bus: can.Bus, motor_id: int): + """ + 电机停止命令:DATA[0]=0x81,其余 0 + """ + arb_id = can_id_single_motor(motor_id) + data = bytes([0x81, 0, 0, 0, 0, 0, 0, 0]) + msg = can.Message(arbitration_id=arb_id, data=data, is_extended_id=False) + bus.send(msg) + + +def send_speed_cmd_a2(bus: can.Bus, motor_id: int, speed_dps: float): + """ + 速度闭环控制命令1(0xA2): + DATA[0]=0xA2 + DATA[4..7]=speedControl(int32, little-endian), 0.01 dps/LSB + + speed_dps: 目标角速度 (deg/s) + """ + speed_control = int(round(speed_dps / 0.01)) # 0.01 dps/LSB + arb_id = can_id_single_motor(motor_id) + + data = bytearray(8) + data[0] = 0xA2 + data[1] = 0x00 + data[2] = 0x00 + data[3] = 0x00 + data[4:8] = struct.pack(" float: + # 1 rev = 360 deg; rpm -> deg/s + return rpm * 360.0 / 60.0 + + +def main(): + # 1) 配置你的 CAN 接口(按你的系统改 channel / bustype) + # 常见:socketcan: channel="can0" + # bus = can.Bus(interface="socketcan", channel="can0", bitrate=1000000) + bus = can.Bus(interface="canalystii", channel=0, bitrate=1000000) + + motor_id = 2 + # 2) 使能运行 + send_run(bus, motor_id) + time.sleep(0.05) + + # 3) 恒速:例如 30 rpm + target_rpm = 100.0 + target_dps = rpm_to_dps(target_rpm) + + # 推荐:循环周期性刷新速度命令(例如 50~200Hz),更稳 + + try: + last_print = 0.0 + while True: + # 方案A:纯速度闭环(0xA2) + send_speed_cmd_a2(bus, 1, target_dps) + send_speed_cmd_a2(bus, 2, target_dps) + send_speed_cmd_a2(bus, 3, target_dps) + # send_speed_cmd_a2(bus, 4, target_dps) + + # 方案B:带转矩电流限制(0xAD),例如限制 iq=300 + # send_speed_cmd_ad(bus, motor_id, target_dps, iq_limit=300) + + time.sleep(0.01) # 100 Hz + + if time.time() - last_print > 0.1: + st1 = read_status2(bus, 1) + st2 = read_status2(bus, 2) + st3 = read_status2(bus, 3) + # st4 = read_status2(bus, 4) + print("status:", [st1, st2, st3]) + last_print = time.time() + + except KeyboardInterrupt: + send_stop(bus, 1) + send_stop(bus, 2) + send_stop(bus, 3) + # send_stop(bus, 4) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/code/test/test_closed_loop_simulation.py b/code/test/test_closed_loop_simulation.py new file mode 100644 index 0000000..5c67356 --- /dev/null +++ b/code/test/test_closed_loop_simulation.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Regression tests for the rigid-body closed-loop simulation.""" + +from dataclasses import replace +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 simulate_closed_loop import ( # noqa: E402 + DEFAULT_CONFIG_PATH, + SCENARIOS, + SimulationConfig, + Wall, + build_mapper, + load_models, + load_simulation_config, + make_wall, + master_endpoint_configurations, + simulate_scenario, +) +from core.energy_audit import audit_haptic_energy # noqa: E402 + + +class ClosedLoopSimulationTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.models = load_models(add_simulated_tcp=True) + + def test_yaml_configuration_is_complete(self) -> None: + config = load_simulation_config(DEFAULT_CONFIG_PATH) + self.assertEqual(config.slave_contact_frame, "R_EE_SIM") + self.assertEqual(config.dt, 0.002) + self.assertEqual(config.mapping_hz, 50.0) + self.assertLess(config.energy_min, config.energy_max) + + def test_wall_is_unilateral_dissipative_and_force_limited(self) -> None: + wall = Wall( + point=np.zeros(3), + normal=np.array([1.0, 0.0, 0.0]), + stiffness=100.0, + damping=10.0, + force_limit=5.0, + ) + free_wrench, free_penetration = wall.wrench( + np.array([-0.1, 0.0, 0.0]), + np.array([1.0, 0.0, 0.0]), + ) + np.testing.assert_allclose(free_wrench, 0.0) + self.assertEqual(free_penetration, 0.0) + + contact_wrench, penetration = wall.wrench( + np.array([0.1, 0.0, 0.0]), + np.array([2.0, 0.0, 0.0]), + ) + self.assertAlmostEqual(penetration, 0.1) + np.testing.assert_allclose(contact_wrench[:3], [-5.0, 0.0, 0.0]) + self.assertLessEqual(float(contact_wrench[:3] @ np.array([2.0, 0.0, 0.0])), 0.0) + + def test_generalized_mapping_preserves_virtual_work(self) -> None: + mapper = build_mapper(self.models) + q_master, _ = master_endpoint_configurations() + _, differential, debug = mapper.retarget_with_differential(q_master) + self.assertTrue(debug["differential_valid"]) + + qd_master = np.array([0.2, -0.1, 0.15, -0.3, 0.05, 0.08, -0.04]) + tau_slave = np.array([-1.0, 0.4, 0.8, -0.5, 0.2, -0.3, 0.1]) + tau_master = differential.T @ tau_slave + self.assertAlmostEqual( + float(tau_master @ qd_master), + float(tau_slave @ (differential @ qd_master)), + places=12, + ) + + def test_short_rigid_body_run_is_finite_and_respects_energy_floor(self) -> None: + config = replace( + SimulationConfig(), + duration=0.8, + feedback_delay_s=0.04, + contact_probe_fraction=0.0, + ) + mapper = build_mapper(self.models) + wall, _, q_slave_start = make_wall(config, self.models, mapper) + result = simulate_scenario( + SCENARIOS[0], + config, + self.models, + wall, + q_slave_start, + ) + + self.assertTrue(result.metrics["completed"]) + self.assertTrue(result.metrics["finite_state"]) + self.assertEqual(result.metrics["master_joint_limit_events"], 0) + self.assertEqual(result.metrics["slave_joint_limit_events"], 0) + self.assertEqual(result.metrics["differential_fallback_count"], 0) + self.assertGreater(result.metrics["wall_contact_duration_s"], 0.0) + self.assertGreaterEqual( + result.metrics["tank_energy_min_J"], + config.energy_min - 1e-12, + ) + + def test_matched_source_map_and_independent_energy_audit(self) -> None: + config = replace( + SimulationConfig(), + duration=0.6, + feedback_delay_s=0.04, + contact_probe_fraction=0.0, + ) + mapper = build_mapper(self.models) + wall, _, q_slave_start = make_wall(config, self.models, mapper) + matched = next( + scenario + for scenario in SCENARIOS + if scenario.key == "matched_wrench_energy" + ) + result = simulate_scenario( + matched, config, self.models, wall, q_slave_start + ) + active = result.logs["return_packet_active"] > 0.5 + self.assertTrue(np.any(active)) + self.assertTrue(np.all(result.logs["source_map_id"][active] >= 0)) + self.assertTrue(np.all(result.logs["map_id"][active] >= 0)) + + audit = audit_haptic_energy( + tau_candidate=result.logs["tau_master_candidate"], + tau_projected=result.logs["tau_master_applied"], + tau_accepted=result.logs["tau_master_accepted"], + qd_master=result.logs["qd_master"], + dt=config.dt, + energy_initial=config.energy_initial, + energy_min=config.energy_min, + energy_max=config.energy_max, + logged_preclip=result.logs["energy_preclip"], + ) + self.assertLessEqual(audit.max_floor_deficit, 1e-12) + self.assertLessEqual(audit.preclip_log_max_error, 1e-12) + + def test_time_domain_popc_is_a_distinct_executable_baseline(self) -> None: + config = replace( + SimulationConfig(), + duration=0.6, + feedback_delay_s=0.02, + contact_probe_fraction=0.0, + ) + mapper = build_mapper(self.models) + wall, _, q_slave_start = make_wall(config, self.models, mapper) + popc = next( + scenario for scenario in SCENARIOS if scenario.key == "proposed_popc" + ) + result = simulate_scenario( + popc, config, self.models, wall, q_slave_start + ) + self.assertTrue(result.metrics["completed"]) + self.assertIsNone(result.metrics["rho_min"]) + self.assertTrue(np.all(np.isfinite(result.logs["popc_damping_gain"]))) + + +if __name__ == "__main__": + unittest.main() diff --git a/code/test/test_energy_audit_allocator.py b/code/test/test_energy_audit_allocator.py new file mode 100644 index 0000000..6c49f08 --- /dev/null +++ b/code/test/test_energy_audit_allocator.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Tests for final accepted-port allocation and independent H4 audit.""" + +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 core.command_allocator import CommandAllocator # noqa: E402 +from core.energy_audit import audit_haptic_energy # noqa: E402 +from core.time_domain_popc import TimeDomainPOPC # noqa: E402 + + +class AllocatorAuditTest(unittest.TestCase): + def test_reserved_headroom_avoids_total_command_clipping(self): + allocator = CommandAllocator(np.array([5.0, 4.0])) + prepared = allocator.prepare( + compensation=np.array([4.0, -3.5]), + haptic_raw=np.array([3.0, -2.0]), + ) + np.testing.assert_allclose(prepared.haptic_candidate, [1.0, -0.5]) + final = allocator.finalize( + prepared, 0.5 * prepared.haptic_candidate + ) + self.assertFalse(final.downstream_modified) + np.testing.assert_allclose( + final.haptic_accepted, 0.5 * prepared.haptic_candidate + ) + + def test_quantization_is_reported_and_accepted_increment_reconstructed(self): + allocator = CommandAllocator( + np.array([5.0]), quantization_step=np.array([0.2]) + ) + prepared = allocator.prepare(np.array([1.0]), np.array([0.34])) + final = allocator.finalize(prepared, np.array([0.34])) + self.assertTrue(final.quantization_active) + self.assertTrue(final.downstream_modified) + np.testing.assert_allclose(final.total_accepted, [1.4]) + np.testing.assert_allclose(final.haptic_accepted, [0.4]) + + def test_independent_audit_catches_preclip_defect(self): + candidate = np.array([[2.0], [2.0]]) + projected = np.array([[0.5], [0.0]]) + qd = np.ones((2, 1)) + clean = audit_haptic_energy( + tau_candidate=candidate, + tau_projected=projected, + tau_accepted=projected, + qd_master=qd, + dt=0.5, + energy_initial=1.25, + energy_min=1.0, + energy_max=5.0, + ) + self.assertAlmostEqual(clean.max_floor_deficit, 0.0) + self.assertGreater(clean.delta_B, 0.0) + self.assertGreater(clean.projection_distortion, 0.0) + + defective = audit_haptic_energy( + tau_candidate=candidate, + tau_projected=projected, + tau_accepted=np.array([[0.7], [0.0]]), + qd_master=qd, + dt=0.5, + energy_initial=1.25, + energy_min=1.0, + energy_max=5.0, + logged_preclip=np.array([1.0, 1.0]), + ) + self.assertGreater(defective.max_floor_deficit, 0.0) + self.assertGreater(defective.downstream_modification_max, 0.0) + self.assertGreater(defective.preclip_log_max_error, 0.0) + + def test_projection_distortion_is_zero_without_intervention(self): + torque = np.array([[1.0, -2.0], [0.5, 0.25]]) + result = audit_haptic_energy( + tau_candidate=torque, + tau_projected=torque, + tau_accepted=torque, + qd_master=np.zeros_like(torque), + dt=0.01, + energy_initial=2.0, + energy_min=1.0, + energy_max=3.0, + ) + self.assertAlmostEqual(result.projection_distortion, 0.0) + self.assertAlmostEqual(result.delta_B, 0.0) + + +class TimeDomainPOPCTest(unittest.TestCase): + def test_no_intervention_for_passive_candidate(self): + controller = TimeDomainPOPC( + initial_energy=0.0, minimum_energy=0.0, maximum_energy=5.0 + ) + applied, diagnostics = controller.apply( + np.array([-2.0]), np.array([1.0]), 0.1 + ) + np.testing.assert_allclose(applied, [-2.0]) + self.assertFalse(diagnostics.intervention_active) + self.assertAlmostEqual(controller.energy, 0.2) + + def test_active_candidate_receives_damping_injection(self): + controller = TimeDomainPOPC( + initial_energy=0.1, minimum_energy=0.0, maximum_energy=5.0 + ) + applied, diagnostics = controller.apply( + np.array([2.0]), np.array([1.0]), 0.1 + ) + np.testing.assert_allclose(applied, [1.0]) + self.assertTrue(diagnostics.intervention_active) + self.assertAlmostEqual(diagnostics.damping_gain, 1.0) + self.assertAlmostEqual(controller.energy, 0.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/code/test/test_estimation_signals.py b/code/test/test_estimation_signals.py new file mode 100644 index 0000000..f9d384f --- /dev/null +++ b/code/test/test_estimation_signals.py @@ -0,0 +1,161 @@ +"""Tests for causal acceleration, friction, and replayable H2 ablations.""" + +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +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 core.estimation_signals import ( # noqa: E402 + AccelerationEstimatorConfig, + AccelerationEstimateStatus, + CalibratedResidualWrenchEstimator, + CausalAccelerationEstimator, + JointFrictionCalibration, + ResidualAblation, + ResidualTorqueModel, + WrenchEstimatorCalibration, +) +from core.wrench_solver import UndampedSVDSolver # noqa: E402 + + +class EstimationSignalsTest(unittest.TestCase): + def test_acceleration_estimator_is_causal_and_rejects_bad_dt(self) -> None: + config = AccelerationEstimatorConfig( + cutoff_hz=10.0, + minimum_dt_s=0.001, + maximum_dt_s=0.1, + ) + estimator = CausalAccelerationEstimator(2, config) + first = estimator.update(np.array([0.0, 0.0]), 1.0) + self.assertIs(first.status, AccelerationEstimateStatus.INITIALIZING) + + second = estimator.update(np.array([0.1, -0.2]), 1.01) + self.assertIs(second.status, AccelerationEstimateStatus.VALID) + np.testing.assert_allclose(second.raw_acceleration, [10.0, -20.0]) + expected_alpha = 1.0 - np.exp(-2.0 * np.pi * 10.0 * 0.01) + np.testing.assert_allclose( + second.acceleration, + expected_alpha * np.array([10.0, -20.0]), + ) + + invalid = estimator.update(np.array([99.0, 99.0]), 1.0101) + self.assertIs(invalid.status, AccelerationEstimateStatus.INVALID_DT) + # Invalid data do not advance the differentiator state. + recovered = estimator.update(np.array([0.2, -0.4]), 1.02) + self.assertIs(recovered.status, AccelerationEstimateStatus.VALID) + np.testing.assert_allclose(recovered.raw_acceleration, [10.0, -20.0]) + + def test_friction_fit_recovers_synthetic_coefficients(self) -> None: + velocity = np.linspace(-1.2, 1.2, 101) + velocity_samples = np.column_stack((velocity, 0.7 * velocity)) + true = JointFrictionCalibration( + coulomb_nm=np.array([0.4, 0.2]), + viscous_nm_per_rad_s=np.array([0.08, 0.12]), + smoothing_velocity_rad_s=0.03, + ) + torque_samples = np.vstack( + [true.torque(sample) for sample in velocity_samples] + ) + fitted = JointFrictionCalibration.fit( + velocity_samples, + torque_samples, + smoothing_velocity_rad_s=0.03, + ) + np.testing.assert_allclose(fitted.coulomb_nm, true.coulomb_nm, atol=1e-13) + np.testing.assert_allclose( + fitted.viscous_nm_per_rad_s, + true.viscous_nm_per_rad_s, + atol=1e-13, + ) + + def test_nominal_and_ablation_residuals_are_explicit(self) -> None: + friction = JointFrictionCalibration( + coulomb_nm=np.array([0.3, 0.2]), + viscous_nm_per_rad_s=np.array([0.1, 0.05]), + ) + calibration = WrenchEstimatorCalibration( + joint_bias_nm=np.array([0.12, -0.08]), + friction=friction, + characteristic_length_m=0.4, + damping=0.02, + calibration_id="fixture-v1", + ) + model = ResidualTorqueModel(calibration) + velocity = np.array([0.5, -0.4]) + contact = np.array([1.2, -0.7]) + rigid = np.array([3.0, 4.0]) + measured = ( + rigid + + contact + + calibration.joint_bias_nm + + friction.torque(velocity) + ) + + nominal = model.compute(measured, rigid, velocity) + no_bias = model.compute( + measured, rigid, velocity, ablation=ResidualAblation.no_bias() + ) + no_friction = model.compute( + measured, + rigid, + velocity, + ablation=ResidualAblation.no_friction(), + ) + np.testing.assert_allclose(nominal.residual_nm, contact, atol=1e-15) + np.testing.assert_allclose( + no_bias.residual_nm, + contact + calibration.joint_bias_nm, + atol=1e-15, + ) + np.testing.assert_allclose( + no_friction.residual_nm, + contact + friction.torque(velocity), + atol=1e-15, + ) + self.assertFalse(calibration.joint_bias_nm.flags.writeable) + + def test_calibrated_estimator_accepts_matched_undamped_solver(self) -> None: + joint_count = 7 + calibration = WrenchEstimatorCalibration( + joint_bias_nm=np.zeros(joint_count), + friction=JointFrictionCalibration.zeros(joint_count), + characteristic_length_m=0.5, + damping=0.03, + relative_rank_tolerance=1e-8, + calibration_id="synthetic-v1", + ) + solver = UndampedSVDSolver( + 0.5, relative_rank_tolerance=1e-8 + ) + estimator = CalibratedResidualWrenchEstimator(calibration, solver) + rng = np.random.default_rng(12) + jacobian = rng.normal(size=(6, joint_count)) + wrench = np.array([2.0, -1.0, 3.0, 0.2, -0.3, 0.4]) + residual = jacobian.T @ wrench + estimate = estimator.estimate( + jacobian, + measured_torque_nm=residual, + rigid_body_torque_nm=np.zeros(joint_count), + joint_velocity_rad_s=np.zeros(joint_count), + ) + np.testing.assert_allclose( + estimate.solve.wrench, wrench, rtol=1e-13, atol=1e-13 + ) + + mismatched = UndampedSVDSolver( + 0.6, relative_rank_tolerance=1e-8 + ) + with self.assertRaisesRegex(ValueError, "characteristic length"): + CalibratedResidualWrenchEstimator(calibration, mismatched) + + +if __name__ == "__main__": + unittest.main() diff --git a/code/test/test_experiment_manifest.py b/code/test/test_experiment_manifest.py new file mode 100644 index 0000000..da74dd2 --- /dev/null +++ b/code/test/test_experiment_manifest.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Reproducibility provenance and locked-split safeguards.""" + +import hashlib +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch + + +CODE_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(CODE_ROOT)) + +from experiments.manifest import build_batch_manifest, ensure_batch # noqa: E402 +from experiments.plan import build_trial_plan # noqa: E402 +from experiments.schema import ContractError # noqa: E402 + + +def _plan(split: str = "pilot"): + return build_trial_plan( + { + "study_id": "manifest_contract", + "split": split, + "root_seed": 7, + "replicates": 1, + "methods": ["method"], + "trajectories": ["trajectory"], + } + ) + + +class ExperimentManifestTest(unittest.TestCase): + def test_reproducibility_files_are_hashed_when_present(self): + contents = { + "pyproject.toml": b"[project]\nname='example'\n", + "uv.lock": b"version = 1\n", + "code/config/master_7dof.urdf": b"\n", + "code/config/real_slave_7dof.urdf": b"\n", + } + with tempfile.TemporaryDirectory() as temporary: + source_root = Path(temporary) + for relative_path, data in contents.items(): + path = source_root / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + + manifest = build_batch_manifest(_plan(), source_root=source_root) + + recorded = manifest["source"]["files"] + self.assertEqual(set(recorded), set(contents)) + for relative_path, data in contents.items(): + self.assertEqual( + recorded[relative_path]["sha256"], + hashlib.sha256(data).hexdigest(), + ) + + def test_runtime_records_optional_scientific_versions_when_importable(self): + manifest = build_batch_manifest(_plan()) + for module_name in ("scipy", "pinocchio"): + try: + module = __import__(module_name) + except Exception: + self.assertNotIn(module_name, manifest["runtime"]) + else: + self.assertIn(module_name, manifest["runtime"]) + expected = getattr(module, "__version__", None) + if expected is not None: + self.assertEqual( + manifest["runtime"][module_name], + str(expected), + ) + + def test_locked_split_rejects_missing_git_metadata(self): + with tempfile.TemporaryDirectory() as temporary: + with self.assertRaisesRegex( + ContractError, "locked split requires available Git metadata" + ): + build_batch_manifest( + _plan("locked"), + source_root=Path(temporary), + ) + + def test_locked_split_rejects_dirty_git_worktree(self): + dirty = {"available": True, "commit": "abc123", "dirty": True} + with patch("experiments.manifest._git_provenance", return_value=dirty): + with self.assertRaisesRegex( + ContractError, "locked split requires a clean Git worktree" + ): + build_batch_manifest(_plan("locked")) + + def test_pilot_split_allows_missing_git_metadata(self): + unavailable = {"available": False, "commit": None, "dirty": None} + with patch("experiments.manifest._git_provenance", return_value=unavailable): + manifest = build_batch_manifest(_plan("pilot")) + self.assertFalse(manifest["source"]["available"]) + + def test_existing_locked_batch_rechecks_current_git_state(self): + clean = {"available": True, "commit": "abc123", "dirty": False} + unavailable = {"available": False, "commit": None, "dirty": None} + with tempfile.TemporaryDirectory() as temporary: + source_root = Path(temporary) / "source" + batch_dir = Path(temporary) / "batch" + source_root.mkdir() + plan = _plan("locked") + with patch("experiments.manifest._git_provenance", return_value=clean): + ensure_batch(batch_dir, plan, source_root=source_root) + + with patch( + "experiments.manifest._git_provenance", + return_value=unavailable, + ): + with self.assertRaisesRegex( + ContractError, "locked split requires available Git metadata" + ): + ensure_batch(batch_dir, plan, source_root=source_root) + + def test_existing_locked_batch_rejects_commit_drift(self): + initial = {"available": True, "commit": "abc123", "dirty": False} + changed = {"available": True, "commit": "def456", "dirty": False} + with tempfile.TemporaryDirectory() as temporary: + source_root = Path(temporary) / "source" + batch_dir = Path(temporary) / "batch" + source_root.mkdir() + plan = _plan("locked") + with patch("experiments.manifest._git_provenance", return_value=initial): + ensure_batch(batch_dir, plan, source_root=source_root) + + with patch("experiments.manifest._git_provenance", return_value=changed): + with self.assertRaisesRegex( + ContractError, "locked batch Git commit mismatch" + ): + ensure_batch(batch_dir, plan, source_root=source_root) + + def test_existing_locked_batch_rejects_source_file_drift(self): + clean = {"available": True, "commit": "abc123", "dirty": False} + with tempfile.TemporaryDirectory() as temporary: + source_root = Path(temporary) / "source" + batch_dir = Path(temporary) / "batch" + source_root.mkdir() + pyproject = source_root / "pyproject.toml" + pyproject.write_text("initial\n", encoding="utf-8") + plan = _plan("locked") + with patch( + "experiments.manifest._git_provenance", + return_value=dict(clean), + ): + ensure_batch(batch_dir, plan, source_root=source_root) + + pyproject.write_text("changed\n", encoding="utf-8") + with patch( + "experiments.manifest._git_provenance", + return_value=dict(clean), + ): + with self.assertRaisesRegex( + ContractError, + "locked batch source file hash mismatch for: pyproject.toml", + ): + ensure_batch(batch_dir, plan, source_root=source_root) + + +if __name__ == "__main__": + unittest.main() diff --git a/code/test/test_experiment_metrics.py b/code/test/test_experiment_metrics.py new file mode 100644 index 0000000..2861174 --- /dev/null +++ b/code/test/test_experiment_metrics.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Independent numerical endpoint tests for H1--H4.""" + +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 analysis.metrics import ( # noqa: E402 + audit_h4_energy, + compute_h1_composite, + compute_h2_wrench_metrics, + compute_h3_power_mismatch, +) + + +class IndependentExperimentMetricsTest(unittest.TestCase): + def test_h1_detects_only_eligible_discontinuity(self): + metrics = compute_h1_composite( + mapping_valid=[True, True, True], + position_error_m=[0.0, 0.0, 0.0], + orientation_error_rad=[0.0, 0.0, 0.0], + q_slave=np.array( + [ + [0.0, 0.0], + [0.05, 0.0], + [0.50, 0.0], + ] + ), + swivel_angle_rad=[0.0, 0.05, 0.10], + master_step_norm=[0.0, 0.05, 0.05], + position_threshold_m=0.01, + orientation_threshold_rad=0.1, + joint_step_threshold_rad=0.2, + swivel_step_threshold_rad=0.2, + input_step_threshold_rad=0.1, + ) + self.assertEqual(metrics["h1_F_r"], 0) + self.assertEqual(metrics["h1_D_r"], 1) + self.assertEqual(metrics["h1_C_r"], 1) + + 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) + self.assertAlmostEqual(metrics["h2_force_rmse_N"], 5.0) + self.assertAlmostEqual(metrics["h2_moment_rmse_Nm"], 2.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]]) + velocity = np.array([[0.2, 0.1], [0.3, -0.1], [0.4, 0.2]]) + metrics = compute_h3_power_mismatch( + tau_master_raw=torque, + qd_master=velocity, + tau_slave_source=torque, + qd_slave_source=velocity, + dt=0.002, + ) + self.assertAlmostEqual(metrics["h3_epsilon_P_act"], 0.0) + + def test_h4_reconstructs_floor_and_projection_distortion(self): + metrics = audit_h4_energy( + energy_before_J=[1.2, 1.1], + energy_after_J=[1.1, 1.0], + tau_candidate=np.array([[0.4], [0.4]]), + tau_applied=np.array([[0.1], [0.1]]), + qd_master=np.ones((2, 1)), + dt=1.0, + energy_min_J=1.0, + energy_max_J=2.0, + audit_tolerance_J=1e-12, + ) + self.assertTrue(metrics["h4_energy_audit_pass"]) + self.assertAlmostEqual(metrics["h4_projected_floor_deficit_J"], 0.0) + self.assertAlmostEqual(metrics["h4_shadow_floor_deficit_J"], 0.6) + self.assertAlmostEqual(metrics["h4_delta_B_J"], 0.6) + self.assertAlmostEqual(metrics["h4_D_proj"], 0.75, places=10) + + def test_h4_detects_downstream_drive_modification(self): + metrics = audit_h4_energy( + energy_before_J=[1.1], + energy_after_J=[1.0], + tau_candidate=np.array([[0.2]]), + tau_applied=np.array([[0.1]]), + tau_accepted=np.array([[0.15]]), + qd_master=np.ones((1, 1)), + dt=1.0, + energy_min_J=1.0, + energy_max_J=2.0, + software_preclip_J=[1.0], + audit_tolerance_J=1e-12, + ) + self.assertFalse(metrics["h4_energy_audit_pass"]) + self.assertAlmostEqual( + metrics["h4_downstream_modification_max_Nm"], 0.05 + ) + self.assertGreater(metrics["h4_software_preclip_max_error_J"], 0.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/code/test/test_experiment_plan_contract.py b/code/test/test_experiment_plan_contract.py new file mode 100644 index 0000000..891f618 --- /dev/null +++ b/code/test/test_experiment_plan_contract.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Contracts for deterministic paired plans and random substreams.""" + +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.plan import build_trial_plan, validate_trial_plan # noqa: E402 +from experiments.rng import generator_from_record, named_seed_record # noqa: E402 +from experiments.schema import ContractError # noqa: E402 + + +def example_specification(): + return { + "study_id": "g0c_bilateral_contract", + "split": "calibration", + "root_seed": 20260727, + "replicates": 2, + "methods": ["proposed", "direct"], + "trajectories": [ + { + "trajectory_id": "contact_probe_001", + "family": "approach_contact_return", + "parameters": {"amplitude": 0.1}, + } + ], + "factors": {"delay_ms": [0, 80], "stiffness_N_per_m": [500]}, + } + + +class ExperimentPlanContractTest(unittest.TestCase): + def test_plan_is_deterministic_and_paired(self): + first = build_trial_plan(example_specification()) + second = build_trial_plan(example_specification()) + self.assertEqual(first, second) + self.assertEqual(first["pair_count"], 4) + self.assertEqual(first["trial_count"], 8) + + by_pair = {} + for trial in first["trials"]: + by_pair.setdefault(trial["pair_id"], []).append(trial) + for pair in by_pair.values(): + self.assertEqual(len(pair), 2) + self.assertEqual(pair[0]["seeds"], pair[1]["seeds"]) + self.assertEqual(pair[0]["trajectory"], pair[1]["trajectory"]) + self.assertEqual(pair[0]["factors"], pair[1]["factors"]) + self.assertNotEqual( + pair[0]["method"]["method_id"], + pair[1]["method"]["method_id"], + ) + + def test_named_streams_are_order_independent_and_distinct(self): + forward = named_seed_record( + 9, + {"pair": 3}, + ["trajectory", "sensor", "network"], + ) + reverse = named_seed_record( + 9, + {"pair": 3}, + ["network", "sensor", "trajectory"], + ) + self.assertEqual(forward, reverse) + self.assertEqual(len({tuple(value) for value in forward.values()}), 3) + + generator_a = generator_from_record(forward, "trajectory") + generator_b = generator_from_record(reverse, "trajectory") + np.testing.assert_array_equal( + generator_a.integers(0, 2**31, size=16), + generator_b.integers(0, 2**31, size=16), + ) + + def test_plan_hash_detects_mutation(self): + plan = build_trial_plan(example_specification()) + plan["trials"][0]["factors"]["delay_ms"] = 999 + with self.assertRaises(ContractError): + validate_trial_plan(plan) + + +if __name__ == "__main__": + unittest.main() diff --git a/code/test/test_experiment_runner_contract.py b/code/test/test_experiment_runner_contract.py new file mode 100644 index 0000000..c62ae73 --- /dev/null +++ b/code/test/test_experiment_runner_contract.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Atomic-write, resume, failure-retention, and validation tests.""" + +from pathlib import Path +import sys +import tempfile +import unittest + +import numpy as np + + +CODE_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(CODE_ROOT)) + +from experiments.io import TrialPayload # noqa: E402 +from experiments.plan import build_trial_plan # noqa: E402 +from experiments.runner import run_trial_plan # noqa: E402 +from experiments.validate import validate_batch # noqa: E402 + + +def runner_specification(): + return { + "study_id": "runner_contract", + "split": "pilot", + "root_seed": 17, + "replicates": 2, + "methods": ["a", "b"], + "trajectories": ["trajectory_001"], + "factors": {"delay_ms": [0]}, + } + + +def successful_executor(trial): + method_offset = 0.0 if trial["method"]["method_id"] == "a" else 1.0 + time = np.arange(5, dtype=float) * 0.002 + return TrialPayload( + samples={ + "time": time, + "signal": np.full((5, 2), method_offset, dtype=float), + }, + events=[{"sample_index": 2, "event": "marker"}], + metadata={"executor": "unit-test"}, + ) + + +class ExperimentRunnerContractTest(unittest.TestCase): + def test_dry_run_writes_nothing(self): + plan = build_trial_plan(runner_specification()) + with tempfile.TemporaryDirectory() as temporary: + batch = Path(temporary) / "batch" + report = run_trial_plan(plan, batch, dry_run=True) + self.assertEqual(report["trial_count"], 4) + self.assertFalse(batch.exists()) + + def test_atomic_run_validate_and_resume(self): + plan = build_trial_plan(runner_specification()) + with tempfile.TemporaryDirectory() as temporary: + batch = Path(temporary) / "batch" + first = run_trial_plan(plan, batch, successful_executor) + self.assertEqual(first["completed_trials"], 4) + self.assertEqual(first["failed_trials"], 0) + validation = validate_batch(batch) + self.assertTrue(validation["valid"], validation["errors"]) + self.assertEqual(validation["completed_trials"], 4) + self.assertFalse( + any(path.name.startswith(".") for path in (batch / "raw").iterdir()) + ) + + second = run_trial_plan(plan, batch, successful_executor, resume=True) + self.assertEqual(second["completed_trials"], 0) + self.assertEqual(second["skipped_trials"], 4) + + def test_failure_is_retained_without_committing_trial(self): + plan = build_trial_plan(runner_specification()) + + def failing_executor(_trial): + raise RuntimeError("intentional failure") + + with tempfile.TemporaryDirectory() as temporary: + batch = Path(temporary) / "batch" + report = run_trial_plan( + plan, + batch, + failing_executor, + max_trials=1, + ) + self.assertEqual(report["failed_trials"], 1) + self.assertFalse((batch / "raw" / plan["trials"][0]["trial_id"]).exists()) + failure_files = list((batch / "failures").rglob("*.json")) + self.assertEqual(len(failure_files), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/code/test/test_feedback_protocol.py b/code/test/test_feedback_protocol.py new file mode 100644 index 0000000..ee6fded --- /dev/null +++ b/code/test/test_feedback_protocol.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Pure numerical contracts for matched H3 feedback and packet semantics.""" + +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 core.feedback_protocol import ( # noqa: E402 + ForwardPacket, + MapPolicy, + MapRegistry, + MapSnapshot, + MappingKind, + PacketRejectReason, + PacketState, + ReturnPacket, + map_return_feedback, + normalized_actual_power_mismatch, +) +from core.network_emulator import ( # noqa: E402 + DeterministicChannel, + NetworkTraceEntry, + PacketReceiver, +) + + +class FeedbackProtocolTest(unittest.TestCase): + def make_packet(self, seq=3, map_id=1): + wrench = np.array([1.0, -2.0, 0.5, 0.2, -0.3, 0.1]) + Js = np.arange(42, dtype=float).reshape(6, 7) / 50.0 + return ReturnPacket( + seq=seq, + source_index=10 + seq, + source_time=0.02 * seq, + echoed_map_id=map_id, + residual=np.linspace(-1.0, 1.0, 7), + wrench=wrench, + js_t_wrench=Js.T @ wrench, + qd_slave_actual=np.linspace(0.1, 0.7, 7), + ) + + def test_matched_conditions_share_one_input_hash(self): + registry = MapRegistry() + A = np.eye(7) + registry.add(MapSnapshot(1, 2, 0.04, A)) + packet = self.make_packet() + Jm = np.arange(42, dtype=float).reshape(6, 7) / 70.0 + + differential = map_return_feedback( + kind=MappingKind.MATCHED_DIFFERENTIAL_WRENCH, + packet=packet, + master_jacobian=Jm, + maps=registry, + ) + direct = map_return_feedback( + kind=MappingKind.DIRECT_MASTER_JACOBIAN, + packet=packet, + master_jacobian=Jm, + maps=registry, + ) + + self.assertEqual( + differential.matched_input_hash, direct.matched_input_hash + ) + np.testing.assert_allclose( + differential.tau_master_raw, A.T @ packet.js_t_wrench + ) + np.testing.assert_allclose( + direct.tau_master_raw, Jm.T @ packet.wrench + ) + + def test_source_stamped_policy_uses_echoed_map(self): + registry = MapRegistry() + registry.add(MapSnapshot(1, 0, 0.0, np.eye(7))) + registry.add(MapSnapshot(2, 1, 0.02, 2.0 * np.eye(7))) + packet = self.make_packet(map_id=1) + Jm = np.zeros((6, 7)) + source = map_return_feedback( + kind=MappingKind.MATCHED_DIFFERENTIAL_WRENCH, + packet=packet, + master_jacobian=Jm, + maps=registry, + map_policy=MapPolicy.SOURCE_STAMPED, + ) + current = map_return_feedback( + kind=MappingKind.MATCHED_DIFFERENTIAL_WRENCH, + packet=packet, + master_jacobian=Jm, + maps=registry, + map_policy=MapPolicy.CURRENT, + ) + self.assertEqual(source.selected_map_id, 1) + self.assertEqual(current.selected_map_id, 2) + np.testing.assert_allclose( + current.tau_master_raw, 2.0 * source.tau_master_raw + ) + + def test_power_endpoint_is_zero_for_exact_source_alignment(self): + tau_s = np.array([[1.0, 2.0], [-1.0, 0.5]]) + qd_s = np.array([[0.3, -0.1], [0.2, 0.4]]) + tau_m = tau_s.copy() + qd_m = qd_s.copy() + mismatch = normalized_actual_power_mismatch( + tau_m, qd_m, tau_s, qd_s, np.array([0.01, 0.02]) + ) + self.assertAlmostEqual(mismatch, 0.0) + + def test_duplicate_out_of_order_and_timeout_are_explicit(self): + packets = [ + ForwardPacket(i, i, i * 0.01, i, np.zeros(2), np.zeros(2)) + for i in range(3) + ] + # seq 0 arrives after seq 1, and seq 1 is duplicated. + channel = DeterministicChannel( + [ + NetworkTraceEntry(0, 0.04), + NetworkTraceEntry(1, 0.01, duplicate=True), + NetworkTraceEntry(2, 0.02), + ] + ) + for packet in packets: + channel.send(packet, packet.source_time) + receiver = PacketReceiver(timeout_s=0.05) + receptions = [] + for delivery in channel.poll(0.05): + receptions.append(receiver.accept(delivery)) + + self.assertTrue(receptions[0].accepted) + self.assertIn( + PacketRejectReason.DUPLICATE_OR_STALE, + [item.reason for item in receptions if not item.accepted], + ) + held = receiver.sample(0.051) + self.assertIn(held.state, (PacketState.ACTIVE, PacketState.RECOVERING)) + self.assertEqual(receiver.sample(0.2).state, PacketState.TIMED_OUT) + + +if __name__ == "__main__": + unittest.main() diff --git a/code/test/test_haptic_applied_port.py b/code/test/test_haptic_applied_port.py new file mode 100644 index 0000000..e34cbec --- /dev/null +++ b/code/test/test_haptic_applied_port.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +"""Unit tests for final applied-port haptic energy supervision. + +These tests use no URDF, simulator, or Pinocchio model. They exercise the +energy supervisor and the renderer's nominal shaping order directly. +""" + +from __future__ import annotations + +import os +import sys +import unittest + +import numpy as np + +CODE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if CODE_DIR not in sys.path: + sys.path.insert(0, CODE_DIR) + +from core.haptic_render import ( # noqa: E402 + AppliedPortDiagnostics, + AppliedPortEnergySupervisor, + HapticRenderer, + TankParams, +) + + +def make_supervisor(E0: float = 3.0, + E_min: float = 1.0, + E_max: float = 5.0) -> AppliedPortEnergySupervisor: + return AppliedPortEnergySupervisor( + TankParams( + E_min=E_min, + E_max=E_max, + alpha_floor=0.0, + alpha_ceil=1.0, + ), + E0=E0, + ) + + +def make_renderer_state(size: int, + *, + E0: float = 3.0, + feedback_strength: float = 1.0, + torque_limit=None, + torque_rate_limit=None) -> HapticRenderer: + """Construct the pure torque pipeline without a Pinocchio dependency.""" + renderer = object.__new__(HapticRenderer) + renderer.feedback_strength = float(feedback_strength) + renderer.tank = make_supervisor(E0=E0) + renderer.tau_alpha = 1.0 + renderer.torque_limit = torque_limit + renderer.torque_rate_limit = torque_rate_limit + renderer._tau_fb_state = np.zeros(size, dtype=float) + renderer._tau_applied_prev = np.zeros(size, dtype=float) + return renderer + + +class FakeChestJacobian: + def __init__(self, jacobian: np.ndarray): + self.jacobian = np.asarray(jacobian, dtype=float) + + def chest_jacobian(self, _q_m, _qd_m): + return self.jacobian.copy() + + +class AppliedPortEnergySupervisorTest(unittest.TestCase): + def test_low_level_apply_returns_complete_diagnostics(self): + supervisor = make_supervisor(E0=3.0) + candidate = np.array([2.0, -1.0]) + tau_app, diagnostics = supervisor.apply( + candidate, + np.array([0.5, 1.0]), + dt=0.25, + ) + + self.assertIsInstance(diagnostics, AppliedPortDiagnostics) + np.testing.assert_allclose(diagnostics.tau_candidate, candidate) + np.testing.assert_allclose(diagnostics.tau_applied, tau_app) + self.assertAlmostEqual(diagnostics.rho, 1.0) + self.assertAlmostEqual(diagnostics.E_before, 3.0) + self.assertAlmostEqual(diagnostics.E_preclip, 3.0) + self.assertAlmostEqual(diagnostics.candidate_power, 0.0) + self.assertAlmostEqual(diagnostics.power, 0.0) + self.assertAlmostEqual(diagnostics.E_after, 3.0) + self.assertFalse(diagnostics.fail_safe_active) + self.assertIs(supervisor.last_diagnostics, diagnostics) + + def test_positive_power_discharges_once(self): + supervisor = make_supervisor(E0=3.0) + alpha, tau_app = supervisor.project_and_account( + np.array([2.0, 0.0]), + np.array([1.0, 0.0]), + dt=0.25, + ) + + self.assertAlmostEqual(alpha, 1.0) + np.testing.assert_allclose(tau_app, [2.0, 0.0]) + self.assertAlmostEqual(supervisor.last_power, 2.0) + self.assertAlmostEqual(supervisor.E, 2.5) + + def test_negative_power_charges_once(self): + supervisor = make_supervisor(E0=3.0) + alpha, tau_app = supervisor.project_and_account( + np.array([-2.0, 1.0]), + np.array([1.0, 0.0]), + dt=0.25, + ) + + self.assertAlmostEqual(alpha, 1.0) + np.testing.assert_allclose(tau_app, [-2.0, 1.0]) + self.assertAlmostEqual(supervisor.last_power, -2.0) + self.assertAlmostEqual(supervisor.E, 3.5) + + def test_zero_power_does_not_change_energy_or_torque(self): + supervisor = make_supervisor(E0=3.0) + candidate = np.array([2.0, -1.0]) + alpha, tau_app = supervisor.project_and_account( + candidate, + np.zeros(2), + dt=0.25, + ) + + self.assertAlmostEqual(alpha, 1.0) + np.testing.assert_allclose(tau_app, candidate) + self.assertAlmostEqual(supervisor.last_power, 0.0) + self.assertAlmostEqual(supervisor.E, 3.0) + + def test_energy_projection_hits_E_min_exactly(self): + supervisor = make_supervisor(E0=1.25) + alpha, tau_app = supervisor.project_and_account( + np.array([2.0]), + np.array([1.0]), + dt=0.5, + ) + + self.assertAlmostEqual(alpha, 0.25) + np.testing.assert_allclose(tau_app, [0.5]) + self.assertAlmostEqual(supervisor.last_power, 0.5) + self.assertAlmostEqual(supervisor.E, 1.0) + + alpha_at_min, tau_at_min = supervisor.project_and_account( + np.array([4.0]), + np.array([1.0]), + dt=0.1, + ) + self.assertAlmostEqual(alpha_at_min, 0.0) + np.testing.assert_allclose(tau_at_min, [0.0]) + self.assertAlmostEqual(supervisor.E, 1.0) + + def test_nonpositive_dt_uses_zero_fail_safe_without_accounting(self): + supervisor = make_supervisor(E0=3.0) + alpha, tau_app = supervisor.project_and_account( + np.array([2.0]), + np.array([1.0]), + dt=0.0, + ) + + self.assertAlmostEqual(alpha, 0.0) + np.testing.assert_allclose(tau_app, [0.0]) + self.assertTrue(supervisor.fail_safe_active) + self.assertAlmostEqual(supervisor.E, 3.0) + + +class HapticRendererPipelineTest(unittest.TestCase): + def test_external_supervisor_candidate_requires_explicit_commit(self): + renderer = make_renderer_state( + 1, + torque_limit=2.0, + torque_rate_limit=10.0, + ) + candidate, valid = renderer.shape_mapped_reaction_candidate( + np.array([-10.0]), + qd_m=np.zeros(1), + dt=0.1, + ) + self.assertTrue(valid) + np.testing.assert_allclose(candidate, [-1.0]) + np.testing.assert_allclose(renderer._tau_applied_prev, [0.0]) + renderer.commit_applied(np.array([0.25])) + np.testing.assert_allclose(renderer._tau_applied_prev, [0.25]) + + def test_generalized_reaction_keeps_environment_on_device_sign(self): + renderer = make_renderer_state(2) + reaction = np.array([-2.0, 1.0]) + + tau_app, rho = renderer.render_mapped_reaction( + reaction, + qd_m=np.zeros(2), + dt=0.01, + ) + + np.testing.assert_allclose(tau_app, reaction) + self.assertAlmostEqual(rho, 1.0) + + def test_direct_baseline_is_exact_J_transpose_F(self): + renderer = object.__new__(HapticRenderer) + jacobian = np.arange(12, dtype=float).reshape(6, 2) / 10.0 + renderer.CJ_master = FakeChestJacobian(jacobian) + wrench = np.array([1.0, -2.0, 0.5, 3.0, -1.0, 2.0]) + + tau_direct = renderer.direct_from_CF( + q_m=np.zeros(2), + qd_m=np.zeros(2), + CF_int_slave_C=wrench, + ) + + np.testing.assert_allclose(tau_direct, jacobian.T @ wrench) + + def test_rate_limit_precedes_energy_projection(self): + renderer = make_renderer_state( + 2, + torque_rate_limit=np.array([2.0, 1.0]), + ) + + tau_step_1, alpha_1 = renderer._shape_and_supervise( + tau_direct=np.array([-10.0, 10.0]), + qd_m=np.zeros(2), + dt=0.1, + ) + tau_step_2, alpha_2 = renderer._shape_and_supervise( + tau_direct=np.array([-10.0, 10.0]), + qd_m=np.zeros(2), + dt=0.1, + ) + + np.testing.assert_allclose(tau_step_1, [0.2, -0.1]) + np.testing.assert_allclose(tau_step_2, [0.4, -0.2]) + self.assertAlmostEqual(alpha_1, 1.0) + self.assertAlmostEqual(alpha_2, 1.0) + self.assertTrue(renderer.last_rate_limit_active) + self.assertFalse(renderer.last_torque_saturation_active) + np.testing.assert_allclose( + renderer.tank.last_tau_candidate, tau_step_2 + ) + + def test_saturation_precedes_energy_projection(self): + renderer = make_renderer_state( + 2, + torque_limit=np.array([3.0, 4.0]), + ) + + tau_app, alpha = renderer._shape_and_supervise( + tau_direct=np.array([-10.0, 10.0]), + qd_m=np.zeros(2), + dt=0.1, + ) + + self.assertAlmostEqual(alpha, 1.0) + np.testing.assert_allclose(tau_app, [3.0, -4.0]) + self.assertFalse(renderer.last_rate_limit_active) + self.assertTrue(renderer.last_torque_saturation_active) + np.testing.assert_allclose( + renderer.tank.last_tau_candidate, [3.0, -4.0] + ) + + def test_projection_is_last_and_uses_saturated_candidate(self): + renderer = make_renderer_state( + 1, + E0=1.1, + torque_limit=2.0, + ) + + tau_app, alpha = renderer._shape_and_supervise( + tau_direct=np.array([-10.0]), + qd_m=np.array([1.0]), + dt=1.0, + ) + + # Direct -> nominal +10 -> saturation +2 -> energy projection +0.1. + self.assertAlmostEqual(alpha, 0.05) + np.testing.assert_allclose(renderer.tank.last_tau_candidate, [2.0]) + np.testing.assert_allclose(renderer.tank.last_tau_applied, [0.1]) + np.testing.assert_allclose(tau_app, renderer.tank.last_tau_applied) + np.testing.assert_allclose(renderer._tau_applied_prev, tau_app) + self.assertAlmostEqual(renderer.tank.last_power, float(tau_app[0])) + self.assertAlmostEqual(renderer.tank.E, 1.0) + + def test_stepwise_offline_energy_reconstruction_matches_exactly(self): + renderer = make_renderer_state( + 2, + E0=2.5, + feedback_strength=1.5, + torque_limit=np.array([2.0, 1.5]), + torque_rate_limit=np.array([4.0, 3.0]), + ) + dt = 0.1 + direct_sequence = [ + np.array([-2.0, 1.0]), + np.array([-4.0, 2.0]), + np.array([1.0, -2.0]), + np.array([0.0, 0.0]), + np.array([-5.0, -5.0]), + np.array([2.0, 2.0]), + ] + velocity_sequence = [ + np.array([1.0, 0.5]), + np.array([0.7, -0.4]), + np.array([1.0, 1.0]), + np.zeros(2), + np.array([-0.6, -0.2]), + np.array([0.5, -0.8]), + ] + + energy_offline = 2.5 + for tau_direct, qd_m in zip(direct_sequence, velocity_sequence): + tau_app, _ = renderer._shape_and_supervise( + tau_direct=tau_direct, + qd_m=qd_m, + dt=dt, + ) + applied_power = float(np.dot(tau_app, qd_m)) + energy_offline = float(np.clip( + energy_offline - applied_power * dt, + renderer.tank.tp.E_min, + renderer.tank.tp.E_max, + )) + + np.testing.assert_allclose( + renderer.tank.last_tau_applied, tau_app + ) + self.assertAlmostEqual(renderer.tank.last_power, applied_power) + self.assertAlmostEqual(renderer.tank.E, energy_offline, places=14) + + +if __name__ == "__main__": + unittest.main() diff --git a/code/test/test_haptic_render.py b/code/test/test_haptic_render.py new file mode 100644 index 0000000..e0f4fc9 --- /dev/null +++ b/code/test/test_haptic_render.py @@ -0,0 +1,309 @@ +# -*- coding: utf-8 -*- +""" +测试脚本:验证 InteractionEstimator + HapticRenderer 的正确性, +并绘制论文中使用的误差和能量罐曲线。 + +运行: + python test_haptic_render.py + +依赖: + - pinocchio + - numpy + - matplotlib + - omegaconf +""" + +import numpy as np +import pinocchio as pin +from omegaconf import OmegaConf +import matplotlib.pyplot as plt + +import os, sys +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +from core.interaction_estimater import InteractionEstimator +from core.haptic_render import HapticRenderer, TankParams + + +def moving_average(x, window: int = 7): + """简单滑动平均,用于平滑 alpha 等曲线。""" + if window <= 1: + return x + kernel = np.ones(window, dtype=float) / float(window) + # 使用 same 保持长度一致 + return np.convolve(x, kernel, mode="same") + + +def build_model_with_frames(): + """ + 备用:构造一个 6-DoF 示例机械臂,并添加: + - chest_link: 挂在关节1 + - ee_link: 挂在末端关节 + 目前脚本直接从 URDF 加载,不一定用得到。 + """ + model = pin.buildSampleModelManipulator() + chest_joint_id = 1 + chest_frame_name = "chest_link" + model.addFrame(pin.Frame( + chest_frame_name, + chest_joint_id, + chest_joint_id, + pin.SE3.Identity(), + pin.FrameType.OP_FRAME + )) + ee_joint_id = model.njoints - 1 + ee_frame_name = "ee_link" + model.addFrame(pin.Frame( + ee_frame_name, + ee_joint_id, + ee_joint_id, + pin.SE3.Identity(), + pin.FrameType.OP_FRAME + )) + return model, chest_frame_name, ee_frame_name + + +def main(): + conf = OmegaConf.load("./config/config.yaml") + np.random.seed(0) + + # 1) 构造主/从模型(这里用真实 URDF) + master_model, _, _ = pin.buildModelsFromUrdf(str(conf.master_urdf)) + slave_model, _, _ = pin.buildModelsFromUrdf(str(conf.slave_urdf)) + + # 从端交互估计器 + est_slave = InteractionEstimator( + slave_model, + chest_frame_name=conf.s_base_frame, + ee_frame_name=conf.s_ee_frame, + lambda_damp=conf.interaction_est.lambda_damp, + ) + + # 主端渲染器 + renderer = HapticRenderer( + master_model, + chest_frame_name=conf.m_base_frame, + ee_frame_name=conf.m_ee_frame, + feedback_strength=conf.haptic_render.feedback_strength, + E_init=conf.haptic_render.E_init, + E_max=conf.haptic_render.E_max, + alpha_floor=conf.haptic_render.alpha_floor, + alpha_ceil=conf.haptic_render.alpha_ceil, + E0=conf.haptic_render.E0, + ) + + nq_s, nv_s = slave_model.nq, slave_model.nv + nq_m, nv_m = master_model.nq, master_model.nv + + dt = 0.002 + n_steps = 500 # 足够画出平滑曲线 + + print("========== TEST START ==========") + print(f"slave nq={nq_s}, master nq={nq_m}, dt={dt}s, steps={n_steps}") + print("--------------------------------") + + # -------- 统计量 / 曲线数据 -------- + times = [] + err_tau_int_hist = [] + err_CF_hist = [] + err_tau_fb_hist = [] + E_hist = [] + alpha_hist = [] + + max_err_tau_int = 0.0 + max_err_CF = 0.0 + max_err_tau_fb = 0.0 + + for k in range(n_steps): + t = k * dt + times.append(t) + + # 2) 随机生成从端状态(可改成真实记录或特定轨迹) + q_s = 0.2 * (np.random.rand(nq_s) - 0.5) # [-0.1,0.1] + qd_s = 0.1 * (np.random.rand(nv_s) - 0.5) + qdd_s = np.zeros_like(qd_s) + + # 从端模型扭矩(M qdd + C qd + g) + tau_model_s = est_slave._tau_model(q_s, qd_s, qdd_s, tau_ff_fric=None) + + # 从端胸腔雅可比 C J_s + CJ_s = est_slave._chest_jacobian(q_s, qd_s) # 6 x nv_s + + # 3) 人为构造“真实”交互扳手 C F_true + # 也可以改成随时间变化的模式,例如正弦。 + CF_true = np.array([10.0, 0.0, 0.0, # 10 N 沿 Cx + 0.0, 0.0, 0.0]) + + # 真 · 交互关节力矩 + tau_int_true = CJ_s.T @ CF_true + + # 构造测量力矩:tau_meas = tau_model + tau_int_true + tau_meas_s = tau_model_s + tau_int_true + + # 4) 调用 InteractionEstimator 估计 + tau_int_est, CF_int_est, CJ_s_est = est_slave.estimate( + q_s, qd_s, qdd_s, + tau_meas=tau_meas_s, + tau_ff_fric=None + ) + + # 从端末端在 C 系的速度扭量(用于能量罐功率) + V_slave_C = CJ_s @ qd_s + + # 5) 主端状态(此处用从端状态代替,仅做算法验证) + q_m = q_s.copy() + qd_m = qd_s.copy() + + tau_fb_m_est, alpha = renderer.render_from_CF( + q_m, qd_m, + CF_int_slave_C=CF_int_est, + V_slave_C=V_slave_C, + dt=dt + ) + + # 主端胸腔雅可比,用于构造“真 · 反馈扭矩”(不经过能量罐) + CJ_m = renderer.CJ_master.chest_jacobian(q_m, qd_m) + tau_fb_m_true = CJ_m.T @ CF_true + + # -------- 误差计算 -------- + err_tau_int = np.linalg.norm(tau_int_est - tau_int_true) + err_CF = np.linalg.norm(CF_int_est - CF_true) + err_tau_fb = np.linalg.norm(tau_fb_m_est - tau_fb_m_true) + + err_tau_int_hist.append(err_tau_int) + err_CF_hist.append(err_CF) + err_tau_fb_hist.append(err_tau_fb) + + max_err_tau_int = max(max_err_tau_int, err_tau_int) + max_err_CF = max(max_err_CF, err_CF) + max_err_tau_fb = max(max_err_tau_fb, err_tau_fb) + + # 能量罐状态 + E_hist.append(renderer.tank.E) + alpha_hist.append(alpha) + + # ---- 每隔若干步打印一次 ---- + if k % 100 == 0: + print(f"\n--- Step {k} (t = {t:.3f} s) ---") + print(f"‣ ||tau_int_true|| = {np.linalg.norm(tau_int_true):.4e}") + print(f" ||tau_int_est - tau_int_true|| = {err_tau_int:.4e}") + print(f"‣ ||CF_true|| = {np.linalg.norm(CF_true):.4e}") + print(f" ||CF_int_est - CF_true|| = {err_CF:.4e}") + print(f"‣ ||tau_fb_m_true|| = {np.linalg.norm(tau_fb_m_true):.4e}") + print(f" ||tau_fb_m_est - tau_fb_m_true|| = {err_tau_fb:.4e}") + print(f" Energy tank: E = {renderer.tank.E:.4f}, alpha = {alpha:.4f}") + + # ---------- 数值结果摘要 ---------- + print("\n========== SUMMARY ==========") + print(f"max ||tau_int_est - tau_int_true|| = {max_err_tau_int:.4e}") + print(f"max ||CF_int_est - CF_true|| = {max_err_CF:.4e}") + print(f"max ||tau_fb_est - tau_fb_true|| = {max_err_tau_fb:.4e}") + + tol_tau_int = 1e-3 + tol_CF = 1e-3 + tol_tau_fb = 1e-3 + if max_err_tau_int < tol_tau_int and max_err_CF < tol_CF and max_err_tau_fb < tol_tau_fb: + print("[PASS] 所有误差均在容许范围内。") + else: + print("[WARN] 误差超出阈值,请检查算法或考虑调小阻尼 lambda_damp。") + + # ===================================================== + # 绘图部分(适合论文呈现) + # ===================================================== + + times = np.array(times) + err_tau_int_hist = np.array(err_tau_int_hist) + err_CF_hist = np.array(err_CF_hist) + err_tau_fb_hist = np.array(err_tau_fb_hist) + E_hist = np.array(E_hist) + alpha_hist = np.array(alpha_hist) + + # 对 alpha 做轻微平滑(论文图更清晰) + alpha_smooth = moving_average(alpha_hist, window=7) + + # ------------------ 误差子图(3 个独立子图) ------------------ + fig_err, (ax1, ax2, ax3) = plt.subplots( + 3, 1, sharex=True, figsize=(6, 7) + ) + + # (a) Interaction torque estimation error + ax1.plot( + times, + err_tau_int_hist, + color="C0", + label=r"$\|\tau_{\mathrm{int}}^{\mathrm{est}}-\tau_{\mathrm{int}}^{\mathrm{true}}\|$", + ) + ax1.set_title("(a) Interaction torque estimation error") + ax1.set_ylabel(r"Error norm $\|\cdot\|$") + ax1.set_yscale("log") + ax1.grid(True, linestyle="--", linewidth=0.5) + ax1.legend(loc="lower right") + + # (b) Interaction wrench estimation error + ax2.plot( + times, + err_CF_hist, + color="C1", + label=r"$\|{}^{C}F_{\mathrm{int}}^{\mathrm{est}}-{}^{C}F_{\mathrm{true}}\|$", + ) + ax2.set_title("(b) Interaction wrench estimation error") + ax2.set_ylabel(r"Error norm $\|\cdot\|$") + ax2.set_yscale("log") + ax2.grid(True, linestyle="--", linewidth=0.5) + ax2.legend(loc="lower right") + + # (c) Feedback torque rendering error + ax3.plot( + times, + err_tau_fb_hist, + color="C2", + label=r"$\|\tau_{\mathrm{fb}}^{\mathrm{est}}-\tau_{\mathrm{fb}}^{\mathrm{true}}\|$", + ) + ax3.set_title("(c) Haptic feedback torque error") + ax3.set_xlabel(r"Time $t$ [s]") + ax3.set_ylabel(r"Error norm $\|\cdot\|$") + ax3.set_yscale("log") + ax3.grid(True, linestyle="--", linewidth=0.5) + ax3.legend(loc="lower right") + + fig_err.tight_layout() + # fig_err.savefig("figs/haptic_errors_3subplots.png", dpi=300, bbox_inches="tight") + + # ------------------ 能量罐 + 缩放系数 ------------------ + fig_tank, ax1_t = plt.subplots(figsize=(6, 4)) + + ax1_t.set_title("Energy tank dynamics and scaling factor") + + l1 = ax1_t.plot( + times, + E_hist, + color="C0", + label=r"Tank energy $E[k]$", + ) + ax1_t.set_xlabel(r"Time $t$ [s]") + ax1_t.set_ylabel(r"Energy $E$ [J]") + ax1_t.grid(True, linestyle="--", linewidth=0.5) + + ax2_t = ax1_t.twinx() + l2 = ax2_t.plot( + times, + alpha_smooth, + color="C1", + linestyle="--", + label=r"Scaling $\alpha[k]$ (smoothed)", + ) + ax2_t.set_ylabel(r"Scaling factor $\alpha$") + + # Legend 合并并右下角防遮挡 + lines = l1 + l2 + labels = [l.get_label() for l in lines] + ax1_t.legend(lines, labels, loc="lower right") + + fig_tank.tight_layout() + # fig_tank.savefig("figs/haptic_tank_smooth.png", dpi=300, bbox_inches="tight") + + plt.show() + + + +if __name__ == "__main__": + main() diff --git a/code/test/test_int_estimater.py b/code/test/test_int_estimater.py new file mode 100644 index 0000000..ffbb752 --- /dev/null +++ b/code/test/test_int_estimater.py @@ -0,0 +1,374 @@ +# -*- coding: utf-8 -*- +import os, sys, numpy as np +import pinocchio as pin +from omegaconf import OmegaConf + + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +from core.interaction_estimater import InteractionEstimator + + +# —— 误差函数(更鲁棒的相对误差 + 绝对误差)—— +def rel_err(a, b, eps=1e-9): + """对称式相对误差:‖a-b‖ / max(0.5(‖a‖+‖b‖), eps)""" + na, nb = np.linalg.norm(a), np.linalg.norm(b) + denom = max(0.5 * (na + nb), eps) + return np.linalg.norm(a - b) / denom + + +def abs_err(a, b): + """绝对误差:‖a-b‖""" + return np.linalg.norm(a - b) + + +# —— 固定范数的扳手采样(方向随机)—— +def sample_wrench_fixed_norm(F_lin_norm=10.0, F_ang_norm=2.0, rng=None): + """ + 在线性部分和角部分分别固定范数,方向随机的 6D 扳手采样 + """ + rng = np.random.default_rng() if rng is None else rng + v = rng.standard_normal(6) + f = v[:3] + m = v[3:] + f = f / (np.linalg.norm(f) + 1e-12) * F_lin_norm + m = m / (np.linalg.norm(m) + 1e-12) * F_ang_norm + return np.hstack([f, m]) + + +def load_model(urdf_path: str) -> pin.Model: + assert os.path.exists(urdf_path), f"URDF not found: {urdf_path}" + model = pin.buildModelFromUrdf(urdf_path) + print(f"[URDF] model loaded: nq={model.nq}, nv={model.nv}, njoints={model.njoints}") + return model + + +def oracle_test(model, + chest_frame: str = 'slave_shoulder', + ee_frame: str = 'slave_ee', + n_trials: int = 300, + noise_tau_std: float = 0.0, + lambda_damp: float = 1e-6, + seed: int = 11, + sv_min_skip: float = 1e-6): + """ + 使用 InteractionEstimator 的“理想/带噪声”仿真: + - 通过动力学模型生成 tau_model + - 叠加胸腔系扳手 CJ^T F_true 和测量噪声得到 tau_meas + - 调用 est.estimate 恢复 tau_int, F_est + - 统计相对/绝对误差的均值、方差、p90 + """ + rng = np.random.default_rng(seed) + est = InteractionEstimator(model, chest_frame, ee_frame, lambda_damp=lambda_damp) + data = model.createData() + + errs_rel, errs_abs = [], [] + kept = 0 + for k in range(n_trials): + # 随机状态 + q = pin.randomConfiguration(model) + qd = np.random.randn(model.nv) * 0.1 + qdd = np.random.randn(model.nv) * 0.2 + + # 动力学力矩 tau_model + M = pin.crba(model, data, q) + M = (M + M.T) - np.diag(M.diagonal()) + nle = pin.nonLinearEffects(model, data, q, qd) + g = pin.computeGeneralizedGravity(model, data, q) + Cqd = nle - g + tau_model = M @ qdd + Cqd + g + + # 胸腔系雅可比 CJ + CJ = est._chest_jacobian(q, qd) # 6 x nv + svals = np.linalg.svd(CJ, compute_uv=False) + if svals[-1] < sv_min_skip: + # 跳过奇异邻域样本 + continue + + # 固定范数扳手 + CF_true = sample_wrench_fixed_norm(10.0, 2.0, rng=rng) + + # 合成“测得力矩” tau_meas = tau_model + CJ^T F_true + noise + tau_meas = tau_model + CJ.T @ CF_true + np.random.randn(model.nv) * noise_tau_std + + # 利用 estimator 做估计 + tau_int, CF_est, CJ_check = est.estimate(q, qd, qdd, tau_meas) + + # 误差统计 + errs_rel.append(rel_err(CF_est, CF_true)) + errs_abs.append(abs_err(CF_est, CF_true)) + kept += 1 + + # 一致性:回投误差 & 功率一致性(抽样打印) + if k % 50 == 0: + back_err = rel_err(CJ_check.T @ CF_est, tau_int) + v_C = CJ @ qd + Pq = float(tau_int @ qd) + Pv = float(CF_true @ v_C) + print( + f"[{k:03d}] rel={errs_rel[-1]:.3e}, abs={errs_abs[-1]:.3e}, " + f"back={back_err:.3e}, power={abs(Pq - Pv):.3e}, sv_min={svals[-1]:.2e}" + ) + + errs_rel = np.array(errs_rel) + errs_abs = np.array(errs_abs) + if kept == 0: + print("[Oracle] all samples skipped by sv_min filter; try smaller sv_min_skip.") + # 返回空 summary + return dict( + errs_rel=errs_rel, + errs_abs=errs_abs, + kept=0, + mean_rel=np.nan, + std_rel=np.nan, + p90_rel=np.nan, + mean_abs=np.nan, + std_abs=np.nan, + p90_abs=np.nan, + ) + + mean_rel = float(errs_rel.mean()) + std_rel = float(errs_rel.std()) + p90_rel = float(np.percentile(errs_rel, 90)) + + mean_abs = float(errs_abs.mean()) + std_abs = float(errs_abs.std()) + p90_abs = float(np.percentile(errs_abs, 90)) + + print( + f"[Oracle REL] mean={mean_rel:.3e}, std={std_rel:.3e}, " + f"median={np.median(errs_rel):.3e}, p90={p90_rel:.3e}" + ) + print( + f"[Oracle ABS] mean={mean_abs:.3e}, std={std_abs:.3e}, " + f"median={np.median(errs_abs):.3e}, p90={p90_abs:.3e}" + ) + + summary = dict( + errs_rel=errs_rel, + errs_abs=errs_abs, + kept=kept, + mean_rel=mean_rel, + std_rel=std_rel, + p90_rel=p90_rel, + mean_abs=mean_abs, + std_abs=std_abs, + p90_abs=p90_abs, + ) + return summary + + +def oracle_test_baseline(model, + chest_frame: str = 'slave_shoulder', + ee_frame: str = 'slave_ee', + n_trials: int = 300, + noise_tau_std: float = 0.05, + seed: int = 17, + sv_min_skip: float = 1e-6): + """ + 基线:不调用 InteractionEstimator 的残差/阻尼公式, + 直接用未阻尼伪逆解胸腔扳手: + + tau_int_true = tau_meas - tau_model + F_est = (CJ CJ^T)^{-1} CJ tau_int_true + + 用来对比在同样噪声/配置下的数值稳定性。 + """ + rng = np.random.default_rng(seed) + est_tmp = InteractionEstimator(model, chest_frame, ee_frame, lambda_damp=0.0) + data = model.createData() + + errs_rel, errs_abs = [], [] + kept = 0 + for k in range(n_trials): + # 随机状态 + q = pin.randomConfiguration(model) + qd = np.random.randn(model.nv) * 0.1 + qdd = np.random.randn(model.nv) * 0.2 + + # 动力学力矩 tau_model + M = pin.crba(model, data, q) + M = (M + M.T) - np.diag(M.diagonal()) + nle = pin.nonLinearEffects(model, data, q, qd) + g = pin.computeGeneralizedGravity(model, data, q) + Cqd = nle - g + tau_model = M @ qdd + Cqd + g + + # 胸腔雅可比 CJ + CJ = est_tmp._chest_jacobian(q, qd) + svals = np.linalg.svd(CJ, compute_uv=False) + if svals[-1] < sv_min_skip: + continue + + # 固定范数扳手(与 oracle_test 相同范数) + CF_true = sample_wrench_fixed_norm(10.0, 2.0, rng=rng) + + # 测得力矩(含噪声) + tau_meas = tau_model + CJ.T @ CF_true + np.random.randn(model.nv) * noise_tau_std + + # “真实”关节残差力矩(在仿真中我们知道 tau_model) + tau_int_true = tau_meas - tau_model + + # 未阻尼伪逆 (CJ CJ^T)^{-1} CJ tau_int_true + JJt = CJ @ CJ.T + try: + F_est = np.linalg.solve(JJt, CJ @ tau_int_true) + except np.linalg.LinAlgError: + # 数值奇异,跳过该样本 + continue + + errs_rel.append(rel_err(F_est, CF_true)) + errs_abs.append(abs_err(F_est, CF_true)) + kept += 1 + + if k % 50 == 0: + back_err = rel_err(CJ.T @ F_est, tau_int_true) + print( + f"[BASE {k:03d}] rel={errs_rel[-1]:.3e}, abs={errs_abs[-1]:.3e}, " + f"back={back_err:.3e}, sv_min={svals[-1]:.2e}" + ) + + errs_rel = np.array(errs_rel) + errs_abs = np.array(errs_abs) + if kept == 0: + print("[Baseline] all samples skipped by sv_min filter; try smaller sv_min_skip.") + return dict( + errs_rel=errs_rel, + errs_abs=errs_abs, + kept=0, + mean_rel=np.nan, + std_rel=np.nan, + p90_rel=np.nan, + mean_abs=np.nan, + std_abs=np.nan, + p90_abs=np.nan, + ) + + mean_rel = float(errs_rel.mean()) + std_rel = float(errs_rel.std()) + p90_rel = float(np.percentile(errs_rel, 90)) + + mean_abs = float(errs_abs.mean()) + std_abs = float(errs_abs.std()) + p90_abs = float(np.percentile(errs_abs, 90)) + + print( + f"[BASELINE REL] mean={mean_rel:.3e}, std={std_rel:.3e}, " + f"median={np.median(errs_rel):.3e}, p90={p90_rel:.3e}" + ) + print( + f"[BASELINE ABS] mean={mean_abs:.3e}, std={std_abs:.3e}, " + f"median={np.median(errs_abs):.3e}, p90={p90_abs:.3e}" + ) + + summary = dict( + errs_rel=errs_rel, + errs_abs=errs_abs, + kept=kept, + mean_rel=mean_rel, + std_rel=std_rel, + p90_rel=p90_rel, + mean_abs=mean_abs, + std_abs=std_abs, + p90_abs=p90_abs, + ) + return summary + + +def consistency_sweep(model, + chest_frame: str = 'slave_shoulder', + ee_frame: str = 'slave_ee'): + """ + 对不同阻尼系数 lambda 进行一个小 sweep,查看: + - 估计扳手范数 + - 回投误差 + - cond(JJ^T) + """ + est_ref = InteractionEstimator(model, chest_frame, ee_frame, lambda_damp=1e-3) + q = pin.randomConfiguration(model) + qd = np.random.randn(model.nv) * 0.05 + qdd = np.zeros(model.nv) + + CJ = est_ref._chest_jacobian(q, qd) + CF_true = np.array([5.0, -3.0, 8.0, 0.5, 0.2, -0.1]) + + M = pin.crba(model, est_ref.data, q) + M = (M + M.T) - np.diag(M.diagonal()) + nle = pin.nonLinearEffects(model, est_ref.data, q, qd) + g = pin.computeGeneralizedGravity(model, est_ref.data, q) + Cqd = nle - g + tau_model = M @ qdd + Cqd + g + tau_meas = tau_model + CJ.T @ CF_true + + lambdas = [1e-6, 3e-6, 1e-5, 3e-5, 1e-4, 3e-4, 1e-3, 3e-3, 1e-2] + print("\n[Lambda sweep] λ, ‖F_est‖, back_err, cond(JJᵀ)") + for lam in lambdas: + est = InteractionEstimator(model, chest_frame, ee_frame, lambda_damp=lam) + tau_int, CF_est, CJ_use = est.estimate(q, qd, qdd, tau_meas) + back_err = rel_err(CJ_use.T @ CF_est, tau_int) + JJt = CJ_use @ CJ_use.T + cond = np.linalg.cond(JJt) if np.linalg.matrix_rank(JJt) == 6 else np.inf + print(f"{lam:8.1e} {np.linalg.norm(CF_est):8.3f} {back_err:8.2e} {cond:8.2e}") + + +def main(): + conf = OmegaConf.load("./config/config.yaml") + urdf_path = getattr(conf, "slave_urdf", None) + model = load_model(urdf_path) + + # 注意:chest_frame 在论文中记为 C 框架,这里保持一致 + chest_frame = "slave_base" # 或 "slave_shoulder",视你的 URDF 定义而定 + ee_frame = "slave_ee" + + print("\n=== ORACLE (no noise, λ=1e-6) ===") + summary_ideal = oracle_test( + model, chest_frame, ee_frame, + n_trials=300, noise_tau_std=0.0, lambda_damp=1e-6, + seed=11, sv_min_skip=1e-6 + ) + + print("\n=== PROPOSED (tau noise 0.05 N·m, λ=1e-3) ===") + summary_proposed = oracle_test( + model, chest_frame, ee_frame, + n_trials=300, noise_tau_std=0.05, lambda_damp=1e-3, + seed=13, sv_min_skip=1e-6 + ) + + print("\n=== BASELINE (tau noise 0.05 N·m, undamped pseudoinverse) ===") + summary_baseline = oracle_test_baseline( + model, chest_frame, ee_frame, + n_trials=300, noise_tau_std=0.05, + seed=17, sv_min_skip=1e-6 + ) + + # 打印一个 markdown 风格的小表格,方便直接贴到论文 + def to_percent(x): + return 100.0 * x if x is not None and not np.isnan(x) else float("nan") + + print("\n=== SUMMARY (relative wrench error e_F) ===") + print("| Method | mean e_F [%] | std e_F [%] | p90 e_F [%] | Kept |") + print("|------------------------|-------------:|------------:|------------:|-----:|") + print( + f"| Ideal (no noise) | {to_percent(summary_ideal['mean_rel']):11.2f} | " + f"{to_percent(summary_ideal['std_rel']):11.2f} | " + f"{to_percent(summary_ideal['p90_rel']):11.2f} | " + f"{summary_ideal['kept']:4d} |" + ) + print( + f"| Proposed (damped) | {to_percent(summary_proposed['mean_rel']):11.2f} | " + f"{to_percent(summary_proposed['std_rel']):11.2f} | " + f"{to_percent(summary_proposed['p90_rel']):11.2f} | " + f"{summary_proposed['kept']:4d} |" + ) + print( + f"| Baseline (PI, undamp) | {to_percent(summary_baseline['mean_rel']):11.2f} | " + f"{to_percent(summary_baseline['std_rel']):11.2f} | " + f"{to_percent(summary_baseline['p90_rel']):11.2f} | " + f"{summary_baseline['kept']:4d} |" + ) + + # 可选:扫 lambda,看条件数/回投误差(和论文里 Fig. 6(a)(b) 对应) + consistency_sweep(model, chest_frame, ee_frame) + + +if __name__ == "__main__": + main() diff --git a/code/test/test_interaction_estimator_contract.py b/code/test/test_interaction_estimator_contract.py new file mode 100644 index 0000000..6ff2391 --- /dev/null +++ b/code/test/test_interaction_estimator_contract.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +import numpy as np +import pinocchio as pin + + +CODE_DIR = Path(__file__).resolve().parents[1] +if str(CODE_DIR) not in sys.path: + sys.path.insert(0, str(CODE_DIR)) + +from core.interaction_estimater import ( # noqa: E402 + InteractionEstimator, + checked_frame_id, + checked_joint_id, +) + + +MASTER_URDF = CODE_DIR / "config" / "master_7dof.urdf" +GENERAL_Q = np.array([0.3, -0.5, 0.4, 0.8, -0.2, 0.6, -0.4]) + + +class InteractionEstimatorContractTest(unittest.TestCase): + def setUp(self) -> None: + self.model = pin.buildModelFromUrdf(str(MASTER_URDF)) + + def test_pinocchio_not_found_sentinels_are_rejected(self) -> None: + model = self.model + self.assertLess(checked_frame_id(model, "master_ee"), model.nframes) + self.assertLess( + checked_joint_id(model, "master_elbow_flex_joint"), model.njoints + ) + + with self.assertRaisesRegex(ValueError, "Frame not found"): + InteractionEstimator(model, "missing_chest", "master_ee") + with self.assertRaisesRegex(ValueError, "Frame not found"): + InteractionEstimator(model, "master_base", "missing_ee") + with self.assertRaisesRegex(ValueError, "Movable joint not found"): + checked_joint_id(model, "missing_joint") + + def test_virtual_work_is_preserved_at_the_ee_point(self) -> None: + model = self.model + estimator = InteractionEstimator( + model, "master_forearm", "master_ee", lambda_damp=1e-4 + ) + joint_velocity = np.array([0.2, -0.1, 0.3, 0.4, -0.2, 0.1, 0.5]) + wrench_chest = np.array([8.0, -3.0, 5.0, 0.7, -0.4, 0.2]) + + jacobian_chest = estimator._chest_jacobian(GENERAL_Q, joint_velocity) + tau_external = jacobian_chest.T @ wrench_chest + twist_chest = jacobian_chest @ joint_velocity + + np.testing.assert_allclose( + tau_external @ joint_velocity, + wrench_chest @ twist_chest, + rtol=1e-13, + atol=1e-13, + ) + + def test_chest_axis_rotation_matches_lwa_jacobian(self) -> None: + model = self.model + estimator = InteractionEstimator( + model, "master_forearm", "master_ee", lambda_damp=1e-4 + ) + joint_velocity = np.array([0.1, 0.2, -0.3, 0.05, 0.4, -0.2, 0.1]) + + jacobian_chest = estimator._chest_jacobian(GENERAL_Q, joint_velocity) + jacobian_lwa = pin.computeFrameJacobian( + model, + estimator.data, + GENERAL_Q, + estimator.fid_EE, + pin.ReferenceFrame.LOCAL_WORLD_ALIGNED, + ) + rotation_world_from_chest = estimator.data.oMf[estimator.fid_C].rotation + rotation_chest_from_world = rotation_world_from_chest.T + rotation6 = estimator._rotation6(rotation_chest_from_world) + + np.testing.assert_allclose( + jacobian_chest, rotation6 @ jacobian_lwa, rtol=1e-13, atol=1e-13 + ) + + wrench_chest = np.array([4.0, -2.0, 6.0, 0.3, 0.5, -0.1]) + wrench_world = rotation6.T @ wrench_chest + np.testing.assert_allclose( + jacobian_chest.T @ wrench_chest, + jacobian_lwa.T @ wrench_world, + rtol=1e-13, + atol=1e-13, + ) + + def test_bias_corrected_ideal_wrench_is_recovered(self) -> None: + model = self.model + estimator = InteractionEstimator( + model, "master_base", "master_ee", lambda_damp=1e-7 + ) + qd = np.array([0.08, -0.04, 0.03, 0.02, -0.05, 0.06, -0.01]) + qdd = np.array([0.2, -0.1, 0.05, 0.08, -0.04, 0.03, -0.02]) + wrench_true = np.array([9.0, -4.0, 6.0, 0.8, -0.3, 0.5]) + bias_true = np.array([0.12, -0.08, 0.05, 0.03, -0.06, 0.04, -0.02]) + + jacobian = estimator._chest_jacobian(GENERAL_Q, qd) + self.assertEqual(np.linalg.matrix_rank(jacobian, tol=1e-9), 6) + tau_contact = jacobian.T @ wrench_true + tau_model = estimator._tau_model(GENERAL_Q, qd, qdd) + + calibration_delta = np.linspace(-0.01, 0.01, model.nv) + calibrated = estimator.calibrate_bias( + np.vstack( + [ + bias_true + calibration_delta, + bias_true - calibration_delta, + bias_true, + ] + ) + ) + np.testing.assert_allclose(calibrated, bias_true, atol=1e-15) + + tau_corrected, wrench_estimated, jacobian_estimated = estimator.estimate( + GENERAL_Q, qd, qdd, tau_model + bias_true + tau_contact + ) + + np.testing.assert_allclose(jacobian_estimated, jacobian, atol=1e-13) + np.testing.assert_allclose( + estimator.last_tau_residual_raw, bias_true + tau_contact, atol=1e-12 + ) + np.testing.assert_allclose( + estimator.last_tau_residual_corrected, tau_contact, atol=1e-12 + ) + np.testing.assert_allclose(tau_corrected, tau_contact, atol=1e-12) + np.testing.assert_allclose( + wrench_estimated, wrench_true, rtol=2e-11, atol=2e-11 + ) + + estimator.clear_bias() + np.testing.assert_array_equal(estimator.tau_bias, np.zeros(model.nv)) + + def test_offline_measurement_batch_calibrates_bias(self) -> None: + model = self.model + estimator = InteractionEstimator( + model, "master_base", "master_ee", lambda_damp=1e-4 + ) + bias_true = np.linspace(-0.09, 0.12, model.nv) + q_batch = np.vstack([GENERAL_Q, GENERAL_Q + 0.03, GENERAL_Q - 0.02]) + qd_batch = np.vstack( + [ + np.zeros(model.nv), + np.linspace(-0.02, 0.03, model.nv), + np.linspace(0.01, -0.04, model.nv), + ] + ) + qdd_batch = np.vstack( + [ + np.zeros(model.nv), + np.linspace(0.04, -0.02, model.nv), + np.linspace(-0.03, 0.05, model.nv), + ] + ) + tau_meas_batch = np.vstack( + [ + estimator._tau_model(q, qd, qdd) + bias_true + for q, qd, qdd in zip(q_batch, qd_batch, qdd_batch) + ] + ) + + calibrated = estimator.calibrate_bias_from_measurements( + q_batch, qd_batch, qdd_batch, tau_meas_batch + ) + np.testing.assert_allclose(calibrated, bias_true, atol=1e-12) + + def test_fixed_dls_remains_finite_at_a_singularity(self) -> None: + model = self.model + estimator = InteractionEstimator( + model, "master_base", "master_ee", lambda_damp=1e-3 + ) + q = np.zeros(model.nq) + qd = np.zeros(model.nv) + qdd = np.zeros(model.nv) + jacobian = estimator._chest_jacobian(q, qd) + self.assertLess(np.linalg.matrix_rank(jacobian, tol=1e-9), 6) + + tau_model = estimator._tau_model(q, qd, qdd) + residual = np.linspace(-0.5, 0.7, model.nv) + tau_corrected, wrench_estimated, jacobian_estimated = estimator.estimate( + q, qd, qdd, tau_model + residual + ) + + np.testing.assert_allclose(tau_corrected, residual, atol=1e-12) + np.testing.assert_allclose(jacobian_estimated, jacobian, atol=1e-13) + self.assertTrue(np.all(np.isfinite(wrench_estimated))) + + +if __name__ == "__main__": + unittest.main() diff --git a/code/test/test_master_mujoco.py b/code/test/test_master_mujoco.py new file mode 100644 index 0000000..5d0f269 --- /dev/null +++ b/code/test/test_master_mujoco.py @@ -0,0 +1,43 @@ +"""Headless smoke test for the canonical master MuJoCo model. + +This file used to launch an interactive viewer at import time, which made test +discovery block or fail depending on the current working directory. +""" + +from __future__ import annotations + +import unittest +from pathlib import Path + +import mujoco + + +CONFIG_ROOT = Path(__file__).resolve().parents[1] / "config" +MASTER_MJCF = CONFIG_ROOT / "master_7dof.mjcf" +MASTER_JOINT_NAMES = ( + "master_shoulder_pitch_joint", + "master_shoulder_yaw_joint", + "master_shoulder_roll_joint", + "master_elbow_flex_joint", + "master_wrist_roll_joint", + "master_wrist_yaw_joint", + "master_wrist_pitch_joint", +) + + +class MasterMujocoModelTest(unittest.TestCase): + def test_canonical_model_loads_headlessly(self) -> None: + model = mujoco.MjModel.from_xml_path(str(MASTER_MJCF)) + self.assertEqual(model.nq, 7) + self.assertEqual(model.nv, 7) + for name in MASTER_JOINT_NAMES: + joint_id = mujoco.mj_name2id( + model, + mujoco.mjtObj.mjOBJ_JOINT, + name, + ) + self.assertGreaterEqual(joint_id, 0, name) + + +if __name__ == "__main__": + unittest.main() diff --git a/code/test/test_model_contract.py b/code/test/test_model_contract.py new file mode 100644 index 0000000..586ad0f --- /dev/null +++ b/code/test/test_model_contract.py @@ -0,0 +1,54 @@ +import unittest + +import numpy as np +import pinocchio as pin + +from core.model_contract import ( + MASTER_FRAMES, + MASTER_JOINT_NAMES, + SLAVE_FRAMES, + SLAVE_JOINT_NAMES, + clip_configuration, + finite_joint_limits, + joint_q_indices, + load_models, + require_frame, + safe_configuration, +) + + +class ModelContractTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.models = load_models() + + def test_canonical_models_and_simulated_tcp_load(self): + self.assertEqual(self.models.master.nq, 7) + self.assertEqual(self.models.slave.nq, 7) + for name in MASTER_FRAMES.values(): + require_frame(self.models.master, name) + for name in SLAVE_FRAMES.values(): + require_frame(self.models.slave, name) + + def test_safe_slave_configuration_is_strictly_inside_limits(self): + q = safe_configuration(self.models.slave, SLAVE_JOINT_NAMES) + qidx = joint_q_indices(self.models.slave, SLAVE_JOINT_NAMES) + lower, upper = finite_joint_limits(self.models.slave, SLAVE_JOINT_NAMES) + self.assertTrue(np.all(q[qidx] > lower)) + self.assertTrue(np.all(q[qidx] < upper)) + + def test_clipping_reports_limit_event(self): + q = pin.neutral(self.models.slave) + qidx = joint_q_indices(self.models.slave, SLAVE_JOINT_NAMES) + lower, upper = finite_joint_limits(self.models.slave, SLAVE_JOINT_NAMES) + q[qidx] = upper + 0.1 + clipped, event = clip_configuration( + self.models.slave, q, SLAVE_JOINT_NAMES, margin=1e-4 + ) + self.assertTrue(event) + self.assertTrue(np.all(clipped[qidx] <= upper - 1e-4 + 1e-12)) + self.assertTrue(np.all(clipped[qidx] >= lower + 1e-4 - 1e-12)) + + +if __name__ == "__main__": + unittest.main() diff --git a/code/test/test_paper_source_data.py b/code/test/test_paper_source_data.py new file mode 100644 index 0000000..659ebae --- /dev/null +++ b/code/test/test_paper_source_data.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""End-to-end source-data generation from completed raw trials.""" + +import csv +import json +from pathlib import Path +import sys +import tempfile +import unittest + +import numpy as np + + +CODE_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(CODE_ROOT)) + +from analysis.make_paper_artifacts import generate_paper_source_data # noqa: E402 +from experiments.io import TrialPayload # noqa: E402 +from experiments.plan import build_trial_plan # noqa: E402 +from experiments.runner import run_trial_plan # noqa: E402 + + +def wrench_executor(_trial): + reference = np.zeros((4, 6), dtype=float) + estimate = reference.copy() + estimate[:, 0] = 1.0 + return TrialPayload( + samples={ + "wrench_estimated": estimate, + "wrench_reference": reference, + } + ) + + +class PaperSourceDataTest(unittest.TestCase): + def test_source_tables_are_generated_from_raw_npz(self): + plan = build_trial_plan( + { + "study_id": "h2_source_data", + "split": "pilot", + "root_seed": 4, + "replicates": 1, + "methods": ["dls", "undamped"], + "trajectories": [ + {"trajectory_id": "load_001", "family": "static_load"} + ], + "factors": {"axis": ["x"]}, + } + ) + configuration = {"enabled": ["h2"], "h2": {}} + with tempfile.TemporaryDirectory() as temporary: + batch = Path(temporary) / "batch" + run_trial_plan(plan, batch, wrench_executor) + manifest = generate_paper_source_data(batch, configuration) + self.assertEqual(manifest["row_count"], 2) + metric_path = batch / "derived" / "trial_metrics.jsonl" + rows = [ + json.loads(line) + for line in metric_path.read_text(encoding="utf-8").splitlines() + ] + self.assertEqual(len(rows), 2) + self.assertTrue( + all(row["h2_force_rmse_N"] == 1.0 for row in rows) + ) + source_path = batch / "paper" / "source_data" / "h2.csv" + with source_path.open(newline="", encoding="utf-8") as stream: + table = list(csv.DictReader(stream)) + self.assertEqual(len(table), 2) + self.assertIn("h2_force_rmse_N", table[0]) + self.assertTrue((batch / "paper" / "artifact_manifest.json").is_file()) + + def test_metric_family_method_filter_prevents_mixed_supervisors(self): + plan = build_trial_plan( + { + "study_id": "filtered_source_data", + "split": "pilot", + "root_seed": 5, + "replicates": 1, + "methods": ["dls", "undamped"], + "trajectories": ["load_001"], + } + ) + configuration = { + "enabled": ["h2"], + "h2": {"include_methods": ["dls"]}, + } + with tempfile.TemporaryDirectory() as temporary: + batch = Path(temporary) / "batch" + run_trial_plan(plan, batch, wrench_executor) + manifest = generate_paper_source_data(batch, configuration) + self.assertEqual(manifest["row_count"], 2) + self.assertEqual(manifest["family_row_counts"], {"h2": 1}) + + metric_rows = [ + json.loads(line) + for line in (batch / "derived" / "trial_metrics.jsonl") + .read_text(encoding="utf-8") + .splitlines() + ] + dls_row = next(row for row in metric_rows if row["method_id"] == "dls") + undamped_row = next( + row for row in metric_rows if row["method_id"] == "undamped" + ) + self.assertIn("h2_force_rmse_N", dls_row) + self.assertNotIn("h2_force_rmse_N", undamped_row) + + with ( + batch / "paper" / "source_data" / "h2.csv" + ).open(newline="", encoding="utf-8") as stream: + table = list(csv.DictReader(stream)) + self.assertEqual([row["method_id"] for row in table], ["dls"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/code/test/test_pinocchio.py b/code/test/test_pinocchio.py new file mode 100644 index 0000000..481aa2d --- /dev/null +++ b/code/test/test_pinocchio.py @@ -0,0 +1,56 @@ +"""Headless dynamics smoke test for the canonical slave URDF.""" + +from __future__ import annotations + +import unittest +from pathlib import Path + +import numpy as np +import pinocchio as pin + + +CONFIG_ROOT = Path(__file__).resolve().parents[1] / "config" +SLAVE_URDF = CONFIG_ROOT / "real_slave_7dof.urdf" +SLAVE_JOINT_NAMES = ( + "R_SHOULDER_P", + "R_SHOULDER_R", + "R_SHOULDER_Y", + "R_ELBOW_R", + "R_WRIST_P", + "R_WRIST_Y", + "R_WRIST_R", +) + + +class SlavePinocchioModelTest(unittest.TestCase): + def test_canonical_model_dynamics_are_finite(self) -> None: + model = pin.buildModelFromUrdf(str(SLAVE_URDF)) + data = model.createData() + self.assertEqual(model.nq, 7) + self.assertEqual(model.nv, 7) + for name in SLAVE_JOINT_NAMES: + self.assertGreater(int(model.getJointId(name)), 0, name) + + q = pin.neutral(model) + qd = np.zeros(model.nv) + mass = pin.crba(model, data, q) + nonlinear = pin.nonLinearEffects(model, data, q, qd) + frame_id = int(model.getFrameId("R_WRIST_R_S")) + jacobian = pin.computeFrameJacobian( + model, + data, + q, + frame_id, + pin.ReferenceFrame.WORLD, + ) + + self.assertEqual(mass.shape, (7, 7)) + self.assertEqual(nonlinear.shape, (7,)) + self.assertEqual(jacobian.shape, (6, 7)) + self.assertTrue(np.all(np.isfinite(mass))) + self.assertTrue(np.all(np.isfinite(nonlinear))) + self.assertTrue(np.all(np.isfinite(jacobian))) + + +if __name__ == "__main__": + unittest.main() diff --git a/code/test/test_retargeting_baselines.py b/code/test/test_retargeting_baselines.py new file mode 100644 index 0000000..2568c7b --- /dev/null +++ b/code/test/test_retargeting_baselines.py @@ -0,0 +1,199 @@ +"""Contract tests for the three formal H1 retargeting baselines.""" + +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +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 core.model_contract import ( # noqa: E402 + MASTER_FRAMES, + MASTER_JOINT_NAMES, + SLAVE_FRAMES, + SLAVE_JOINT_NAMES, + joint_q_indices, + load_models, +) +from core.retargeting_baselines import ( # noqa: E402 + Retargeter, + RetargetingFailure, + RetargetingSolverStatus, + SEWRetargeterAdapter, + build_canonical_baselines, + build_canonical_sew_target_baselines, +) +from core.sew_mapper2 import BallJointConfig, SEWMapper # noqa: E402 + + +class RetargetingBaselinesTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.models = load_models() + cls.baselines = build_canonical_baselines(cls.models) + cls.slave_indices = joint_q_indices( + cls.models.slave, SLAVE_JOINT_NAMES + ) + + def test_factory_exposes_three_uniform_methods(self) -> None: + self.assertEqual( + set(self.baselines), + {"scaled_joint_space", "bounded_dls_ik", "task_priority_ik"}, + ) + for baseline in self.baselines.values(): + self.assertIsInstance(baseline, Retargeter) + + def test_frozen_reference_is_exact_for_all_methods(self) -> None: + q_master = np.zeros(7) + for name, baseline in self.baselines.items(): + with self.subTest(method=name): + result = baseline.retarget(q_master) + self.assertTrue(result.success, result.events) + self.assertTrue(result.smooth, result.events) + self.assertIs(result.failure, RetargetingFailure.NONE) + self.assertEqual(result.q_slave.shape, (self.models.slave.nq,)) + self.assertFalse(result.q_slave.flags.writeable) + self.assertIsNotNone(result.target) + self.assertGreaterEqual(result.diagnostics.runtime_s, 0.0) + self.assertGreaterEqual(result.diagnostics.cost, 0.0) + + def test_cartesian_baselines_share_target_and_converge(self) -> None: + q_master = np.array( + [0.05, -0.04, 0.03, 0.08, -0.02, 0.03, -0.01], + dtype=float, + ) + dls = self.baselines["bounded_dls_ik"].retarget(q_master) + priority = self.baselines["task_priority_ik"].retarget(q_master) + + self.assertTrue(dls.success, dls.events) + self.assertTrue(priority.success, priority.events) + self.assertIs( + dls.diagnostics.status, RetargetingSolverStatus.CONVERGED + ) + self.assertIs( + priority.diagnostics.status, RetargetingSolverStatus.CONVERGED + ) + np.testing.assert_allclose( + dls.target.position, priority.target.position, atol=0.0 + ) + np.testing.assert_allclose( + dls.target.rotation, priority.target.rotation, atol=0.0 + ) + + self.assertLess(dls.diagnostics.position_error_m, 2e-4) + self.assertLess(dls.diagnostics.orientation_error_rad, 2e-3) + self.assertLess(priority.diagnostics.position_error_m, 2e-4) + self.assertLess(priority.diagnostics.orientation_error_rad, 2e-3) + self.assertEqual( + priority.diagnostics.message, + "secondary_objective=log_manipulability", + ) + + def test_scaled_joint_mapping_uses_declared_urdf_ranges(self) -> None: + baseline = self.baselines["scaled_joint_space"] + result = baseline.retarget(np.zeros(7)) + expected_midpoint = 0.5 * ( + baseline.slave_lower + baseline.slave_upper + ) + np.testing.assert_allclose( + result.q_slave[self.slave_indices], expected_midpoint, atol=1e-15 + ) + self.assertIs( + result.diagnostics.status, RetargetingSolverStatus.CLOSED_FORM + ) + + def test_master_limit_failure_is_returned_not_thrown(self) -> None: + q_outside = np.zeros(7) + q_outside[0] = np.pi + 0.01 + for name, baseline in self.baselines.items(): + with self.subTest(method=name): + result = baseline.retarget(q_outside) + self.assertFalse(result.success) + self.assertFalse(result.smooth) + self.assertIs( + result.failure, + RetargetingFailure.MASTER_LIMIT_VIOLATION, + ) + self.assertIs( + result.diagnostics.status, + RetargetingSolverStatus.INVALID_INPUT, + ) + self.assertIn("master_limit_violation", result.events) + + def test_invalid_seed_has_stable_failure(self) -> None: + for name in ("bounded_dls_ik", "task_priority_ik"): + result = self.baselines[name].retarget( + np.zeros(7), q_slave_seed=np.zeros(3) + ) + self.assertIs(result.failure, RetargetingFailure.INVALID_INPUT) + self.assertEqual(result.events, ("invalid_input",)) + + def test_existing_sew_mapper_has_compatible_adapter(self) -> None: + mapper = SEWMapper( + master_model=self.models.master, + slave_model=self.models.slave, + m_shoulder=MASTER_FRAMES["shoulder"], + m_elbow=MASTER_FRAMES["elbow"], + m_wrist=MASTER_FRAMES["wrist"], + m_ee=MASTER_FRAMES["ee"], + s_shoulder=SLAVE_FRAMES["shoulder"], + s_elbow=SLAVE_FRAMES["elbow"], + s_wrist=SLAVE_FRAMES["wrist"], + s_ee=SLAVE_FRAMES["wrist"], + master_joint_names=MASTER_JOINT_NAMES, + slave_joint_names=SLAVE_JOINT_NAMES, + slave_shoulder_cfg=BallJointConfig( + "yxy", SLAVE_JOINT_NAMES[:3], (-1.0, 1.0, -1.0) + ), + slave_wrist_cfg=BallJointConfig( + "yzx", SLAVE_JOINT_NAMES[4:], (-1.0, 1.0, 1.0) + ), + ) + adapter = SEWRetargeterAdapter(mapper) + result = adapter.retarget( + np.array([0.534, 0.314, -0.1, 2.14, 0.38, 0.38, -0.72]) + ) + self.assertTrue(result.success, result.events) + self.assertEqual(result.method, "sew") + self.assertIs( + result.diagnostics.status, RetargetingSolverStatus.CONVERGED + ) + + invalid = adapter.retarget(np.zeros(3)) + self.assertIs(invalid.failure, RetargetingFailure.INVALID_INPUT) + + def test_common_sew_target_factory_shares_wrist_target(self) -> None: + baselines, proposed = build_canonical_sew_target_baselines(self.models) + q_master = np.array( + [0.534, 0.314, -0.1, 2.14, 0.38, 0.38, -0.72] + ) + dls = baselines["bounded_dls_ik"].retarget(q_master) + priority = baselines["task_priority_ik"].retarget(q_master) + sew = proposed.retarget(q_master) + self.assertTrue(dls.success, dls.events) + self.assertTrue(priority.success, priority.events) + self.assertTrue(sew.success, sew.events) + np.testing.assert_allclose( + dls.target.position, priority.target.position, atol=0.0 + ) + np.testing.assert_allclose( + dls.target.rotation, priority.target.rotation, atol=0.0 + ) + + degenerate = baselines["bounded_dls_ik"].retarget( + np.array([-np.pi / 2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]) + ) + self.assertIs( + degenerate.failure, RetargetingFailure.DEGENERATE_GEOMETRY + ) + self.assertIn("master_arm_plane_degenerate", degenerate.events) + + +if __name__ == "__main__": + unittest.main() diff --git a/code/test/test_retargeting_differential.py b/code/test/test_retargeting_differential.py new file mode 100644 index 0000000..074afa6 --- /dev/null +++ b/code/test/test_retargeting_differential.py @@ -0,0 +1,179 @@ +"""Regression tests for the bounded SEW retargeting differential.""" + +from pathlib import Path +import sys +import unittest + +import numpy as np +import pinocchio as pin + + +CODE_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(CODE_ROOT)) + +from core.sew_mapper2 import ( # noqa: E402 + BallJointConfig, + SEWMapper, + _wrap_angle_delta, +) + + +def build_real_mapper() -> SEWMapper: + config_dir = CODE_ROOT / "config" + master_model = pin.buildModelFromUrdf(str(config_dir / "master_7dof.urdf")) + slave_model = pin.buildModelFromUrdf(str(config_dir / "real_slave_7dof.urdf")) + + master_joints = ( + "master_shoulder_pitch_joint", + "master_shoulder_yaw_joint", + "master_shoulder_roll_joint", + "master_elbow_flex_joint", + "master_wrist_roll_joint", + "master_wrist_yaw_joint", + "master_wrist_pitch_joint", + ) + slave_joints = ( + "R_SHOULDER_P", + "R_SHOULDER_R", + "R_SHOULDER_Y", + "R_ELBOW_R", + "R_WRIST_P", + "R_WRIST_Y", + "R_WRIST_R", + ) + shoulder = BallJointConfig( + axis_order="yxy", + joint_names=("R_SHOULDER_P", "R_SHOULDER_R", "R_SHOULDER_Y"), + signs=(-1.0, 1.0, -1.0), + ) + wrist = BallJointConfig( + axis_order="yzx", + joint_names=("R_WRIST_P", "R_WRIST_Y", "R_WRIST_R"), + signs=(-1.0, 1.0, 1.0), + ) + return SEWMapper( + master_model=master_model, + slave_model=slave_model, + m_shoulder="master_shoulder", + m_elbow="master_forearm", + m_wrist="master_wrist", + m_ee="master_ee", + s_shoulder="R_SHOULDER_R_S", + s_elbow="R_ELBOW_R_S", + s_wrist="R_WRIST_R_S", + s_ee="R_WRIST_R_S", + master_joint_names=master_joints, + slave_joint_names=slave_joints, + slave_shoulder_cfg=shoulder, + slave_wrist_cfg=wrist, + ) + + +class TestRetargetingDifferential(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.mapper = build_real_mapper() + # An interior, exactly recoverable pose on the real slave arm. + cls.q_master = np.array( + [0.534, 0.314, -0.1, 2.14, 0.38, 0.38, -0.72], + dtype=float, + ) + + def test_virtual_work_identity(self) -> None: + q_slave, A, debug = self.mapper.retarget_with_differential(self.q_master) + + self.assertTrue(debug["success"], debug["events"]) + self.assertTrue( + debug["differential_valid"], + debug["differential"]["events"], + ) + self.assertFalse(debug["clipped"]) + self.assertFalse(debug["near_limit"]) + q_slave7 = self.mapper._slave_q7(q_slave) + self.assertTrue(np.all(q_slave7 >= self.mapper.s_lower7)) + self.assertTrue(np.all(q_slave7 <= self.mapper.s_upper7)) + + qdot_master = np.array( + [0.21, -0.34, 0.17, 0.09, -0.11, 0.28, -0.07] + ) + tau_slave = np.array( + [-1.2, 0.4, 2.3, -0.7, 0.5, -1.1, 0.8] + ) + qdot_slave = A @ qdot_master + tau_master = A.T @ tau_slave + self.assertAlmostEqual( + float(tau_master @ qdot_master), + float(tau_slave @ qdot_slave), + places=12, + ) + + def test_matches_independent_directional_finite_difference(self) -> None: + q_slave, A, debug = self.mapper.retarget_with_differential( + self.q_master, fd_step=1e-4 + ) + self.assertTrue(debug["differential_valid"]) + + direction = np.array([0.3, -0.2, 0.5, -0.1, 0.4, -0.25, 0.35]) + direction /= np.linalg.norm(direction) + independent_step = 2.5e-5 + q_plus, plus_debug = self.mapper.retarget( + self.q_master + independent_step * direction, + q_s_init=q_slave, + ) + q_minus, minus_debug = self.mapper.retarget( + self.q_master - independent_step * direction, + q_s_init=q_slave, + ) + self.assertTrue(plus_debug["success"], plus_debug["events"]) + self.assertTrue(minus_debug["success"], minus_debug["events"]) + + directional_fd = _wrap_angle_delta( + self.mapper._slave_q7(q_plus) - self.mapper._slave_q7(q_minus) + ) / (2.0 * independent_step) + np.testing.assert_allclose( + directional_fd, + A @ direction, + rtol=2e-4, + atol=2e-5, + ) + + def test_degenerate_geometry_invalidates_differential(self) -> None: + # Zero elbow flex makes the master shoulder-elbow-wrist plane undefined. + q_degenerate = np.array([-np.pi / 2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]) + A, debug = self.mapper.compute_differential(q_degenerate) + + self.assertFalse(debug["valid"]) + self.assertTrue(np.isnan(A).all()) + self.assertIn("base_invalid", debug["events"]) + self.assertIn( + "master_arm_plane_degenerate", + debug["base"]["events"], + ) + + def test_active_limit_invalidates_differential(self) -> None: + old_margin = self.mapper.joint_limit_margin + try: + # Enlarge only the diagnostic margin to exercise the active-set + # safety gate without changing the bounded pose solution. + self.mapper.joint_limit_margin = 10.0 + A, debug = self.mapper.compute_differential(self.q_master) + finally: + self.mapper.joint_limit_margin = old_margin + + self.assertTrue(debug["base"]["success"]) + self.assertTrue(debug["base"]["near_limit"]) + self.assertFalse(debug["valid"]) + self.assertTrue(np.isnan(A).all()) + self.assertIn("base_nonsmooth", debug["events"]) + + def test_angle_difference_wrap(self) -> None: + raw = np.array([2.0 * np.pi - 0.1, -2.0 * np.pi + 0.2, np.pi]) + np.testing.assert_allclose( + _wrap_angle_delta(raw), + np.array([-0.1, 0.2, -np.pi]), + atol=1e-14, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/code/test/test_sew.py b/code/test/test_sew.py new file mode 100644 index 0000000..cacce94 --- /dev/null +++ b/code/test/test_sew.py @@ -0,0 +1,158 @@ +import os +import sys +import numpy as np +import pinocchio as pin +import matplotlib.pyplot as plt +from omegaconf import OmegaConf + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +from core.sew_mapper import SEWMapper, fk_update, pose_of_frame, rot_error_deg + +# ...(这里保留你之前的 safe_normalize、random_qm、几何绘图函数等)... +def random_qm(mapper, N=50, margin=0.2): + lb = []; ub = [] + for j in mapper.m_model.joints[1:]: # skip universe + if j.nq == 1: # 1-DoF revolute + jid = j.id + iq = j.idx_q + lb.append(mapper.m_model.lowerPositionLimit[iq]) + ub.append(mapper.m_model.upperPositionLimit[iq]) + lb = np.array(lb); ub = np.array(ub) + rng = (ub - lb) + lb2 = lb + margin * rng + ub2 = ub - margin * rng + + qs = [] + for _ in range(N): + v = lb2 + np.random.rand(len(lb2)) * (ub2 - lb2) + q_full = pin.neutral(mapper.m_model) + k = 0 + for j in mapper.m_model.joints[1:]: + if j.nq == 1: + q_full[j.idx_q] = v[k]; k += 1 + qs.append(q_full) + return qs + + + +def evaluate_sew_statistics(mapper, conf, N=500, margin=0.1, save_dir="assets"): + """ + 统计验证 SEW retargeting 的精度,并输出论文用图: + (1) 腕点位置误差直方图 + (2) 腕点姿态误差直方图 + (3) (可选)位置误差 vs 肩-腕距离散点 + """ + os.makedirs(save_dir, exist_ok=True) + + # 随机采样 master 关节 + qs_m = random_qm(mapper, N=N, margin=margin) + + pos_errs = [] # mm + rot_errs = [] # deg + dists_sw = [] # shoulder–wrist distance (m) + clipped_flags = [] + + for q_m in qs_m: + # SEW 映射 + q_s, dbg = mapper.retargetting(q_m) + + # master FK + pS_m, _ = pose_of_frame(mapper.m_model, mapper.m_data, conf.m_shoulder_frame) + pE_m, RE_m = pose_of_frame(mapper.m_model, mapper.m_data, conf.m_ee_frame) + + # slave FK + fk_update(mapper.s_model, mapper.s_data, q_s) + pE_s, RE_s = pose_of_frame(mapper.s_model, mapper.s_data, conf.s_ee_frame) + + # 误差(改成 EE) + e_p = np.linalg.norm(pE_s - pE_m) * 1000.0 # 末端位置误差 [mm] + e_R = rot_error_deg(RE_m, RE_s) # 末端姿态误差 [deg] + + pos_errs.append(e_p) + rot_errs.append(e_R) + + d_m = np.linalg.norm(pE_m - pS_m) # m + dists_sw.append(d_m) + + # 是否发生 reach 裁剪(根据 d_s vs master 原始 d_m、以及 L1+L2 区间) + d_s = dbg["d_s"] + clipped = (d_s < d_m - 1e-9) or (d_s > mapper.L1 + mapper.L2 - mapper.eps_clip + 1e-9) + clipped_flags.append(clipped) + + pos_errs = np.asarray(pos_errs) + rot_errs = np.asarray(rot_errs) + dists_sw = np.asarray(dists_sw) + clipped_flags = np.asarray(clipped_flags, dtype=bool) + + # --------- 打印统计量 (你可以把结果手工抄进论文表格) ---------- + def summary_str(x): + return f"mean={x.mean():.3f}, std={x.std():.3f}, max={x.max():.3f}" + + print("=== SEW retargeting statistics over {} samples ===".format(N)) + print("Wrist position error [mm]:", summary_str(pos_errs)) + print("Wrist orientation error [deg]:", summary_str(rot_errs)) + print("Clipped configurations: {} / {} ({:.1f}%)" + .format(clipped_flags.sum(), N, 100.0 * clipped_flags.mean())) + + # --------- 图 1: 位置 & 姿态误差直方图 ---------- + fig, axes = plt.subplots(1, 2, figsize=(10, 4)) + + axes[0].hist(pos_errs, bins=30, color='C0', alpha=0.8) + axes[0].set_xlabel("Wrist position error $e_p$ [mm]") + axes[0].set_ylabel("Count") + axes[0].set_title("(a) Distribution of $e_p$") + + axes[1].hist(rot_errs, bins=30, color='C1', alpha=0.8) + axes[1].set_xlabel("Wrist orientation error $e_R$ [deg]") + axes[1].set_ylabel("Count") + axes[1].set_title("(b) Distribution of $e_R$") + + plt.tight_layout() + fig.savefig(os.path.join(save_dir, "sew_error_hist.png"), dpi=300) + # plt.show() + + # --------- 图 2: 位置误差 vs 肩-腕距离 (可视化剪裁区) ---------- + fig2, ax2 = plt.subplots(figsize=(5, 4)) + ax2.scatter(dists_sw[~clipped_flags], pos_errs[~clipped_flags], + s=15, c='C0', label="Unclipped") + ax2.scatter(dists_sw[clipped_flags], pos_errs[clipped_flags], + s=25, c='C3', marker='x', label="Clipped") + ax2.set_xlabel("Shoulder–wrist distance $d_m$ [m]") + ax2.set_ylabel("Position error $e_p$ [mm]") + ax2.set_title("SEW error vs. reach distance") + ax2.grid(True, linestyle='--', linewidth=0.5) + ax2.legend(loc="upper left") + + fig2.tight_layout() + fig2.savefig(os.path.join(save_dir, "sew_error_scatter.png"), dpi=300) + plt.show() + + +def main(): + conf = OmegaConf.load("./config/config.yaml") + + master_model, _, _ = pin.buildModelsFromUrdf(str(conf.master_urdf)) + slave_model, _, _ = pin.buildModelsFromUrdf(str(conf.slave_urdf)) + + mapper = SEWMapper( + master_model=master_model, + slave_model=slave_model, + m_shoulder_frame=conf.m_shoulder_frame, + m_elbow_frame=conf.m_elbow_frame, + m_wrist_frame=conf.m_wrist_frame, + m_ee_frame=conf.m_ee_frame, + s_shoulder_frame=conf.s_shoulder_frame, + s_elbow_frame=conf.s_elbow_frame, + s_wrist_frame=conf.s_wrist_frame, + s_ee_frame=conf.s_ee_frame, + slave_joint_names=conf.sew_mapper.slave_joint_names, + up_dir=np.array(conf.sew_mapper.up_dir), + eps_clip=conf.sew_mapper.eps_clip, + ) + + # 1) 如果要画几何示意图(方法部分),可以单独写一个函数;这里我们只跑统计 + evaluate_sew_statistics(mapper, conf, N=500, margin=0.1, save_dir="assets") + + +if __name__ == "__main__": + main() diff --git a/code/test/test_sew_mapper2.py b/code/test/test_sew_mapper2.py new file mode 100644 index 0000000..7588323 --- /dev/null +++ b/code/test/test_sew_mapper2.py @@ -0,0 +1,136 @@ +import numpy as np +import pinocchio as pin +from core.sew_mapper2 import SEWMapper, BallJointConfig + + +# -------------------------- quick test --------------------- ----- # + +def test_once(mapper: SEWMapper) -> None: + q_m7 = np.array([0.534, 0.314, -0.1, 2.14, 0.38, 0.38, -0.72], dtype=float) + q_s, dbg = mapper.retarget(q_m7) + + # evaluate + mapper._fk_master(_build_q7(mapper.m_model, mapper.m_qidx7, q_m7)) + mapper._fk_slave(q_s) + Rm = mapper._rot(mapper.m_data, mapper.fid_mEE) + Rs = mapper._rot(mapper.s_data, mapper.fid_sEE) + ang_err = float(np.linalg.norm(pin.log3(Rs.T @ Rm))) + pW = mapper._pos(mapper.s_data, mapper.fid_sW) + pos_err = float(np.linalg.norm(pW - dbg["pW_s_ref"])) + + print("q_s(7):", np.array([q_s[i] for i in mapper.s_qidx7])) + print("angle_err(rad):", ang_err) + print("wrist_pos_err(m):", pos_err) + +def _build_q7(model: pin.Model, qidx7, q7): + q = pin.neutral(model) + for i, idx in enumerate(qidx7): + q[idx] = float(q7[i]) + return q + + +def test_fake(): + master_urdf = "../config/master_7dof.urdf" + slave_urdf = "../config/slave_7dof.urdf" + + m_model = pin.buildModelFromUrdf(master_urdf) + s_model = pin.buildModelFromUrdf(slave_urdf) + + # frames + mS, mE, mW, mEE = "master_shoulder", "master_forearm", "master_wrist", "master_ee" + sS, sE, sW, sEE = "slave_shoulder", "slave_forearm", "slave_wrist", "slave_ee" + + # joint order: [S1,S2,S3, EL, W1,W2,W3] + master_joint_names = ( + "master_shoulder_pitch_joint", "master_shoulder_yaw_joint", "master_shoulder_roll_joint", + "master_elbow_flex_joint", + "master_wrist_roll_joint", "master_wrist_yaw_joint", "master_wrist_pitch_joint", + ) + slave_joint_names = ( + "slave_shoulder_pitch_joint", "slave_shoulder_yaw_joint", "slave_shoulder_roll_joint", + "slave_elbow_flex_joint", + "slave_wrist_roll_joint", "slave_wrist_yaw_joint", "slave_wrist_pitch_joint", + ) + + # configs (your confirmed settings) + s_sh_cfg = BallJointConfig( + axis_order="yxy", + joint_names=("slave_shoulder_pitch_joint", "slave_shoulder_yaw_joint", "slave_shoulder_roll_joint"), + signs=(-1.0, 1.0, -1.0), + ) + s_wr_cfg = BallJointConfig( + axis_order="yzx", + joint_names=("slave_wrist_roll_joint", "slave_wrist_yaw_joint", "slave_wrist_pitch_joint"), + signs=(-1.0, 1.0, 1.0), + ) + + mapper = SEWMapper( + master_model=m_model, + slave_model=s_model, + m_shoulder=mS, m_elbow=mE, m_wrist=mW, m_ee=mEE, + s_shoulder=sS, s_elbow=sE, s_wrist=sW, s_ee=sEE, + master_joint_names=master_joint_names, + slave_joint_names=slave_joint_names, + slave_shoulder_cfg=s_sh_cfg, + slave_wrist_cfg=s_wr_cfg, + slave_elbow_axis_local=np.array([1.0, 0.0, 0.0]), + debug=False, + ) + + test_once(mapper) + + +def test_real(): + master_urdf = "../config/master_7dof.urdf" + slave_urdf = "../config/real_slave_7dof.urdf" + + m_model = pin.buildModelFromUrdf(master_urdf) + s_model = pin.buildModelFromUrdf(slave_urdf) + + # frames + mS, mE, mW, mEE = "master_shoulder", "master_forearm", "master_wrist", "master_ee" + sS, sE, sW, sEE = "R_SHOULDER_R_S", "R_ELBOW_R_S", "R_WRIST_R_S", "R_WRIST_R_S" + + # joint order: [S1,S2,S3, EL, W1,W2,W3] + master_joint_names = ( + "master_shoulder_pitch_joint", "master_shoulder_yaw_joint", "master_shoulder_roll_joint", + "master_elbow_flex_joint", + "master_wrist_roll_joint", "master_wrist_yaw_joint", "master_wrist_pitch_joint", + ) + slave_joint_names = ( + "R_SHOULDER_P", "R_SHOULDER_R", "R_SHOULDER_Y", + "R_ELBOW_R", + "R_WRIST_P", "R_WRIST_Y", "R_WRIST_R", + ) + + # configs (your confirmed settings) + s_sh_cfg = BallJointConfig( + axis_order="yxy", + joint_names=("R_SHOULDER_P", "R_SHOULDER_R", "R_SHOULDER_Y"), + signs=(-1.0, 1.0, -1.0), + ) + s_wr_cfg = BallJointConfig( + axis_order="yzx", + joint_names=("R_WRIST_P", "R_WRIST_Y", "R_WRIST_R"), + signs=(-1.0, 1.0, 1.0), + ) + + mapper = SEWMapper( + master_model=m_model, + slave_model=s_model, + m_shoulder=mS, m_elbow=mE, m_wrist=mW, m_ee=mEE, + s_shoulder=sS, s_elbow=sE, s_wrist=sW, s_ee=sEE, + master_joint_names=master_joint_names, + slave_joint_names=slave_joint_names, + slave_shoulder_cfg=s_sh_cfg, + slave_wrist_cfg=s_wr_cfg, + slave_elbow_axis_local=np.array([1.0, 0.0, 0.0]), + debug=False, + ) + + test_once(mapper) + + +if __name__ == "__main__": + test_real() + diff --git a/code/test/test_slave_mujoco.py b/code/test/test_slave_mujoco.py new file mode 100644 index 0000000..7f75cce --- /dev/null +++ b/code/test/test_slave_mujoco.py @@ -0,0 +1,28 @@ +"""Document the unsupported historical direct-URDF MuJoCo path. + +The closed-loop simulator uses Pinocchio for the canonical slave dynamics. +MuJoCo's URDF importer strips the mesh subdirectory from this vendor URDF, so +the former interactive demo is not a valid automated contract test. +""" + +from __future__ import annotations + +import unittest +from pathlib import Path + + +CONFIG_ROOT = Path(__file__).resolve().parents[1] / "config" +SLAVE_URDF = CONFIG_ROOT / "real_slave_7dof.urdf" + + +class SlaveMujocoLegacyDemoTest(unittest.TestCase): + @unittest.skip( + "legacy direct-URDF MuJoCo demo is unsupported; " + "canonical slave dynamics are covered by Pinocchio model tests" + ) + def test_legacy_direct_urdf_import(self) -> None: + self.assertTrue(SLAVE_URDF.is_file()) + + +if __name__ == "__main__": + unittest.main() diff --git a/code/test/test_wrench_solver.py b/code/test/test_wrench_solver.py new file mode 100644 index 0000000..ec9b330 --- /dev/null +++ b/code/test/test_wrench_solver.py @@ -0,0 +1,115 @@ +"""Numerical tests for the dimensionally scaled H2 solvers.""" + +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +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 core.wrench_solver import ( # noqa: E402 + ScaledDLSSolver, + UndampedSVDSolver, + WrenchSolveStatus, +) + + +class WrenchSolverTest(unittest.TestCase): + def setUp(self) -> None: + rng = np.random.default_rng(240319) + self.jacobian = rng.normal(size=(6, 7)) + self.wrench = np.array([8.0, -3.0, 5.0, 0.7, -0.4, 0.2]) + self.length = 0.37 + self.residual = self.jacobian.T @ self.wrench + + def test_scaling_preserves_virtual_work(self) -> None: + solver = UndampedSVDSolver(self.length) + scaled_jacobian = solver.scaled_jacobian(self.jacobian) + scaled_wrench = np.concatenate( + (self.length * self.wrench[:3], self.wrench[3:]) + ) + np.testing.assert_allclose( + scaled_jacobian.T @ scaled_wrench, + self.jacobian.T @ self.wrench, + rtol=1e-14, + atol=1e-14, + ) + + def test_undamped_full_rank_recovers_known_wrench(self) -> None: + result = UndampedSVDSolver( + self.length, relative_rank_tolerance=1e-10 + ).solve(self.jacobian, self.residual) + self.assertIs(result.status, WrenchSolveStatus.FULL_RANK) + self.assertEqual(result.rank, 6) + np.testing.assert_allclose( + result.wrench, self.wrench, rtol=2e-13, atol=2e-13 + ) + np.testing.assert_allclose( + result.reconstructed_residual, + self.residual, + rtol=2e-13, + atol=2e-13, + ) + + def test_dls_matches_scaled_svd_filter_formula(self) -> None: + damping = 0.08 + solver = ScaledDLSSolver(self.length, damping) + result = solver.solve(self.jacobian, self.residual) + + scaled_jacobian = solver.scaled_jacobian(self.jacobian) + u, singular_values, vt = np.linalg.svd( + scaled_jacobian, full_matrices=False + ) + expected_scaled = u @ ( + singular_values + / (singular_values**2 + damping**2) + * (vt @ self.residual) + ) + expected_wrench = np.diag( + [1.0 / self.length] * 3 + [1.0] * 3 + ) @ expected_scaled + np.testing.assert_allclose(result.wrench, expected_wrench, atol=1e-14) + np.testing.assert_allclose( + result.singular_values, singular_values, atol=0.0 + ) + self.assertAlmostEqual(result.damping, damping) + + def test_rank_deficiency_is_retained_and_reported(self) -> None: + jacobian = self.jacobian.copy() + jacobian[-1, :] = jacobian[-2, :] + residual = jacobian.T @ self.wrench + for solver in ( + UndampedSVDSolver(self.length), + ScaledDLSSolver(self.length, 0.05), + ): + with self.subTest(method=solver.name): + result = solver.solve(jacobian, residual) + self.assertIs( + result.status, WrenchSolveStatus.RANK_DEFICIENT + ) + self.assertEqual(result.rank, 5) + self.assertTrue(np.isinf(result.condition_number)) + self.assertTrue(np.all(np.isfinite(result.wrench))) + + def test_invalid_units_and_shapes_are_rejected(self) -> None: + with self.assertRaises(ValueError): + ScaledDLSSolver(0.0, 0.1) + with self.assertRaises(ValueError): + ScaledDLSSolver(self.length, 0.0) + with self.assertRaises(ValueError): + UndampedSVDSolver(self.length, relative_rank_tolerance=1.0) + solver = UndampedSVDSolver(self.length) + with self.assertRaisesRegex(ValueError, "jacobian"): + solver.solve(np.eye(7), np.ones(7)) + with self.assertRaisesRegex(ValueError, "joint_residual"): + solver.solve(self.jacobian, np.ones(6)) + + +if __name__ == "__main__": + unittest.main() diff --git a/code/test/torque_control.py b/code/test/torque_control.py new file mode 100644 index 0000000..2a0ce2d --- /dev/null +++ b/code/test/torque_control.py @@ -0,0 +1,80 @@ +import time +import struct +import can + + +def arb_id_single(motor_id: int) -> int: + # 单电机命令:0x140 + ID(1~32) + return 0x140 + motor_id + + +def send_cmd(bus, motor_id: int, data8: bytes): + msg = can.Message( + arbitration_id=arb_id_single(motor_id), + data=data8, + is_extended_id=False, + ) + bus.send(msg) + + +def motor_run(bus, motor_id: int): + # 电机运行命令:DATA[0]=0x88,其余 0 + send_cmd(bus, motor_id, bytes([0x88, 0, 0, 0, 0, 0, 0, 0])) + + +def motor_stop(bus, motor_id: int): + # 电机停止命令:DATA[0]=0x81,其余 0 + send_cmd(bus, motor_id, bytes([0x81, 0, 0, 0, 0, 0, 0, 0])) + + +def torque_iq_cmd_a1(bus, motor_id: int, iq_control: int): + """ + 转矩闭环控制命令 0xA1: + DATA[0] = 0xA1 + DATA[4..5] = iqControl (int16, little-endian), 范围 -2048~2048 + 其余字节 0 + 【协议:0xA1,iqControl:int16,DATA[4]=低字节,DATA[5]=高字节】 + """ + if not (-2048 <= iq_control <= 2048): + raise ValueError("iq_control must be in [-2048, 2048]") + + data = bytearray(8) + data[0] = 0xA1 + data[4:6] = struct.pack(" np.ndarray: + x, y, z = v + return np.array([[0, -z, y], + [z, 0, -x], + [-y, x, 0]], dtype=float) + + +def adjoint_SE3(T: np.ndarray) -> np.ndarray: + """Adjoint(T) for SE(3): 6x6, spatial 顺序([ω; v]).""" + R = T[:3, :3] + p = T[:3, 3] + Ad = np.block([[R, np.zeros((3, 3))], + [_skew(p) @ R, R]]) + return Ad + + +def spatial_to_geometric(J_spatial: np.ndarray) -> np.ndarray: + """把 Pinocchio 的 [ω; v] 排列变成 [v; ω]。""" + return np.vstack([J_spatial[3:, :], J_spatial[:3, :]]) + + +def wrench_geo_to_spa(F_geo: np.ndarray) -> np.ndarray: + """[f; τ] -> [τ; f]""" + return np.hstack([F_geo[3:], F_geo[:3]]) + + +def wrench_spa_to_geo(F_spa: np.ndarray) -> np.ndarray: + """[τ; f] -> [f; τ]""" + return np.hstack([F_spa[3:], F_spa[:3]]) \ No newline at end of file diff --git a/code/utils/math.py b/code/utils/math.py new file mode 100644 index 0000000..eb61813 --- /dev/null +++ b/code/utils/math.py @@ -0,0 +1,40 @@ +import numpy as np + +def hat(w): + wx, wy, wz = w + return np.array([[0, -wz, wy], + [wz, 0, -wx], + [-wy, wx, 0]], dtype=float) + + +def rotvec_to_R(w): + """Axis-angle (rotation vector) to rotation matrix.""" + th = np.linalg.norm(w) + if th < 1e-12: + return np.eye(3) + k = w / th + K = hat(k) + return np.eye(3) + np.sin(th) * K + (1-np.cos(th)) * (K @ K) + + +def R_to_rotvec(R): + """Rotation matrix to rotation vector (log map).""" + tr = np.trace(R) + cos_th = (tr - 1) / 2 + cos_th = np.clip(cos_th, -1.0, 1.0) + th = np.arccos(cos_th) + if th < 1e-12: + return np.zeros(3) + w_hat = (R - R.T) / (2*np.sin(th)) + return th * np.array([w_hat[2,1], w_hat[0,2], w_hat[1,0]]) + + +def normalize(v, eps=1e-12): + n = np.linalg.norm(v) + if n < eps: + return v*0.0 + return v / n + + +def clip(x, a, b): + return max(a, min(b, x)) diff --git a/paper/exoskeleton/IEEEtran/IEEEtran.cls b/paper/exoskeleton/IEEEtran/IEEEtran.cls new file mode 100644 index 0000000..8d2b1c6 --- /dev/null +++ b/paper/exoskeleton/IEEEtran/IEEEtran.cls @@ -0,0 +1,6347 @@ +%% +%% IEEEtran.cls 2015/08/26 version V1.8b +%% +%% This is the IEEEtran LaTeX class for authors of the Institute of +%% Electrical and Electronics Engineers (IEEE) Transactions journals and +%% conferences. +%% +%% Support sites: +%% http://www.michaelshell.org/tex/ieeetran/ +%% http://www.ctan.org/pkg/ieeetran +%% and +%% http://www.ieee.org/ +%% +%% Based on the original 1993 IEEEtran.cls, but with many bug fixes +%% and enhancements (from both JVH and MDS) over the 1996/7 version. +%% +%% +%% Contributors: +%% Gerry Murray (1993), Silvano Balemi (1993), +%% Jon Dixon (1996), Peter N"uchter (1996), +%% Juergen von Hagen (2000), and Michael Shell (2001-2014) +%% +%% +%% Copyright (c) 1993-2000 by Gerry Murray, Silvano Balemi, +%% Jon Dixon, Peter N"uchter, +%% Juergen von Hagen +%% and +%% Copyright (c) 2001-2015 by Michael Shell +%% +%% Current maintainer (V1.3 to V1.8b): Michael Shell +%% See: +%% http://www.michaelshell.org/ +%% for current contact information. +%% +%% Special thanks to Peter Wilson (CUA) and Donald Arseneau +%% for allowing the inclusion of the \@ifmtarg command +%% from their ifmtarg LaTeX package. +%% +%%************************************************************************* +%% Legal Notice: +%% This code is offered as-is without any warranty either expressed or +%% implied; without even the implied warranty of MERCHANTABILITY or +%% FITNESS FOR A PARTICULAR PURPOSE! +%% User assumes all risk. +%% In no event shall the IEEE or any contributor to this code be liable for +%% any damages or losses, including, but not limited to, incidental, +%% consequential, or any other damages, resulting from the use or misuse +%% of any information contained here. +%% +%% All comments are the opinions of their respective authors and are not +%% necessarily endorsed by the IEEE. +%% +%% This work is distributed under the LaTeX Project Public License (LPPL) +%% ( http://www.latex-project.org/ ) version 1.3, and may be freely used, +%% distributed and modified. A copy of the LPPL, version 1.3, is included +%% in the base LaTeX documentation of all distributions of LaTeX released +%% 2003/12/01 or later. +%% Retain all contribution notices and credits. +%% ** Modified files should be clearly indicated as such, including ** +%% ** renaming them and changing author support contact information. ** +%% +%% File list of work: IEEEtran.cls, IEEEtran_HOWTO.pdf, bare_adv.tex, +%% bare_conf.tex, bare_jrnl.tex, bare_conf_compsoc.tex, +%% bare_jrnl_compsoc.tex +%% +%% Major changes to the user interface should be indicated by an +%% increase in the version numbers. If a version is a beta, it will +%% be indicated with a BETA suffix, i.e., 1.4 BETA. +%% Small changes can be indicated by appending letters to the version +%% such as "IEEEtran_v14a.cls". +%% In all cases, \Providesclass, any \typeout messages to the user, +%% \IEEEtransversionmajor and \IEEEtransversionminor must reflect the +%% correct version information. +%% The changes should also be documented via source comments. +%%************************************************************************* +%% +% +% Available class options +% e.g., \documentclass[10pt,conference]{IEEEtran} +% +% *** choose only one from each category *** +% +% 9pt, 10pt, 11pt, 12pt +% Sets normal font size. The default is 10pt. +% +% conference, journal, technote, peerreview, peerreviewca +% determines format mode - conference papers, journal papers, +% correspondence papers (technotes), or peer review papers. The user +% should also select 9pt when using technote. peerreview is like +% journal mode, but provides for a single-column "cover" title page for +% anonymous peer review. The paper title (without the author names) is +% repeated at the top of the page after the cover page. For peer review +% papers, the \IEEEpeerreviewmaketitle command must be executed (will +% automatically be ignored for non-peerreview modes) at the place the +% cover page is to end, usually just after the abstract (keywords are +% not normally used with peer review papers). peerreviewca is like +% peerreview, but allows the author names to be entered and formatted +% as with conference mode so that author affiliation and contact +% information can be easily seen on the cover page. +% The default is journal. +% +% draft, draftcls, draftclsnofoot, final +% determines if paper is formatted as a widely spaced draft (for +% handwritten editor comments) or as a properly typeset final version. +% draftcls restricts draft mode to the class file while all other LaTeX +% packages (i.e., \usepackage{graphicx}) will behave as final - allows +% for a draft paper with visible figures, etc. draftclsnofoot is like +% draftcls, but does not display the date and the word "DRAFT" at the foot +% of the pages. If using one of the draft modes, the user will probably +% also want to select onecolumn. +% The default is final. +% +% letterpaper, a4paper, cspaper +% determines paper size: 8.5in X 11in, 210mm X 297mm or 7.875in X 10.75in. +% Changing the paper size in the standard journal and conference modes +% will not alter the typesetting of the document - only the margins will +% be affected. In particular, documents using the a4paper option will +% have reduced side margins (A4 is narrower than US letter) and a longer +% bottom margin (A4 is longer than US letter). For both cases, the top +% margins will be the same and the text will be horizontally centered. +% For the compsoc conference and draft modes, it is the margins that will +% remain constant, and thus the text area size will vary, with changes in +% the paper size. +% The cspaper option is the special ``trim'' paper size (7.875in x 10.75in) +% used in the actual publication of Computer Society journals. Under +% compsoc journal mode, this option does not alter the typesetting of the +% document. Authors should invoke the cspaper option only if requested to +% do so by the editors of the specific journal they are submitting to. +% For final submission to the IEEE, authors should generally use US letter +% (8.5 X 11in) paper unless otherwise instructed. Note that authors should +% ensure that all post-processing (ps, pdf, etc.) uses the same paper +% specificiation as the .tex document. Problems here are by far the number +% one reason for incorrect margins. IEEEtran will automatically set the +% default paper size under pdflatex (without requiring any change to +% pdftex.cfg), so this issue is more important to dvips users. Fix +% config.ps, config.pdf, or ~/.dvipsrc for dvips, or use the +% dvips -t papersize option instead as needed. For the cspaper option, +% the corresponding dvips paper name is "ieeecs". +% See the testflow documentation +% http://www.ctan.org/tex-archive/macros/latex/contrib/IEEEtran/testflow +% for more details on dvips paper size configuration. +% The default is letterpaper. +% +% oneside, twoside +% determines if layout follows single sided or two sided (duplex) +% printing. The only notable change is with the headings at the top of +% the pages. +% The default is oneside. +% +% onecolumn, twocolumn +% determines if text is organized into one or two columns per page. One +% column mode is usually used only with draft papers. +% The default is twocolumn. +% +% comsoc, compsoc, transmag +% Use the format of the IEEE Communications Society, IEEE Computer Society +% or IEEE Transactions on Magnetics, respectively. +% +% romanappendices +% Use the "Appendix I" convention when numbering appendices. IEEEtran.cls +% now defaults to Alpha "Appendix A" convention - the opposite of what +% v1.6b and earlier did. +% +% captionsoff +% disables the display of the figure/table captions. Some IEEE journals +% request that captions be removed and figures/tables be put on pages +% of their own at the end of an initial paper submission. The endfloat +% package can be used with this class option to achieve this format. +% +% nofonttune +% turns off tuning of the font interword spacing. Maybe useful to those +% not using the standard Times fonts or for those who have already "tuned" +% their fonts. +% The default is to enable IEEEtran to tune font parameters. +% +% +%---------- +% Available CLASSINPUTs provided (all are macros unless otherwise noted): +% \CLASSINPUTbaselinestretch +% \CLASSINPUTinnersidemargin +% \CLASSINPUToutersidemargin +% \CLASSINPUTtoptextmargin +% \CLASSINPUTbottomtextmargin +% +% Available CLASSINFOs provided: +% \ifCLASSINFOpdf (TeX if conditional) +% \CLASSINFOpaperwidth (macro) +% \CLASSINFOpaperheight (macro) +% \CLASSINFOnormalsizebaselineskip (length) +% \CLASSINFOnormalsizeunitybaselineskip (length) +% +% Available CLASSOPTIONs provided: +% all class option flags (TeX if conditionals) unless otherwise noted, +% e.g., \ifCLASSOPTIONcaptionsoff +% point size options provided as a single macro: +% \CLASSOPTIONpt +% which will be defined as 9, 10, 11, or 12 depending on the document's +% normalsize point size. +% also, class option peerreviewca implies the use of class option peerreview +% and classoption draft implies the use of class option draftcls + + + + + +\ProvidesClass{IEEEtran}[2015/08/26 V1.8b by Michael Shell] +\typeout{-- See the "IEEEtran_HOWTO" manual for usage information.} +\typeout{-- http://www.michaelshell.org/tex/ieeetran/} +\NeedsTeXFormat{LaTeX2e} + +% IEEEtran.cls version numbers, provided as of V1.3 +% These values serve as a way a .tex file can +% determine if the new features are provided. +% The version number of this IEEEtrans.cls can be obtained from +% these values. i.e., V1.4 +% KEEP THESE AS INTEGERS! i.e., NO {4a} or anything like that- +% (no need to enumerate "a" minor changes here) +\def\IEEEtransversionmajor{1} +\def\IEEEtransversionminor{8} + + +% hook to allow easy changeover to IEEEtran.cls/tools.sty error reporting +\def\@IEEEclspkgerror{\ClassError{IEEEtran}} + + +% These do nothing, but provide them like in article.cls +\newif\if@restonecol +\newif\if@titlepage + + +% class option conditionals +\newif\ifCLASSOPTIONonecolumn \CLASSOPTIONonecolumnfalse +\newif\ifCLASSOPTIONtwocolumn \CLASSOPTIONtwocolumntrue + +\newif\ifCLASSOPTIONoneside \CLASSOPTIONonesidetrue +\newif\ifCLASSOPTIONtwoside \CLASSOPTIONtwosidefalse + +\newif\ifCLASSOPTIONfinal \CLASSOPTIONfinaltrue +\newif\ifCLASSOPTIONdraft \CLASSOPTIONdraftfalse +\newif\ifCLASSOPTIONdraftcls \CLASSOPTIONdraftclsfalse +\newif\ifCLASSOPTIONdraftclsnofoot \CLASSOPTIONdraftclsnofootfalse + +\newif\ifCLASSOPTIONpeerreview \CLASSOPTIONpeerreviewfalse +\newif\ifCLASSOPTIONpeerreviewca \CLASSOPTIONpeerreviewcafalse + +\newif\ifCLASSOPTIONjournal \CLASSOPTIONjournaltrue +\newif\ifCLASSOPTIONconference \CLASSOPTIONconferencefalse +\newif\ifCLASSOPTIONtechnote \CLASSOPTIONtechnotefalse + +\newif\ifCLASSOPTIONnofonttune \CLASSOPTIONnofonttunefalse + +\newif\ifCLASSOPTIONcaptionsoff \CLASSOPTIONcaptionsofffalse + +\newif\ifCLASSOPTIONcomsoc \CLASSOPTIONcomsocfalse +\newif\ifCLASSOPTIONcompsoc \CLASSOPTIONcompsocfalse +\newif\ifCLASSOPTIONtransmag \CLASSOPTIONtransmagfalse + +\newif\ifCLASSOPTIONromanappendices \CLASSOPTIONromanappendicesfalse + + +% class info conditionals + +% indicates if pdf (via pdflatex) output +\newif\ifCLASSINFOpdf \CLASSINFOpdffalse + + +% V1.6b internal flag to show if using a4paper +\newif\if@IEEEusingAfourpaper \@IEEEusingAfourpaperfalse +% V1.6b internal flag to show if using cspaper +\newif\if@IEEEusingcspaper \@IEEEusingcspaperfalse + + +% IEEEtran class scratch pad registers +% dimen +\newdimen\@IEEEtrantmpdimenA +\newdimen\@IEEEtrantmpdimenB +\newdimen\@IEEEtrantmpdimenC +% count +\newcount\@IEEEtrantmpcountA +\newcount\@IEEEtrantmpcountB +\newcount\@IEEEtrantmpcountC +% token list +\newtoks\@IEEEtrantmptoksA + +% we use \CLASSOPTIONpt so that we can ID the point size (even for 9pt docs) +% as well as LaTeX's \@ptsize to retain some compatability with some +% external packages +\def\@ptsize{0} +% LaTeX does not support 9pt, so we set \@ptsize to 0 - same as that of 10pt +\DeclareOption{9pt}{\def\CLASSOPTIONpt{9}\def\@ptsize{0}} +\DeclareOption{10pt}{\def\CLASSOPTIONpt{10}\def\@ptsize{0}} +\DeclareOption{11pt}{\def\CLASSOPTIONpt{11}\def\@ptsize{1}} +\DeclareOption{12pt}{\def\CLASSOPTIONpt{12}\def\@ptsize{2}} + + + +\DeclareOption{letterpaper}{\setlength{\paperwidth}{8.5in}% + \setlength{\paperheight}{11in}% + \@IEEEusingAfourpaperfalse + \@IEEEusingcspaperfalse + \def\CLASSOPTIONpaper{letter}% + \def\CLASSINFOpaperwidth{8.5in}% + \def\CLASSINFOpaperheight{11in}} + + +\DeclareOption{a4paper}{\setlength{\paperwidth}{210mm}% + \setlength{\paperheight}{297mm}% + \@IEEEusingAfourpapertrue + \@IEEEusingcspaperfalse + \def\CLASSOPTIONpaper{a4}% + \def\CLASSINFOpaperwidth{210mm}% + \def\CLASSINFOpaperheight{297mm}} + +% special paper option for compsoc journals +\DeclareOption{cspaper}{\setlength{\paperwidth}{7.875in}% + \setlength{\paperheight}{10.75in}% + \@IEEEusingcspapertrue + \@IEEEusingAfourpaperfalse + \def\CLASSOPTIONpaper{ieeecs}% + \def\CLASSINFOpaperwidth{7.875in}% + \def\CLASSINFOpaperheight{10.75in}} + +\DeclareOption{oneside}{\@twosidefalse\@mparswitchfalse + \CLASSOPTIONonesidetrue\CLASSOPTIONtwosidefalse} +\DeclareOption{twoside}{\@twosidetrue\@mparswitchtrue + \CLASSOPTIONtwosidetrue\CLASSOPTIONonesidefalse} + +\DeclareOption{onecolumn}{\CLASSOPTIONonecolumntrue\CLASSOPTIONtwocolumnfalse} +\DeclareOption{twocolumn}{\CLASSOPTIONtwocolumntrue\CLASSOPTIONonecolumnfalse} + +% If the user selects draft, then this class AND any packages +% will go into draft mode. +\DeclareOption{draft}{\CLASSOPTIONdrafttrue\CLASSOPTIONdraftclstrue + \CLASSOPTIONdraftclsnofootfalse} +% draftcls is for a draft mode which will not affect any packages +% used by the document. +\DeclareOption{draftcls}{\CLASSOPTIONdraftfalse\CLASSOPTIONdraftclstrue + \CLASSOPTIONdraftclsnofootfalse} +% draftclsnofoot is like draftcls, but without the footer. +\DeclareOption{draftclsnofoot}{\CLASSOPTIONdraftfalse\CLASSOPTIONdraftclstrue + \CLASSOPTIONdraftclsnofoottrue} +\DeclareOption{final}{\CLASSOPTIONdraftfalse\CLASSOPTIONdraftclsfalse + \CLASSOPTIONdraftclsnofootfalse} + +\DeclareOption{journal}{\CLASSOPTIONpeerreviewfalse\CLASSOPTIONpeerreviewcafalse + \CLASSOPTIONjournaltrue\CLASSOPTIONconferencefalse\CLASSOPTIONtechnotefalse} + +\DeclareOption{conference}{\CLASSOPTIONpeerreviewfalse\CLASSOPTIONpeerreviewcafalse + \CLASSOPTIONjournalfalse\CLASSOPTIONconferencetrue\CLASSOPTIONtechnotefalse} + +\DeclareOption{technote}{\CLASSOPTIONpeerreviewfalse\CLASSOPTIONpeerreviewcafalse + \CLASSOPTIONjournalfalse\CLASSOPTIONconferencefalse\CLASSOPTIONtechnotetrue} + +\DeclareOption{peerreview}{\CLASSOPTIONpeerreviewtrue\CLASSOPTIONpeerreviewcafalse + \CLASSOPTIONjournalfalse\CLASSOPTIONconferencefalse\CLASSOPTIONtechnotefalse} + +\DeclareOption{peerreviewca}{\CLASSOPTIONpeerreviewtrue\CLASSOPTIONpeerreviewcatrue + \CLASSOPTIONjournalfalse\CLASSOPTIONconferencefalse\CLASSOPTIONtechnotefalse} + +\DeclareOption{nofonttune}{\CLASSOPTIONnofonttunetrue} + +\DeclareOption{captionsoff}{\CLASSOPTIONcaptionsofftrue} + +\DeclareOption{comsoc}{\CLASSOPTIONcomsoctrue\CLASSOPTIONcompsocfalse\CLASSOPTIONtransmagfalse} + +\DeclareOption{compsoc}{\CLASSOPTIONcomsocfalse\CLASSOPTIONcompsoctrue\CLASSOPTIONtransmagfalse} + +\DeclareOption{transmag}{\CLASSOPTIONtransmagtrue\CLASSOPTIONcomsocfalse\CLASSOPTIONcompsocfalse} + +\DeclareOption{romanappendices}{\CLASSOPTIONromanappendicestrue} + + +% default to US letter paper, 10pt, twocolumn, one sided, final, journal +\ExecuteOptions{letterpaper,10pt,twocolumn,oneside,final,journal} +% overrride these defaults per user requests +\ProcessOptions + + + +%% -- Command Argument Scanning Support Functions -- + +% Sets the category codes for punctuation to their normal values. +% For local use with argument scanning. +\def\IEEEnormalcatcodespunct{\catcode`\!=12 \catcode`\,=12 \catcode`\:=12 +\catcode`\;=12 \catcode`\`=12 \catcode`\'=12 \catcode`\"=12 \catcode`\.=12 +\catcode`\/=12 \catcode`\?=12 \catcode`\*=12 \catcode`\+=12 \catcode`\-=12 +\catcode`\<=12 \catcode`\>=12 \catcode`\(=12 \catcode`\)=12 \catcode`\[=12 +\catcode`\]=12 \catcode`\==12 \catcode`\|=12} +% Sets the category codes for numbers to their normal values. +% For local use with argument scanning. +\def\IEEEnormalcatcodesnum{\catcode`\0=12 \catcode`\1=12 \catcode`\2=12 +\catcode`\3=12 \catcode`\4=12 \catcode`\5=12 \catcode`\6=12 \catcode`\7=12 +\catcode`\8=12 \catcode`\9=12} +% combined action of \IEEEnormalcatcodespunct and \IEEEnormalcatcodesnum +\def\IEEEnormalcatcodes{\IEEEnormalcatcodespunct\IEEEnormalcatcodesnum} + + +% usage: \@IEEEextracttoken*{} +% \@IEEEextracttoken fully expands its argument (which it then stores in +% \@IEEEextracttokenarg) via \edef and then the meaning of the first +% nonbrace (but including the empty group) token found is assigned via \let +% to \@IEEEextractedtoken as well as stored in the macro +% \@IEEEextractedtokenmacro. Tokens that would otherwise be discarded during +% the acquisition of the first are stored in \@IEEEextractedtokensdiscarded, +% however their original relative brace nesting depths are not guaranteed to +% be preserved. +% If the argument is empty, or if a first nonbrace token does not exist (or +% is an empty group), \@IEEEextractedtoken will be \relax and +% \@IEEEextractedtokenmacro and \@IEEEextractedtokensdiscarded will be empty. +% +% For example: +% \@IEEEextracttoken{{{ab}{cd}}{{ef}g}} +% results in: +% +% \@IEEEextracttokenarg ==> a macro containing {{ab}{cd}}{{ef}g} +% \@IEEEextractedtoken ==> the letter a +% \@IEEEextractedtokenmacro ==> a macro containing a +% \@IEEEextractedtokensdiscarded ==> a macro containing bcd{ef}g +% +% the *-star form, \@IEEEextracttoken*, does not expand its argument +% contents during processing. +\def\@IEEEextracttoken{\@ifstar{\let\@IEEEextracttokendef=\def\@@IEEEextracttoken}{\let\@IEEEextracttokendef=\edef\@@IEEEextracttoken}} + +\def\@@IEEEextracttoken#1{\@IEEEextracttokendef\@IEEEextracttokenarg{#1}\relax +\def\@IEEEextractedtokensdiscarded{}\relax % initialize to empty +% if the macro is unchanged after being acquired as a single undelimited argument +% with anything after it being stripped off as a delimited argument +% we know we have one token without any enclosing braces. loop until this is true. +\let\@IEEEextracttokencurgroup\@IEEEextracttokenarg +\loop + % trap case of an empty argument as this would cause a problem with + % \@@@IEEEextracttoken's first (nondelimited) argument acquisition + \ifx\@IEEEextracttokencurgroup\@empty + \def\@IEEEextractedtokenmacro{}\relax + \else + \expandafter\@@@IEEEextracttoken\@IEEEextracttokencurgroup\@IEEEgeneralsequenceDELIMITER\relax + \fi + \ifx\@IEEEextractedtokenmacro\@IEEEextracttokencurgroup + \else + \let\@IEEEextracttokencurgroup=\@IEEEextractedtokenmacro +\repeat +% we can safely do a \let= here because there should be at most one token +% the relax is needed to handle the case of no token found +\expandafter\let\expandafter\@IEEEextractedtoken\@IEEEextractedtokenmacro\relax} + +\def\@@@IEEEextracttoken#1#2\@IEEEgeneralsequenceDELIMITER{\def\@IEEEextractedtokenmacro{#1}\relax +\def\@@IEEEextractedtokensdiscarded{#2}\expandafter\expandafter\expandafter\def\expandafter\expandafter\expandafter +\@IEEEextractedtokensdiscarded\expandafter\expandafter\expandafter +{\expandafter\@@IEEEextractedtokensdiscarded\@IEEEextractedtokensdiscarded}} +%% +%% -- End of Command Argument Scanning Support Functions -- + + + +% Computer Society conditional execution command +\long\def\@IEEEcompsoconly#1{\relax\ifCLASSOPTIONcompsoc\relax#1\relax\fi\relax} +% inverse +\long\def\@IEEEnotcompsoconly#1{\relax\ifCLASSOPTIONcompsoc\else\relax#1\relax\fi\relax} +% compsoc conference +\long\def\@IEEEcompsocconfonly#1{\relax\ifCLASSOPTIONcompsoc\ifCLASSOPTIONconference\relax#1\relax\fi\fi\relax} +% compsoc not conference +\long\def\@IEEEcompsocnotconfonly#1{\relax\ifCLASSOPTIONcompsoc\ifCLASSOPTIONconference\else\relax#1\relax\fi\fi\relax} + + +% comsoc verify that newtxmath, mtpro2, mt11p or mathtime has been loaded +\def\@IEEEcomsocverifymathfont{\typeout{-- Verifying Times compatible math font.}\relax + \@ifpackageloaded{newtxmath}{\typeout{-- newtxmath loaded, OK.}}{\@@IEEEcomsocverifymathfont}} +\def\@@IEEEcomsocverifymathfont{\@ifpackageloaded{mtpro2}{\typeout{-- mtpro2 loaded, OK.}}{\@@@IEEEcomsocverifymathfont}} +\def\@@@IEEEcomsocverifymathfont{\@ifpackageloaded{mt11p}{\typeout{-- mt11p2 loaded, OK.}}{\@@@@IEEEcomsocverifymathfont}} +\def\@@@@IEEEcomsocverifymathfont{\@ifpackageloaded{mathtime}{\typeout{-- mathtime loaded, OK.}}{\@IEEEcomsocenforcemathfont}} + +% comsoc, if a Times math font was not loaded by user, enforce it +\def\@IEEEcomsocenforcemathfont{\typeout{** Times compatible math font not found, forcing.}\relax +\IfFileExists{newtxmath.sty}{\typeout{-- Found newtxmath, loading.}\RequirePackage{newtxmath}}{\@@IEEEcomsocenforcemathfont}} +\def\@@IEEEcomsocenforcemathfont{\IfFileExists{mtpro2.sty}{\typeout{-- Found mtpro2, loading.}\RequirePackage{mtpro2}}{\@@@IEEEcomsocenforcemathfont}} +\def\@@@IEEEcomsocenforcemathfont{\IfFileExists{mt11p.sty}{\typeout{-- Found mt11p, loading.}\RequirePackage{mt11p}}{\@@@@IEEEcomsocenforcemathfont}} +\def\@@@@IEEEcomsocenforcemathfont{\IfFileExists{mathtime.sty}{\typeout{-- Found mathtime, loading.}\RequirePackage{mathtime}}{\@@@@@IEEEcomsocenforcemathfont}} +% if no acceptable Times math font package found, error with newtxmath requirement +\def\@@@@@IEEEcomsocenforcemathfont{\typeout{** No Times compatible math font package found. newtxmath is required.}\RequirePackage{newtxmath}} + + +\ifCLASSOPTIONcomsoc + % ensure that if newtxmath is used, the cmintegrals option is also invoked + \PassOptionsToPackage{cmintegrals}{newtxmath} + % comsoc requires a Times like math font + % ensure this requirement is satisfied at document start + \AtBeginDocument{\@IEEEcomsocverifymathfont} +\fi + + + +% The IEEE uses Times Roman font, so we'll default to Times. +% These three commands make up the entire times.sty package. +\renewcommand{\sfdefault}{phv} +\renewcommand{\rmdefault}{ptm} +\renewcommand{\ttdefault}{pcr} + +% V1.7 compsoc nonconference papers, use Palatino/Palladio as the main text font, +% not Times Roman. +\@IEEEcompsocnotconfonly{\renewcommand{\rmdefault}{ppl}} + +% enable the selected main text font +\normalfont\selectfont + + +\ifCLASSOPTIONcomsoc + \typeout{-- Using IEEE Communications Society mode.} +\fi + +\ifCLASSOPTIONcompsoc + \typeout{-- Using IEEE Computer Society mode.} +\fi + + +% V1.7 conference notice message hook +\def\@IEEEconsolenoticeconference{\typeout{}% +\typeout{** Conference Paper **}% +\typeout{Before submitting the final camera ready copy, remember to:}% +\typeout{}% +\typeout{ 1. Manually equalize the lengths of two columns on the last page}% +\typeout{ of your paper;}% +\typeout{}% +\typeout{ 2. Ensure that any PostScript and/or PDF output post-processing}% +\typeout{ uses only Type 1 fonts and that every step in the generation}% +\typeout{ process uses the appropriate paper size.}% +\typeout{}} + + +% we can send console reminder messages to the user here +\AtEndDocument{\ifCLASSOPTIONconference\@IEEEconsolenoticeconference\fi} + + +% warn about the use of single column other than for draft mode +\ifCLASSOPTIONtwocolumn\else% + \ifCLASSOPTIONdraftcls\else% + \typeout{** ATTENTION: Single column mode is not typically used with IEEE publications.}% + \fi% +\fi + + +% V1.7 improved paper size setting code. +% Set pdfpage and dvips paper sizes. Conditional tests are similar to that +% of ifpdf.sty. Retain within {} to ensure tested macros are never altered, +% even if only effect is to set them to \relax. +% if \pdfoutput is undefined or equal to relax, output a dvips special +{\@ifundefined{pdfoutput}{\AtBeginDvi{\special{papersize=\CLASSINFOpaperwidth,\CLASSINFOpaperheight}}}{% +% pdfoutput is defined and not equal to \relax +% check for pdfpageheight existence just in case someone sets pdfoutput +% under non-pdflatex. If exists, set them regardless of value of \pdfoutput. +\@ifundefined{pdfpageheight}{\relax}{\global\pdfpagewidth\paperwidth +\global\pdfpageheight\paperheight}% +% if using \pdfoutput=0 under pdflatex, send dvips papersize special +\ifcase\pdfoutput +\AtBeginDvi{\special{papersize=\CLASSINFOpaperwidth,\CLASSINFOpaperheight}}% +\else +% we are using pdf output, set CLASSINFOpdf flag +\global\CLASSINFOpdftrue +\fi}} + +% let the user know the selected papersize +\typeout{-- Using \CLASSINFOpaperwidth\space x \CLASSINFOpaperheight\space +(\CLASSOPTIONpaper)\space paper.} + +\ifCLASSINFOpdf +\typeout{-- Using PDF output.} +\else +\typeout{-- Using DVI output.} +\fi + + +% The idea hinted here is for LaTeX to generate markleft{} and markright{} +% automatically for you after you enter \author{}, \journal{}, +% \journaldate{}, journalvol{}, \journalnum{}, etc. +% However, there may be some backward compatibility issues here as +% well as some special applications for IEEEtran.cls and special issues +% that may require the flexible \markleft{}, \markright{} and/or \markboth{}. +% We'll leave this as an open future suggestion. +%\newcommand{\journal}[1]{\def\@journal{#1}} +%\def\@journal{} + + + +% pointsize values +% used with ifx to determine the document's normal size +\def\@IEEEptsizenine{9} +\def\@IEEEptsizeten{10} +\def\@IEEEptsizeeleven{11} +\def\@IEEEptsizetwelve{12} + + + +% FONT DEFINITIONS (No sizexx.clo file needed) +% V1.6 revised font sizes, displayskip values and +% revised normalsize baselineskip to reduce underfull vbox problems +% on the 58pc = 696pt = 9.5in text height we want +% normalsize #lines/column baselineskip (aka leading) +% 9pt 63 11.0476pt (truncated down) +% 10pt 58 12pt (exact) +% 11pt 52 13.3846pt (truncated down) +% 12pt 50 13.92pt (exact) +% + +% we need to store the nominal baselineskip for the given font size +% in case baselinestretch ever changes. +% this is a dimen, so it will not hold stretch or shrink +\newdimen\@IEEEnormalsizeunitybaselineskip +\@IEEEnormalsizeunitybaselineskip\baselineskip + + + +%% ******* WARNING! ******* +%% +%% Authors should not alter font sizes, baselineskip ("leading"), +%% margins or other spacing values in an attempt to squeeze more +%% material on each page. +%% +%% The IEEE's own typesetting software will restore the correct +%% values when re-typesetting/proofing the submitted document, +%% possibly resulting in unexpected article over length charges. +%% +%% ******* WARNING! ******* + + +% 9pt option defaults +\ifx\CLASSOPTIONpt\@IEEEptsizenine +\typeout{-- This is a 9 point document.} +\def\normalsize{\@setfontsize{\normalsize}{9}{11.0476pt}} +\setlength{\@IEEEnormalsizeunitybaselineskip}{11.0476pt} +\normalsize +\abovedisplayskip 1.5ex plus 3pt minus 1pt +\belowdisplayskip \abovedisplayskip +\abovedisplayshortskip 0pt plus 3pt +\belowdisplayshortskip 1.5ex plus 3pt minus 1pt +\def\small{\@setfontsize{\small}{8.5}{10pt}} +\def\footnotesize{\@setfontsize{\footnotesize}{8}{9pt}} +\def\scriptsize{\@setfontsize{\scriptsize}{7}{8pt}} +\def\tiny{\@setfontsize{\tiny}{5}{6pt}} +% sublargesize is the same as large - 10pt +\def\sublargesize{\@setfontsize{\sublargesize}{10}{12pt}} +\def\large{\@setfontsize{\large}{10}{12pt}} +\def\Large{\@setfontsize{\Large}{12}{14pt}} +\def\LARGE{\@setfontsize{\LARGE}{14}{17pt}} +\def\huge{\@setfontsize{\huge}{17}{20pt}} +\def\Huge{\@setfontsize{\Huge}{20}{24pt}} +\fi +% +% 10pt option defaults +\ifx\CLASSOPTIONpt\@IEEEptsizeten +\typeout{-- This is a 10 point document.} +\def\normalsize{\@setfontsize{\normalsize}{10}{12.00pt}} +\setlength{\@IEEEnormalsizeunitybaselineskip}{12pt} +\normalsize +\abovedisplayskip 1.5ex plus 4pt minus 2pt +\belowdisplayskip \abovedisplayskip +\abovedisplayshortskip 0pt plus 4pt +\belowdisplayshortskip 1.5ex plus 4pt minus 2pt +\def\small{\@setfontsize{\small}{9}{10pt}} +\def\footnotesize{\@setfontsize{\footnotesize}{8}{9pt}} +\def\scriptsize{\@setfontsize{\scriptsize}{7}{8pt}} +\def\tiny{\@setfontsize{\tiny}{5}{6pt}} +% sublargesize is a tad smaller than large - 11pt +\def\sublargesize{\@setfontsize{\sublargesize}{11}{13.4pt}} +\def\large{\@setfontsize{\large}{12}{14pt}} +\def\Large{\@setfontsize{\Large}{14}{17pt}} +\def\LARGE{\@setfontsize{\LARGE}{17}{20pt}} +\def\huge{\@setfontsize{\huge}{20}{24pt}} +\def\Huge{\@setfontsize{\Huge}{24}{28pt}} +\fi +% +% 11pt option defaults +\ifx\CLASSOPTIONpt\@IEEEptsizeeleven +\typeout{-- This is an 11 point document.} +\def\normalsize{\@setfontsize{\normalsize}{11}{13.3846pt}} +\setlength{\@IEEEnormalsizeunitybaselineskip}{13.3846pt} +\normalsize +\abovedisplayskip 1.5ex plus 5pt minus 3pt +\belowdisplayskip \abovedisplayskip +\abovedisplayshortskip 0pt plus 5pt +\belowdisplayshortskip 1.5ex plus 5pt minus 3pt +\def\small{\@setfontsize{\small}{10}{12pt}} +\def\footnotesize{\@setfontsize{\footnotesize}{9}{10.5pt}} +\def\scriptsize{\@setfontsize{\scriptsize}{8}{9pt}} +\def\tiny{\@setfontsize{\tiny}{6}{7pt}} +% sublargesize is the same as large - 12pt +\def\sublargesize{\@setfontsize{\sublargesize}{12}{14pt}} +\def\large{\@setfontsize{\large}{12}{14pt}} +\def\Large{\@setfontsize{\Large}{14}{17pt}} +\def\LARGE{\@setfontsize{\LARGE}{17}{20pt}} +\def\huge{\@setfontsize{\huge}{20}{24pt}} +\def\Huge{\@setfontsize{\Huge}{24}{28pt}} +\fi +% +% 12pt option defaults +\ifx\CLASSOPTIONpt\@IEEEptsizetwelve +\typeout{-- This is a 12 point document.} +\def\normalsize{\@setfontsize{\normalsize}{12}{13.92pt}} +\setlength{\@IEEEnormalsizeunitybaselineskip}{13.92pt} +\normalsize +\abovedisplayskip 1.5ex plus 6pt minus 4pt +\belowdisplayskip \abovedisplayskip +\abovedisplayshortskip 0pt plus 6pt +\belowdisplayshortskip 1.5ex plus 6pt minus 4pt +\def\small{\@setfontsize{\small}{10}{12pt}} +\def\footnotesize{\@setfontsize{\footnotesize}{9}{10.5pt}} +\def\scriptsize{\@setfontsize{\scriptsize}{8}{9pt}} +\def\tiny{\@setfontsize{\tiny}{6}{7pt}} +% sublargesize is the same as large - 14pt +\def\sublargesize{\@setfontsize{\sublargesize}{14}{17pt}} +\def\large{\@setfontsize{\large}{14}{17pt}} +\def\Large{\@setfontsize{\Large}{17}{20pt}} +\def\LARGE{\@setfontsize{\LARGE}{20}{24pt}} +\def\huge{\@setfontsize{\huge}{22}{26pt}} +\def\Huge{\@setfontsize{\Huge}{24}{28pt}} +\fi + + + +% V1.8a compsoc font sizes +% compsoc font sizes use bp "Postscript" point units (1/72in) +% rather than the traditional pt (1/72.27) +\ifCLASSOPTIONcompsoc +% -- compsoc defaults -- +% ** will override some of these values later ** +% 9pt +\ifx\CLASSOPTIONpt\@IEEEptsizenine +\def\normalsize{\@setfontsize{\normalsize}{9bp}{11bp}} +\setlength{\@IEEEnormalsizeunitybaselineskip}{11bp} +\normalsize +\abovedisplayskip 1.5ex plus 3bp minus 1bp +\belowdisplayskip \abovedisplayskip +\abovedisplayshortskip 0bp plus 3bp +\belowdisplayshortskip 1.5ex plus 3bp minus 1bp +\def\small{\@setfontsize{\small}{8.5bp}{10bp}} +\def\footnotesize{\@setfontsize{\footnotesize}{8bp}{9bp}} +\def\scriptsize{\@setfontsize{\scriptsize}{7bp}{8bp}} +\def\tiny{\@setfontsize{\tiny}{5bp}{6bp}} +% sublargesize is the same as large - 10bp +\def\sublargesize{\@setfontsize{\sublargesize}{10bp}{12bp}} +\def\large{\@setfontsize{\large}{10bp}{12bp}} +\def\Large{\@setfontsize{\Large}{12bp}{14bp}} +\def\LARGE{\@setfontsize{\LARGE}{14bp}{17bp}} +\def\huge{\@setfontsize{\huge}{17bp}{20bp}} +\def\Huge{\@setfontsize{\Huge}{20bp}{24bp}} +\fi +% +% 10pt +\ifx\CLASSOPTIONpt\@IEEEptsizeten +\def\normalsize{\@setfontsize{\normalsize}{10bp}{12bp}} +\setlength{\@IEEEnormalsizeunitybaselineskip}{12bp} +\normalsize +\abovedisplayskip 1.5ex plus 4bp minus 2bp +\belowdisplayskip \abovedisplayskip +\abovedisplayshortskip 0pt plus 4bp +\belowdisplayshortskip 1.5ex plus 4bp minus 2bp +\def\small{\@setfontsize{\small}{9bp}{10bp}} +\def\footnotesize{\@setfontsize{\footnotesize}{8bp}{9bp}} +\def\scriptsize{\@setfontsize{\scriptsize}{7bp}{8bp}} +\def\tiny{\@setfontsize{\tiny}{5bp}{6bp}} +% sublargesize is a tad smaller than large - 11bp +\def\sublargesize{\@setfontsize{\sublargesize}{11bp}{13.5bp}} +\def\large{\@setfontsize{\large}{12bp}{14bp}} +\def\Large{\@setfontsize{\Large}{14bp}{17bp}} +\def\LARGE{\@setfontsize{\LARGE}{17bp}{20bp}} +\def\huge{\@setfontsize{\huge}{20bp}{24bp}} +\def\Huge{\@setfontsize{\Huge}{24bp}{28bp}} +\fi +% +% 11pt +\ifx\CLASSOPTIONpt\@IEEEptsizeeleven +\def\normalsize{\@setfontsize{\normalsize}{11bp}{13.5bp}} +\setlength{\@IEEEnormalsizeunitybaselineskip}{13.5bp} +\normalsize +\abovedisplayskip 1.5ex plus 5bp minus 3bp +\belowdisplayskip \abovedisplayskip +\abovedisplayshortskip 0pt plus 5bp +\belowdisplayshortskip 1.5ex plus 5bp minus 3bp +\def\small{\@setfontsize{\small}{10bp}{12bp}} +\def\footnotesize{\@setfontsize{\footnotesize}{9bp}{10.5bp}} +\def\scriptsize{\@setfontsize{\scriptsize}{8bp}{9bp}} +\def\tiny{\@setfontsize{\tiny}{6bp}{7bp}} +% sublargesize is the same as large - 12bp +\def\sublargesize{\@setfontsize{\sublargesize}{12bp}{14bp}} +\def\large{\@setfontsize{\large}{12bp}{14bp}} +\def\Large{\@setfontsize{\Large}{14bp}{17bp}} +\def\LARGE{\@setfontsize{\LARGE}{17bp}{20bp}} +\def\huge{\@setfontsize{\huge}{20bp}{24bp}} +\def\Huge{\@setfontsize{\Huge}{24bp}{28bp}} +\fi +% +% 12pt +\ifx\CLASSOPTIONpt\@IEEEptsizetwelve +\def\normalsize{\@setfontsize{\normalsize}{12bp}{14bp}}% +\setlength{\@IEEEnormalsizeunitybaselineskip}{14bp}% +\normalsize +\abovedisplayskip 1.5ex plus 6bp minus 4bp +\belowdisplayskip \abovedisplayskip +\abovedisplayshortskip 0pt plus 6bp +\belowdisplayshortskip 1.5ex plus 6bp minus 4bp +\def\small{\@setfontsize{\small}{10bp}{12bp}} +\def\footnotesize{\@setfontsize{\footnotesize}{9bp}{10.5bp}} +\def\scriptsize{\@setfontsize{\scriptsize}{8bp}{9bp}} +\def\tiny{\@setfontsize{\tiny}{6bp}{7bp}} +% sublargesize is the same as large - 14bp +\def\sublargesize{\@setfontsize{\sublargesize}{14bp}{17bp}} +\def\large{\@setfontsize{\large}{14bp}{17bp}} +\def\Large{\@setfontsize{\Large}{17bp}{20bp}} +\def\LARGE{\@setfontsize{\LARGE}{20bp}{24bp}} +\def\huge{\@setfontsize{\huge}{22bp}{26bp}} +\def\Huge{\@setfontsize{\Huge}{24bp}{28bp}} +\fi +% +% -- override defaults: compsoc journals use special normalsizes -- +\ifCLASSOPTIONconference +% +% compsoc conferences +% 9pt +\ifx\CLASSOPTIONpt\@IEEEptsizenine +\def\normalsize{\@setfontsize{\normalsize}{9bp}{10.8bp}} +\setlength{\@IEEEnormalsizeunitybaselineskip}{10.8bp} +\normalsize +\abovedisplayskip 1.5ex plus 3bp minus 1bp +\belowdisplayskip \abovedisplayskip +\abovedisplayshortskip 0bp plus 3bp +\belowdisplayshortskip 1.5ex plus 3bp minus 1bp +\fi +% 10pt +\ifx\CLASSOPTIONpt\@IEEEptsizeten +\def\normalsize{\@setfontsize{\normalsize}{10bp}{11.2bp}} +\setlength{\@IEEEnormalsizeunitybaselineskip}{11.2bp} +\normalsize +\abovedisplayskip 1.5ex plus 4bp minus 2bp +\belowdisplayskip \abovedisplayskip +\abovedisplayshortskip 0pt plus 4bp +\belowdisplayshortskip 1.5ex plus 4bp minus 2bp +\fi +% 11pt +\ifx\CLASSOPTIONpt\@IEEEptsizeeleven +\def\normalsize{\@setfontsize{\normalsize}{11bp}{13.2bp}} +\setlength{\@IEEEnormalsizeunitybaselineskip}{13.2bp} +\normalsize +\abovedisplayskip 1.5ex plus 5bp minus 3bp +\belowdisplayskip \abovedisplayskip +\abovedisplayshortskip 0pt plus 5bp +\belowdisplayshortskip 1.5ex plus 5bp minus 3bp +\fi +% 12pt +\ifx\CLASSOPTIONpt\@IEEEptsizetwelve +\def\normalsize{\@setfontsize{\normalsize}{12bp}{14.4bp}} +\setlength{\@IEEEnormalsizeunitybaselineskip}{14.4bp} +\normalsize +\abovedisplayskip 1.5ex plus 6bp minus 4bp +\belowdisplayskip \abovedisplayskip +\abovedisplayshortskip 0pt plus 6bp +\belowdisplayshortskip 1.5ex plus 6bp minus 4bp +\fi +% +% compsoc nonconferences +\else +% 9pt +\ifx\CLASSOPTIONpt\@IEEEptsizenine +\def\normalsize{\@setfontsize{\normalsize}{9bp}{10.8bp}} +\setlength{\@IEEEnormalsizeunitybaselineskip}{10.8bp} +\normalsize +\abovedisplayskip 1.5ex plus 3bp minus 1bp +\belowdisplayskip \abovedisplayskip +\abovedisplayshortskip 0bp plus 3bp +\belowdisplayshortskip 1.5ex plus 3bp minus 1bp +\fi +% 10pt +\ifx\CLASSOPTIONpt\@IEEEptsizeten +% the official spec is 9.5bp with 11.4bp leading for 10pt, +% but measurements of proofs suggest upto 11.723bp leading +% here we'll use 11.54bp which gives 61 lines per column +% with the standard compsoc margins +\def\normalsize{\@setfontsize{\normalsize}{9.5bp}{11.54bp}} +\setlength{\@IEEEnormalsizeunitybaselineskip}{11.54bp} +\normalsize +\abovedisplayskip 1.5ex plus 4bp minus 2bp +\belowdisplayskip \abovedisplayskip +\abovedisplayshortskip 0pt plus 4bp +\belowdisplayshortskip 1.5ex plus 4bp minus 2bp +\fi +% 11pt +\ifx\CLASSOPTIONpt\@IEEEptsizeeleven +\def\normalsize{\@setfontsize{\normalsize}{11bp}{13.2bp}} +\setlength{\@IEEEnormalsizeunitybaselineskip}{13.2bp} +\normalsize +\abovedisplayskip 1.5ex plus 5bp minus 3bp +\belowdisplayskip \abovedisplayskip +\abovedisplayshortskip 0pt plus 5bp +\belowdisplayshortskip 1.5ex plus 5bp minus 3bp +\fi +% 12pt +\ifx\CLASSOPTIONpt\@IEEEptsizetwelve +\def\normalsize{\@setfontsize{\normalsize}{12bp}{14.4bp}} +\setlength{\@IEEEnormalsizeunitybaselineskip}{14.4bp} +\normalsize +\abovedisplayskip 1.5ex plus 6bp minus 4bp +\belowdisplayskip \abovedisplayskip +\abovedisplayshortskip 0pt plus 6bp +\belowdisplayshortskip 1.5ex plus 6bp minus 4bp +\fi +\fi\fi + + + + +% V1.6 The Computer Modern Fonts will issue a substitution warning for +% 24pt titles (24.88pt is used instead, but the default and correct +% Times font will scale exactly as needed) increase the substitution +% tolerance to turn off this warning. +% +% V1.8a, the compsoc bp font sizes can also cause bogus font substitution +% warnings with footnote or scriptsize math and the $\bullet$ itemized +% list of \IEEEcompsocitemizethanks. So, increase this to 1.5pt or more. +\def\fontsubfuzz{1.7bp} + + +% warn the user in case they forget to use the 9pt option with +% technote +\ifCLASSOPTIONtechnote% + \ifx\CLASSOPTIONpt\@IEEEptsizenine\else% + \typeout{** ATTENTION: Technotes are normally 9pt documents.}% + \fi% +\fi + + +% V1.7 +% Improved \textunderscore to provide a much better fake _ when used with +% OT1 encoding. Under OT1, detect use of pcr or cmtt \ttfamily and use +% available true _ glyph for those two typewriter fonts. +\def\@IEEEstringptm{ptm} % Times Roman family +\def\@IEEEstringppl{ppl} % Palatino Roman family +\def\@IEEEstringphv{phv} % Helvetica Sans Serif family +\def\@IEEEstringpcr{pcr} % Courier typewriter family +\def\@IEEEstringcmtt{cmtt} % Computer Modern typewriter family +\DeclareTextCommandDefault{\textunderscore}{\leavevmode +\ifx\f@family\@IEEEstringpcr\string_\else +\ifx\f@family\@IEEEstringcmtt\string_\else +\ifx\f@family\@IEEEstringptm\kern 0em\vbox{\hrule\@width 0.5em\@height 0.5pt\kern -0.3ex}\else +\ifx\f@family\@IEEEstringppl\kern 0em\vbox{\hrule\@width 0.5em\@height 0.5pt\kern -0.3ex}\else +\ifx\f@family\@IEEEstringphv\kern -0.03em\vbox{\hrule\@width 0.62em\@height 0.52pt\kern -0.33ex}\kern -0.03em\else +\kern 0.09em\vbox{\hrule\@width 0.6em\@height 0.44pt\kern -0.63pt\kern -0.42ex}\kern 0.09em\fi\fi\fi\fi\fi\relax} + + + + +% set the default \baselinestretch +\def\baselinestretch{1} +\ifCLASSOPTIONdraftcls + \def\baselinestretch{1.5}% default baselinestretch for draft modes +\fi + + +% process CLASSINPUT baselinestretch +\ifx\CLASSINPUTbaselinestretch\@IEEEundefined +\else + \edef\baselinestretch{\CLASSINPUTbaselinestretch} % user CLASSINPUT override + \typeout{** ATTENTION: Overriding \string\baselinestretch\space to + \baselinestretch\space via \string\CLASSINPUT.} +\fi + +\small\normalsize % make \baselinestretch take affect + + + + +% store the normalsize baselineskip +\newdimen\CLASSINFOnormalsizebaselineskip +\CLASSINFOnormalsizebaselineskip=\baselineskip\relax +% and the normalsize unity (baselinestretch=1) baselineskip +% we could save a register by giving the user access to +% \@IEEEnormalsizeunitybaselineskip. However, let's protect +% its read only internal status +\newdimen\CLASSINFOnormalsizeunitybaselineskip +\CLASSINFOnormalsizeunitybaselineskip=\@IEEEnormalsizeunitybaselineskip\relax +% store the nominal value of jot +\newdimen\IEEEnormaljot +\IEEEnormaljot=0.25\baselineskip\relax + +% set \jot +\jot=\IEEEnormaljot\relax + + + + +% V1.6, we are now going to fine tune the interword spacing +% The default interword glue for Times under TeX appears to use a +% nominal interword spacing of 25% (relative to the font size, i.e., 1em) +% a maximum of 40% and a minimum of 19%. +% For example, 10pt text uses an interword glue of: +% +% 2.5pt plus 1.49998pt minus 0.59998pt +% +% However, the IEEE allows for a more generous range which reduces the need +% for hyphenation, especially for two column text. Furthermore, the IEEE +% tends to use a little bit more nominal space between the words. +% The IEEE's interword spacing percentages appear to be: +% 35% nominal +% 23% minimum +% 50% maximum +% (They may even be using a tad more for the largest fonts such as 24pt.) +% +% for bold text, the IEEE increases the spacing a little more: +% 37.5% nominal +% 23% minimum +% 55% maximum + +% here are the interword spacing ratios we'll use +% for medium (normal weight) +\def\@IEEEinterspaceratioM{0.35} +\def\@IEEEinterspaceMINratioM{0.23} +\def\@IEEEinterspaceMAXratioM{0.50} + +% for bold +\def\@IEEEinterspaceratioB{0.375} +\def\@IEEEinterspaceMINratioB{0.23} +\def\@IEEEinterspaceMAXratioB{0.55} + + +% compsoc nonconference papers use Palatino, +% tweak settings to better match the proofs +\ifCLASSOPTIONcompsoc +\ifCLASSOPTIONconference\else +% for medium (normal weight) +\def\@IEEEinterspaceratioM{0.28} +\def\@IEEEinterspaceMINratioM{0.21} +\def\@IEEEinterspaceMAXratioM{0.47} +% for bold +\def\@IEEEinterspaceratioB{0.305} +\def\@IEEEinterspaceMINratioB{0.21} +\def\@IEEEinterspaceMAXratioB{0.52} +\fi\fi + + +% command to revise the interword spacing for the current font under TeX: +% \fontdimen2 = nominal interword space +% \fontdimen3 = interword stretch +% \fontdimen4 = interword shrink +% since all changes to the \fontdimen are global, we can enclose these commands +% in braces to confine any font attribute or length changes +\def\@@@IEEEsetfontdimens#1#2#3{{% +\setlength{\@IEEEtrantmpdimenB}{\f@size pt}% grab the font size in pt, could use 1em instead. +\setlength{\@IEEEtrantmpdimenA}{#1\@IEEEtrantmpdimenB}% +\fontdimen2\font=\@IEEEtrantmpdimenA\relax +\addtolength{\@IEEEtrantmpdimenA}{-#2\@IEEEtrantmpdimenB}% +\fontdimen3\font=-\@IEEEtrantmpdimenA\relax +\setlength{\@IEEEtrantmpdimenA}{#1\@IEEEtrantmpdimenB}% +\addtolength{\@IEEEtrantmpdimenA}{-#3\@IEEEtrantmpdimenB}% +\fontdimen4\font=\@IEEEtrantmpdimenA\relax}} + +% revise the interword spacing for each font weight +\def\@@IEEEsetfontdimens{{% +\mdseries +\@@@IEEEsetfontdimens{\@IEEEinterspaceratioM}{\@IEEEinterspaceMAXratioM}{\@IEEEinterspaceMINratioM}% +\bfseries +\@@@IEEEsetfontdimens{\@IEEEinterspaceratioB}{\@IEEEinterspaceMAXratioB}{\@IEEEinterspaceMINratioB}% +}} + +% revise the interword spacing for each font shape +% \slshape is not often used for IEEE work and is not altered here. The \scshape caps are +% already a tad too large in the free LaTeX fonts (as compared to what the IEEE uses) so we +% won't alter these either. +\def\@IEEEsetfontdimens{{% +\normalfont +\@@IEEEsetfontdimens +\normalfont\itshape +\@@IEEEsetfontdimens +}} + +% command to revise the interword spacing for each font size (and shape +% and weight). Only the \rmfamily is done here as \ttfamily uses a +% fixed spacing and \sffamily is not used as the main text of IEEE papers. +\def\@IEEEtunefonts{{\selectfont\rmfamily +\tiny\@IEEEsetfontdimens +\scriptsize\@IEEEsetfontdimens +\footnotesize\@IEEEsetfontdimens +\small\@IEEEsetfontdimens +\normalsize\@IEEEsetfontdimens +\sublargesize\@IEEEsetfontdimens +\large\@IEEEsetfontdimens +\LARGE\@IEEEsetfontdimens +\huge\@IEEEsetfontdimens +\Huge\@IEEEsetfontdimens}} + +% if the nofonttune class option is not given, revise the interword spacing +% now - in case IEEEtran makes any default length measurements, and make +% sure all the default fonts are loaded +\ifCLASSOPTIONnofonttune\else +\@IEEEtunefonts +\fi + +% and again at the start of the document in case the user loaded different fonts +\AtBeginDocument{\ifCLASSOPTIONnofonttune\else\@IEEEtunefonts\fi} + + + + + +% -- V1.8a page setup commands -- + +% The default sample text for calculating margins +% Note that IEEE publications use \scriptsize for headers and footers. +\def\IEEEdefaultsampletext{\normalfont\normalsize gT} +\def\IEEEdefaultheadersampletext{\normalfont\scriptsize T}% IEEE headers default to uppercase +\def\IEEEdefaultfootersampletext{\normalfont\scriptsize gT} + + + +% usage: \IEEEsettextwidth{inner margin}{outer margin} +% Sets \textwidth to allow the specified inner and outer margins +% for the current \paperwidth. +\def\IEEEsettextwidth#1#2{\@IEEEtrantmpdimenA\paperwidth +\@IEEEtrantmpdimenB#1\relax +\advance\@IEEEtrantmpdimenA by -\@IEEEtrantmpdimenB +\@IEEEtrantmpdimenB#2\relax +\advance\@IEEEtrantmpdimenA by -\@IEEEtrantmpdimenB +\textwidth\@IEEEtrantmpdimenA} + + + +% usage: \IEEEsetsidemargin{mode: i, o, c, a}{margin/offset} +% Sets \oddsidemargin and \evensidemargin to yield the specified margin +% of the given mode. +% The available modes are: +% i = inner margin +% o = outer margin +% c = centered, with the given offset +% a = adjust the margins using the given offset +% For the offsets, positive values increase the inner margin. +% \textwidth should be set properly for the given margins before calling this +% function. +\def\IEEEsetsidemargin#1#2{\@IEEEtrantmpdimenA #2\relax +\@IEEEextracttoken{#1}\relax +% check for mode errors +\ifx\@IEEEextractedtokenmacro\@empty + \@IEEEclspkgerror{Empty mode type in \string\IEEEsetsidemargin\space (line \the\inputlineno).\MessageBreak + Defaulting to `i'}{Valid modes for \string\IEEEsetsidemargin\space are: i, o, c and a.}\relax + \let\@IEEEextractedtoken=i\relax + \def\@IEEEextractedtokenmacro{i}\relax +\else + \ifx\@IEEEextractedtokensdiscarded\@empty\else + \typeout{** WARNING: \string\IEEEsetsidemargin\space mode specifiers after the first in `\@IEEEextracttokenarg' ignored (line \the\inputlineno).}\relax + \fi +\fi +% handle each mode +\if\@IEEEextractedtoken a\relax + \advance\oddsidemargin by \@IEEEtrantmpdimenA\relax +\else +\if\@IEEEextractedtoken c\relax + \oddsidemargin\paperwidth + \advance\oddsidemargin by -\textwidth + \divide\oddsidemargin by 2\relax + \advance\oddsidemargin by -1in\relax + \advance\oddsidemargin by \@IEEEtrantmpdimenA\relax +\else +\if\@IEEEextractedtoken o\relax + \oddsidemargin\paperwidth + \advance\oddsidemargin by -\textwidth + \advance\oddsidemargin by -\@IEEEtrantmpdimenA + \advance\oddsidemargin by -1in\relax +\else + \if\@IEEEextractedtoken i\relax + \else + \@IEEEclspkgerror{Unknown mode type `\@IEEEextractedtokenmacro' in \string\IEEEsetsidemargin\space (line \the\inputlineno).\MessageBreak + Defaulting to `i'}% + {Valid modes for \string\IEEEsetsidemargin\space are: i, o, c and a.}% + \fi + \oddsidemargin\@IEEEtrantmpdimenA + \advance\oddsidemargin by -1in\relax +\fi\fi\fi +% odd and even side margins both mean "inner" for single sided pages +\evensidemargin\oddsidemargin +% but are mirrors of each other when twosided is in effect +\if@twoside + \evensidemargin\paperwidth + \advance\evensidemargin by -\textwidth + \advance\evensidemargin by -\oddsidemargin + % have to compensate for both the builtin 1in LaTex offset + % and the fact we already subtracted this offset from \oddsidemargin + \advance\evensidemargin -2in\relax +\fi} + + + +% usage: \IEEEsettextheight[sample text]{top text margin}{bottom text margin} +% Sets \textheight based on the specified top margin and bottom margin. +% Takes into consideration \paperheight, \topskip, and (by default) the +% the actual height and depth of the \IEEEdefaultsampletext text. +\def\IEEEsettextheight{\@ifnextchar [{\@IEEEsettextheight}{\@IEEEsettextheight[\IEEEdefaultsampletext]}} +\def\@IEEEsettextheight[#1]#2#3{\textheight\paperheight\relax + \@IEEEtrantmpdimenA #2\relax + \advance \textheight by -\@IEEEtrantmpdimenA% subtract top margin + \@IEEEtrantmpdimenA #3\relax + \advance \textheight by -\@IEEEtrantmpdimenA% subtract bottom margin + \advance \textheight by \topskip% add \topskip + % subtract off everything above the top, and below the bottom, baselines + \settoheight{\@IEEEtrantmpdimenA}{\begingroup #1\relax\relax\relax\endgroup}\relax + \advance \textheight by -\@IEEEtrantmpdimenA + \settodepth{\@IEEEtrantmpdimenA}{\begingroup #1\relax\relax\relax\endgroup}\relax + \advance \textheight by -\@IEEEtrantmpdimenA} + + + +\newdimen\IEEEquantizedlength +\IEEEquantizedlength 0sp\relax +\newdimen\IEEEquantizedlengthdiff +\IEEEquantizedlengthdiff 0sp\relax +\def\IEEEquantizedlengthint{0} + +% usage: \IEEEquantizelength{mode: d, c, i}{base unit}{length} +% Sets the length \IEEEquantizedlength to be an integer multiple of the given +% (nonzero) base unit such that \IEEEquantizedlength approximates the given +% length. +% \IEEEquantizedlengthdiff is a length equal to the difference between the +% \IEEEquantizedlength and the given length. +% \IEEEquantizedlengthint is a macro containing the integer number of base units +% in \IEEEquantizedlength. +% i.e., \IEEEquantizedlength = \IEEEquantizedlengthint * base unit +% The mode determines how \IEEEquantizedlength is quantized: +% d = always decrease (always round down \IEEEquantizeint) +% c = use the closest match +% i = always increase (always round up \IEEEquantizeint) +% In anycase, if the given length is already quantized, +% \IEEEquantizedlengthdiff will be set to zero. +\def\IEEEquantizelength#1#2#3{\begingroup +% work in isolation so as not to externally disturb the \@IEEEtrantmp +% variables +% load the argument values indirectly via \IEEEquantizedlengthdiff +% in case the user refers to our \@IEEEtrantmpdimenX, \IEEEquantizedlength, +% etc. in the arguments. we also will work with these as counters, +% i.e., in sp units +% A has the base unit +\IEEEquantizedlengthdiff #2\relax\relax\relax\relax +\@IEEEtrantmpcountA\IEEEquantizedlengthdiff +% B has the input length +\IEEEquantizedlengthdiff #3\relax\relax\relax\relax +\@IEEEtrantmpcountB\IEEEquantizedlengthdiff +\@IEEEtrantmpdimenA\the\@IEEEtrantmpcountA sp\relax +\@IEEEtrantmpdimenB\the\@IEEEtrantmpcountB sp\relax +% \@IEEEtrantmpcountC will have the quantized int +% \IEEEquantizedlength will have the quantized length +% \@IEEEtrantmpdimenC will have the quantized diff +% initialize them to zero as this is what will be +% exported if an error occurs +\@IEEEtrantmpcountC 0\relax +\IEEEquantizedlength 0sp\relax +\@IEEEtrantmpdimenC 0sp\relax +% extract mode +\@IEEEextracttoken{#1}\relax +% check for mode errors +\ifx\@IEEEextractedtokenmacro\@empty + \@IEEEclspkgerror{Empty mode type in \string\IEEEquantizelength\space (line \the\inputlineno).\MessageBreak + Defaulting to `d'}{Valid modes for \string\IEEEquantizelength\space are: d, c and i.}\relax + \let\@IEEEextractedtoken=d\relax + \def\@IEEEextractedtokenmacro{d}\relax +\else + \ifx\@IEEEextractedtokensdiscarded\@empty\else + \typeout{** WARNING: \string\IEEEquantizelength\space mode specifiers after the first in `\@IEEEextracttokenarg' ignored (line \the\inputlineno).}\relax + \fi +\fi +% check for base unit is zero error +\ifnum\@IEEEtrantmpcountA=0\relax +\@IEEEclspkgerror{Base unit is zero in \string\IEEEquantizelength\space (line \the\inputlineno).\MessageBreak + \string\IEEEquantizedlength\space and \string\IEEEquantizedlengthdiff\space are set to zero}{Division by zero is not allowed.}\relax +\else% base unit is nonzero + % \@IEEEtrantmpcountC carries the number of integer units + % in the quantized length (integer length \ base) + \@IEEEtrantmpcountC\@IEEEtrantmpcountB\relax + \divide\@IEEEtrantmpcountC by \@IEEEtrantmpcountA\relax + % \IEEEquantizedlength has the (rounded down) quantized length + % = base * int + \IEEEquantizedlength\@IEEEtrantmpdimenA\relax + \multiply\IEEEquantizedlength by \@IEEEtrantmpcountC\relax + % \@IEEEtrantmpdimenC has the difference + % = quantized length - length + \@IEEEtrantmpdimenC\IEEEquantizedlength\relax + \advance\@IEEEtrantmpdimenC by -\@IEEEtrantmpdimenB\relax + % trap special case of length being already quantized + % to avoid a roundup under i option + \ifdim\@IEEEtrantmpdimenC=0sp\relax + \else % length not is already quantized + % set dimenA to carry the upper quantized (absolute value) difference: + % quantizedlength + base - length + \advance\@IEEEtrantmpdimenA by \IEEEquantizedlength\relax + \advance\@IEEEtrantmpdimenA by -\@IEEEtrantmpdimenB\relax + % set dimenB to carry the lower quantized (absolute value) difference: + % length - quantizedlength + \advance\@IEEEtrantmpdimenB by -\IEEEquantizedlength\relax + % handle each mode + \if\@IEEEextractedtoken c\relax + % compare upper and lower amounts, select upper if lower > upper + \ifdim\@IEEEtrantmpdimenB>\@IEEEtrantmpdimenA\relax + % use upper + \advance\IEEEquantizedlength by \the\@IEEEtrantmpcountA sp\relax + \advance\@IEEEtrantmpcountC by 1\relax + \@IEEEtrantmpdimenC\@IEEEtrantmpdimenA + \else% <=. uselower + % no need to do anything for lower, use output values already setup + \fi + \else% not mode c + \if\@IEEEextractedtoken i\relax + % always round up under i mode + \advance\IEEEquantizedlength by \the\@IEEEtrantmpcountA sp\relax + \advance\@IEEEtrantmpcountC by 1\relax + \@IEEEtrantmpdimenC\@IEEEtrantmpdimenA + \else + \if\@IEEEextractedtoken d\relax + \else + \@IEEEclspkgerror{Unknown mode type `\@IEEEextractedtokenmacro' in \string\IEEEquantizelength\space (line \the\inputlineno).\MessageBreak + Defaulting to `d'}% + {Valid modes for \string\IEEEquantizelength\space are: d, c, and i.}\relax + \fi % if d + % no need to do anything for d, use output values already setup + \fi\fi % if i, c + \fi % if length is already quantized +\fi% if base unit is zero +% globally assign the results to macros we use here to escape the enclosing +% group without needing to call \global on any of the \@IEEEtrantmp variables. +% \@IEEEtrantmpcountC has the quantized int +% \IEEEquantizedlength has the quantized length +% \@IEEEtrantmpdimenC has the quantized diff +\xdef\@IEEEquantizedlengthintmacro{\the\@IEEEtrantmpcountC}\relax +\@IEEEtrantmpcountC\IEEEquantizedlength\relax +\xdef\@IEEEquantizedlengthmacro{\the\@IEEEtrantmpcountC}\relax +\@IEEEtrantmpcountC\@IEEEtrantmpdimenC\relax +\xdef\@IEEEquantizedlengthdiffmacro{\the\@IEEEtrantmpcountC}\relax +\endgroup +% locally assign the outputs here from the macros +\expandafter\IEEEquantizedlength\@IEEEquantizedlengthmacro sp\relax +\expandafter\IEEEquantizedlengthdiff\@IEEEquantizedlengthdiffmacro sp\relax +\edef\IEEEquantizedlengthint{\@IEEEquantizedlengthintmacro}\relax} + + + +\newdimen\IEEEquantizedtextheightdiff +\IEEEquantizedtextheightdiff 0sp\relax + +% usage: \IEEEquantizetextheight[base unit]{mode: d, c, i} +% Sets \textheight to be an integer multiple of the current \baselineskip +% (or the optionally specified base unit) plus the first (\topskip) line. +% \IEEEquantizedtextheightdiff is a length equal to the difference between +% the new quantized and original \textheight. +% \IEEEquantizedtextheightlpc is a macro containing the integer number of +% lines per column under the quantized \textheight. i.e., +% \textheight = \IEEEquantizedtextheightlpc * \baselineskip + \topskip +% The mode determines how \textheight is quantized: +% d = always decrease (always round down the number of lines per column) +% c = use the closest match +% i = always increase (always round up the number of lines per column) +% In anycase, if \textheight is already quantized, it will remain unchanged, +% and \IEEEquantizedtextheightdiff will be set to zero. +% Depends on: \IEEEquantizelength +\def\IEEEquantizetextheight{\@ifnextchar [{\@IEEEquantizetextheight}{\@IEEEquantizetextheight[\baselineskip]}} +\def\@IEEEquantizetextheight[#1]#2{\begingroup +% use our \IEEEquantizedtextheightdiff as a scratch pad +% we need to subtract off \topskip before quantization +\IEEEquantizedtextheightdiff\textheight +\advance\IEEEquantizedtextheightdiff by -\topskip\relax +\IEEEquantizelength{#2}{#1}{\IEEEquantizedtextheightdiff} +% add back \topskip line +\advance\IEEEquantizedlength by \topskip +\@IEEEtrantmpcountC\IEEEquantizedlengthint\relax +\advance\@IEEEtrantmpcountC by 1\relax +% globally assign the results to macros we use here to escape the enclosing +% group without needing to call \global on any of the \@IEEEtrantmp variables. +\xdef\@IEEEquantizedtextheightlpcmacro{\the\@IEEEtrantmpcountC}\relax +\@IEEEtrantmpcountC\IEEEquantizedlength\relax +\xdef\@IEEEquantizedtextheightmacro{\the\@IEEEtrantmpcountC}\relax +\@IEEEtrantmpcountC\IEEEquantizedlengthdiff\relax +\xdef\@IEEEquantizedtextheightdiffmacro{\the\@IEEEtrantmpcountC}\relax +\endgroup +% locally assign the outputs here from the macros +\textheight\@IEEEquantizedtextheightmacro sp\relax +\IEEEquantizedtextheightdiff\@IEEEquantizedtextheightdiffmacro sp\relax +\edef\IEEEquantizedtextheightlpc{\@IEEEquantizedtextheightlpcmacro}} + + + +% usage: \IEEEsettopmargin[sample text]{mode: t, b, c, a, q}{margin/offset} +% Sets \topmargin based on the specified vertical margin. +% Takes into consideration the base 1in offset, \headheight, \headsep, +% \topskip, and (by default) the the actual height (or, for the bottom, depth) +% of the \IEEEdefaultsampletext text. +% The available modes are: +% t = top margin +% b = bottom margin +% c = vertically centered, with the given offset +% a = adjust the vertical margins using the given offset +% q = adjust the margins using \IEEEquantizedtextheightdiff and the given offset +% For the offsets, positive values increase the top margin. +% \headheight, \headsep, \topskip and \textheight should be set properly for the +% given margins before calling this function. +\def\IEEEsettopmargin{\@ifnextchar [{\@IEEEsettopmargin}{\@IEEEsettopmargin[\IEEEdefaultsampletext]}} +\def\@IEEEsettopmargin[#1]#2#3{\@IEEEtrantmpdimenA #3\relax +\@IEEEextracttoken{#2}\relax +% check for mode errors +\ifx\@IEEEextractedtokenmacro\@empty + \@IEEEclspkgerror{Empty mode type in \string\IEEEsettopmargin\space (line \the\inputlineno).\MessageBreak + Defaulting to `t'}{Valid modes for \string\IEEEsettopmargin\space are: t, b, c, a and q.}\relax + \let\@IEEEextractedtoken=t\relax + \def\@IEEEextractedtokenmacro{t}\relax +\else + \ifx\@IEEEextractedtokensdiscarded\@empty\else + \typeout{** WARNING: \string\IEEEsettopmargin\space mode specifiers after the first in `\@IEEEextracttokenarg' ignored (line \the\inputlineno).}\relax + \fi +\fi +% handle each mode +\if\@IEEEextractedtoken a\relax + \advance\topmargin by \@IEEEtrantmpdimenA\relax +\else +\if\@IEEEextractedtoken q\relax + % we need to adjust by half the \IEEEquantizedtextheightdiff value + \@IEEEtrantmpdimenB\IEEEquantizedtextheightdiff\relax + \divide\@IEEEtrantmpdimenB by 2\relax + % a positive \IEEEquantizedtextheightdiff means we need to reduce \topmargin + % because \textheight has been lenghtened + \advance\topmargin by -\@IEEEtrantmpdimenB\relax + \advance\topmargin by \@IEEEtrantmpdimenA\relax +\else +\if\@IEEEextractedtoken c\relax + \topmargin\paperheight + \advance\topmargin by -\textheight + % \textheight includes \topskip, but we should not count topskip whitespace here, backout + \advance \topmargin by \topskip + \settoheight{\@IEEEtrantmpdimenB}{\begingroup #1\relax\relax\relax\endgroup}\relax + \advance\topmargin by -\@IEEEtrantmpdimenB\relax + \settodepth{\@IEEEtrantmpdimenB}{\begingroup #1\relax\relax\relax\endgroup}\relax + \advance\topmargin by -\@IEEEtrantmpdimenB\relax + \divide\topmargin by 2\relax + \advance\topmargin by \@IEEEtrantmpdimenA\relax +\else +\if\@IEEEextractedtoken b\relax + \topmargin\paperheight + \advance\topmargin by -\textheight + % \textheight includes \topskip, but we should not count topskip whitespace here, backout + \advance \topmargin by \topskip + \settodepth{\@IEEEtrantmpdimenB}{\begingroup #1\relax\relax\relax\endgroup}\relax + \advance\topmargin by -\@IEEEtrantmpdimenB\relax + \advance\topmargin by -\@IEEEtrantmpdimenA\relax +\else + \if\@IEEEextractedtoken t\relax + \else + \@IEEEclspkgerror{Unknown mode type `\@IEEEextractedtokenmacro' in \string\IEEEsettopmargin\space (line \the\inputlineno).\MessageBreak + Defaulting to `t'}% + {Valid modes for \string\IEEEsettopmargin\space are: t, b, c, a and q.}\relax + \fi + \topmargin\@IEEEtrantmpdimenA\relax + \settoheight{\@IEEEtrantmpdimenB}{\begingroup #1\relax\relax\relax\endgroup}\relax + \advance\topmargin by \@IEEEtrantmpdimenB\relax +\fi\fi % if t, b, c +% convert desired top margin into actual \topmargin +% this is not done for the q or a modes because they are only adjustments +\advance \topmargin by -\topskip +\advance \topmargin by -1in +\advance \topmargin by -\headheight +\advance \topmargin by -\headsep +\fi\fi % if q, a +} + + + +% usage: \IEEEsetheadermargin[header sample][text sample]{mode: t, b, c, a}{margin/offset} +% Differentially adjusts \topmargin and \headsep (such that their sum is unchanged) +% based on the specified header margin. +% Takes into consideration the base 1in offset, \headheight, \topskip, and (by default) +% the actual height (or depth) of the \IEEEdefaultheadersampletext and +% \IEEEdefaultsampletext text. +% The available modes are: +% t = top margin (top of the header text to the top of the page) +% b = bottom margin (bottom of the header text to the top of the main text) +% c = vertically centered between the main text and the top of the page, +% with the given offset +% a = adjust the vertical position using the given offset +% For the offsets, positive values move the header downward. +% \headheight, \headsep, \topskip and \topmargin should be set properly before +% calling this function. +\def\IEEEsetheadermargin{\@ifnextchar [{\@IEEEsetheadermargin}{\@IEEEsetheadermargin[\IEEEdefaultheadersampletext]}} +\def\@IEEEsetheadermargin[#1]{\@ifnextchar [{\@@IEEEsetheadermargin[#1]}{\@@IEEEsetheadermargin[#1][\IEEEdefaultsampletext]}} +\def\@@IEEEsetheadermargin[#1][#2]#3#4{\@IEEEtrantmpdimenA #4\relax +\@IEEEextracttoken{#3}\relax +% check for mode errors +\ifx\@IEEEextractedtokenmacro\@empty + \@IEEEclspkgerror{Empty mode type in \string\IEEEsetheadermargin\space (line \the\inputlineno).\MessageBreak + Defaulting to `t'}{Valid modes for \string\IEEEsetheadermargin\space are: t, b, c, and a.}\relax + \let\@IEEEextractedtoken=t\relax + \def\@IEEEextractedtokenmacro{t}\relax +\else + \ifx\@IEEEextractedtokensdiscarded\@empty\else + \typeout{** WARNING: \string\IEEEsetheadermargin\space mode specifiers after the first in `\@IEEEextracttokenarg' ignored (line \the\inputlineno).}\relax + \fi +\fi +% handle each mode +\if\@IEEEextractedtoken a\relax + % No need to do anything here and can pass through the adjustment + % value as is. The end adjustment of \topmargin and \headsep will + % do all that is needed +\else +\if\@IEEEextractedtoken c\relax + % get the bottom margin + \@IEEEtrantmpdimenB\headsep\relax + \settodepth{\@IEEEtrantmpdimenC}{\begingroup #1\relax\relax\relax\endgroup}\relax + \advance\@IEEEtrantmpdimenB by -\@IEEEtrantmpdimenC + \advance\@IEEEtrantmpdimenB by \topskip + \settoheight{\@IEEEtrantmpdimenC}{\begingroup #2\relax\relax\relax\endgroup}\relax + \advance\@IEEEtrantmpdimenB by -\@IEEEtrantmpdimenC + % at this point \@IEEEtrantmpdimenB has the actual header bottom margin + % subtract from it the top header margin + \advance\@IEEEtrantmpdimenB -1in\relax % take into consideration the system 1in offset of the top margin + \advance\@IEEEtrantmpdimenB by -\topmargin + \advance\@IEEEtrantmpdimenB by -\headheight + \settoheight{\@IEEEtrantmpdimenC}{\begingroup #1\relax\relax\relax\endgroup}\relax + \advance\@IEEEtrantmpdimenB by \@IEEEtrantmpdimenC + % at this point \@IEEEtrantmpdimenB has the difference between the bottom and top margins + % we need to adjust by half this amount to center the header + \divide\@IEEEtrantmpdimenB by 2\relax + % and add to offset + \advance\@IEEEtrantmpdimenA by \@IEEEtrantmpdimenB +\else +\if\@IEEEextractedtoken b\relax + \@IEEEtrantmpdimenB\headsep\relax + \settodepth{\@IEEEtrantmpdimenC}{\begingroup #1\relax\relax\relax\endgroup}\relax + \advance\@IEEEtrantmpdimenB by -\@IEEEtrantmpdimenC + \advance\@IEEEtrantmpdimenB by \topskip + \settoheight{\@IEEEtrantmpdimenC}{\begingroup #2\relax\relax\relax\endgroup}\relax + \advance\@IEEEtrantmpdimenB by -\@IEEEtrantmpdimenC + % at this point \@IEEEtrantmpdimenB has the actual header bottom margin + % get the difference between the actual and the desired + \advance\@IEEEtrantmpdimenB by -\@IEEEtrantmpdimenA + \@IEEEtrantmpdimenA\@IEEEtrantmpdimenB +\else + \if\@IEEEextractedtoken t\relax + \else + \@IEEEclspkgerror{Unknown mode type `\@IEEEextractedtokenmacro' in \string\IEEEsetheadermargin\space (line \the\inputlineno).\MessageBreak + Defaulting to `t'}% + {Valid modes for \string\IEEEsetheadermargin\space are: t, b, c and a.}\relax + \fi + \@IEEEtrantmpdimenB 1in\relax % take into consideration the system 1in offset of the top margin + \advance\@IEEEtrantmpdimenB by \topmargin + \advance\@IEEEtrantmpdimenB by \headheight + \settoheight{\@IEEEtrantmpdimenC}{\begingroup #1\relax\relax\relax\endgroup}\relax + \advance\@IEEEtrantmpdimenB by -\@IEEEtrantmpdimenC + % at this point \@IEEEtrantmpdimenB has the actual header top margin + % get the difference between the desired and the actual + \advance\@IEEEtrantmpdimenA by -\@IEEEtrantmpdimenB +\fi\fi % if t, b, c +\fi % if a +% advance \topmargin by the needed amount and reduce \headsep by the same +% so as not to disturb the location of the main text +\advance\topmargin by \@IEEEtrantmpdimenA\relax +\advance\headsep by -\@IEEEtrantmpdimenA\relax +} + + + +% usage: \IEEEsetfootermargin[footer sample][text sample]{mode: t, b, c, a}{margin/offset} +% Adjusts \footskip based on the specified footer margin. +% Takes into consideration the base 1in offset, \paperheight, \headheight, +% \headsep, \textheight and (by default) the actual height (or depth) of the +% \IEEEdefaultfootersampletext and \IEEEdefaultsampletext text. +% The available modes are: +% t = top margin (top of the footer text to the bottom of the main text) +% b = bottom margin (bottom of the footer text to the bottom of page) +% c = vertically centered between the main text and the bottom of the page, +% with the given offset +% a = adjust the vertical position using the given offset +% For the offsets, positive values move the footer downward. +% \headheight, \headsep, \topskip, \topmargin, and \textheight should be set +% properly before calling this function. +\def\IEEEsetfootermargin{\@ifnextchar [{\@IEEEsetfootermargin}{\@IEEEsetfootermargin[\IEEEdefaultfootersampletext]}} +\def\@IEEEsetfootermargin[#1]{\@ifnextchar [{\@@IEEEsetfootermargin[#1]}{\@@IEEEsetfootermargin[#1][\IEEEdefaultsampletext]}} +\def\@@IEEEsetfootermargin[#1][#2]#3#4{\@IEEEtrantmpdimenA #4\relax +\@IEEEextracttoken{#3}\relax +% check for mode errors +\ifx\@IEEEextractedtokenmacro\@empty + \@IEEEclspkgerror{Empty mode type in \string\IEEEsetfootermargin\space (line \the\inputlineno).\MessageBreak + Defaulting to `t'}{Valid modes for \string\IEEEsetfootermargin\space are: t, b, c, and a.}\relax + \let\@IEEEextractedtoken=t\relax + \def\@IEEEextractedtokenmacro{t}\relax +\else + \ifx\@IEEEextractedtokensdiscarded\@empty\else + \typeout{** WARNING: \string\IEEEsetfootermargin\space mode specifiers after the first in `\@IEEEextracttokenarg' ignored (line \the\inputlineno).}\relax + \fi +\fi +% handle each mode +\if\@IEEEextractedtoken a\relax + % No need to do anything here and can pass through the adjustment + % value as is. The end adjustment of \footskip will do all that + % is needed +\else +\if\@IEEEextractedtoken c\relax + % calculate the bottom margin + \@IEEEtrantmpdimenB 1in\relax % system 1in offset + \advance\@IEEEtrantmpdimenB\topmargin\relax + \advance\@IEEEtrantmpdimenB\headheight\relax + \advance\@IEEEtrantmpdimenB\headsep\relax + \advance\@IEEEtrantmpdimenB\textheight\relax + \advance\@IEEEtrantmpdimenB\footskip\relax + \settodepth{\@IEEEtrantmpdimenC}{\begingroup #1\relax\relax\relax\endgroup}\relax + \advance\@IEEEtrantmpdimenC by \@IEEEtrantmpdimenB + \@IEEEtrantmpdimenB\paperheight + \advance\@IEEEtrantmpdimenB by -\@IEEEtrantmpdimenC + % at this point \@IEEEtrantmpdimenB has the actual footer bottom margin + % now subtract off the footer top margin + \advance\@IEEEtrantmpdimenB -\footskip\relax + \settodepth{\@IEEEtrantmpdimenC}{\begingroup #2\relax\relax\relax\endgroup}\relax + \advance\@IEEEtrantmpdimenB by \@IEEEtrantmpdimenC + \settoheight{\@IEEEtrantmpdimenC}{\begingroup #1\relax\relax\relax\endgroup}\relax + \advance\@IEEEtrantmpdimenB by \@IEEEtrantmpdimenC + % at this point \@IEEEtrantmpdimenB has the difference between the bottom + % and top footer margins + % our adjustment must be half this value to center the footer + \divide\@IEEEtrantmpdimenB by 2\relax + % add to the offset + \advance\@IEEEtrantmpdimenA by \@IEEEtrantmpdimenB +\else +\if\@IEEEextractedtoken b\relax + % calculate the bottom margin + \@IEEEtrantmpdimenB 1in\relax % system 1in offset + \advance\@IEEEtrantmpdimenB\topmargin\relax + \advance\@IEEEtrantmpdimenB\headheight\relax + \advance\@IEEEtrantmpdimenB\headsep\relax + \advance\@IEEEtrantmpdimenB\textheight\relax + \advance\@IEEEtrantmpdimenB\footskip\relax + \settodepth{\@IEEEtrantmpdimenC}{\begingroup #1\relax\relax\relax\endgroup}\relax + \advance\@IEEEtrantmpdimenC by \@IEEEtrantmpdimenB + \@IEEEtrantmpdimenB\paperheight + \advance\@IEEEtrantmpdimenB by -\@IEEEtrantmpdimenC + % at this point \@IEEEtrantmpdimenB has the actual footer bottom margin + % get the difference between the actual and the desired + \advance\@IEEEtrantmpdimenB by -\@IEEEtrantmpdimenA + \@IEEEtrantmpdimenA\@IEEEtrantmpdimenB +\else + \if\@IEEEextractedtoken t\relax + \else + \@IEEEclspkgerror{Unknown mode type `\@IEEEextractedtokenmacro' in \string\IEEEsetfootermargin\space (line \the\inputlineno).\MessageBreak + Defaulting to `t'}% + {Valid modes for \string\IEEEsetfootermargin\space are: t, b, c and a.}\relax + \fi + \@IEEEtrantmpdimenB\footskip\relax + \settodepth{\@IEEEtrantmpdimenC}{\begingroup #2\relax\relax\relax\endgroup}\relax + \advance\@IEEEtrantmpdimenB by -\@IEEEtrantmpdimenC + \settoheight{\@IEEEtrantmpdimenC}{\begingroup #1\relax\relax\relax\endgroup}\relax + \advance\@IEEEtrantmpdimenB by -\@IEEEtrantmpdimenC + % at this point \@IEEEtrantmpdimenB has the actual footer top margin + % get the difference between the desired and the actual + \advance\@IEEEtrantmpdimenA by -\@IEEEtrantmpdimenB +\fi\fi % if t, b, c +\fi % if a +% advance \footskip by the needed amount +\advance\footskip by \@IEEEtrantmpdimenA\relax +} + +% -- End V1.8a page setup commands -- + + + + + +% V1.6 +% LaTeX is a little to quick to use hyphenations +% So, we increase the penalty for their use and raise +% the badness level that triggers an underfull hbox +% warning. The author may still have to tweak things, +% but the appearance will be much better "right out +% of the box" than that under V1.5 and prior. +% TeX default is 50 +\hyphenpenalty=750 +\ifCLASSOPTIONcompsoc +\hyphenpenalty 500 +\fi +% If we didn't adjust the interword spacing, 2200 might be better. +% The TeX default is 1000 +\hbadness=1350 +% The IEEE does not use extra spacing after punctuation +\frenchspacing + +% V1.7 increase this a tad to discourage equation breaks +\binoppenalty=1000 % default 700 +\relpenalty=800 % default 500 + +% v1.8a increase these to discourage widows and orphans +\clubpenalty=1000 % default 150 +\widowpenalty=1000 % default 150 +\displaywidowpenalty=1000 % default 50 + + +% margin note stuff +\marginparsep 10pt +\marginparwidth 20pt +\marginparpush 25pt + + +% if things get too close, go ahead and let them touch +\lineskip 0pt +\normallineskip 0pt +\lineskiplimit 0pt +\normallineskiplimit 0pt + +% The distance from the lower edge of the text body to the +% footline +\footskip 0.4in + +% normally zero, should be relative to font height. +% put in a little rubber to help stop some bad breaks (underfull vboxes) +\parskip 0ex plus 0.2ex minus 0.1ex + +\parindent 1.0em +\ifCLASSOPTIONcompsoc + \parindent 1.5em +\fi + +\headheight 12pt +\headsep 18pt +% use the normal font baselineskip +% so that \topskip is unaffected by changes in \baselinestretch +\topskip=\@IEEEnormalsizeunitybaselineskip + + +% V1.8 \maxdepth defaults to 4pt, but should be font size dependent +\maxdepth=0.5\@IEEEnormalsizeunitybaselineskip +\textheight 58pc % 9.63in, 696pt + +% set the default top margin to 58pt +% which results in a \topmargin of -49.59pt for 10pt documents +\IEEEsettopmargin{t}{58pt} +% tweak textheight to a perfect integer number of lines/column. +% standard is: 9pt/63 lpc; 10pt/58 lpc; 11pt/52 lpc; 12pt/50 lpc +\IEEEquantizetextheight{c} +% tweak top margin so that the error is shared equally at the top and bottom +\IEEEsettopmargin{q}{0sp} + + +\columnsep 1pc +\textwidth 43pc % 2 x 21pc + 1pc = 43pc + +% set the default side margins to center the text +\IEEEsetsidemargin{c}{0pt} + + +% adjust margins for default conference mode +\ifCLASSOPTIONconference + \textheight 9.25in % The standard for conferences (668.4975pt) + \IEEEsettopmargin{t}{0.75in} + % tweak textheight to a perfect integer number of lines/page. + % standard is: 9pt/61 lpc; 10pt/56 lpc; 11pt/50 lpc; 12pt/48 lpc + \IEEEquantizetextheight{c} + % tweak top margin so that the error is shared equally at the top and bottom + \IEEEsettopmargin{q}{0sp} +\fi + + +% compsoc text sizes, margins and spacings +\ifCLASSOPTIONcompsoc + \columnsep 12bp + % CS specs for \textwdith are 6.875in + % \textwidth 6.875in + % however, measurements from proofs show they are using 3.5in columns + \textwidth 7in + \advance\textwidth by \columnsep + % set the side margins to center the text + \IEEEsetsidemargin{c}{0pt} + % top/bottom margins to center + % could just set \textheight to 9.75in for all the different paper sizes + % and then quantize, but we'll do it the long way here to allow for easy + % future per-paper size adjustments + \IEEEsettextheight{0.625in}{0.625in}% 11in - 2 * 0.625in = 9.75in is the standard text height for compsoc journals + \IEEEsettopmargin{t}{0.625in} + \if@IEEEusingcspaper + \IEEEsettextheight{0.5in}{0.5in}% 10.75in - 2 * 0.5in = 9.75in + \IEEEsettopmargin{t}{0.5in} + \fi + \if@IEEEusingAfourpaper + \IEEEsettextheight{24.675mm}{24.675mm}% 297mm - 2 * 24.675mm = 247.650mm (9.75in) + \IEEEsettopmargin{t}{24.675mm} + \fi + % tweak textheight to a perfect integer number of lines/page. + % standard is: 9pt/65 lpc; 10pt/61 lpc; 11pt/53 lpc; 12pt/49 lpc + \IEEEquantizetextheight{c} + % tweak top margin so that the error is shared equally at the top and bottom + \IEEEsettopmargin{q}{0sp} + +% compsoc conference + \ifCLASSOPTIONconference + % compsoc conference use a larger value for columnsep + \columnsep 0.25in + \IEEEsettextwidth{0.75in}{0.75in} + % set the side margins to center the text (0.75in for letterpaper) + \IEEEsetsidemargin{c}{0pt} + % compsoc conferences want 1in top and bottom margin + \IEEEsettextheight{1in}{1in} + \IEEEsettopmargin{t}{1in} + % tweak textheight to a perfect integer number of lines/page. + % standard is: 9pt/58 lpc; 10pt/53 lpc; 11pt/48 lpc; 12pt/46 lpc + \IEEEquantizetextheight{c} + % tweak top margin so that the error is shared equally at the top and bottom + \IEEEsettopmargin{q}{0sp} + \fi +\fi + + + +% draft mode settings override that of all other modes +% provides a nice 1in margin all around the paper and extra +% space between the lines for editor's comments +\ifCLASSOPTIONdraftcls + % we want 1in side margins regardless of paper type + \IEEEsettextwidth{1in}{1in} + \IEEEsetsidemargin{c}{0pt} + % want 1in top and bottom margins + \IEEEsettextheight{1in}{1in} + \IEEEsettopmargin{t}{1in} + % digitize textheight to be an integer number of lines. + % this may cause the top and bottom margins to be off a tad + \IEEEquantizetextheight{c} + % tweak top margin so that the error is shared equally at the top and bottom + \IEEEsettopmargin{q}{0sp} +\fi + + + +% process CLASSINPUT inner/outer margin +% if inner margin defined, but outer margin not, set outer to inner. +\ifx\CLASSINPUTinnersidemargin\@IEEEundefined +\else + \ifx\CLASSINPUToutersidemargin\@IEEEundefined + \edef\CLASSINPUToutersidemargin{\CLASSINPUTinnersidemargin} + \fi +\fi + +\ifx\CLASSINPUToutersidemargin\@IEEEundefined +\else + % if outer margin defined, but inner margin not, set inner to outer. + \ifx\CLASSINPUTinnersidemargin\@IEEEundefined + \edef\CLASSINPUTinnersidemargin{\CLASSINPUToutersidemargin} + \fi + \IEEEsettextwidth{\CLASSINPUTinnersidemargin}{\CLASSINPUToutersidemargin} + \IEEEsetsidemargin{i}{\CLASSINPUTinnersidemargin} + \typeout{** ATTENTION: Overriding inner side margin to \CLASSINPUTinnersidemargin\space and + outer side margin to \CLASSINPUToutersidemargin\space via \string\CLASSINPUT.} +\fi + + + +% process CLASSINPUT top/bottom text margin +% if toptext margin defined, but bottomtext margin not, set bottomtext to toptext margin +\ifx\CLASSINPUTtoptextmargin\@IEEEundefined +\else + \ifx\CLASSINPUTbottomtextmargin\@IEEEundefined + \edef\CLASSINPUTbottomtextmargin{\CLASSINPUTtoptextmargin} + \fi +\fi + +\ifx\CLASSINPUTbottomtextmargin\@IEEEundefined +\else + % if bottomtext margin defined, but toptext margin not, set toptext to bottomtext margin + \ifx\CLASSINPUTtoptextmargin\@IEEEundefined + \edef\CLASSINPUTtoptextmargin{\CLASSINPUTbottomtextmargin} + \fi + \IEEEsettextheight{\CLASSINPUTtoptextmargin}{\CLASSINPUTbottomtextmargin} + \IEEEsettopmargin{t}{\CLASSINPUTtoptextmargin} + \typeout{** ATTENTION: Overriding top text margin to \CLASSINPUTtoptextmargin\space and + bottom text margin to \CLASSINPUTbottomtextmargin\space via \string\CLASSINPUT.} +\fi + + + +% default to center header and footer text in the margins +\IEEEsetheadermargin{c}{0pt} +\IEEEsetfootermargin{c}{0pt} + +% adjust header and footer positions for compsoc journals +\ifCLASSOPTIONcompsoc + \ifCLASSOPTIONjournal + \IEEEsetheadermargin{b}{\@IEEEnormalsizeunitybaselineskip} + \IEEEsetfootermargin{t}{\@IEEEnormalsizeunitybaselineskip} + \fi +\fi + + +% V1.8a display lines per column info message on user's console +\def\IEEEdisplayinfolinespercolumn{\@IEEEtrantmpdimenA=\textheight +% topskip represents only one line even if > baselineskip +\advance\@IEEEtrantmpdimenA by -1\topskip +\@IEEEtrantmpcountA=\@IEEEtrantmpdimenA +\@IEEEtrantmpcountB=\@IEEEtrantmpdimenA +\divide\@IEEEtrantmpcountB by \baselineskip +% need to add one line to include topskip (first) line +\advance\@IEEEtrantmpcountB by 1 +% save lines per column value as text +\edef\@IEEEnumlinespercolumninfotxt{\the\@IEEEtrantmpcountB} +% backout topskip advance to allow direct \@IEEEtrantmpcountA comparison +\advance\@IEEEtrantmpcountB by -1 +% restore value as text height (without topskip) rather than just as number of lines +\multiply\@IEEEtrantmpcountB by \baselineskip +% is the column height an integer number of lines per column? +\ifnum\@IEEEtrantmpcountA=\@IEEEtrantmpcountB +\edef\@IEEEnumlinespercolumnexactinfotxt{exact} +\else +\@IEEEtrantmpdimenA\@IEEEtrantmpcountA sp\relax +\advance\@IEEEtrantmpdimenA by -\@IEEEtrantmpcountB sp\relax +\edef\@IEEEnumlinespercolumnexactinfotxt{approximate, difference = \the\@IEEEtrantmpdimenA} +\fi +\typeout{-- Lines per column: \@IEEEnumlinespercolumninfotxt\space (\@IEEEnumlinespercolumnexactinfotxt).}} +% delay execution till start of document to allow for user changes +\AtBeginDocument{\IEEEdisplayinfolinespercolumn} + + + +% LIST SPACING CONTROLS + +% Controls the amount of EXTRA spacing +% above and below \trivlist +% Both \list and IED lists override this. +% However, \trivlist will use this as will most +% things built from \trivlist like the \center +% environment. +\topsep 0.5\baselineskip + +% Controls the additional spacing around lists preceded +% or followed by blank lines. the IEEE does not increase +% spacing before or after paragraphs so it is set to zero. +% \z@ is the same as zero, but faster. +\partopsep \z@ + +% Controls the spacing between paragraphs in lists. +% The IEEE does not increase spacing before or after paragraphs +% so this is also zero. +% With IEEEtran.cls, global changes to +% this value DO affect lists (but not IED lists). +\parsep \z@ + +% Controls the extra spacing between list items. +% The IEEE does not put extra spacing between items. +% With IEEEtran.cls, global changes to this value DO affect +% lists (but not IED lists). +\itemsep \z@ + +% \itemindent is the amount to indent the FIRST line of a list +% item. It is auto set to zero within the \list environment. To alter +% it, you have to do so when you call the \list. +% However, the IEEE uses this for the theorem environment +% There is an alternative value for this near \leftmargini below +\itemindent -1em + +% \leftmargin, the spacing from the left margin of the main text to +% the left of the main body of a list item is set by \list. +% Hence this statement does nothing for lists. +% But, quote and verse do use it for indention. +\leftmargin 2em + +% we retain this stuff from the older IEEEtran.cls so that \list +% will work the same way as before. However, itemize, enumerate and +% description (IED) could care less about what these are as they +% all are overridden. +\leftmargini 2em +%\itemindent 2em % Alternative values: sometimes used. +%\leftmargini 0em +\leftmarginii 1em +\leftmarginiii 1.5em +\leftmarginiv 1.5em +\leftmarginv 1.0em +\leftmarginvi 1.0em +\labelsep 0.5em +\labelwidth \z@ + + +% The old IEEEtran.cls behavior of \list is retained. +% However, the new V1.3 IED list environments override all the +% @list stuff (\@listX is called within \list for the +% appropriate level just before the user's list_decl is called). +% \topsep is now 2pt as the IEEE puts a little extra space around +% lists - used by those non-IED macros that depend on \list. +% Note that \parsep and \itemsep are not redefined as in +% the sizexx.clo \@listX (which article.cls uses) so global changes +% of these values DO affect \list +% +\def\@listi{\leftmargin\leftmargini \topsep 2pt plus 1pt minus 1pt} +\let\@listI\@listi +\def\@listii{\leftmargin\leftmarginii\labelwidth\leftmarginii% + \advance\labelwidth-\labelsep \topsep 2pt} +\def\@listiii{\leftmargin\leftmarginiii\labelwidth\leftmarginiii% + \advance\labelwidth-\labelsep \topsep 2pt} +\def\@listiv{\leftmargin\leftmarginiv\labelwidth\leftmarginiv% + \advance\labelwidth-\labelsep \topsep 2pt} +\def\@listv{\leftmargin\leftmarginv\labelwidth\leftmarginv% + \advance\labelwidth-\labelsep \topsep 2pt} +\def\@listvi{\leftmargin\leftmarginvi\labelwidth\leftmarginvi% + \advance\labelwidth-\labelsep \topsep 2pt} + + +% The IEEE uses 5) not 5. +\def\labelenumi{\theenumi)} \def\theenumi{\arabic{enumi}} + +% The IEEE uses a) not (a) +\def\labelenumii{\theenumii)} \def\theenumii{\alph{enumii}} + +% The IEEE uses iii) not iii. +\def\labelenumiii{\theenumiii)} \def\theenumiii{\roman{enumiii}} + +% The IEEE uses A) not A. +\def\labelenumiv{\theenumiv)} \def\theenumiv{\Alph{enumiv}} + +% exactly the same as in article.cls +\def\p@enumii{\theenumi} +\def\p@enumiii{\theenumi(\theenumii)} +\def\p@enumiv{\p@enumiii\theenumiii} + +% itemized list label styles +\def\labelitemi{$\scriptstyle\bullet$} +\def\labelitemii{\textbf{--}} +\def\labelitemiii{$\ast$} +\def\labelitemiv{$\cdot$} + + + +% **** V1.3 ENHANCEMENTS **** +% Itemize, Enumerate and Description (IED) List Controls +% *************************** +% +% +% The IEEE seems to use at least two different values by +% which ITEMIZED list labels are indented to the right +% For The Journal of Lightwave Technology (JLT) and The Journal +% on Selected Areas in Communications (JSAC), they tend to use +% an indention equal to \parindent. For Transactions on Communications +% they tend to indent ITEMIZED lists a little more--- 1.3\parindent. +% We'll provide both values here for you so that you can choose +% which one you like in your document using a command such as: +% setlength{\IEEEilabelindent}{\IEEEilabelindentB} +\newdimen\IEEEilabelindentA +\IEEEilabelindentA \parindent + +\newdimen\IEEEilabelindentB +\IEEEilabelindentB 1.3\parindent +% However, we'll default to using \parindent +% which makes more sense to me +\newdimen\IEEEilabelindent +\IEEEilabelindent \IEEEilabelindentA + + +% This controls the default amount the enumerated list labels +% are indented to the right. +% Normally, this is the same as the paragraph indention +\newdimen\IEEEelabelindent +\IEEEelabelindent \parindent + +% This controls the default amount the description list labels +% are indented to the right. +% Normally, this is the same as the paragraph indention +\newdimen\IEEEdlabelindent +\IEEEdlabelindent \parindent + +% This is the value actually used within the IED lists. +% The IED environments automatically set its value to +% one of the three values above, so global changes do +% not have any effect +\newdimen\IEEElabelindent +\IEEElabelindent \parindent + +% The actual amount labels will be indented is +% \IEEElabelindent multiplied by the factor below +% corresponding to the level of nesting depth +% This provides a means by which the user can +% alter the effective \IEEElabelindent for deeper +% levels +% There may not be such a thing as correct "standard IEEE" +% values. What the IEEE actually does may depend on the specific +% circumstances. +% The first list level almost always has full indention. +% The second levels I've seen have only 75% of the normal indentation +% Three level or greater nestings are very rare. I am guessing +% that they don't use any indentation. +\def\IEEElabelindentfactori{1.0} % almost always one +\def\IEEElabelindentfactorii{0.75} % 0.0 or 1.0 may be used in some cases +\def\IEEElabelindentfactoriii{0.0} % 0.75? 0.5? 0.0? +\def\IEEElabelindentfactoriv{0.0} +\def\IEEElabelindentfactorv{0.0} +\def\IEEElabelindentfactorvi{0.0} + +% value actually used within IED lists, it is auto +% set to one of the 6 values above +% global changes here have no effect +\def\IEEElabelindentfactor{1.0} + +% This controls the default spacing between the end of the IED +% list labels and the list text, when normal text is used for +% the labels. +% compsoc uses a larger value here, but we'll set that later +% in the class so that this code block area can be extracted +% as-is for IEEEtrantools.sty +\newdimen\IEEEiednormlabelsep +\IEEEiednormlabelsep 0.6em + +% This controls the default spacing between the end of the IED +% list labels and the list text, when math symbols are used for +% the labels (nomenclature lists). The IEEE usually increases the +% spacing in these cases +\newdimen\IEEEiedmathlabelsep +\IEEEiedmathlabelsep 1.2em + +% This controls the extra vertical separation put above and +% below each IED list. the IEEE usually puts a little extra spacing +% around each list. However, this spacing is barely noticeable. +% compsoc uses a larger value here, but we'll set that later +% in the class so that this code block area can be extracted +% as-is for IEEEtrantools.sty +\newskip\IEEEiedtopsep +\IEEEiedtopsep 2pt plus 1pt minus 1pt + + +% This command is executed within each IED list environment +% at the beginning of the list. You can use this to set the +% parameters for some/all your IED list(s) without disturbing +% global parameters that affect things other than lists. +% i.e., renewcommand{\IEEEiedlistdecl}{\setlength{\labelsep}{5em}} +% will alter the \labelsep for the next list(s) until +% \IEEEiedlistdecl is redefined. +\def\IEEEiedlistdecl{\relax} + +% This command provides an easy way to set \leftmargin based +% on the \labelwidth, \labelsep and the argument \IEEElabelindent +% Usage: \IEEEcalcleftmargin{width-to-indent-the-label} +% output is in the \leftmargin variable, i.e., effectively: +% \leftmargin = argument + \labelwidth + \labelsep +% Note controlled spacing here, shield end of lines with % +\def\IEEEcalcleftmargin#1{\setlength{\leftmargin}{#1}% +\addtolength{\leftmargin}{\labelwidth}% +\addtolength{\leftmargin}{\labelsep}} + +% This command provides an easy way to set \labelwidth to the +% width of the given text. It is the same as +% \settowidth{\labelwidth}{label-text} +% and useful as a shorter alternative. +% Typically used to set \labelwidth to be the width +% of the longest label in the list +\def\IEEEsetlabelwidth#1{\settowidth{\labelwidth}{#1}} + +% When this command is executed, IED lists will use the +% IEEEiedmathlabelsep label separation rather than the normal +% spacing. To have an effect, this command must be executed via +% the \IEEEiedlistdecl or within the option of the IED list +% environments. +\def\IEEEusemathlabelsep{\setlength{\labelsep}{\IEEEiedmathlabelsep}} + +% A flag which controls whether the IED lists automatically +% calculate \leftmargin from \IEEElabelindent, \labelwidth and \labelsep +% Useful if you want to specify your own \leftmargin +% This flag must be set (\IEEEnocalcleftmargintrue or \IEEEnocalcleftmarginfalse) +% via the \IEEEiedlistdecl or within the option of the IED list +% environments to have an effect. +\newif\ifIEEEnocalcleftmargin +\IEEEnocalcleftmarginfalse + +% A flag which controls whether \IEEElabelindent is multiplied by +% the \IEEElabelindentfactor for each list level. +% This flag must be set via the \IEEEiedlistdecl or within the option +% of the IED list environments to have an effect. +\newif\ifIEEEnolabelindentfactor +\IEEEnolabelindentfactorfalse + + +% internal variable to indicate type of IED label +% justification +% 0 - left; 1 - center; 2 - right +\def\@IEEEiedjustify{0} + + +% commands to allow the user to control IED +% label justifications. Use these commands within +% the IED environment option or in the \IEEEiedlistdecl +% Note that changing the normal list justifications +% is nonstandard and the IEEE may not like it if you do so! +% I include these commands as they may be helpful to +% those who are using these enhanced list controls for +% other non-IEEE related LaTeX work. +% itemize and enumerate automatically default to right +% justification, description defaults to left. +\def\IEEEiedlabeljustifyl{\def\@IEEEiedjustify{0}}%left +\def\IEEEiedlabeljustifyc{\def\@IEEEiedjustify{1}}%center +\def\IEEEiedlabeljustifyr{\def\@IEEEiedjustify{2}}%right + + + + +% commands to save to and restore from the list parameter copies +% this allows us to set all the list parameters within +% the list_decl and prevent \list (and its \@list) +% from overriding any of our parameters +% V1.6 use \edefs instead of dimen's to conserve dimen registers +% Note controlled spacing here, shield end of lines with % +\def\@IEEEsavelistparams{\edef\@IEEEiedtopsep{\the\topsep}% +\edef\@IEEEiedlabelwidth{\the\labelwidth}% +\edef\@IEEEiedlabelsep{\the\labelsep}% +\edef\@IEEEiedleftmargin{\the\leftmargin}% +\edef\@IEEEiedpartopsep{\the\partopsep}% +\edef\@IEEEiedparsep{\the\parsep}% +\edef\@IEEEieditemsep{\the\itemsep}% +\edef\@IEEEiedrightmargin{\the\rightmargin}% +\edef\@IEEEiedlistparindent{\the\listparindent}% +\edef\@IEEEieditemindent{\the\itemindent}} + +% Note controlled spacing here +\def\@IEEErestorelistparams{\topsep\@IEEEiedtopsep\relax% +\labelwidth\@IEEEiedlabelwidth\relax% +\labelsep\@IEEEiedlabelsep\relax% +\leftmargin\@IEEEiedleftmargin\relax% +\partopsep\@IEEEiedpartopsep\relax% +\parsep\@IEEEiedparsep\relax% +\itemsep\@IEEEieditemsep\relax% +\rightmargin\@IEEEiedrightmargin\relax% +\listparindent\@IEEEiedlistparindent\relax% +\itemindent\@IEEEieditemindent\relax} + + +% v1.6b provide original LaTeX IED list environments +% note that latex.ltx defines \itemize and \enumerate, but not \description +% which must be created by the base classes +% save original LaTeX itemize and enumerate +\let\LaTeXitemize\itemize +\let\endLaTeXitemize\enditemize +\let\LaTeXenumerate\enumerate +\let\endLaTeXenumerate\endenumerate + +% provide original LaTeX description environment from article.cls +\newenvironment{LaTeXdescription} + {\list{}{\labelwidth\z@ \itemindent-\leftmargin + \let\makelabel\descriptionlabel}} + {\endlist} +\newcommand*\descriptionlabel[1]{\hspace\labelsep + \normalfont\bfseries #1} + + +% override LaTeX's default IED lists +\def\itemize{\@IEEEitemize} +\def\enditemize{\@endIEEEitemize} +\def\enumerate{\@IEEEenumerate} +\def\endenumerate{\@endIEEEenumerate} +\def\description{\@IEEEdescription} +\def\enddescription{\@endIEEEdescription} + +% provide the user with aliases - may help those using packages that +% override itemize, enumerate, or description +\def\IEEEitemize{\@IEEEitemize} +\def\endIEEEitemize{\@endIEEEitemize} +\def\IEEEenumerate{\@IEEEenumerate} +\def\endIEEEenumerate{\@endIEEEenumerate} +\def\IEEEdescription{\@IEEEdescription} +\def\endIEEEdescription{\@endIEEEdescription} + + +% V1.6 we want to keep the IEEEtran IED list definitions as our own internal +% commands so they are protected against redefinition +\def\@IEEEitemize{\@ifnextchar[{\@@IEEEitemize}{\@@IEEEitemize[\relax]}} +\def\@IEEEenumerate{\@ifnextchar[{\@@IEEEenumerate}{\@@IEEEenumerate[\relax]}} +\def\@IEEEdescription{\@ifnextchar[{\@@IEEEdescription}{\@@IEEEdescription[\relax]}} +\def\@endIEEEitemize{\endlist} +\def\@endIEEEenumerate{\endlist} +\def\@endIEEEdescription{\endlist} + + +% DO NOT ALLOW BLANK LINES TO BE IN THESE IED ENVIRONMENTS +% AS THIS WILL FORCE NEW PARAGRAPHS AFTER THE IED LISTS +% IEEEtran itemized list MDS 1/2001 +% Note controlled spacing here, shield end of lines with % +\def\@@IEEEitemize[#1]{% + \ifnum\@itemdepth>3\relax\@toodeep\else% + \ifnum\@listdepth>5\relax\@toodeep\else% + \advance\@itemdepth\@ne% + \edef\@itemitem{labelitem\romannumeral\the\@itemdepth}% + % get the IEEElabelindentfactor for this level + \advance\@listdepth\@ne% we need to know what the level WILL be + \edef\IEEElabelindentfactor{\csname IEEElabelindentfactor\romannumeral\the\@listdepth\endcsname}% + \advance\@listdepth-\@ne% undo our increment + \def\@IEEEiedjustify{2}% right justified labels are default + % set other defaults + \IEEEnocalcleftmarginfalse% + \IEEEnolabelindentfactorfalse% + \topsep\IEEEiedtopsep% + \IEEElabelindent\IEEEilabelindent% + \labelsep\IEEEiednormlabelsep% + \partopsep 0ex% + \parsep 0ex% + \itemsep 0ex% + \rightmargin 0em% + \listparindent 0em% + \itemindent 0em% + % calculate the label width + % the user can override this later if + % they specified a \labelwidth + \settowidth{\labelwidth}{\csname labelitem\romannumeral\the\@itemdepth\endcsname}% + \@IEEEsavelistparams% save our list parameters + \list{\csname\@itemitem\endcsname}{% + \@IEEErestorelistparams% override any list{} changes + % to our globals + \let\makelabel\@IEEEiedmakelabel% v1.6b setup \makelabel + \IEEEiedlistdecl% let user alter parameters + #1\relax% + % If the user has requested not to use the + % IEEElabelindent factor, don't revise \IEEElabelindent + \ifIEEEnolabelindentfactor\relax% + \else\IEEElabelindent=\IEEElabelindentfactor\IEEElabelindent% + \fi% + % Unless the user has requested otherwise, + % calculate our left margin based + % on \IEEElabelindent, \labelwidth and + % \labelsep + \ifIEEEnocalcleftmargin\relax% + \else\IEEEcalcleftmargin{\IEEElabelindent}% + \fi}\fi\fi}% + + +% DO NOT ALLOW BLANK LINES TO BE IN THESE IED ENVIRONMENTS +% AS THIS WILL FORCE NEW PARAGRAPHS AFTER THE IED LISTS +% IEEEtran enumerate list MDS 1/2001 +% Note controlled spacing here, shield end of lines with % +\def\@@IEEEenumerate[#1]{% + \ifnum\@enumdepth>3\relax\@toodeep\else% + \ifnum\@listdepth>5\relax\@toodeep\else% + \advance\@enumdepth\@ne% + \edef\@enumctr{enum\romannumeral\the\@enumdepth}% + % get the IEEElabelindentfactor for this level + \advance\@listdepth\@ne% we need to know what the level WILL be + \edef\IEEElabelindentfactor{\csname IEEElabelindentfactor\romannumeral\the\@listdepth\endcsname}% + \advance\@listdepth-\@ne% undo our increment + \def\@IEEEiedjustify{2}% right justified labels are default + % set other defaults + \IEEEnocalcleftmarginfalse% + \IEEEnolabelindentfactorfalse% + \topsep\IEEEiedtopsep% + \IEEElabelindent\IEEEelabelindent% + \labelsep\IEEEiednormlabelsep% + \partopsep 0ex% + \parsep 0ex% + \itemsep 0ex% + \rightmargin 0em% + \listparindent 0em% + \itemindent 0em% + % calculate the label width + % We'll set it to the width suitable for all labels using + % normalfont 1) to 9) + % The user can override this later + \settowidth{\labelwidth}{9)}% + \@IEEEsavelistparams% save our list parameters + \list{\csname label\@enumctr\endcsname}{\usecounter{\@enumctr}% + \@IEEErestorelistparams% override any list{} changes + % to our globals + \let\makelabel\@IEEEiedmakelabel% v1.6b setup \makelabel + \IEEEiedlistdecl% let user alter parameters + #1\relax% + % If the user has requested not to use the + % IEEElabelindent factor, don't revise \IEEElabelindent + \ifIEEEnolabelindentfactor\relax% + \else\IEEElabelindent=\IEEElabelindentfactor\IEEElabelindent% + \fi% + % Unless the user has requested otherwise, + % calculate our left margin based + % on \IEEElabelindent, \labelwidth and + % \labelsep + \ifIEEEnocalcleftmargin\relax% + \else\IEEEcalcleftmargin{\IEEElabelindent}% + \fi}\fi\fi}% + + +% DO NOT ALLOW BLANK LINES TO BE IN THESE IED ENVIRONMENTS +% AS THIS WILL FORCE NEW PARAGRAPHS AFTER THE IED LISTS +% IEEEtran description list MDS 1/2001 +% Note controlled spacing here, shield end of lines with % +\def\@@IEEEdescription[#1]{% + \ifnum\@listdepth>5\relax\@toodeep\else% + % get the IEEElabelindentfactor for this level + \advance\@listdepth\@ne% we need to know what the level WILL be + \edef\IEEElabelindentfactor{\csname IEEElabelindentfactor\romannumeral\the\@listdepth\endcsname}% + \advance\@listdepth-\@ne% undo our increment + \def\@IEEEiedjustify{0}% left justified labels are default + % set other defaults + \IEEEnocalcleftmarginfalse% + \IEEEnolabelindentfactorfalse% + \topsep\IEEEiedtopsep% + \IEEElabelindent\IEEEdlabelindent% + % assume normal labelsep + \labelsep\IEEEiednormlabelsep% + \partopsep 0ex% + \parsep 0ex% + \itemsep 0ex% + \rightmargin 0em% + \listparindent 0em% + \itemindent 0em% + % Bogus label width in case the user forgets + % to set it. + % TIP: If you want to see what a variable's width is you + % can use the TeX command \showthe\width-variable to + % display it on the screen during compilation + % (This might be helpful to know when you need to find out + % which label is the widest) + \settowidth{\labelwidth}{Hello}% + \@IEEEsavelistparams% save our list parameters + \list{}{\@IEEErestorelistparams% override any list{} changes + % to our globals + \let\makelabel\@IEEEiedmakelabel% v1.6b setup \makelabel + \IEEEiedlistdecl% let user alter parameters + #1\relax% + % If the user has requested not to use the + % labelindent factor, don't revise \IEEElabelindent + \ifIEEEnolabelindentfactor\relax% + \else\IEEElabelindent=\IEEElabelindentfactor\IEEElabelindent% + \fi% + % Unless the user has requested otherwise, + % calculate our left margin based + % on \IEEElabelindent, \labelwidth and + % \labelsep + \ifIEEEnocalcleftmargin\relax% + \else\IEEEcalcleftmargin{\IEEElabelindent}\relax% + \fi}\fi} + +% v1.6b we use one makelabel that does justification as needed. +\def\@IEEEiedmakelabel#1{\relax\if\@IEEEiedjustify 0\relax +\makebox[\labelwidth][l]{\normalfont #1}\else +\if\@IEEEiedjustify 1\relax +\makebox[\labelwidth][c]{\normalfont #1}\else +\makebox[\labelwidth][r]{\normalfont #1}\fi\fi} + + +% compsoc uses a larger value for the normal labelsep +% and also extra spacing above and below each list +\ifCLASSOPTIONcompsoc + \IEEEiednormlabelsep 1.2em + \IEEEiedtopsep 6pt plus 3pt minus 3pt +\fi + + +% VERSE and QUOTE +% V1.7 define environments with newenvironment +\newenvironment{verse}{\let\\=\@centercr + \list{}{\itemsep\z@ \itemindent -1.5em \listparindent \itemindent + \rightmargin\leftmargin\advance\leftmargin 1.5em}\item\relax} + {\endlist} +\newenvironment{quotation}{\list{}{\listparindent 1.5em \itemindent\listparindent + \rightmargin\leftmargin \parsep 0pt plus 1pt}\item\relax} + {\endlist} +\newenvironment{quote}{\list{}{\rightmargin\leftmargin}\item\relax} + {\endlist} + + +% \titlepage +% provided only for backward compatibility. \maketitle is the correct +% way to create the title page. +\def\titlepage{\@restonecolfalse\if@twocolumn\@restonecoltrue\onecolumn + \else \newpage \fi \thispagestyle{empty}\c@page\z@} +\def\endtitlepage{\if@restonecol\twocolumn \else \newpage \fi} + +% standard values from article.cls +\arraycolsep 5pt +\arrayrulewidth .4pt +\doublerulesep 2pt + +\tabcolsep 6pt +\tabbingsep 0.5em + + +%% FOOTNOTES +% +%\skip\footins 10pt plus 4pt minus 2pt +% V1.6 respond to changes in font size +% space added above the footnotes (if present) +\skip\footins 0.9\baselineskip plus 0.4\baselineskip minus 0.2\baselineskip + +% V1.6, we need to make \footnotesep responsive to changes +% in \baselineskip or strange spacings will result when in +% draft mode. Here is a little LaTeX secret - \footnotesep +% determines the height of an invisible strut that is placed +% *above* the baseline of footnotes after the first. Since +% LaTeX considers the space for characters to be 0.7\baselineskip +% above the baseline and 0.3\baselineskip below it, we need to +% use 0.7\baselineskip as a \footnotesep to maintain equal spacing +% between all the lines of the footnotes. The IEEE often uses a tad +% more, so use 0.8\baselineskip. This slightly larger value also helps +% the text to clear the footnote marks. Note that \thanks in IEEEtran +% uses its own value of \footnotesep which is set in \maketitle. +{\footnotesize +\global\footnotesep 0.8\baselineskip} + + +\skip\@mpfootins = \skip\footins +\fboxsep = 3pt +\fboxrule = .4pt +% V1.6 use 1em, then use LaTeX2e's \@makefnmark +% Note that the IEEE normally *left* aligns the footnote marks, so we don't need +% box resizing tricks here. +\long\def\@makefntext#1{\parindent 1em\indent\hbox{\@makefnmark}#1}% V1.6 use 1em +% V1.7 compsoc does not use superscipts for footnote marks +\ifCLASSOPTIONcompsoc +\def\@IEEEcompsocmakefnmark{\hbox{\normalfont\@thefnmark.\ }} +\long\def\@makefntext#1{\parindent 1em\indent\hbox{\@IEEEcompsocmakefnmark}#1} +\fi + +% The IEEE does not use footnote rules +\def\footnoterule{} + +% V1.7 for compsoc, the IEEE uses a footnote rule only for \thanks. We devise a "one-shot" +% system to implement this. +\newif\if@IEEEenableoneshotfootnoterule +\@IEEEenableoneshotfootnoterulefalse +\ifCLASSOPTIONcompsoc +\def\footnoterule{\relax\if@IEEEenableoneshotfootnoterule +\kern-5pt +\hbox to \columnwidth{\hfill\vrule width 0.5\columnwidth height 0.4pt\hfill} +\kern4.6pt +\global\@IEEEenableoneshotfootnoterulefalse +\else +\relax +\fi} +\fi + +% V1.6 do not allow LaTeX to break a footnote across multiple pages +\interfootnotelinepenalty=10000 + +% V1.6 discourage breaks within equations +% Note that amsmath normally sets this to 10000, +% but LaTeX2e normally uses 100. +\interdisplaylinepenalty=2500 + +% default allows section depth up to /paragraph +\setcounter{secnumdepth}{4} + +% technotes do not allow /paragraph +\ifCLASSOPTIONtechnote + \setcounter{secnumdepth}{3} +\fi +% neither do compsoc conferences +\@IEEEcompsocconfonly{\setcounter{secnumdepth}{3}} + + +\newcounter{section} +\newcounter{subsection}[section] +\newcounter{subsubsection}[subsection] +\newcounter{paragraph}[subsubsection] + +% used only by IEEEtran's IEEEeqnarray as other packages may +% have their own, different, implementations +\newcounter{IEEEsubequation}[equation] + +% as shown when called by user from \ref, \label and in table of contents +\def\theequation{\arabic{equation}} % 1 +\def\theIEEEsubequation{\theequation\alph{IEEEsubequation}} % 1a (used only by IEEEtran's IEEEeqnarray) +\ifCLASSOPTIONcompsoc +% compsoc is all arabic +\def\thesection{\arabic{section}} +\def\thesubsection{\thesection.\arabic{subsection}} +\def\thesubsubsection{\thesubsection.\arabic{subsubsection}} +\def\theparagraph{\thesubsubsection.\arabic{paragraph}} +\else +\def\thesection{\Roman{section}} % I +% V1.7, \mbox prevents breaks around - +\def\thesubsection{\mbox{\thesection-\Alph{subsection}}} % I-A +% V1.7 use I-A1 format used by the IEEE rather than I-A.1 +\def\thesubsubsection{\thesubsection\arabic{subsubsection}} % I-A1 +\def\theparagraph{\thesubsubsection\alph{paragraph}} % I-A1a +\fi + +% From Heiko Oberdiek. Because of the \mbox in \thesubsection, we need to +% tell hyperref to disable the \mbox command when making PDF bookmarks. +% This done already with hyperref.sty version 6.74o and later, but +% it will not hurt to do it here again for users of older versions. +\@ifundefined{pdfstringdefPreHook}{\let\pdfstringdefPreHook\@empty}{}% +\g@addto@macro\pdfstringdefPreHook{\let\mbox\relax} + + +% Main text forms (how shown in main text headings) +% V1.6, using \thesection in \thesectiondis allows changes +% in the former to automatically appear in the latter +\ifCLASSOPTIONcompsoc + \ifCLASSOPTIONconference% compsoc conference + \def\thesectiondis{\thesection.} + \def\thesubsectiondis{\thesectiondis\arabic{subsection}.} + \def\thesubsubsectiondis{\thesubsectiondis\arabic{subsubsection}.} + \def\theparagraphdis{\thesubsubsectiondis\arabic{paragraph}.} + \else% compsoc not conferencs + \def\thesectiondis{\thesection} + \def\thesubsectiondis{\thesectiondis.\arabic{subsection}} + \def\thesubsubsectiondis{\thesubsectiondis.\arabic{subsubsection}} + \def\theparagraphdis{\thesubsubsectiondis.\arabic{paragraph}} + \fi +\else% not compsoc + \def\thesectiondis{\thesection.} % I. + \def\thesubsectiondis{\Alph{subsection}.} % B. + \def\thesubsubsectiondis{\arabic{subsubsection})} % 3) + \def\theparagraphdis{\alph{paragraph})} % d) +\fi + +% just like LaTeX2e's \@eqnnum +\def\theequationdis{{\normalfont \normalcolor (\theequation)}}% (1) +% IEEEsubequation used only by IEEEtran's IEEEeqnarray +\def\theIEEEsubequationdis{{\normalfont \normalcolor (\theIEEEsubequation)}}% (1a) +% redirect LaTeX2e's equation number display and all that depend on +% it, through IEEEtran's \theequationdis +\def\@eqnnum{\theequationdis} + + + +% V1.7 provide string macros as article.cls does +\def\contentsname{Contents} +\def\listfigurename{List of Figures} +\def\listtablename{List of Tables} +\def\refname{References} +\def\indexname{Index} +\def\figurename{Fig.} +\def\tablename{TABLE} +\@IEEEcompsocconfonly{\def\figurename{Figure}} +\def\partname{Part} +\def\appendixname{Appendix} +\def\abstractname{Abstract} +% IEEE specific names +\def\IEEEkeywordsname{Index Terms} +\def\IEEEproofname{Proof} + + +% LIST OF FIGURES AND TABLES AND TABLE OF CONTENTS +% +\def\@pnumwidth{1.55em} +\def\@tocrmarg{2.55em} +\def\@dotsep{4.5} +\setcounter{tocdepth}{3} + +% adjusted some spacings here so that section numbers will not easily +% collide with the section titles. +% VIII; VIII-A; and VIII-A.1 are usually the worst offenders. +% MDS 1/2001 +\def\tableofcontents{\section*{\contentsname}\@starttoc{toc}} +\def\l@section#1#2{\addpenalty{\@secpenalty}\addvspace{1.0em plus 1pt}% + \@tempdima 2.75em \begingroup \parindent \z@ \rightskip \@pnumwidth% + \parfillskip-\@pnumwidth {\bfseries\leavevmode #1}\hfil\hbox to\@pnumwidth{\hss #2}\par% + \endgroup} +% argument format #1:level, #2:labelindent,#3:labelsep +\def\l@subsection{\@dottedtocline{2}{2.75em}{3.75em}} +\def\l@subsubsection{\@dottedtocline{3}{6.5em}{4.5em}} +% must provide \l@ defs for ALL sublevels EVEN if tocdepth +% is such as they will not appear in the table of contents +% these defs are how TOC knows what level these things are! +\def\l@paragraph{\@dottedtocline{4}{6.5em}{5.5em}} +\def\l@subparagraph{\@dottedtocline{5}{6.5em}{6.5em}} +\def\listoffigures{\section*{\listfigurename}\@starttoc{lof}} +\def\l@figure{\@dottedtocline{1}{0em}{2.75em}} +\def\listoftables{\section*{\listtablename}\@starttoc{lot}} +\let\l@table\l@figure + + +% Definitions for floats +% +% Normal Floats +% V1.8 floatsep et al. revised down by 0.15\baselineskip +% to account for the sideeffects of \topskip compensation +\floatsep 0.85\baselineskip plus 0.2\baselineskip minus 0.2\baselineskip +\textfloatsep 1.55\baselineskip plus 0.2\baselineskip minus 0.4\baselineskip +\@fptop 0pt plus 1fil +\@fpsep 0.75\baselineskip plus 2fil +\@fpbot 0pt plus 1fil +\def\topfraction{0.9} +\def\bottomfraction{0.4} +\def\floatpagefraction{0.8} +% V1.7, let top floats approach 90% of page +\def\textfraction{0.1} + +% Double Column Floats +\dblfloatsep 0.85\baselineskip plus 0.2\baselineskip minus 0.2\baselineskip + +\dbltextfloatsep 1.55\baselineskip plus 0.2\baselineskip minus 0.4\baselineskip +% Note that it would be nice if the rubber here actually worked in LaTeX2e. +% There is a long standing limitation in LaTeX, first discovered (to the best +% of my knowledge) by Alan Jeffrey in 1992. LaTeX ignores the stretchable +% portion of \dbltextfloatsep, and as a result, double column figures can and +% do result in an non-integer number of lines in the main text columns with +% underfull vbox errors as a consequence. A post to comp.text.tex +% by Donald Arseneau confirms that this had not yet been fixed in 1998. +% IEEEtran V1.6 will fix this problem for you in the titles, but it doesn't +% protect you from other double floats. Happy vspace'ing. + +\@dblfptop 0pt plus 1fil +\@dblfpsep 0.75\baselineskip plus 2fil +\@dblfpbot 0pt plus 1fil +\def\dbltopfraction{0.8} +\def\dblfloatpagefraction{0.8} +\setcounter{dbltopnumber}{4} + +\intextsep 0.85\baselineskip plus 0.2\baselineskip minus 0.2\baselineskip +\setcounter{topnumber}{2} +\setcounter{bottomnumber}{2} +\setcounter{totalnumber}{4} + + + +% article class provides these, we should too. +\newlength\abovecaptionskip +\newlength\belowcaptionskip +% but only \abovecaptionskip is used above figure captions and *below* table +% captions +\setlength\abovecaptionskip{0.5\baselineskip} +% compsoc journals are a little more generous +\ifCLASSOPTIONcompsoc\ifCLASSOPTIONjournal + \setlength\abovecaptionskip{0.75\baselineskip} +\fi\fi +\setlength\belowcaptionskip{0pt} +% V1.6 create hooks in case the caption spacing ever needs to be +% overridden by a user +\def\@IEEEfigurecaptionsepspace{\vskip\abovecaptionskip\relax}% +\def\@IEEEtablecaptionsepspace{\vskip\abovecaptionskip\relax}% + + +% 1.6b revise caption system so that \@makecaption uses two arguments +% as with LaTeX2e. Otherwise, there will be problems when using hyperref. +\def\@IEEEtablestring{table} + + +% V1.8 compensate for \topskip so top of top figures align with tops of the first lines of main text +% here we calculate a space equal to the amount \topskip exceeds the main text height +% we hook in at \@floatboxreset +\def\@IEEEfiguretopskipspace{\ifdim\prevdepth=-1000pt\relax +\setlength{\@IEEEtrantmpdimenA}{1\topskip}\relax +\addtolength{\@IEEEtrantmpdimenA}{-0.7\@IEEEnormalsizeunitybaselineskip}\relax +\vspace*{\@IEEEtrantmpdimenA}\fi} +% V1.8 compensate for \topskip at the top of top tables so caption text is on main text baseline +% use a strut set on the caption baseline within \@makecaption +\def\@IEEEtabletopskipstrut{\ifdim\prevdepth=-1000pt\rule{0pt}{\topskip}\fi} +% the \ifdim\prevdepth checks are always expected to be true for IEEE style float caption ordering +% because top of figure content and top of captions in tables is the first thing on the vertical +% list of these floats +% thanks to Donald Arseneau for his 2000/11/11 post "Re: caption hacking" with info on this topic. + + +\ifCLASSOPTIONcompsoc +% V1.7 compsoc \@makecaption +\ifCLASSOPTIONconference% compsoc conference +\long\def\@makecaption#1#2{% +% test if is a for a figure or table +\ifx\@captype\@IEEEtablestring% +% if a table, do table caption +\footnotesize\bgroup\par\centering\@IEEEtabletopskipstrut{\normalfont\footnotesize {#1.}\nobreakspace\scshape #2}\par\addvspace{0.5\baselineskip}\egroup% +\@IEEEtablecaptionsepspace +% if not a table, format it as a figure +\else +\@IEEEfigurecaptionsepspace +\setbox\@tempboxa\hbox{\normalfont\footnotesize {#1.}\nobreakspace #2}% +\ifdim \wd\@tempboxa >\hsize% +% if caption is longer than a line, let it wrap around +\setbox\@tempboxa\hbox{\normalfont\footnotesize {#1.}\nobreakspace}% +\parbox[t]{\hsize}{\normalfont\footnotesize \noindent\unhbox\@tempboxa#2}% +% if caption is shorter than a line, center +\else% +\hbox to\hsize{\normalfont\footnotesize\hfil\box\@tempboxa\hfil}% +\fi\fi} +% +\else% nonconference compsoc +\long\def\@makecaption#1#2{% +% test if is a for a figure or table +\ifx\@captype\@IEEEtablestring% +% if a table, do table caption +\footnotesize\bgroup\par\centering\@IEEEtabletopskipstrut{\normalfont\sffamily\footnotesize #1}\\{\normalfont\sffamily\footnotesize #2}\par\addvspace{0.5\baselineskip}\egroup% +\@IEEEtablecaptionsepspace +% if not a table, format it as a figure +\else +\@IEEEfigurecaptionsepspace +\setbox\@tempboxa\hbox{\normalfont\sffamily\footnotesize {#1.}\nobreakspace #2}% +\ifdim \wd\@tempboxa >\hsize% +% if caption is longer than a line, let it wrap around +\setbox\@tempboxa\hbox{\normalfont\sffamily\footnotesize {#1.}\nobreakspace}% +\parbox[t]{\hsize}{\normalfont\sffamily\footnotesize \noindent\unhbox\@tempboxa#2}% +% if caption is shorter than a line, left justify +\else% +\hbox to\hsize{\normalfont\sffamily\footnotesize\box\@tempboxa\hfil}% +\fi\fi} +\fi +% +\else% traditional noncompsoc \@makecaption +\long\def\@makecaption#1#2{% +% test if is a for a figure or table +\ifx\@captype\@IEEEtablestring% +% if a table, do table caption +\footnotesize\bgroup\par\centering\@IEEEtabletopskipstrut{\normalfont\footnotesize #1}\\{\normalfont\footnotesize\scshape #2}\par\addvspace{0.5\baselineskip}\egroup% +\@IEEEtablecaptionsepspace +% if not a table, format it as a figure +\else +\@IEEEfigurecaptionsepspace +% 3/2001 use footnotesize, not small; use two nonbreaking spaces, not one +\setbox\@tempboxa\hbox{\normalfont\footnotesize {#1.}\nobreakspace\nobreakspace #2}% +\ifdim \wd\@tempboxa >\hsize% +% if caption is longer than a line, let it wrap around +\setbox\@tempboxa\hbox{\normalfont\footnotesize {#1.}\nobreakspace\nobreakspace}% +\parbox[t]{\hsize}{\normalfont\footnotesize\noindent\unhbox\@tempboxa#2}% +% if caption is shorter than a line, center if conference, left justify otherwise +\else% +\ifCLASSOPTIONconference \hbox to\hsize{\normalfont\footnotesize\hfil\box\@tempboxa\hfil}% +\else \hbox to\hsize{\normalfont\footnotesize\box\@tempboxa\hfil}% +\fi\fi\fi} +\fi + + + +% V1.7 disable captions class option, do so in a way that retains operation of \label +% within \caption +\ifCLASSOPTIONcaptionsoff +\long\def\@makecaption#1#2{\vspace*{2em}\footnotesize\bgroup\par\addvspace{0.5\baselineskip}\centering{\footnotesize #1}\par\addvspace{0.5\baselineskip}\egroup% +\let\@IEEEtemporiglabeldefsave\label +\let\@IEEEtemplabelargsave\relax +\def\label##1{\gdef\@IEEEtemplabelargsave{##1}}% +\setbox\@tempboxa\hbox{#2}% +\let\label\@IEEEtemporiglabeldefsave +\ifx\@IEEEtemplabelargsave\relax\else\label{\@IEEEtemplabelargsave}\fi} +\fi + + +% V1.7 define end environments with \def not \let so as to work OK with +% preview-latex +\newcounter{figure} +\def\thefigure{\@arabic\c@figure} +\def\fps@figure{tbp} +\def\ftype@figure{1} +\def\ext@figure{lof} +\def\fnum@figure{\figurename\nobreakspace\thefigure} +% V1.8 within figures add \@IEEEfiguretopskipspace compensation to LaTeX2e's \@floatboxreset +\def\figure{\def\@floatboxreset{\reset@font\normalsize\@setminipage\@IEEEfiguretopskipspace}\@float{figure}} +\def\endfigure{\end@float} +% V1.8 also add \@IEEEfiguretopskipspace compensation to \figure* +\@namedef{figure*}{\def\@floatboxreset{\reset@font\normalsize\@setminipage\@IEEEfiguretopskipspace}\@dblfloat{figure}} +\@namedef{endfigure*}{\end@dblfloat} + +\newcounter{table} +\ifCLASSOPTIONcompsoc +\def\thetable{\arabic{table}} +\else +\def\thetable{\@Roman\c@table} +\fi +\def\fps@table{tbp} +\def\ftype@table{2} +\def\ext@table{lot} +\def\fnum@table{\tablename\nobreakspace\thetable} +% V1.6 The IEEE uses 8pt text for tables +% within tables alter LaTeX2e's \@floatboxreset to use \footnotesize +\def\table{\def\@floatboxreset{\reset@font\footnotesize\@setminipage}\@float{table}} +\def\endtable{\end@float} +% v1.6b double column tables need to default to footnotesize as well. +\@namedef{table*}{\def\@floatboxreset{\reset@font\footnotesize\@setminipage}\@dblfloat{table}} +\@namedef{endtable*}{\end@dblfloat} + + + + +%% -- Command Argument Scanning Support Functions -- +%% V1.8a + +% usage: \@IEEEstripouterbraces*{} +% \@IEEEstripouterbraces fully expands its argument (which it then stores +% in \@IEEEstripouterbracesarg) via \edef, then removes any outer enclosing +% braces, and finally stores the result in the macro +% \@IEEEstrippedouterbraces. +% +% For example: +% \@IEEEstripouterbraces{{{{ab}c}}} +% results in: +% +% \@IEEEstripouterbracesarg ==> a macro containing {{{ab}c}} +% \@IEEEstrippedouterbraces ==> a macro containing {ab}c +% +% the *-star form,\@IEEEstripouterbraces*, does not expand the argument +% contents during processing +\def\@IEEEstripouterbraces{\@ifstar{\let\@IEEEstripouterbracesdef=\def\@@IEEEstripouterbraces}{\let\@IEEEstripouterbracesdef=\edef\@@IEEEstripouterbraces}} + +\def\@@IEEEstripouterbraces#1{\@IEEEstripouterbracesdef\@IEEEstripouterbracesarg{#1}\relax +% If the macro is unchanged after being acquired as a single delimited +% argument, we know we have one sequence of tokens without any enclosing +% braces. Loop until this is true. +\loop + \expandafter\@@@IEEEstripouterbraces\@IEEEstripouterbracesarg\@IEEEgeneralsequenceDELIMITER +\ifx\@IEEEstrippedouterbraces\@IEEEstripouterbracesarg +\else + \let\@IEEEstripouterbracesarg\@IEEEstrippedouterbraces +\repeat} + +\def\@@@IEEEstripouterbraces#1\@IEEEgeneralsequenceDELIMITER{\def\@IEEEstrippedouterbraces{#1}} + + + +% usage: \@IEEEextractgroup*{} +% \@IEEEextractgroup fully expands its argument (which it then stores in +% \@IEEEextractgrouparg) via \edef and then assigns the first "brace group" +% of tokens to the macro \@IEEEextractedgroup. +% The remaining groups, if any, are stored in the macro +% \@IEEEextractedgroupremain. If the argument does not contain the requisite +% groups, the respective macros will be defined to be empty. +% There is an asymmetry in that \@IEEEextractedgroup is stripped of its first +% outer grouping while \@IEEEextractedgroupremain retains even the outer +% grouping (if present) that originally identified it as a group. +% +% For example: +% \@IEEEextractgroup{{{ab}}{c{de}}} +% results in: +% +% \@IEEEextractgrouparg ==> a macro containing {{ab}}{c{de}} +% \@IEEEextractedgroup ==> a macro containing {ab} +% \@IEEEextractedgroupremain ==> a macro containing {c{de}} +% +% The *-star form, \@IEEEextractgroup*, does not expand its argument +% contents during processing. +\def\@IEEEextractgroup{\@ifstar{\let\@IEEEextractgroupdef=\def\@@IEEEextractgroup}{\let\@IEEEextractgroupdef=\edef\@@IEEEextractgroup}} + +\def\@@IEEEextractgroup#1{\@IEEEextractgroupdef\@IEEEextractgrouparg{#1}\relax +% trap the case of an empty extracted group as this would cause problems with +% \@IEEEextractgroupremain's argument acquisition +\ifx\@IEEEextractgrouparg\@empty + \def\@IEEEextractedgroup{}\relax + \def\@IEEEextractedgroupremain{}\relax +\else + % We have to use some dirty tricks here. We want to insert {} around + % whatever remains after the first group so that TeX's argument scanner + % will preserve any originally enclosing braces as well as provide an + % empty argument to acquire even if there isn't a second group. + % In this first of two dirty tricks, we put a } at the end of the structure + % we are going to extract from. The \ifnum0=`{\fi keeps TeX happy to allow + % what would otherwise be an unbalanced macro definition for + % \@@IEEEextractgroup to be acceptable to it. + \ifnum0=`{\fi\expandafter\@IEEEextractgroupremain\@IEEEextractgrouparg}\relax +\fi} + +% In the second part of the dirty tricks, we insert a leading { right after +% the first group is acquired, but before the remainder is. Again, the +% \ifnum0=`}\fi keeps TeX happy during definition time, but will disappear +% during run time. +\def\@IEEEextractgroupremain#1{\def\@IEEEextractedgroup{#1}\expandafter\@@IEEEextractgroupremain\expandafter{\ifnum0=`}\fi} + +\def\@@IEEEextractgroupremain#1{\def\@IEEEextractedgroupremain{#1}} + + + +% \@IEEEextracttoken relocated at top because margin setting commands rely on it + + + +% usage: \@IEEEextracttokengroups*{} +% \@IEEEextracttokengroups fully expands its argument (which it then stores +% in \@IEEEextracttokengroupsarg) and then assigns the first "brace group" of +% tokens (with the outermost braces removed) to the macro +% \@IEEEextractedfirstgroup. +% The meaning of the first nonbrace (but including the empty group) token +% within this first group is assigned via \let to \@IEEEextractedfirsttoken +% as well as stored in the macro \@IEEEextractedfirsttokenmacro. If a first +% nonbrace token does not exist (or is an empty group), these will be \relax +% and empty, respectively. Tokens that would otherwise be discarded during +% the acquisition of the first token in the first group are stored in +% \@IEEEextractedfirsttokensdiscarded, however their original relative brace +% nesting depths are not guaranteed to be preserved. +% The first group within this first group is stored in the macro +% \@IEEEextractedfirstfirstgroup. +% Likewise for the next group after the first: \@IEEEextractednextgroup, +% \@IEEEextractednextfirstgroup, \@IEEEextractednextgroupfirsttoken, +% \@IEEEextractednextgroupfirsttokenmacro, and +% \@IEEEextractednextfirsttokensdiscarded. +% All tokens/groups after the first group, including any enclosing braces, +% are stored in the macro \@IEEEextractedafterfirstgroupremain which will +% be empty if none exist. +% +% For example: +% \@IEEEextracttokengroups{{{ab}{cd}}{{ef}g}} +% will result in: +% +% \@IEEEextracttokengroupsarg ==> a macro containing {{ab}{cd}}{{ef}g} +% \@IEEEextractedfirstgroup ==> a macro containing {ab}{cd} +% \@IEEEextractedafterfirstgroupremain ==> a macro containing {{ef}g} +% \@IEEEextractedfirsttoken ==> the letter a +% \@IEEEextractedfirsttokenmacro ==> a macro containing a +% \@IEEEextractedfirsttokensdiscarded ==> a macro containing bcd +% \@IEEEextractedfirstfirstgroup ==> a macro containing ab +% \@IEEEextractednextgroup ==> a macro containing {ef}g +% \@IEEEextractednextfirsttoken ==> the letter e +% \@IEEEextractednextfirsttokenmacro ==> a macro containing e +% \@IEEEextractednextfirsttokensdiscarded ==> a macro containing fg +% \@IEEEextractednextfirstgroup ==> a macro containing ef +% +% If given an empty argument, \@IEEEextractedfirsttoken and +% \@IEEEextractednextfirsttoken will be set to \relax +% and all the macros will be empty. +% the *-star form, \@IEEEextracttokengroups*, does not expand its argument +% contents during processing. +% +% Depends on: \@IEEEextractgroup, \@IEEEextracttoken +\def\@IEEEextracttokengroups{\@ifstar{\let\@IEEEextracttokengroupsdef=\def\@@IEEEextracttokengroups}{\let\@IEEEextracttokengroupsdef=\edef\@@IEEEextracttokengroups}} +\def\@@IEEEextracttokengroups#1{\@IEEEextracttokengroupsdef\@IEEEextracttokengroupsarg{#1}\relax +% begin extraction, these functions are safe with empty arguments +% first group +\expandafter\@IEEEextractgroup\expandafter*\expandafter{\@IEEEextracttokengroupsarg}\relax +\let\@IEEEextractedfirstgroup\@IEEEextractedgroup +\let\@IEEEextractedafterfirstgroupremain\@IEEEextractedgroupremain +\expandafter\@IEEEextracttoken\expandafter*\expandafter{\@IEEEextractedfirstgroup}\relax +\let\@IEEEextractedfirsttoken\@IEEEextractedtoken +\let\@IEEEextractedfirsttokenmacro\@IEEEextractedtokenmacro +\let\@IEEEextractedfirsttokensdiscarded\@IEEEextractedtokensdiscarded +% first first group +\expandafter\@IEEEextractgroup\expandafter*\expandafter{\@IEEEextractedfirstgroup}\relax +\let\@IEEEextractedfirstfirstgroup\@IEEEextractedgroup +% next group +\expandafter\@IEEEextractgroup\expandafter*\expandafter{\@IEEEextractedafterfirstgroupremain}\relax +\let\@IEEEextractednextgroup\@IEEEextractedgroup +\expandafter\@IEEEextracttoken\expandafter*\expandafter{\@IEEEextractednextgroup}\relax +\let\@IEEEextractednextfirsttoken\@IEEEextractedtoken +\let\@IEEEextractednextfirsttokenmacro\@IEEEextractedtokenmacro +\let\@IEEEextractednextfirsttokensdiscarded\@IEEEextractedtokensdiscarded +% next first group +\expandafter\@IEEEextractgroup\expandafter*\expandafter{\@IEEEextractednextgroup}\relax +\let\@IEEEextractednextfirstgroup\@IEEEextractedgroup} + + +%% -- End of Command Argument Scanning Support Functions -- + + + + +%% +%% START OF IEEEeqnarray DEFINITIONS +%% +%% Inspired by the concepts, examples, and previous works of LaTeX +%% coders and developers such as Donald Arseneau, Fred Bartlett, +%% David Carlisle, Tony Liu, Frank Mittelbach, Piet van Oostrum, +%% Roland Winkler and Mark Wooding. +%% I don't make the claim that my work here is even near their calibre. ;) + + +\newif\if@IEEEeqnarrayboxnojot% flag to indicate if the environment was called as the star form +\@IEEEeqnarrayboxnojotfalse + +\newif\if@advanceIEEEeqncolcnt% tracks if the environment should advance the col counter +% allows a way to make an \IEEEeqnarraybox that can be used within an \IEEEeqnarray +% used by IEEEeqnarraymulticol so that it can work properly in both +\@advanceIEEEeqncolcnttrue + +\newcount\@IEEEeqnnumcols % tracks how many IEEEeqnarray cols are defined +\newcount\@IEEEeqncolcnt % tracks how many IEEEeqnarray cols the user actually used + + +% The default math style used by the columns +\def\IEEEeqnarraymathstyle{\displaystyle} +% The default text style used by the columns +% default to using the current font +\def\IEEEeqnarraytextstyle{\relax} + +% like the iedlistdecl but for \IEEEeqnarray +\def\IEEEeqnarraydecl{\relax} +\def\IEEEeqnarrayboxdecl{\relax} + + + +% V1.8 flags to indicate that equation numbering is to persist +\newif\if@IEEEeqnumpersist% +\@IEEEeqnumpersistfalse +\newif\if@IEEEsubeqnumpersist% +\@IEEEsubeqnumpersistfalse +% +% V1.8 flags to indicate if (sub)equation number of last line was preadvanced +\newif\if@IEEEeqnumpreadv% +\@IEEEeqnumpreadvfalse +\newif\if@IEEEsubeqnumpreadv% +\@IEEEsubeqnumpreadvfalse + +\newcount\@IEEEsubeqnnumrollback% saves previous value of IEEEsubequation number in case we need to restore it + +% \yesnumber is the opposite of \nonumber +% a novel concept with the same def as the equationarray package +% However, we give IEEE versions too since some LaTeX packages such as +% the MDWtools mathenv.sty redefine \nonumber to something else. +% This command is intended for use in non-IEEEeqnarray math environments +\providecommand{\yesnumber}{\global\@eqnswtrue} + + +% IEEEyes/nonumber +% V1.8 add persistant * forms +% These commands can alter the type of equation an IEEEeqnarray line is. +\def\IEEEyesnumber{\@ifstar{\global\@IEEEeqnumpersisttrue\global\@IEEEsubeqnumpersistfalse\@IEEEyesnumber}{\@IEEEyesnumber}} + +\def\@IEEEyesnumber{\global\@eqnswtrue +\if@IEEEeqnarrayISinner% alter counters and label only inside an IEEEeqnarray +\ifnum\c@IEEEsubequation>0\relax + \stepcounter{equation}\setcounter{IEEEsubequation}{0}\gdef\@currentlabel{\p@equation\theequation}\relax + \gdef\@currentHref{\@IEEEtheHrefequation}% setup hyperref label +\fi +% even if we reached this eqn num via a preadv, it is legit now +\global\@IEEEeqnumpreadvfalse\global\@IEEEsubeqnumpreadvfalse +\fi} + +\def\IEEEnonumber{\@ifstar{\global\@IEEEeqnumpersistfalse\global\@IEEEsubeqnumpersistfalse\global\@eqnswfalse}{\global\@eqnswfalse}} + + +\def\IEEEyessubnumber{\@ifstar{\global\@IEEEsubeqnumpersisttrue\@IEEEyessubnumber}{\@IEEEyessubnumber}} +% +\def\@IEEEyessubnumber{\if@IEEEeqnarrayISinner% alter counters and label only inside an IEEEeqnarray + \ifnum\c@IEEEsubequation>0\relax% if it already is a subequation, we are good to go as-is + \else% if we are a regular equation we have to watch out for two cases + \if@IEEEeqnumpreadv% if this equation is the result of a preadvance, backout and bump the sub eqnnum + \global\advance\c@equation\m@ne\global\c@IEEEsubequation=\@IEEEsubeqnnumrollback\addtocounter{IEEEsubequation}{1}\relax + \else% non-preadvanced equations just need initialization of their sub eqnnum + \setcounter{IEEEsubequation}{1}\relax + \fi + \fi% fi already is subequation + \gdef\@currentlabel{\p@IEEEsubequation\theIEEEsubequation}\relax + \gdef\@currentHref{\@IEEEtheHrefsubequation}% setup hyperref label + \global\@IEEEeqnumpreadvfalse\global\@IEEEsubeqnumpreadvfalse% no longer a preadv anymore + \global\@eqnswtrue +\fi} + + +\def\IEEEnosubnumber{\@ifstar{\global\@IEEEsubeqnumpersistfalse\@IEEEnosubnumber}{\@IEEEnosubnumber}} +% +\def\@IEEEnosubnumber{\if@IEEEeqnarrayISinner% alter counters and label only inside an IEEEeqnarray + \if@eqnsw % we do nothing unless we know we will display because we play with the counters here + % if it currently is a subequation, bump up to the next equation number and turn off the subequation + \ifnum\c@IEEEsubequation>0\relax\addtocounter{equation}{1}\setcounter{IEEEsubequation}{0}\relax + \fi + \global\@IEEEeqnumpreadvfalse\global\@IEEEsubeqnumpreadvfalse% no longer a preadv anymore + \gdef\@currentlabel{\p@equation\theequation}\relax + \gdef\@currentHref{\@IEEEtheHrefequation}% setup hyperref label + \fi +\fi} + + + +% allows users to "push away" equations that get too close to the equation numbers +\def\IEEEeqnarraynumspace{\hphantom{\ifnum\c@IEEEsubequation>0\relax\theIEEEsubequationdis\else\theequationdis\fi}} + +% provides a way to span multiple columns within IEEEeqnarray environments +% will consider \if@advanceIEEEeqncolcnt before globally advancing the +% column counter - so as to work within \IEEEeqnarraybox +% usage: \IEEEeqnarraymulticol{number cols. to span}{col type}{cell text} +\long\def\IEEEeqnarraymulticol#1#2#3{\multispan{#1}\relax +% check if column is defined for the precolumn definition +% We have to be careful here because TeX scans for & even within an \iffalse +% where it does not expand macros. So, if we used only one \ifx and a #3 +% appeared in the false branch and the user inserted another alignment +% structure that uses & in the \IEEEeqnarraymulticol{}, TeX will not see that +% there is an inner alignment in the false branch yet still will see any & +% there and will think that they apply to the outer alignment resulting in an +% incomplete \ifx error. +% So, here we use separate checks for the pre and post parts in order to keep +% the #3 outside of all conditionals. +\relax\expandafter\ifx\csname @IEEEeqnarraycolDEF#2\endcsname\@IEEEeqnarraycolisdefined\relax +\csname @IEEEeqnarraycolPRE#2\endcsname +\else% if not, error and use default type +\@IEEEclspkgerror{Invalid column type "#2" in \string\IEEEeqnarraymulticol.\MessageBreak +Using a default centering column instead}% +{You must define IEEEeqnarray column types before use.}% +\csname @IEEEeqnarraycolPRE@IEEEdefault\endcsname +\fi +% The ten \relax are to help prevent misleading error messages in case a user +% accidently inserted a macro that tries to acquire additional arguments. +#3\relax\relax\relax\relax\relax\relax\relax\relax\relax\relax +% check if column is defined for the postcolumn definition +\expandafter\ifx\csname @IEEEeqnarraycolDEF#2\endcsname\@IEEEeqnarraycolisdefined\relax +\csname @IEEEeqnarraycolPOST#2\endcsname +\else% if not, use the default type +\csname @IEEEeqnarraycolPOST@IEEEdefault\endcsname +\fi +% advance column counter only if the IEEEeqnarray environment wants it +\if@advanceIEEEeqncolcnt\global\advance\@IEEEeqncolcnt by #1\relax\fi} + +% like \omit, but maintains track of the column counter for \IEEEeqnarray +\def\IEEEeqnarrayomit{\omit\if@advanceIEEEeqncolcnt\global\advance\@IEEEeqncolcnt by 1\relax\fi} + + +% provides a way to define a letter referenced column type +% usage: \IEEEeqnarraydefcol{col. type letter/name}{pre insertion text}{post insertion text} +\def\IEEEeqnarraydefcol#1#2#3{\expandafter\def\csname @IEEEeqnarraycolPRE#1\endcsname{#2}% +\expandafter\def\csname @IEEEeqnarraycolPOST#1\endcsname{#3}% +\expandafter\def\csname @IEEEeqnarraycolDEF#1\endcsname{1}} + + +% provides a way to define a numerically referenced inter-column glue types +% usage: \IEEEeqnarraydefcolsep{col. glue number}{glue definition} +\def\IEEEeqnarraydefcolsep#1#2{\expandafter\def\csname @IEEEeqnarraycolSEP\romannumeral #1\endcsname{#2}% +\expandafter\def\csname @IEEEeqnarraycolSEPDEF\romannumeral #1\endcsname{1}} + + +\def\@IEEEeqnarraycolisdefined{1}% just a macro for 1, used for checking undefined column types + + +% expands and appends the given argument to the \@IEEEtrantmptoksA token list +% used to build up the \halign preamble +\def\@IEEEappendtoksA#1{\edef\@@IEEEappendtoksA{\@IEEEtrantmptoksA={\the\@IEEEtrantmptoksA #1}}% +\@@IEEEappendtoksA} + +% also appends to \@IEEEtrantmptoksA, but does not expand the argument +% uses \toks8 as a scratchpad register +\def\@IEEEappendNOEXPANDtoksA#1{\toks8={#1}% +\edef\@@IEEEappendNOEXPANDtoksA{\@IEEEtrantmptoksA={\the\@IEEEtrantmptoksA\the\toks8}}% +\@@IEEEappendNOEXPANDtoksA} + +% define some common column types for the user +% math +\IEEEeqnarraydefcol{l}{$\IEEEeqnarraymathstyle}{$\hfil} +\IEEEeqnarraydefcol{c}{\hfil$\IEEEeqnarraymathstyle}{$\hfil} +\IEEEeqnarraydefcol{r}{\hfil$\IEEEeqnarraymathstyle}{$} +\IEEEeqnarraydefcol{L}{$\IEEEeqnarraymathstyle{}}{{}$\hfil} +\IEEEeqnarraydefcol{C}{\hfil$\IEEEeqnarraymathstyle{}}{{}$\hfil} +\IEEEeqnarraydefcol{R}{\hfil$\IEEEeqnarraymathstyle{}}{{}$} +% text +\IEEEeqnarraydefcol{s}{\IEEEeqnarraytextstyle}{\hfil} +\IEEEeqnarraydefcol{t}{\hfil\IEEEeqnarraytextstyle}{\hfil} +\IEEEeqnarraydefcol{u}{\hfil\IEEEeqnarraytextstyle}{} + +% vertical rules +\IEEEeqnarraydefcol{v}{}{\vrule width\arrayrulewidth} +\IEEEeqnarraydefcol{vv}{\vrule width\arrayrulewidth\hfil}{\hfil\vrule width\arrayrulewidth} +\IEEEeqnarraydefcol{V}{}{\vrule width\arrayrulewidth\hskip\doublerulesep\vrule width\arrayrulewidth} +\IEEEeqnarraydefcol{VV}{\vrule width\arrayrulewidth\hskip\doublerulesep\vrule width\arrayrulewidth\hfil}% +{\hfil\vrule width\arrayrulewidth\hskip\doublerulesep\vrule width\arrayrulewidth} + +% horizontal rules +\IEEEeqnarraydefcol{h}{}{\leaders\hrule height\arrayrulewidth\hfil} +\IEEEeqnarraydefcol{H}{}{\leaders\vbox{\hrule width\arrayrulewidth\vskip\doublerulesep\hrule width\arrayrulewidth}\hfil} + +% plain +\IEEEeqnarraydefcol{x}{}{} +\IEEEeqnarraydefcol{X}{$}{$} + +% the default column type to use in the event a column type is not defined +\IEEEeqnarraydefcol{@IEEEdefault}{\hfil$\IEEEeqnarraymathstyle}{$\hfil} + + +% a zero tabskip (used for "-" col types) +\def\@IEEEeqnarraycolSEPzero{0pt plus 0pt minus 0pt} +% a centering tabskip (used for "+" col types) +\def\@IEEEeqnarraycolSEPcenter{1000pt plus 0pt minus 1000pt} + +% top level default tabskip glues for the start, end, and inter-column +% may be reset within environments not always at the top level, e.g., \IEEEeqnarraybox +\edef\@IEEEeqnarraycolSEPdefaultstart{\@IEEEeqnarraycolSEPcenter}% default start glue +\edef\@IEEEeqnarraycolSEPdefaultend{\@IEEEeqnarraycolSEPcenter}% default end glue +\edef\@IEEEeqnarraycolSEPdefaultmid{\@IEEEeqnarraycolSEPzero}% default inter-column glue + + + +% creates a vertical rule that extends from the bottom to the top a a cell +% Provided in case other packages redefine \vline some other way. +% usage: \IEEEeqnarrayvrule[rule thickness] +% If no argument is provided, \arrayrulewidth will be used for the rule thickness. +\newcommand\IEEEeqnarrayvrule[1][\arrayrulewidth]{\vrule\@width#1\relax} + +% creates a blank separator row +% usage: \IEEEeqnarrayseprow[separation length][font size commands] +% default is \IEEEeqnarrayseprow[0.25\normalbaselineskip][\relax] +% blank arguments inherit the default values +% uses \skip5 as a scratch register - calls \@IEEEeqnarraystrutsize which uses more scratch registers +\def\IEEEeqnarrayseprow{\relax\@ifnextchar[{\@IEEEeqnarrayseprow}{\@IEEEeqnarrayseprow[0.25\normalbaselineskip]}} +\def\@IEEEeqnarrayseprow[#1]{\relax\@ifnextchar[{\@@IEEEeqnarrayseprow[#1]}{\@@IEEEeqnarrayseprow[#1][\relax]}} +\def\@@IEEEeqnarrayseprow[#1][#2]{\def\@IEEEeqnarrayseprowARGONE{#1}% +\ifx\@IEEEeqnarrayseprowARGONE\@empty% +% get the skip value, based on the font commands +% use skip5 because \IEEEeqnarraystrutsize uses \skip0, \skip2, \skip3 +% assign within a bogus box to confine the font changes +{\setbox0=\hbox{#2\relax\global\skip5=0.25\normalbaselineskip}}% +\else% +{\setbox0=\hbox{#2\relax\global\skip5=#1}}% +\fi% +\@IEEEeqnarrayhoptolastcolumn\IEEEeqnarraystrutsize{\skip5}{0pt}[\relax]\relax} + +% creates a blank separator row, but omits all the column templates +% usage: \IEEEeqnarrayseprowcut[separation length][font size commands] +% default is \IEEEeqnarrayseprowcut[0.25\normalbaselineskip][\relax] +% blank arguments inherit the default values +% uses \skip5 as a scratch register - calls \@IEEEeqnarraystrutsize which uses more scratch registers +\def\IEEEeqnarrayseprowcut{\multispan{\@IEEEeqnnumcols}\relax% span all the cols +% advance column counter only if the IEEEeqnarray environment wants it +\if@advanceIEEEeqncolcnt\global\advance\@IEEEeqncolcnt by \@IEEEeqnnumcols\relax\fi% +\@ifnextchar[{\@IEEEeqnarrayseprowcut}{\@IEEEeqnarrayseprowcut[0.25\normalbaselineskip]}} +\def\@IEEEeqnarrayseprowcut[#1]{\relax\@ifnextchar[{\@@IEEEeqnarrayseprowcut[#1]}{\@@IEEEeqnarrayseprowcut[#1][\relax]}} +\def\@@IEEEeqnarrayseprowcut[#1][#2]{\def\@IEEEeqnarrayseprowARGONE{#1}% +\ifx\@IEEEeqnarrayseprowARGONE\@empty% +% get the skip value, based on the font commands +% use skip5 because \IEEEeqnarraystrutsize uses \skip0, \skip2, \skip3 +% assign within a bogus box to confine the font changes +{\setbox0=\hbox{#2\relax\global\skip5=0.25\normalbaselineskip}}% +\else% +{\setbox0=\hbox{#2\relax\global\skip5=#1}}% +\fi% +\IEEEeqnarraystrutsize{\skip5}{0pt}[\relax]\relax} + + + +% draws a single rule across all the columns optional +% argument determines the rule width, \arrayrulewidth is the default +% updates column counter as needed and turns off struts +% usage: \IEEEeqnarrayrulerow[rule line thickness] +\def\IEEEeqnarrayrulerow{\multispan{\@IEEEeqnnumcols}\relax% span all the cols +% advance column counter only if the IEEEeqnarray environment wants it +\if@advanceIEEEeqncolcnt\global\advance\@IEEEeqncolcnt by \@IEEEeqnnumcols\relax\fi% +\@ifnextchar[{\@IEEEeqnarrayrulerow}{\@IEEEeqnarrayrulerow[\arrayrulewidth]}} +\def\@IEEEeqnarrayrulerow[#1]{\leaders\hrule height#1\hfil\relax% put in our rule +% turn off any struts +\IEEEeqnarraystrutsize{0pt}{0pt}[\relax]\relax} + + +% draws a double rule by using a single rule row, a separator row, and then +% another single rule row +% first optional argument determines the rule thicknesses, \arrayrulewidth is the default +% second optional argument determines the rule spacing, \doublerulesep is the default +% usage: \IEEEeqnarraydblrulerow[rule line thickness][rule spacing] +\def\IEEEeqnarraydblrulerow{\multispan{\@IEEEeqnnumcols}\relax% span all the cols +% advance column counter only if the IEEEeqnarray environment wants it +\if@advanceIEEEeqncolcnt\global\advance\@IEEEeqncolcnt by \@IEEEeqnnumcols\relax\fi% +\@ifnextchar[{\@IEEEeqnarraydblrulerow}{\@IEEEeqnarraydblrulerow[\arrayrulewidth]}} +\def\@IEEEeqnarraydblrulerow[#1]{\relax\@ifnextchar[{\@@IEEEeqnarraydblrulerow[#1]}% +{\@@IEEEeqnarraydblrulerow[#1][\doublerulesep]}} +\def\@@IEEEeqnarraydblrulerow[#1][#2]{\def\@IEEEeqnarraydblrulerowARG{#1}% +% we allow the user to say \IEEEeqnarraydblrulerow[][] +\ifx\@IEEEeqnarraydblrulerowARG\@empty% +\@IEEEeqnarrayrulerow[\arrayrulewidth]% +\else% +\@IEEEeqnarrayrulerow[#1]\relax% +\fi% +\def\@IEEEeqnarraydblrulerowARG{#2}% +\ifx\@IEEEeqnarraydblrulerowARG\@empty% +\\\IEEEeqnarrayseprow[\doublerulesep][\relax]% +\else% +\\\IEEEeqnarrayseprow[#2][\relax]% +\fi% +\\\multispan{\@IEEEeqnnumcols}% +% advance column counter only if the IEEEeqnarray environment wants it +\if@advanceIEEEeqncolcnt\global\advance\@IEEEeqncolcnt by \@IEEEeqnnumcols\relax\fi% +\def\@IEEEeqnarraydblrulerowARG{#1}% +\ifx\@IEEEeqnarraydblrulerowARG\@empty% +\@IEEEeqnarrayrulerow[\arrayrulewidth]% +\else% +\@IEEEeqnarrayrulerow[#1]% +\fi% +} + +% draws a double rule by using a single rule row, a separator (cutting) row, and then +% another single rule row +% first optional argument determines the rule thicknesses, \arrayrulewidth is the default +% second optional argument determines the rule spacing, \doublerulesep is the default +% usage: \IEEEeqnarraydblrulerow[rule line thickness][rule spacing] +\def\IEEEeqnarraydblrulerowcut{\multispan{\@IEEEeqnnumcols}\relax% span all the cols +% advance column counter only if the IEEEeqnarray environment wants it +\if@advanceIEEEeqncolcnt\global\advance\@IEEEeqncolcnt by \@IEEEeqnnumcols\relax\fi% +\@ifnextchar[{\@IEEEeqnarraydblrulerowcut}{\@IEEEeqnarraydblrulerowcut[\arrayrulewidth]}} +\def\@IEEEeqnarraydblrulerowcut[#1]{\relax\@ifnextchar[{\@@IEEEeqnarraydblrulerowcut[#1]}% +{\@@IEEEeqnarraydblrulerowcut[#1][\doublerulesep]}} +\def\@@IEEEeqnarraydblrulerowcut[#1][#2]{\def\@IEEEeqnarraydblrulerowARG{#1}% +% we allow the user to say \IEEEeqnarraydblrulerow[][] +\ifx\@IEEEeqnarraydblrulerowARG\@empty% +\@IEEEeqnarrayrulerow[\arrayrulewidth]% +\else% +\@IEEEeqnarrayrulerow[#1]% +\fi% +\def\@IEEEeqnarraydblrulerowARG{#2}% +\ifx\@IEEEeqnarraydblrulerowARG\@empty% +\\\IEEEeqnarrayseprowcut[\doublerulesep][\relax]% +\else% +\\\IEEEeqnarrayseprowcut[#2][\relax]% +\fi% +\\\multispan{\@IEEEeqnnumcols}% +% advance column counter only if the IEEEeqnarray environment wants it +\if@advanceIEEEeqncolcnt\global\advance\@IEEEeqncolcnt by \@IEEEeqnnumcols\relax\fi% +\def\@IEEEeqnarraydblrulerowARG{#1}% +\ifx\@IEEEeqnarraydblrulerowARG\@empty% +\@IEEEeqnarrayrulerow[\arrayrulewidth]% +\else% +\@IEEEeqnarrayrulerow[#1]% +\fi% +} + + + +% inserts a full row's worth of &'s +% relies on \@IEEEeqnnumcols to provide the correct number of columns +% uses \@IEEEtrantmptoksA, \count0 as scratch registers +\def\@IEEEeqnarrayhoptolastcolumn{\@IEEEtrantmptoksA={}\count0=1\relax% +\loop% add cols if the user did not use them all +\ifnum\count0<\@IEEEeqnnumcols\relax% +\@IEEEappendtoksA{&}% +\advance\count0 by 1\relax% update the col count +\repeat% +\the\@IEEEtrantmptoksA%execute the &'s +} + + + +\newif\if@IEEEeqnarrayISinner % flag to indicate if we are within the lines +\@IEEEeqnarrayISinnerfalse % of an IEEEeqnarray - after the IEEEeqnarraydecl + +\edef\@IEEEeqnarrayTHEstrutheight{0pt} % height and depth of IEEEeqnarray struts +\edef\@IEEEeqnarrayTHEstrutdepth{0pt} + +\edef\@IEEEeqnarrayTHEmasterstrutheight{0pt} % default height and depth of +\edef\@IEEEeqnarrayTHEmasterstrutdepth{0pt} % struts within an IEEEeqnarray + +\edef\@IEEEeqnarrayTHEmasterstrutHSAVE{0pt} % saved master strut height +\edef\@IEEEeqnarrayTHEmasterstrutDSAVE{0pt} % and depth + +\newif\if@IEEEeqnarrayusemasterstrut % flag to indicate that the master strut value +\@IEEEeqnarrayusemasterstruttrue % is to be used + + + +% saves the strut height and depth of the master strut +\def\@IEEEeqnarraymasterstrutsave{\relax% +\expandafter\skip0=\@IEEEeqnarrayTHEmasterstrutheight\relax% +\expandafter\skip2=\@IEEEeqnarrayTHEmasterstrutdepth\relax% +% remove stretchability +\dimen0\skip0\relax% +\dimen2\skip2\relax% +% save values +\edef\@IEEEeqnarrayTHEmasterstrutHSAVE{\the\dimen0}% +\edef\@IEEEeqnarrayTHEmasterstrutDSAVE{\the\dimen2}} + +% restores the strut height and depth of the master strut +\def\@IEEEeqnarraymasterstrutrestore{\relax% +\expandafter\skip0=\@IEEEeqnarrayTHEmasterstrutHSAVE\relax% +\expandafter\skip2=\@IEEEeqnarrayTHEmasterstrutDSAVE\relax% +% remove stretchability +\dimen0\skip0\relax% +\dimen2\skip2\relax% +% restore values +\edef\@IEEEeqnarrayTHEmasterstrutheight{\the\dimen0}% +\edef\@IEEEeqnarrayTHEmasterstrutdepth{\the\dimen2}} + + +% globally restores the strut height and depth to the +% master values and sets the master strut flag to true +\def\@IEEEeqnarraystrutreset{\relax% +\expandafter\skip0=\@IEEEeqnarrayTHEmasterstrutheight\relax% +\expandafter\skip2=\@IEEEeqnarrayTHEmasterstrutdepth\relax% +% remove stretchability +\dimen0\skip0\relax% +\dimen2\skip2\relax% +% restore values +\xdef\@IEEEeqnarrayTHEstrutheight{\the\dimen0}% +\xdef\@IEEEeqnarrayTHEstrutdepth{\the\dimen2}% +\global\@IEEEeqnarrayusemasterstruttrue} + + +% if the master strut is not to be used, make the current +% values of \@IEEEeqnarrayTHEstrutheight, \@IEEEeqnarrayTHEstrutdepth +% and the use master strut flag, global +% this allows user strut commands issued in the last column to be carried +% into the isolation/strut column +\def\@IEEEeqnarrayglobalizestrutstatus{\relax% +\if@IEEEeqnarrayusemasterstrut\else% +\xdef\@IEEEeqnarrayTHEstrutheight{\@IEEEeqnarrayTHEstrutheight}% +\xdef\@IEEEeqnarrayTHEstrutdepth{\@IEEEeqnarrayTHEstrutdepth}% +\global\@IEEEeqnarrayusemasterstrutfalse% +\fi} + + + +% usage: \IEEEeqnarraystrutsize{height}{depth}[font size commands] +% If called outside the lines of an IEEEeqnarray, sets the height +% and depth of both the master and local struts. If called inside +% an IEEEeqnarray line, sets the height and depth of the local strut +% only and sets the flag to indicate the use of the local strut +% values. If the height or depth is left blank, 0.7\normalbaselineskip +% and 0.3\normalbaselineskip will be used, respectively. +% The optional argument can be used to evaluate the lengths under +% a different font size and styles. If none is specified, the current +% font is used. +% uses scratch registers \skip0, \skip2, \skip3, \dimen0, \dimen2 +\def\IEEEeqnarraystrutsize#1#2{\relax\@ifnextchar[{\@IEEEeqnarraystrutsize{#1}{#2}}{\@IEEEeqnarraystrutsize{#1}{#2}[\relax]}} +\def\@IEEEeqnarraystrutsize#1#2[#3]{\def\@IEEEeqnarraystrutsizeARG{#1}% +\ifx\@IEEEeqnarraystrutsizeARG\@empty% +{\setbox0=\hbox{#3\relax\global\skip3=0.7\normalbaselineskip}}% +\skip0=\skip3\relax% +\else% arg one present +{\setbox0=\hbox{#3\relax\global\skip3=#1\relax}}% +\skip0=\skip3\relax% +\fi% if null arg +\def\@IEEEeqnarraystrutsizeARG{#2}% +\ifx\@IEEEeqnarraystrutsizeARG\@empty% +{\setbox0=\hbox{#3\relax\global\skip3=0.3\normalbaselineskip}}% +\skip2=\skip3\relax% +\else% arg two present +{\setbox0=\hbox{#3\relax\global\skip3=#2\relax}}% +\skip2=\skip3\relax% +\fi% if null arg +% remove stretchability, just to be safe +\dimen0\skip0\relax% +\dimen2\skip2\relax% +% dimen0 = height, dimen2 = depth +\if@IEEEeqnarrayISinner% inner does not touch master strut size +\edef\@IEEEeqnarrayTHEstrutheight{\the\dimen0}% +\edef\@IEEEeqnarrayTHEstrutdepth{\the\dimen2}% +\@IEEEeqnarrayusemasterstrutfalse% do not use master +\else% outer, have to set master strut too +\edef\@IEEEeqnarrayTHEmasterstrutheight{\the\dimen0}% +\edef\@IEEEeqnarrayTHEmasterstrutdepth{\the\dimen2}% +\edef\@IEEEeqnarrayTHEstrutheight{\the\dimen0}% +\edef\@IEEEeqnarrayTHEstrutdepth{\the\dimen2}% +\@IEEEeqnarrayusemasterstruttrue% use master strut +\fi} + + +% usage: \IEEEeqnarraystrutsizeadd{added height}{added depth}[font size commands] +% If called outside the lines of an IEEEeqnarray, adds the given height +% and depth to both the master and local struts. +% If called inside an IEEEeqnarray line, adds the given height and depth +% to the local strut only and sets the flag to indicate the use +% of the local strut values. +% In both cases, if a height or depth is left blank, 0pt is used instead. +% The optional argument can be used to evaluate the lengths under +% a different font size and styles. If none is specified, the current +% font is used. +% uses scratch registers \skip0, \skip2, \skip3, \dimen0, \dimen2 +\def\IEEEeqnarraystrutsizeadd#1#2{\relax\@ifnextchar[{\@IEEEeqnarraystrutsizeadd{#1}{#2}}{\@IEEEeqnarraystrutsizeadd{#1}{#2}[\relax]}} +\def\@IEEEeqnarraystrutsizeadd#1#2[#3]{\def\@IEEEeqnarraystrutsizearg{#1}% +\ifx\@IEEEeqnarraystrutsizearg\@empty% +\skip0=0pt\relax% +\else% arg one present +{\setbox0=\hbox{#3\relax\global\skip3=#1}}% +\skip0=\skip3\relax% +\fi% if null arg +\def\@IEEEeqnarraystrutsizearg{#2}% +\ifx\@IEEEeqnarraystrutsizearg\@empty% +\skip2=0pt\relax% +\else% arg two present +{\setbox0=\hbox{#3\relax\global\skip3=#2}}% +\skip2=\skip3\relax% +\fi% if null arg +% remove stretchability, just to be safe +\dimen0\skip0\relax% +\dimen2\skip2\relax% +% dimen0 = height, dimen2 = depth +\if@IEEEeqnarrayISinner% inner does not touch master strut size +% get local strut size +\expandafter\skip0=\@IEEEeqnarrayTHEstrutheight\relax% +\expandafter\skip2=\@IEEEeqnarrayTHEstrutdepth\relax% +% add it to the user supplied values +\advance\dimen0 by \skip0\relax% +\advance\dimen2 by \skip2\relax% +% update the local strut size +\edef\@IEEEeqnarrayTHEstrutheight{\the\dimen0}% +\edef\@IEEEeqnarrayTHEstrutdepth{\the\dimen2}% +\@IEEEeqnarrayusemasterstrutfalse% do not use master +\else% outer, have to set master strut too +% get master strut size +\expandafter\skip0=\@IEEEeqnarrayTHEmasterstrutheight\relax% +\expandafter\skip2=\@IEEEeqnarrayTHEmasterstrutdepth\relax% +% add it to the user supplied values +\advance\dimen0 by \skip0\relax% +\advance\dimen2 by \skip2\relax% +% update the local and master strut sizes +\edef\@IEEEeqnarrayTHEmasterstrutheight{\the\dimen0}% +\edef\@IEEEeqnarrayTHEmasterstrutdepth{\the\dimen2}% +\edef\@IEEEeqnarrayTHEstrutheight{\the\dimen0}% +\edef\@IEEEeqnarrayTHEstrutdepth{\the\dimen2}% +\@IEEEeqnarrayusemasterstruttrue% use master strut +\fi} + + +% allow user a way to see the struts +\newif\ifIEEEvisiblestruts +\IEEEvisiblestrutsfalse + +% inserts an invisible strut using the master or local strut values +% uses scratch registers \skip0, \skip2, \dimen0, \dimen2 +\def\@IEEEeqnarrayinsertstrut{\relax% +\if@IEEEeqnarrayusemasterstrut +% get master strut size +\expandafter\skip0=\@IEEEeqnarrayTHEmasterstrutheight\relax% +\expandafter\skip2=\@IEEEeqnarrayTHEmasterstrutdepth\relax% +\else% +% get local strut size +\expandafter\skip0=\@IEEEeqnarrayTHEstrutheight\relax% +\expandafter\skip2=\@IEEEeqnarrayTHEstrutdepth\relax% +\fi% +% remove stretchability, probably not needed +\dimen0\skip0\relax% +\dimen2\skip2\relax% +% dimen0 = height, dimen2 = depth +% allow user to see struts if desired +\ifIEEEvisiblestruts% +\vrule width0.2pt height\dimen0 depth\dimen2\relax% +\else% +\vrule width0pt height\dimen0 depth\dimen2\relax\fi} + + +% creates an invisible strut, useable even outside \IEEEeqnarray +% if \IEEEvisiblestrutstrue, the strut will be visible and 0.2pt wide. +% usage: \IEEEstrut[height][depth][font size commands] +% default is \IEEEstrut[0.7\normalbaselineskip][0.3\normalbaselineskip][\relax] +% blank arguments inherit the default values +% uses \dimen0, \dimen2, \skip0, \skip2 +\def\IEEEstrut{\relax\@ifnextchar[{\@IEEEstrut}{\@IEEEstrut[0.7\normalbaselineskip]}} +\def\@IEEEstrut[#1]{\relax\@ifnextchar[{\@@IEEEstrut[#1]}{\@@IEEEstrut[#1][0.3\normalbaselineskip]}} +\def\@@IEEEstrut[#1][#2]{\relax\@ifnextchar[{\@@@IEEEstrut[#1][#2]}{\@@@IEEEstrut[#1][#2][\relax]}} +\def\@@@IEEEstrut[#1][#2][#3]{\mbox{#3\relax% +\def\@IEEEstrutARG{#1}% +\ifx\@IEEEstrutARG\@empty% +\skip0=0.7\normalbaselineskip\relax% +\else% +\skip0=#1\relax% +\fi% +\def\@IEEEstrutARG{#2}% +\ifx\@IEEEstrutARG\@empty% +\skip2=0.3\normalbaselineskip\relax% +\else% +\skip2=#2\relax% +\fi% +% remove stretchability, probably not needed +\dimen0\skip0\relax% +\dimen2\skip2\relax% +\ifIEEEvisiblestruts% +\vrule width0.2pt height\dimen0 depth\dimen2\relax% +\else% +\vrule width0.0pt height\dimen0 depth\dimen2\relax\fi}} + + +% enables strut mode by setting a default strut size and then zeroing the +% \baselineskip, \lineskip, \lineskiplimit and \jot +\def\IEEEeqnarraystrutmode{\IEEEeqnarraystrutsize{0.7\normalbaselineskip}{0.3\normalbaselineskip}[\relax]% +\baselineskip=0pt\lineskip=0pt\lineskiplimit=0pt\jot=0pt} + + +% equation and subequation forms to use to setup hyperref's \@currentHref +\def\@IEEEtheHrefequation{equation.\theHequation} +\def\@IEEEtheHrefsubequation{equation.\theHequation\alph{IEEEsubequation}} + + +\def\IEEEeqnarray{\@IEEEeqnumpersisttrue\@IEEEsubeqnumpersistfalse\@IEEEeqnarray} +\def\endIEEEeqnarray{\end@IEEEeqnarray} + +\@namedef{IEEEeqnarray*}{\@IEEEeqnumpersistfalse\@IEEEsubeqnumpersistfalse\@IEEEeqnarray} +\@namedef{endIEEEeqnarray*}{\end@IEEEeqnarray} + + +% \IEEEeqnarray is an enhanced \eqnarray. +% The star form defaults to not putting equation numbers at the end of each row. +% usage: \IEEEeqnarray[decl]{cols} +\def\@IEEEeqnarray{\relax\@ifnextchar[{\@@IEEEeqnarray}{\@@IEEEeqnarray[\relax]}} +% We have to be careful here to normalize catcodes just before acquiring the +% cols as that specification may contain punctuation which could be subject +% to document catcode changes. +\def\@@IEEEeqnarray[#1]{\begingroup\IEEEnormalcatcodes\@@@IEEEeqnarray[#1]} +\def\@@@IEEEeqnarray[#1]#2{\endgroup + % default to showing the equation number or not based on whether or not + % the star form was involked + \if@IEEEeqnumpersist\global\@eqnswtrue + \else% not the star form + \global\@eqnswfalse + \fi% if star form + % provide a basic hyperref \theHequation if this has not already been setup (hyperref not loaded, or no section counter) + \@ifundefined{theHequation}{\def\theHequation{\arabic{equation}}}{}\relax + % provide dummy hyperref commands in case hyperref is not loaded + \providecommand{\Hy@raisedlink}[1]{}\relax + \providecommand{\hyper@anchorstart}[1]{}\relax + \providecommand{\hyper@anchorend}{}\relax + \providecommand{\@currentHref}{}\relax + \@IEEEeqnumpreadvfalse% reset eqnpreadv flag + \@IEEEsubeqnumpreadvfalse% reset subeqnpreadv flag + \@IEEEeqnarrayISinnerfalse% not yet within the lines of the halign + \@IEEEeqnarraystrutsize{0pt}{0pt}[\relax]% turn off struts by default + \@IEEEeqnarrayusemasterstruttrue% use master strut till user asks otherwise + \IEEEvisiblestrutsfalse% diagnostic mode defaults to off + % no extra space unless the user specifically requests it + \lineskip=0pt\relax + \lineskiplimit=0pt\relax + \baselineskip=\normalbaselineskip\relax% + \jot=\IEEEnormaljot\relax% + \mathsurround\z@\relax% no extra spacing around math + \@advanceIEEEeqncolcnttrue% advance the col counter for each col the user uses, + % used in \IEEEeqnarraymulticol and in the preamble build + %V1.8 Here we preadvance to the next equation number. + % If the user later wants a continued subequation, we can roll back. + \global\@IEEEsubeqnnumrollback=\c@IEEEsubequation% + \stepcounter{equation}\@IEEEeqnumpreadvtrue% advance equation counter before first line + \setcounter{IEEEsubequation}{0}% no subequation yet + \let\@IEEEcurrentlabelsave\@currentlabel% save current label as we later change it globally + \let\@IEEEcurrentHrefsave\@currentHref% save current href label as we later change it globally + \def\@currentlabel{\p@equation\theequation}% redefine the ref label + \def\@currentHref{\@IEEEtheHrefequation}% setup hyperref label + \IEEEeqnarraydecl\relax% allow a way for the user to make global overrides + #1\relax% allow user to override defaults + \let\\\@IEEEeqnarraycr% replace newline with one that can put in eqn. numbers + \global\@IEEEeqncolcnt\z@% col. count = 0 for first line + \@IEEEbuildpreamble{#2}\relax% build the preamble and put it into \@IEEEtrantmptoksA + % put in the column for the equation number + \ifnum\@IEEEeqnnumcols>0\relax\@IEEEappendtoksA{&}\fi% col separator for those after the first + \toks0={##}% + % advance the \@IEEEeqncolcnt for the isolation col, this helps with error checking + \@IEEEappendtoksA{\global\advance\@IEEEeqncolcnt by 1\relax}% + % add the isolation column + \@IEEEappendtoksA{\tabskip\z@skip\bgroup\the\toks0\egroup}% + % advance the \@IEEEeqncolcnt for the equation number col, this helps with error checking + \@IEEEappendtoksA{&\global\advance\@IEEEeqncolcnt by 1\relax}% + % add the equation number col to the preamble + \@IEEEappendtoksA{\tabskip\z@skip\hb@xt@\z@\bgroup\hss\the\toks0\egroup}% + % note \@IEEEeqnnumcols does not count the equation col or isolation col + % set the starting tabskip glue as determined by the preamble build + \tabskip=\@IEEEBPstartglue\relax + % begin the display alignment + \@IEEEeqnarrayISinnertrue% commands are now within the lines + $$\everycr{}\halign to\displaywidth\bgroup + % "exspand" the preamble + \span\the\@IEEEtrantmptoksA\cr} + +% enter isolation/strut column (or the next column if the user did not use +% every column), record the strut status, complete the columns, do the strut if needed, +% restore counters (to backout any equation setup for a next line that was never used) +% to their correct values and exit +\def\end@IEEEeqnarray{\@IEEEeqnarrayglobalizestrutstatus&\@@IEEEeqnarraycr\egroup +\if@IEEEsubeqnumpreadv\global\advance\c@IEEEsubequation\m@ne\fi +\if@IEEEeqnumpreadv\global\advance\c@equation\m@ne\global\c@IEEEsubequation=\@IEEEsubeqnnumrollback\fi +\global\let\@currentlabel\@IEEEcurrentlabelsave% restore current label +\global\let\@currentHref\@IEEEcurrentHrefsave% restore current href label +$$\@ignoretrue} + + +% IEEEeqnarray uses a modifed \\ instead of the plain \cr to +% end rows. This allows for things like \\*[vskip amount] +% These "cr" macros are modified versions of those for LaTeX2e's eqnarray +% the {\ifnum0=`} braces must be kept away from the last column to avoid +% altering spacing of its math, so we use & to advance to the next column +% as there is an isolation/strut column after the user's columns +\def\@IEEEeqnarraycr{\@IEEEeqnarrayglobalizestrutstatus&% save strut status and advance to next column + {\ifnum0=`}\fi + \@ifstar{% + \global\@eqpen\@M\@IEEEeqnarrayYCR + }{% + \global\@eqpen\interdisplaylinepenalty \@IEEEeqnarrayYCR + }% +} + +\def\@IEEEeqnarrayYCR{\@testopt\@IEEEeqnarrayXCR\z@skip} + +\def\@IEEEeqnarrayXCR[#1]{% + \ifnum0=`{\fi}% + \@@IEEEeqnarraycr + \noalign{\penalty\@eqpen\vskip\jot\vskip #1\relax}}% + +\def\@@IEEEeqnarraycr{\@IEEEtrantmptoksA={}% clear token register + \advance\@IEEEeqncolcnt by -1\relax% adjust col count because of the isolation column + \ifnum\@IEEEeqncolcnt>\@IEEEeqnnumcols\relax + \@IEEEclspkgerror{Too many columns within the IEEEeqnarray\MessageBreak + environment}% + {Use fewer \string &'s or put more columns in the IEEEeqnarray column\MessageBreak + specifications.}\relax% + \else + \loop% add cols if the user did not use them all + \ifnum\@IEEEeqncolcnt<\@IEEEeqnnumcols\relax + \@IEEEappendtoksA{&}% + \advance\@IEEEeqncolcnt by 1\relax% update the col count + \repeat + % this number of &'s will take us the the isolation column + \fi + % execute the &'s + \the\@IEEEtrantmptoksA% + % handle the strut/isolation column + \@IEEEeqnarrayinsertstrut% do the strut if needed + \@IEEEeqnarraystrutreset% reset the strut system for next line or IEEEeqnarray + &% and enter the equation number column + \if@eqnsw% only if we display something + \Hy@raisedlink{\hyper@anchorstart{\@currentHref}}% start a hyperref anchor + \global\@IEEEeqnumpreadvfalse\relax% displaying an equation number means + \global\@IEEEsubeqnumpreadvfalse\relax% the equation counters point to valid equations + % V1.8 Here we setup the counters, currentlabel and status for what would be the *next* + % equation line as would be the case under the current settings. However, there are two problems. + % One problem is that there might not ever be a next line. The second problem is that the user + % may later alter the meaning of a line with commands such as \IEEEyessubnumber. So, to handle + % these cases we have to record the current values of the (sub)equation counters and revert back + % to them if the next line is changed or never comes. The \if@IEEEeqnumpreadv, \if@IEEEsubeqnumpreadv + % and \@IEEEsubeqnnumrollback stuff tracks this. + % The logic to handle all this is surprisingly complex, but a nice feature of the approach here is + % that the equation counters and labels remain valid for what the line would be unless a + % \IEEEyessubnumber et al. later changes it. So, any hyperref links are always correct. + \ifnum\c@IEEEsubequation>0\relax% handle subequation + \theIEEEsubequationdis\relax + \if@IEEEsubeqnumpersist% setup for default type of next line + \stepcounter{IEEEsubequation}\global\@IEEEsubeqnumpreadvtrue\relax + \gdef\@currentlabel{\p@IEEEsubequation\theIEEEsubequation}\relax + \gdef\@currentHref{\@IEEEtheHrefsubequation}% setup hyperref label + \else + % if no subeqnum persist, go ahead and setup for a new equation number + \global\@IEEEsubeqnnumrollback=\c@IEEEsubequation + \stepcounter{equation}\global\@IEEEeqnumpreadvtrue\relax + \setcounter{IEEEsubequation}{0}\gdef\@currentlabel{\p@equation\theequation}\relax + \gdef\@currentHref{\@IEEEtheHrefequation}% setup hyperref label + \fi + \else% display a standard equation number + \theequationdis\relax + \setcounter{IEEEsubequation}{0}\relax% not really needed + \if@IEEEsubeqnumpersist% setup for default type of next line + % subequations that follow plain equations carry the same equation number e.g, 5, 5a rather than 5, 6a + \stepcounter{IEEEsubequation}\global\@IEEEsubeqnumpreadvtrue\relax + \gdef\@currentlabel{\p@IEEEsubequation\theIEEEsubequation}\relax + \gdef\@currentHref{\@IEEEtheHrefsubequation}% setup hyperref label + \else + % if no subeqnum persist, go ahead and setup for a new equation number + \global\@IEEEsubeqnnumrollback=\c@IEEEsubequation + \stepcounter{equation}\global\@IEEEeqnumpreadvtrue\relax + \setcounter{IEEEsubequation}{0}\gdef\@currentlabel{\p@equation\theequation}\relax + \gdef\@currentHref{\@IEEEtheHrefequation}% setup hyperref label + \fi + \fi% + \Hy@raisedlink{\hyper@anchorend}% end hyperref anchor + \fi% fi only if we display something + % reset the flags to indicate the default preferences of the display of equation numbers + \if@IEEEeqnumpersist\global\@eqnswtrue\else\global\@eqnswfalse\fi + \if@IEEEsubeqnumpersist\global\@eqnswtrue\fi% ditto for the subequation flag + % reset the number of columns the user actually used + \global\@IEEEeqncolcnt\z@\relax + % the real end of the line + \cr} + + + + + +% \IEEEeqnarraybox is like \IEEEeqnarray except the box form puts everything +% inside a vtop, vbox, or vcenter box depending on the letter in the second +% optional argument (t,b,c). Vbox is the default. Unlike \IEEEeqnarray, +% equation numbers are not displayed and \IEEEeqnarraybox can be nested. +% \IEEEeqnarrayboxm is for math mode (like \array) and does not put the vbox +% within an hbox. +% \IEEEeqnarrayboxt is for text mode (like \tabular) and puts the vbox within +% a \hbox{$ $} construct. +% \IEEEeqnarraybox will auto detect whether to use \IEEEeqnarrayboxm or +% \IEEEeqnarrayboxt depending on the math mode. +% The third optional argument specifies the width this box is to be set to - +% natural width is the default. +% The * forms do not add \jot line spacing +% usage: \IEEEeqnarraybox[decl][pos][width]{cols} +\def\IEEEeqnarrayboxm{\@IEEEeqnarrayboxnojotfalse\@IEEEeqnarrayboxHBOXSWfalse\@IEEEeqnarraybox} +\def\endIEEEeqnarrayboxm{\end@IEEEeqnarraybox} +\@namedef{IEEEeqnarrayboxm*}{\@IEEEeqnarrayboxnojottrue\@IEEEeqnarrayboxHBOXSWfalse\@IEEEeqnarraybox} +\@namedef{endIEEEeqnarrayboxm*}{\end@IEEEeqnarraybox} + +\def\IEEEeqnarrayboxt{\@IEEEeqnarrayboxnojotfalse\@IEEEeqnarrayboxHBOXSWtrue\@IEEEeqnarraybox} +\def\endIEEEeqnarrayboxt{\end@IEEEeqnarraybox} +\@namedef{IEEEeqnarrayboxt*}{\@IEEEeqnarrayboxnojottrue\@IEEEeqnarrayboxHBOXSWtrue\@IEEEeqnarraybox} +\@namedef{endIEEEeqnarrayboxt*}{\end@IEEEeqnarraybox} + +\def\IEEEeqnarraybox{\@IEEEeqnarrayboxnojotfalse\ifmmode\@IEEEeqnarrayboxHBOXSWfalse\else\@IEEEeqnarrayboxHBOXSWtrue\fi% +\@IEEEeqnarraybox} +\def\endIEEEeqnarraybox{\end@IEEEeqnarraybox} + +\@namedef{IEEEeqnarraybox*}{\@IEEEeqnarrayboxnojottrue\ifmmode\@IEEEeqnarrayboxHBOXSWfalse\else\@IEEEeqnarrayboxHBOXSWtrue\fi% +\@IEEEeqnarraybox} +\@namedef{endIEEEeqnarraybox*}{\end@IEEEeqnarraybox} + +% flag to indicate if the \IEEEeqnarraybox needs to put things into an hbox{$ $} +% for \vcenter in non-math mode +\newif\if@IEEEeqnarrayboxHBOXSW% +\@IEEEeqnarrayboxHBOXSWfalse + +\def\@IEEEeqnarraybox{\relax\@ifnextchar[{\@@IEEEeqnarraybox}{\@@IEEEeqnarraybox[\relax]}} +% We have to be careful here to normalize catcodes just before acquiring the +% cols as that specification may contain punctuation which could be subject +% to document catcode changes. +\def\@@IEEEeqnarraybox[#1]{\relax\begingroup\IEEEnormalcatcodes\@ifnextchar[{\@@@IEEEeqnarraybox[#1]}{\@@@IEEEeqnarraybox[#1][b]}} +\def\@@@IEEEeqnarraybox[#1][#2]{\relax\@ifnextchar[{\@@@@IEEEeqnarraybox[#1][#2]}{\@@@@IEEEeqnarraybox[#1][#2][\relax]}} + +% #1 = decl; #2 = t,b,c; #3 = width, #4 = col specs +\def\@@@@IEEEeqnarraybox[#1][#2][#3]#4{\endgroup\@IEEEeqnarrayISinnerfalse % not yet within the lines of the halign + \@IEEEeqnarraymasterstrutsave% save current master strut values + \@IEEEeqnarraystrutsize{0pt}{0pt}[\relax]% turn off struts by default + \@IEEEeqnarrayusemasterstruttrue% use master strut till user asks otherwise + \IEEEvisiblestrutsfalse% diagnostic mode defaults to off + % no extra space unless the user specifically requests it + \lineskip=0pt\relax% + \lineskiplimit=0pt\relax% + \baselineskip=\normalbaselineskip\relax% + \jot=\IEEEnormaljot\relax% + \mathsurround\z@\relax% no extra spacing around math + % the default end glues are zero for an \IEEEeqnarraybox + \edef\@IEEEeqnarraycolSEPdefaultstart{\@IEEEeqnarraycolSEPzero}% default start glue + \edef\@IEEEeqnarraycolSEPdefaultend{\@IEEEeqnarraycolSEPzero}% default end glue + \edef\@IEEEeqnarraycolSEPdefaultmid{\@IEEEeqnarraycolSEPzero}% default inter-column glue + \@advanceIEEEeqncolcntfalse% do not advance the col counter for each col the user uses, + % used in \IEEEeqnarraymulticol and in the preamble build + \IEEEeqnarrayboxdecl\relax% allow a way for the user to make global overrides + #1\relax% allow user to override defaults + \let\\\@IEEEeqnarrayboxcr% replace newline with one that allows optional spacing + \@IEEEbuildpreamble{#4}\relax% build the preamble and put it into \@IEEEtrantmptoksA + % add an isolation column to the preamble to stop \\'s {} from getting into the last col + \ifnum\@IEEEeqnnumcols>0\relax\@IEEEappendtoksA{&}\fi% col separator for those after the first + \toks0={##}% + % add the isolation column to the preamble + \@IEEEappendtoksA{\tabskip\z@skip\bgroup\the\toks0\egroup}% + % set the starting tabskip glue as determined by the preamble build + \tabskip=\@IEEEBPstartglue\relax + % begin the alignment + \everycr{}% + % use only the very first token to determine the positioning + \@IEEEextracttoken{#2}\relax + \ifx\@IEEEextractedtokensdiscarded\@empty\else + \typeout{** WARNING: IEEEeqnarraybox position specifiers after the first in `\@IEEEextracttokenarg' ignored (line \the\inputlineno).}\relax + \fi + % \@IEEEextractedtoken has the first token, the rest are ignored + % if we need to put things into and hbox and go into math mode, do so now + \if@IEEEeqnarrayboxHBOXSW \leavevmode \hbox \bgroup $\fi% + % use the appropriate vbox type + \if\@IEEEextractedtoken t\relax\vtop\else\if\@IEEEextractedtoken c\relax% + \vcenter\else\vbox\fi\fi\bgroup% + \@IEEEeqnarrayISinnertrue% commands are now within the lines + \ifx#3\relax\halign\else\halign to #3\relax\fi% + \bgroup + % "exspand" the preamble + \span\the\@IEEEtrantmptoksA\cr} + +% carry strut status and enter the isolation/strut column, +% exit from math mode if needed, and exit +\def\end@IEEEeqnarraybox{\@IEEEeqnarrayglobalizestrutstatus% carry strut status +&% enter isolation/strut column +\@IEEEeqnarrayinsertstrut% do strut if needed +\@IEEEeqnarraymasterstrutrestore% restore the previous master strut values +% reset the strut system for next IEEEeqnarray +% (sets local strut values back to previous master strut values) +\@IEEEeqnarraystrutreset% +% ensure last line, exit from halign, close vbox +\crcr\egroup\egroup% +% exit from math mode and close hbox if needed +\if@IEEEeqnarrayboxHBOXSW $\egroup\fi} + + + +% IEEEeqnarraybox uses a modifed \\ instead of the plain \cr to +% end rows. This allows for things like \\[vskip amount] +% This "cr" macros are modified versions those for LaTeX2e's eqnarray +% For IEEEeqnarraybox, \\* is the same as \\ +% the {\ifnum0=`} braces must be kept away from the last column to avoid +% altering spacing of its math, so we use & to advance to the isolation/strut column +% carry strut status into isolation/strut column +\def\@IEEEeqnarrayboxcr{\@IEEEeqnarrayglobalizestrutstatus% carry strut status +&% enter isolation/strut column +\@IEEEeqnarrayinsertstrut% do strut if needed +% reset the strut system for next line or IEEEeqnarray +\@IEEEeqnarraystrutreset% +{\ifnum0=`}\fi% +\@ifstar{\@IEEEeqnarrayboxYCR}{\@IEEEeqnarrayboxYCR}} + +% test and setup the optional argument to \\[] +\def\@IEEEeqnarrayboxYCR{\@testopt\@IEEEeqnarrayboxXCR\z@skip} + +% IEEEeqnarraybox does not automatically increase line spacing by \jot +\def\@IEEEeqnarrayboxXCR[#1]{\ifnum0=`{\fi}% +\cr\noalign{\if@IEEEeqnarrayboxnojot\else\vskip\jot\fi\vskip#1\relax}} + + + +% usage: \@IEEEbuildpreamble{column specifiers} +% starts the halign preamble build +% the assembled preamble is put in \@IEEEtrantmptoksA +\def\@IEEEbuildpreamble#1{\@IEEEtrantmptoksA={}% clear token register +\let\@IEEEBPcurtype=u%current column type is not yet known +\let\@IEEEBPprevtype=s%the previous column type was the start +\let\@IEEEBPnexttype=u%next column type is not yet known +% ensure these are valid +\def\@IEEEBPcurglue={0pt plus 0pt minus 0pt}% +\def\@IEEEBPcurcolname{@IEEEdefault}% name of current column definition +% currently acquired numerically referenced glue +% use a name that is easier to remember +\let\@IEEEBPcurnum=\@IEEEtrantmpcountA% +\@IEEEBPcurnum=0% +% tracks number of columns in the preamble +\@IEEEeqnnumcols=0% +% record the default end glues +\edef\@IEEEBPstartglue{\@IEEEeqnarraycolSEPdefaultstart}% +\edef\@IEEEBPendglue{\@IEEEeqnarraycolSEPdefaultend}% +\edef\@IEEEedefMACRO{#1}\relax% fully expand the preamble to support macro containers +% now parse the user's column specifications +% \ignorespaces is used as a delimiter, need at least one trailing \relax because +% \@@IEEEbuildpreamble looks into the future +\expandafter\@@IEEEbuildpreamble\@IEEEedefMACRO\ignorespaces\relax\relax} + + +% usage: \@@IEEEbuildpreamble{current column}{next column} +% parses and builds the halign preamble +\def\@@IEEEbuildpreamble#1#2{\let\@@nextIEEEbuildpreamble=\@@IEEEbuildpreamble% +% use only the very first token to check the end +\@IEEEextracttokengroups{#1}\relax +\ifx\@IEEEextractedfirsttoken\ignorespaces\let\@@nextIEEEbuildpreamble=\@@IEEEfinishpreamble\else% +% identify current and next token type +\@IEEEgetcoltype{#1}{\@IEEEBPcurtype}{1}% current, error on invalid +\@IEEEgetcoltype{#2}{\@IEEEBPnexttype}{0}% next, no error on invalid next +% if curtype is a glue, get the glue def +\if\@IEEEBPcurtype g\@IEEEgetcurglue{#1}{\@IEEEBPcurglue}\fi% +% if curtype is a column, get the column def and set the current column name +\if\@IEEEBPcurtype c\@IEEEgetcurcol{#1}\fi% +% if curtype is a numeral, acquire the user defined glue +\if\@IEEEBPcurtype n\@IEEEprocessNcol{#1}\fi% +% process the acquired glue +\if\@IEEEBPcurtype g\@IEEEprocessGcol\fi% +% process the acquired col +\if\@IEEEBPcurtype c\@IEEEprocessCcol\fi% +% ready prevtype for next col spec. +\let\@IEEEBPprevtype=\@IEEEBPcurtype% +% be sure and put back the future token(s) as a group +\fi\@@nextIEEEbuildpreamble{#2}} + + +% usage: \@@IEEEfinishpreamble{discarded} +% executed just after preamble build is completed +% warn about zero cols, and if prevtype type = u, put in end tabskip glue +% argument is not used +\def\@@IEEEfinishpreamble#1{\ifnum\@IEEEeqnnumcols<1\relax +\@IEEEclspkgerror{No column specifiers declared for IEEEeqnarray}% +{At least one column type must be declared for each IEEEeqnarray.}% +\fi%num cols less than 1 +%if last type undefined, set default end tabskip glue +\if\@IEEEBPprevtype u\@IEEEappendtoksA{\tabskip=\@IEEEBPendglue}\fi} + + +% usage: \@IEEEgetcoltype{col specifier}{\output}{error more} +% Identify and return the column specifier's type code in the given +% \output macro: +% n = number +% g = glue (any other char in catagory 12) +% c = letter +% e = \ignorespaces (end of sequence) +% u = undefined +% error mode: 0 = no error message, 1 = error on invalid char +\def\@IEEEgetcoltype#1#2#3{% +% use only the very first token to determine the type +\@IEEEextracttoken{#1}\relax +% \@IEEEextractedtoken has the first token, the rest are discarded +\let#2=u\relax% assume invalid until know otherwise +\ifx\@IEEEextractedtoken\ignorespaces\let#2=e\else +\ifcat\@IEEEextractedtoken\relax\else% screen out control sequences +\if0\@IEEEextractedtoken\let#2=n\else +\if1\@IEEEextractedtoken\let#2=n\else +\if2\@IEEEextractedtoken\let#2=n\else +\if3\@IEEEextractedtoken\let#2=n\else +\if4\@IEEEextractedtoken\let#2=n\else +\if5\@IEEEextractedtoken\let#2=n\else +\if6\@IEEEextractedtoken\let#2=n\else +\if7\@IEEEextractedtoken\let#2=n\else +\if8\@IEEEextractedtoken\let#2=n\else +\if9\@IEEEextractedtoken\let#2=n\else +\ifcat,\@IEEEextractedtoken\let#2=g\relax +\else\ifcat a\@IEEEextractedtoken\let#2=c\relax\fi\fi\fi\fi\fi\fi\fi\fi\fi\fi\fi\fi\fi\fi +\if#2u\relax +\if0\noexpand#3\relax\else\@IEEEclspkgerror{Invalid character in column specifications}% +{Only letters, numerals and certain other symbols are allowed \MessageBreak +as IEEEeqnarray column specifiers.}\fi\fi} + + +% usage: \@IEEEgetcurcol{col specifier} +% verify the letter referenced column exists +% and return its name in \@IEEEBPcurcolname +% if column specifier is invalid, use the default column @IEEEdefault +\def\@IEEEgetcurcol#1{\expandafter\ifx\csname @IEEEeqnarraycolDEF#1\endcsname\@IEEEeqnarraycolisdefined% +\def\@IEEEBPcurcolname{#1}\else% invalid column name +\@IEEEclspkgerror{Invalid column type "#1" in column specifications.\MessageBreak +Using a default centering column instead}% +{You must define IEEEeqnarray column types before use.}% +\def\@IEEEBPcurcolname{@IEEEdefault}\fi} + + +% usage: \@IEEEgetcurglue{glue specifier}{\output} +% identify the predefined (punctuation) glue value +% and return it in the given output macro +\def\@IEEEgetcurglue#1#2{% +% ! = \! (neg small) -0.16667em (-3/18 em) +% , = \, (small) 0.16667em ( 3/18 em) +% : = \: (med) 0.22222em ( 4/18 em) +% ; = \; (large) 0.27778em ( 5/18 em) +% ' = \quad 1em +% " = \qquad 2em +% . = 0.5\arraycolsep +% / = \arraycolsep +% ? = 2\arraycolsep +% * = 1fil +% + = \@IEEEeqnarraycolSEPcenter +% - = \@IEEEeqnarraycolSEPzero +% Note that all em values are referenced to the math font (textfont2) fontdimen6 +% value for 1em. +% +% use only the very first token to determine the type +\@IEEEextracttoken{#1}\relax +\ifx\@IEEEextractedtokensdiscarded\@empty\else + \typeout{** WARNING: IEEEeqnarray predefined inter-column glue type specifiers after the first in `\@IEEEextracttokenarg' ignored (line \the\inputlineno).}\relax +\fi +% get the math font 1em value +% LaTeX2e's NFSS2 does not preload the fonts, but \IEEEeqnarray needs +% to gain access to the math (\textfont2) font's spacing parameters. +% So we create a bogus box here that uses the math font to ensure +% that \textfont2 is loaded and ready. If this is not done, +% the \textfont2 stuff here may not work. +% Thanks to Bernd Raichle for his 1997 post on this topic. +{\setbox0=\hbox{$\displaystyle\relax$}}% +% fontdimen6 has the width of 1em (a quad). +\@IEEEtrantmpdimenA=\fontdimen6\textfont2\relax% +% identify the glue value based on the first token +% we discard anything after the first +\if!\@IEEEextractedtoken\@IEEEtrantmpdimenA=-0.16667\@IEEEtrantmpdimenA\edef#2{\the\@IEEEtrantmpdimenA}\else +\if,\@IEEEextractedtoken\@IEEEtrantmpdimenA=0.16667\@IEEEtrantmpdimenA\edef#2{\the\@IEEEtrantmpdimenA}\else +\if:\@IEEEextractedtoken\@IEEEtrantmpdimenA=0.22222\@IEEEtrantmpdimenA\edef#2{\the\@IEEEtrantmpdimenA}\else +\if;\@IEEEextractedtoken\@IEEEtrantmpdimenA=0.27778\@IEEEtrantmpdimenA\edef#2{\the\@IEEEtrantmpdimenA}\else +\if'\@IEEEextractedtoken\@IEEEtrantmpdimenA=1\@IEEEtrantmpdimenA\edef#2{\the\@IEEEtrantmpdimenA}\else +\if"\@IEEEextractedtoken\@IEEEtrantmpdimenA=2\@IEEEtrantmpdimenA\edef#2{\the\@IEEEtrantmpdimenA}\else +\if.\@IEEEextractedtoken\@IEEEtrantmpdimenA=0.5\arraycolsep\edef#2{\the\@IEEEtrantmpdimenA}\else +\if/\@IEEEextractedtoken\edef#2{\the\arraycolsep}\else +\if?\@IEEEextractedtoken\@IEEEtrantmpdimenA=2\arraycolsep\edef#2{\the\@IEEEtrantmpdimenA}\else +\if *\@IEEEextractedtoken\edef#2{0pt plus 1fil minus 0pt}\else +\if+\@IEEEextractedtoken\edef#2{\@IEEEeqnarraycolSEPcenter}\else +\if-\@IEEEextractedtoken\edef#2{\@IEEEeqnarraycolSEPzero}\else +\edef#2{\@IEEEeqnarraycolSEPzero}% +\@IEEEclspkgerror{Invalid predefined inter-column glue type "#1" in\MessageBreak +column specifications. Using a default value of\MessageBreak +0pt instead}% +{Only !,:;'"./?*+ and - are valid predefined glue types in the\MessageBreak +IEEEeqnarray column specifications.}\fi\fi\fi\fi\fi\fi\fi\fi\fi\fi\fi\fi} + + +% usage: \@IEEEprocessNcol{digit} +% process a numerical digit from the column specification +% and look up the corresponding user defined glue value +% can transform current type from n to g or a as the user defined glue is acquired +\def\@IEEEprocessNcol#1{\if\@IEEEBPprevtype g% +\@IEEEclspkgerror{Back-to-back inter-column glue specifiers in column\MessageBreak +specifications. Ignoring consecutive glue specifiers\MessageBreak +after the first}% +{You cannot have two or more glue types next to each other\MessageBreak +in the IEEEeqnarray column specifications.}% +\let\@IEEEBPcurtype=a% abort this glue, future digits will be discarded +\@IEEEBPcurnum=0\relax% +\else% if we previously aborted a glue +\if\@IEEEBPprevtype a\@IEEEBPcurnum=0\let\@IEEEBPcurtype=a%maintain digit abortion +\else%acquire this number +% save the previous type before the numerical digits started +\if\@IEEEBPprevtype n\else\let\@IEEEBPprevsavedtype=\@IEEEBPprevtype\fi% +\multiply\@IEEEBPcurnum by 10\relax% +\advance\@IEEEBPcurnum by #1\relax% add in number, \relax is needed to stop TeX's number scan +\if\@IEEEBPnexttype n\else%close acquisition +\expandafter\ifx\csname @IEEEeqnarraycolSEPDEF\expandafter\romannumeral\number\@IEEEBPcurnum\endcsname\@IEEEeqnarraycolisdefined% +\edef\@IEEEBPcurglue{\csname @IEEEeqnarraycolSEP\expandafter\romannumeral\number\@IEEEBPcurnum\endcsname}% +\else%user glue not defined +\@IEEEclspkgerror{Invalid user defined inter-column glue type "\number\@IEEEBPcurnum" in\MessageBreak +column specifications. Using a default value of\MessageBreak +0pt instead}% +{You must define all IEEEeqnarray numerical inter-column glue types via\MessageBreak +\string\IEEEeqnarraydefcolsep \space before they are used in column specifications.}% +\edef\@IEEEBPcurglue{\@IEEEeqnarraycolSEPzero}% +\fi% glue defined or not +\let\@IEEEBPcurtype=g% change the type to reflect the acquired glue +\let\@IEEEBPprevtype=\@IEEEBPprevsavedtype% restore the prev type before this number glue +\@IEEEBPcurnum=0\relax%ready for next acquisition +\fi%close acquisition, get glue +\fi%discard or acquire number +\fi%prevtype glue or not +} + + +% process an acquired glue +% add any acquired column/glue pair to the preamble +\def\@IEEEprocessGcol{\if\@IEEEBPprevtype a\let\@IEEEBPcurtype=a%maintain previous glue abortions +\else +% if this is the start glue, save it, but do nothing else +% as this is not used in the preamble, but before +\if\@IEEEBPprevtype s\edef\@IEEEBPstartglue{\@IEEEBPcurglue}% +\else%not the start glue +\if\@IEEEBPprevtype g%ignore if back to back glues +\@IEEEclspkgerror{Back-to-back inter-column glue specifiers in column\MessageBreak +specifications. Ignoring consecutive glue specifiers\MessageBreak +after the first}% +{You cannot have two or more glue types next to each other\MessageBreak +in the IEEEeqnarray column specifications.}% +\let\@IEEEBPcurtype=a% abort this glue +\else% not a back to back glue +\if\@IEEEBPprevtype c\relax% if the previoustype was a col, add column/glue pair to preamble +\ifnum\@IEEEeqnnumcols>0\relax\@IEEEappendtoksA{&}\fi +\toks0={##}% +% make preamble advance col counter if this environment needs this +\if@advanceIEEEeqncolcnt\@IEEEappendtoksA{\global\advance\@IEEEeqncolcnt by 1\relax}\fi +% insert the column defintion into the preamble, being careful not to expand +% the column definition +\@IEEEappendtoksA{\tabskip=\@IEEEBPcurglue}% +\@IEEEappendNOEXPANDtoksA{\begingroup\csname @IEEEeqnarraycolPRE}% +\@IEEEappendtoksA{\@IEEEBPcurcolname}% +\@IEEEappendNOEXPANDtoksA{\endcsname}% +\@IEEEappendtoksA{\the\toks0}% +\@IEEEappendNOEXPANDtoksA{\relax\relax\relax\relax\relax% +\relax\relax\relax\relax\relax\csname @IEEEeqnarraycolPOST}% +\@IEEEappendtoksA{\@IEEEBPcurcolname}% +\@IEEEappendNOEXPANDtoksA{\endcsname\relax\relax\relax\relax\relax% +\relax\relax\relax\relax\relax\endgroup}% +\advance\@IEEEeqnnumcols by 1\relax%one more column in the preamble +\else% error: non-start glue with no pending column +\@IEEEclspkgerror{Inter-column glue specifier without a prior column\MessageBreak +type in the column specifications. Ignoring this glue\MessageBreak +specifier}% +{Except for the first and last positions, glue can be placed only\MessageBreak +between column types.}% +\let\@IEEEBPcurtype=a% abort this glue +\fi% previous was a column +\fi% back-to-back glues +\fi% is start column glue +\fi% prev type not a +} + + +% process an acquired letter referenced column and, if necessary, add it to the preamble +\def\@IEEEprocessCcol{\if\@IEEEBPnexttype g\else +\if\@IEEEBPnexttype n\else +% we have a column followed by something other than a glue (or numeral glue) +% so we must add this column to the preamble now +\ifnum\@IEEEeqnnumcols>0\relax\@IEEEappendtoksA{&}\fi%col separator for those after the first +\if\@IEEEBPnexttype e\@IEEEappendtoksA{\tabskip=\@IEEEBPendglue\relax}\else%put in end glue +\@IEEEappendtoksA{\tabskip=\@IEEEeqnarraycolSEPdefaultmid\relax}\fi% or default mid glue +\toks0={##}% +% make preamble advance col counter if this environment needs this +\if@advanceIEEEeqncolcnt\@IEEEappendtoksA{\global\advance\@IEEEeqncolcnt by 1\relax}\fi +% insert the column definition into the preamble, being careful not to expand +% the column definition +\@IEEEappendNOEXPANDtoksA{\begingroup\csname @IEEEeqnarraycolPRE}% +\@IEEEappendtoksA{\@IEEEBPcurcolname}% +\@IEEEappendNOEXPANDtoksA{\endcsname}% +\@IEEEappendtoksA{\the\toks0}% +\@IEEEappendNOEXPANDtoksA{\relax\relax\relax\relax\relax% +\relax\relax\relax\relax\relax\csname @IEEEeqnarraycolPOST}% +\@IEEEappendtoksA{\@IEEEBPcurcolname}% +\@IEEEappendNOEXPANDtoksA{\endcsname\relax\relax\relax\relax\relax% +\relax\relax\relax\relax\relax\endgroup}% +\advance\@IEEEeqnnumcols by 1\relax%one more column in the preamble +\fi%next type not numeral +\fi%next type not glue +} + + +%% +%% END OF IEEEeqnarray DEFINITIONS +%% + + + + + +% set up the running headers and footers +% +% header and footer font and size specifications +\def\@IEEEheaderstyle{\normalfont\scriptsize} +\def\@IEEEfooterstyle{\normalfont\scriptsize} +% +% compsoc uses sans-serif headers and footers +\ifCLASSOPTIONcompsoc + \def\@IEEEheaderstyle{\normalfont\sffamily\scriptsize} + \def\@IEEEfooterstyle{\normalfont\sffamily\scriptsize} +\fi + + +% standard page style, ps@headings +\def\ps@headings{% default to standard twoside headers, no footers +% will change later if the mode requires otherwise +\def\@oddhead{\hbox{}\@IEEEheaderstyle\rightmark\hfil\thepage}\relax +\def\@evenhead{\@IEEEheaderstyle\thepage\hfil\leftmark\hbox{}}\relax +\let\@oddfoot\@empty +\let\@evenfoot\@empty +\ifCLASSOPTIONtechnote + % technote twoside + \def\@oddhead{\hbox{}\@IEEEheaderstyle\leftmark\hfil\thepage}\relax + \def\@evenhead{\@IEEEheaderstyle\thepage\hfil\leftmark\hbox{}}\relax +\fi +\ifCLASSOPTIONdraftcls + % draft footers + \def\@oddfoot{\@IEEEfooterstyle\@date\hfil DRAFT}\relax + \def\@evenfoot{\@IEEEfooterstyle DRAFT\hfil\@date}\relax +\fi +% oneside +\if@twoside\else + % standard one side headers + \def\@oddhead{\hbox{}\@IEEEheaderstyle\leftmark\hfil\thepage}\relax + \let\@evenhead\@empty + \ifCLASSOPTIONdraftcls + % oneside draft footers + \def\@oddfoot{\@IEEEfooterstyle\@date\hfil DRAFT}\relax + \let\@evenfoot\@empty + \fi +\fi +% turn off headers for conferences +\ifCLASSOPTIONconference + \let\@oddhead\@empty + \let\@evenhead\@empty +\fi +% turn off footers for draftclsnofoot +\ifCLASSOPTIONdraftclsnofoot + \let\@oddfoot\@empty + \let\@evenfoot\@empty +\fi} + + +% title page style, ps@IEEEtitlepagestyle +\def\ps@IEEEtitlepagestyle{% default title page headers, no footers +\def\@oddhead{\hbox{}\@IEEEheaderstyle\leftmark\hfil\thepage}\relax +\def\@evenhead{\@IEEEheaderstyle\thepage\hfil\leftmark\hbox{}}\relax +\let\@oddfoot\@empty +\let\@evenfoot\@empty +% will change later if the mode requires otherwise +\ifCLASSOPTIONdraftcls + % draft footers + \ifCLASSOPTIONdraftclsnofoot\else + % but only if not draftclsnofoot + \def\@oddfoot{\@IEEEfooterstyle\@date\hfil DRAFT}\relax + \def\@evenfoot{\@IEEEfooterstyle DRAFT\hfil\@date}\relax + \fi +\else + % all nondraft mode footers + \if@IEEEusingpubid + % for title pages that are using a pubid + % do not repeat pubid on the title page if using a peer review cover page + \ifCLASSOPTIONpeerreview\else + % for noncompsoc papers, the pubid uses footnotesize and + % is at the same vertical position as where the last baseline would normally be + \def\@oddfoot{\hbox{}\hss\@IEEEfooterstyle\footnotesize\raisebox{\footskip}[0pt][0pt]{\@IEEEpubid}\hss\hbox{}}\relax + \def\@evenfoot{\hbox{}\hss\@IEEEfooterstyle\footnotesize\raisebox{\footskip}[0pt][0pt]{\@IEEEpubid}\hss\hbox{}}\relax + \ifCLASSOPTIONcompsoc + % for compsoc papers, the pubid is at the same vertical position as the normal footer + \def\@oddfoot{\hbox{}\hss\@IEEEfooterstyle\raisebox{0pt}[0pt][0pt]{\@IEEEpubid}\hss\hbox{}}\relax + \def\@evenfoot{\hbox{}\hss\@IEEEfooterstyle\raisebox{0pt}[0pt][0pt]{\@IEEEpubid}\hss\hbox{}}\relax + \fi + \fi + \fi +\fi +% turn off headers for conferences +\ifCLASSOPTIONconference + \let\@oddhead\@empty + \let\@evenhead\@empty +\fi} + + +% peer review cover page style, ps@IEEEpeerreviewcoverpagestyle +\def\ps@IEEEpeerreviewcoverpagestyle{% default peer review cover no headers, no footers +\let\@oddhead\@empty +\let\@evenhead\@empty +\let\@oddfoot\@empty +\let\@evenfoot\@empty +% will change later if the mode requires otherwise +\ifCLASSOPTIONdraftcls + % draft footers + \ifCLASSOPTIONdraftclsnofoot\else + % but only if not draftclsnofoot + \def\@oddfoot{\@IEEEfooterstyle\@date\hfil DRAFT}\relax + \def\@evenfoot{\@IEEEfooterstyle DRAFT\hfil\@date}\relax + \fi +\else + % all nondraft mode footers + \if@IEEEusingpubid + % for peer review cover pages that are using a pubid + % for noncompsoc papers, the pubid uses footnotesize and + % is at the same vertical position as where the last baseline would normally be + \def\@oddfoot{\hbox{}\hss\@IEEEfooterstyle\footnotesize\raisebox{\footskip}[0pt][0pt]{\@IEEEpubid}\hss\hbox{}}\relax + \def\@evenfoot{\hbox{}\hss\@IEEEfooterstyle\footnotesize\raisebox{\footskip}[0pt][0pt]{\@IEEEpubid}\hss\hbox{}}\relax + \ifCLASSOPTIONcompsoc + % for compsoc papers, the pubid is at the same vertical position as the normal footer + \def\@oddfoot{\hbox{}\hss\@IEEEfooterstyle\raisebox{0pt}[0pt][0pt]{\@IEEEpubid}\hss\hbox{}}\relax + \def\@evenfoot{\hbox{}\hss\@IEEEfooterstyle\raisebox{0pt}[0pt][0pt]{\@IEEEpubid}\hss\hbox{}}\relax + \fi + \fi +\fi} + + + +%% Defines the command for putting the header. +%% Note that all the text is forced into uppercase, if you have some text +%% that needs to be in lower case, for instance et. al., then either manually +%% set \leftmark and \rightmark or use \MakeLowercase{et. al.} within the +%% arguments to \markboth. +%% V1.7b add \protect to work with Babel +\def\markboth#1#2{\def\leftmark{\MakeUppercase{\protect#1}}% +\def\rightmark{\MakeUppercase{\protect#2}}} + +\def\today{\ifcase\month\or + January\or February\or March\or April\or May\or June\or + July\or August\or September\or October\or November\or December\fi + \space\number\day, \number\year} + + + + +%% CITATION AND BIBLIOGRAPHY COMMANDS +%% +%% V1.6 no longer supports the older, nonstandard \shortcite and \citename setup stuff +% +% +% Modify Latex2e \@citex to separate citations with "], [" +\def\@citex[#1]#2{% + \let\@citea\@empty + \@cite{\@for\@citeb:=#2\do + {\@citea\def\@citea{], [}% + \edef\@citeb{\expandafter\@firstofone\@citeb\@empty}% + \if@filesw\immediate\write\@auxout{\string\citation{\@citeb}}\fi + \@ifundefined{b@\@citeb}{\mbox{\reset@font\bfseries ?}% + \G@refundefinedtrue + \@latex@warning + {Citation `\@citeb' on page \thepage \space undefined}}% + {\hbox{\csname b@\@citeb\endcsname}}}}{#1}} + +% V1.6 we create hooks for the optional use of Donald Arseneau's +% cite.sty package. cite.sty is "smart" and will notice that the +% following format controls are already defined and will not +% redefine them. The result will be the proper sorting of the +% citation numbers and auto detection of 3 or more entry "ranges" - +% all in IEEE style: [1], [2], [5]--[7], [12] +% This also allows for an optional note, i.e., \cite[mynote]{..}. +% If the \cite with note has more than one reference, the note will +% be applied to the last of the listed references. It is generally +% desired that if a note is given, only one reference is listed in +% that \cite. +% Thanks to Mr. Arseneau for providing the required format arguments +% to produce the IEEE style. +\def\citepunct{], [} +\def\citedash{]--[} + +% V1.7 default to using same font for urls made by url.sty +\AtBeginDocument{\csname url@samestyle\endcsname} + +% V1.6 class files should always provide these +\def\newblock{\hskip .11em\@plus.33em\@minus.07em} +\let\@openbib@code\@empty +% V1.8b article.cls is now providing these too +% we do not use \@mkboth, nor alter the page style +\newenvironment{theindex} + {\if@twocolumn + \@restonecolfalse + \else + \@restonecoltrue + \fi + \twocolumn[\section*{\indexname}]% + \parindent\z@ + \parskip\z@ \@plus .3\p@\relax + \columnseprule \z@ + \columnsep 35\p@ + \let\item\@idxitem} + {\if@restonecol\onecolumn\else\clearpage\fi} +\newcommand\@idxitem{\par\hangindent 40\p@} +\newcommand\subitem{\@idxitem \hspace*{20\p@}} +\newcommand\subsubitem{\@idxitem \hspace*{30\p@}} +\newcommand\indexspace{\par \vskip 10\p@ \@plus5\p@ \@minus3\p@\relax} + + + +% Provide support for the control entries of IEEEtran.bst V1.00 and later. +% V1.7 optional argument allows for a different aux file to be specified in +% order to handle multiple bibliographies. For example, with multibib.sty: +% \newcites{sec}{Secondary Literature} +% \bstctlcite[@auxoutsec]{BSTcontrolhak} +\def\bstctlcite{\@ifnextchar[{\@bstctlcite}{\@bstctlcite[@auxout]}} +\def\@bstctlcite[#1]#2{\@bsphack + \@for\@citeb:=#2\do{% + \edef\@citeb{\expandafter\@firstofone\@citeb}% + \if@filesw\immediate\write\csname #1\endcsname{\string\citation{\@citeb}}\fi}% + \@esphack} + +% \IEEEnoauxwrite{} allows for citations that do not add to or affect +% the order of the existing citation list. Can be useful for \cite +% within \thanks{}. +\DeclareRobustCommand{\IEEEnoauxwrite}[1]{\relax +\if@filesw +\@fileswfalse +#1\relax\relax\relax\relax\relax +\@fileswtrue +\else +#1\relax\relax\relax\relax\relax +\fi} + +% V1.6 provide a way for a user to execute a command just before +% a given reference number - used to insert a \newpage to balance +% the columns on the last page +\edef\@IEEEtriggerrefnum{0} % the default of zero means that + % the command is not executed +\def\@IEEEtriggercmd{\newpage} + +% allow the user to alter the triggered command +\long\def\IEEEtriggercmd#1{\long\def\@IEEEtriggercmd{#1}} + +% allow user a way to specify the reference number just before the +% command is executed +\def\IEEEtriggeratref#1{\@IEEEtrantmpcountA=#1% +\edef\@IEEEtriggerrefnum{\the\@IEEEtrantmpcountA}}% + +% trigger command at the given reference +\def\@IEEEbibitemprefix{\@IEEEtrantmpcountA=\@IEEEtriggerrefnum\relax% +\advance\@IEEEtrantmpcountA by -1\relax% +\ifnum\c@enumiv=\@IEEEtrantmpcountA\relax\@IEEEtriggercmd\relax\fi} + + +\def\@biblabel#1{[#1]} + +% compsoc journals and conferences left align the reference numbers +\@IEEEcompsoconly{\def\@biblabel#1{[#1]\hfill}} + +% controls bib item spacing +\def\IEEEbibitemsep{0pt plus .5pt} + +\@IEEEcompsocconfonly{\def\IEEEbibitemsep{0.5\baselineskip plus 0.25\baselineskip minus 0.25\baselineskip}} + + +\def\thebibliography#1{\section*{\refname}% + \addcontentsline{toc}{section}{\refname}% + % V1.6 add some rubber space here and provide a command trigger + \footnotesize\vskip 0.3\baselineskip plus 0.1\baselineskip minus 0.1\baselineskip% + \list{\@biblabel{\@arabic\c@enumiv}}% + {\settowidth\labelwidth{\@biblabel{#1}}% + \leftmargin\labelwidth + \advance\leftmargin\labelsep\relax + \itemsep \IEEEbibitemsep\relax + \usecounter{enumiv}% + \let\p@enumiv\@empty + \renewcommand\theenumiv{\@arabic\c@enumiv}}% + \let\@IEEElatexbibitem\bibitem% + \def\bibitem{\@IEEEbibitemprefix\@IEEElatexbibitem}% +\def\newblock{\hskip .11em plus .33em minus .07em}% +% originally: +% \sloppy\clubpenalty4000\widowpenalty4000% +% by adding the \interlinepenalty here, we make it more +% difficult, but not impossible, for LaTeX to break within a reference. +% The IEEE almost never breaks a reference (but they do it more often with +% technotes). You may get an underfull vbox warning around the bibliography, +% but the final result will be much more like what the IEEE will publish. +% MDS 11/2000 +\ifCLASSOPTIONtechnote\sloppy\clubpenalty4000\widowpenalty4000\interlinepenalty100% +\else\sloppy\clubpenalty4000\widowpenalty4000\interlinepenalty500\fi% + \sfcode`\.=1000\relax} +\let\endthebibliography=\endlist + + + + +% TITLE PAGE COMMANDS +% +% +% \IEEEmembership is used to produce the sublargesize italic font used to indicate author +% IEEE membership. compsoc uses a large size sans slant font +\def\IEEEmembership#1{{\@IEEEnotcompsoconly{\sublargesize}\normalfont\@IEEEcompsoconly{\sffamily}\textit{#1}}} + + +% \IEEEauthorrefmark{} produces a footnote type symbol to indicate author affiliation. +% When given an argument of 1 to 9, \IEEEauthorrefmark{} follows the standard LaTeX footnote +% symbol sequence convention. However, for arguments 10 and above, \IEEEauthorrefmark{} +% reverts to using lower case roman numerals, so it cannot overflow. Do note that you +% cannot use \footnotemark[] in place of \IEEEauthorrefmark{} within \author as the footnote +% symbols will have been turned off to prevent \thanks from creating footnote marks. +% \IEEEauthorrefmark{} produces a symbol that appears to LaTeX as having zero vertical +% height - this allows for a more compact line packing, but the user must ensure that +% the interline spacing is large enough to prevent \IEEEauthorrefmark{} from colliding +% with the text above. +% V1.7 make this a robust command +% V1.8 transmag uses an arabic author affiliation symbol +\ifCLASSOPTIONtransmag +\DeclareRobustCommand*{\IEEEauthorrefmark}[1]{\raisebox{0pt}[0pt][0pt]{\textsuperscript{\footnotesize #1}}} +\else +\DeclareRobustCommand*{\IEEEauthorrefmark}[1]{\raisebox{0pt}[0pt][0pt]{\textsuperscript{\footnotesize\ensuremath{\ifcase#1\or *\or \dagger\or \ddagger\or% + \mathsection\or \mathparagraph\or \|\or **\or \dagger\dagger% + \or \ddagger\ddagger \else\textsuperscript{\expandafter\romannumeral#1}\fi}}}} +\fi + + +% FONT CONTROLS AND SPACINGS FOR CONFERENCE MODE AUTHOR NAME AND AFFILIATION BLOCKS +% +% The default font styles for the author name and affiliation blocks (confmode) +\def\@IEEEauthorblockNstyle{\normalfont\@IEEEcompsocnotconfonly{\sffamily}\sublargesize} +\def\@IEEEauthorblockAstyle{\normalfont\@IEEEcompsocnotconfonly{\sffamily}\@IEEEcompsocconfonly{\itshape}\normalsize} +% The default if the user does not use an author block +\def\@IEEEauthordefaulttextstyle{\normalfont\@IEEEcompsocnotconfonly{\sffamily}\sublargesize} + +% adjustment spacing from title (or special paper notice) to author name blocks (confmode) +% can be negative +\def\@IEEEauthorblockconfadjspace{-0.25em} +% compsoc conferences need more space here +\@IEEEcompsocconfonly{\def\@IEEEauthorblockconfadjspace{0.75\@IEEEnormalsizeunitybaselineskip}} + +% spacing between name and affiliation blocks (confmode) +% This can be negative. +% The IEEE doesn't want any added spacing here, but I will leave these +% controls in place in case they ever change their mind. +% Personally, I like 0.75ex. +%\def\@IEEEauthorblockNtopspace{0.75ex} +%\def\@IEEEauthorblockAtopspace{0.75ex} +\def\@IEEEauthorblockNtopspace{0.0ex} +\def\@IEEEauthorblockAtopspace{0.0ex} +\ifCLASSOPTIONtransmag +% transmag uses one line of space above first affiliation block +\def\@IEEEauthorblockAtopspace{1\@IEEEnormalsizeunitybaselineskip} +\fi + +% baseline spacing within name and affiliation blocks (confmode) +% must be positive, spacings below certain values will make +% the position of line of text sensitive to the contents of the +% line above it i.e., whether or not the prior line has descenders, +% subscripts, etc. For this reason it is a good idea to keep +% these above 2.6ex +\def\@IEEEauthorblockNinterlinespace{2.6ex} +\def\@IEEEauthorblockAinterlinespace{2.75ex} + +% This tracks the required strut size. +% See the \@IEEEauthorhalign command for the actual default value used. +\def\@IEEEauthorblockXinterlinespace{2.7ex} + +% variables to retain font size and style across groups +% values given here have no effect as they will be overwritten later +\gdef\@IEEESAVESTATEfontsize{10} +\gdef\@IEEESAVESTATEfontbaselineskip{12} +\gdef\@IEEESAVESTATEfontencoding{OT1} +\gdef\@IEEESAVESTATEfontfamily{ptm} +\gdef\@IEEESAVESTATEfontseries{m} +\gdef\@IEEESAVESTATEfontshape{n} + +% saves the current font attributes +\def\@IEEEcurfontSAVE{\global\let\@IEEESAVESTATEfontsize\f@size% +\global\let\@IEEESAVESTATEfontbaselineskip\f@baselineskip% +\global\let\@IEEESAVESTATEfontencoding\f@encoding% +\global\let\@IEEESAVESTATEfontfamily\f@family% +\global\let\@IEEESAVESTATEfontseries\f@series% +\global\let\@IEEESAVESTATEfontshape\f@shape} + +% restores the saved font attributes +\def\@IEEEcurfontRESTORE{\fontsize{\@IEEESAVESTATEfontsize}{\@IEEESAVESTATEfontbaselineskip}% +\fontencoding{\@IEEESAVESTATEfontencoding}% +\fontfamily{\@IEEESAVESTATEfontfamily}% +\fontseries{\@IEEESAVESTATEfontseries}% +\fontshape{\@IEEESAVESTATEfontshape}% +\selectfont} + + +% variable to indicate if the current block is the first block in the column +\newif\if@IEEEprevauthorblockincol \@IEEEprevauthorblockincolfalse + + +% the command places a strut with height and depth = \@IEEEauthorblockXinterlinespace +% we use this technique to have complete manual control over the spacing of the lines +% within the halign environment. +% We set the below baseline portion at 30%, the above +% baseline portion at 70% of the total length. +% Responds to changes in the document's \baselinestretch +\def\@IEEEauthorstrutrule{\@IEEEtrantmpdimenA\@IEEEauthorblockXinterlinespace% +\@IEEEtrantmpdimenA=\baselinestretch\@IEEEtrantmpdimenA% +\rule[-0.3\@IEEEtrantmpdimenA]{0pt}{\@IEEEtrantmpdimenA}} + + +% blocks to hold the authors' names and affilations. +% Makes formatting easy for conferences +% +% use real definitions in conference mode +% name block +\def\IEEEauthorblockN#1{\relax\@IEEEauthorblockNstyle% set the default text style +\gdef\@IEEEauthorblockXinterlinespace{0pt}% disable strut for spacer row +% the \expandafter hides the \cr in conditional tex, see the array.sty docs +% for details, probably not needed here as the \cr is in a macro +% do a spacer row if needed +\if@IEEEprevauthorblockincol\expandafter\@IEEEauthorblockNtopspaceline\fi +\global\@IEEEprevauthorblockincoltrue% we now have a block in this column +%restore the correct strut value +\gdef\@IEEEauthorblockXinterlinespace{\@IEEEauthorblockNinterlinespace}% +% input the author names +#1% +% end the row if the user did not already +\crcr} +% spacer row for names +\def\@IEEEauthorblockNtopspaceline{\cr\noalign{\vskip\@IEEEauthorblockNtopspace}} +% +% affiliation block +\def\IEEEauthorblockA#1{\relax\@IEEEauthorblockAstyle% set the default text style +\gdef\@IEEEauthorblockXinterlinespace{0pt}%disable strut for spacer row +% the \expandafter hides the \cr in conditional tex, see the array.sty docs +% for details, probably not needed here as the \cr is in a macro +% do a spacer row if needed +\if@IEEEprevauthorblockincol\expandafter\@IEEEauthorblockAtopspaceline\fi +\global\@IEEEprevauthorblockincoltrue% we now have a block in this column +%restore the correct strut value +\gdef\@IEEEauthorblockXinterlinespace{\@IEEEauthorblockAinterlinespace}% +% input the author affiliations +#1% +% end the row if the user did not already +\crcr +% V1.8 transmag does not use any additional affiliation spacing after the first author +\ifCLASSOPTIONtransmag\gdef\@IEEEauthorblockAtopspace{0pt}\fi} + +% spacer row for affiliations +\def\@IEEEauthorblockAtopspaceline{\cr\noalign{\vskip\@IEEEauthorblockAtopspace}} + + +% allow papers to compile even if author blocks are used in modes other +% than conference or peerreviewca. For such cases, we provide dummy blocks. +\ifCLASSOPTIONconference +\else + \ifCLASSOPTIONpeerreviewca\else + % not conference, peerreviewca or transmag mode + \ifCLASSOPTIONtransmag\else + \def\IEEEauthorblockN#1{#1}% + \def\IEEEauthorblockA#1{#1}% + \fi + \fi +\fi + + + +% we provide our own halign so as not to have to depend on tabular +\def\@IEEEauthorhalign{\@IEEEauthordefaulttextstyle% default text style + \lineskip=0pt\relax% disable line spacing + \lineskiplimit=0pt\relax% + \baselineskip=0pt\relax% + \@IEEEcurfontSAVE% save the current font + \mathsurround\z@\relax% no extra spacing around math + \let\\\@IEEEauthorhaligncr% replace newline with halign friendly one + \tabskip=0pt\relax% no column spacing + \everycr{}% ensure no problems here + \@IEEEprevauthorblockincolfalse% no author blocks yet + \def\@IEEEauthorblockXinterlinespace{2.7ex}% default interline space + \vtop\bgroup%vtop box + \halign\bgroup&\relax\hfil\@IEEEcurfontRESTORE\relax ##\relax + \hfil\@IEEEcurfontSAVE\@IEEEauthorstrutrule\cr} + +% ensure last line, exit from halign, close vbox +\def\end@IEEEauthorhalign{\crcr\egroup\egroup} + +% handle bogus star form +\def\@IEEEauthorhaligncr{{\ifnum0=`}\fi\@ifstar{\@@IEEEauthorhaligncr}{\@@IEEEauthorhaligncr}} + +% test and setup the optional argument to \\[] +\def\@@IEEEauthorhaligncr{\@testopt\@@@IEEEauthorhaligncr\z@skip} + +% end the line and do the optional spacer +\def\@@@IEEEauthorhaligncr[#1]{\ifnum0=`{\fi}\cr\noalign{\vskip#1\relax}} + + + +% flag to prevent multiple \and warning messages +\newif\if@IEEEWARNand +\@IEEEWARNandtrue + +% if in conference or peerreviewca modes, we support the use of \and as \author is a +% tabular environment, otherwise we warn the user that \and is invalid +% outside of conference or peerreviewca modes. +\def\and{\relax} % provide a bogus \and that we will then override + +\renewcommand{\and}[1][\relax]{\if@IEEEWARNand\typeout{** WARNING: \noexpand\and is valid only + when in conference or peerreviewca}\typeout{modes (line \the\inputlineno).}\fi\global\@IEEEWARNandfalse} + +\ifCLASSOPTIONconference% +\renewcommand{\and}[1][\hfill]{\end{@IEEEauthorhalign}#1\begin{@IEEEauthorhalign}}% +\fi +\ifCLASSOPTIONpeerreviewca +\renewcommand{\and}[1][\hfill]{\end{@IEEEauthorhalign}#1\begin{@IEEEauthorhalign}}% +\fi +% V1.8 transmag uses conference author format +\ifCLASSOPTIONtransmag +\renewcommand{\and}[1][\hfill]{\end{@IEEEauthorhalign}#1\begin{@IEEEauthorhalign}}% +\fi + +% page clearing command +% based on LaTeX2e's \cleardoublepage, but allows different page styles +% for the inserted blank pages +\def\@IEEEcleardoublepage#1{\clearpage\if@twoside\ifodd\c@page\else +\hbox{}\thispagestyle{#1}\newpage\if@twocolumn\hbox{}\thispagestyle{#1}\newpage\fi\fi\fi} + +% V1.8b hooks to allow adjustment of space above title +\def\IEEEtitletopspace{0.5\baselineskip} +% an added extra amount to allow for adjustment/offset +\def\IEEEtitletopspaceextra{0pt} + +% user command to invoke the title page +\def\maketitle{\par% + \begingroup% + \normalfont% + \def\thefootnote{}% the \thanks{} mark type is empty + \def\footnotemark{}% and kill space from \thanks within author + \let\@makefnmark\relax% V1.7, must *really* kill footnotemark to remove all \textsuperscript spacing as well. + \footnotesize% equal spacing between thanks lines + \footnotesep 0.7\baselineskip%see global setting of \footnotesep for more info + % V1.7 disable \thanks note indention for compsoc + \@IEEEcompsoconly{\long\def\@makefntext##1{\parindent 1em\noindent\hbox{\@makefnmark}##1}}% + \normalsize% + \ifCLASSOPTIONpeerreview + \newpage\global\@topnum\z@ \@maketitle\@IEEEstatictitlevskip\@IEEEaftertitletext% + \thispagestyle{IEEEpeerreviewcoverpagestyle}\@thanks% + \else + \if@twocolumn% + \ifCLASSOPTIONtechnote% + \newpage\global\@topnum\z@ \@maketitle\@IEEEstatictitlevskip\@IEEEaftertitletext% + \else + \twocolumn[{\IEEEquantizevspace{\@maketitle}[\IEEEquantizedisabletitlecmds]{0pt}[-\topskip]{\baselineskip}{\@IEEENORMtitlevspace}{\@IEEEMINtitlevspace}\@IEEEaftertitletext}]% + \fi + \else + \newpage\global\@topnum\z@ \@maketitle\@IEEEstatictitlevskip\@IEEEaftertitletext% + \fi + \thispagestyle{IEEEtitlepagestyle}\@thanks% + \fi + % pullup page for pubid if used. + \if@IEEEusingpubid + \enlargethispage{-\@IEEEpubidpullup}% + \fi + \endgroup + \setcounter{footnote}{0}\let\maketitle\relax\let\@maketitle\relax + \gdef\@thanks{}% + % v1.6b do not clear these as we will need the title again for peer review papers + % \gdef\@author{}\gdef\@title{}% + \let\thanks\relax} + + +% V1.8 parbox to format \@IEEEtitleabstractindextext +\long\def\@IEEEtitleabstractindextextbox#1{\parbox{1\textwidth}{#1}} +% V1.8 compsoc is partial width +\ifCLASSOPTIONcompsoc +% comparison with proofs suggests it's in the range of 92.1-92.3% +\long\def\@IEEEtitleabstractindextextbox#1{\parbox{0.922\textwidth}{\@IEEEcompsocnotconfonly{\rightskip\@flushglue\leftskip\z@skip}#1}} +\fi + +% formats the Title, authors names, affiliations and special paper notice +% THIS IS A CONTROLLED SPACING COMMAND! Do not allow blank lines or unintentional +% spaces to enter the definition - use % at the end of each line +\def\@maketitle{\newpage +\bgroup\par\vskip\IEEEtitletopspace\vskip\IEEEtitletopspaceextra\centering% +\ifCLASSOPTIONtechnote% technotes, V1.8a abstract and index terms are not treated differently for compsoc technotes + {\bfseries\large\@IEEEcompsoconly{\Large\sffamily}\@title\par}\vskip 1.3em{\lineskip .5em\@IEEEcompsoconly{\large\sffamily}\@author + \@IEEEspecialpapernotice\par}\relax +\else% not a technote + \vskip0.2em{\Huge\ifCLASSOPTIONtransmag\bfseries\LARGE\fi\@IEEEcompsoconly{\sffamily}\@IEEEcompsocconfonly{\normalfont\normalsize\vskip 2\@IEEEnormalsizeunitybaselineskip + \bfseries\Large}\@IEEEcompsocnotconfonly{\vskip 0.75\@IEEEnormalsizeunitybaselineskip}\@title\par}\relax + \@IEEEcompsocnotconfonly{\vskip 0.5\@IEEEnormalsizeunitybaselineskip}\vskip1.0em\par% + % V1.6 handle \author differently if in conference mode + \ifCLASSOPTIONconference% + {\@IEEEspecialpapernotice\mbox{}\vskip\@IEEEauthorblockconfadjspace% + \mbox{}\hfill\begin{@IEEEauthorhalign}\@author\end{@IEEEauthorhalign}\hfill\mbox{}\par}\relax + \else% peerreviewca, peerreview or journal + \ifCLASSOPTIONpeerreviewca + % peerreviewca handles author names just like conference mode + {\@IEEEcompsoconly{\sffamily}\@IEEEspecialpapernotice\mbox{}\vskip\@IEEEauthorblockconfadjspace% + \mbox{}\hfill\begin{@IEEEauthorhalign}\@author\end{@IEEEauthorhalign}\hfill\mbox{}\par + {\@IEEEcompsoconly{\vskip 1.5em\relax + \@IEEEtitleabstractindextextbox{\@IEEEtitleabstractindextext}\par\noindent\hfill + \IEEEcompsocdiamondline\hfill\hbox{}\par}}}\relax + \else% journal, peerreview or transmag + \ifCLASSOPTIONtransmag + % transmag also handles author names just like conference mode + % it also uses \@IEEEtitleabstractindextex, but with one line less + % space above, and one more below + {\@IEEEspecialpapernotice\mbox{}\vskip\@IEEEauthorblockconfadjspace% + \mbox{}\hfill\begin{@IEEEauthorhalign}\@author\end{@IEEEauthorhalign}\hfill\mbox{}\par + {\vspace{0.5\baselineskip}\relax\@IEEEtitleabstractindextextbox{\@IEEEtitleabstractindextext}\vspace{-1\baselineskip}\par}}\relax + \else% journal or peerreview + {\lineskip.5em\@IEEEcompsoconly{\sffamily}\sublargesize\@author\@IEEEspecialpapernotice\par + {\@IEEEcompsoconly{\vskip 1.5em\relax + \@IEEEtitleabstractindextextbox{\@IEEEtitleabstractindextext}\par\noindent\hfill + \IEEEcompsocdiamondline\hfill\hbox{}\par}}}\relax + \fi + \fi + \fi +\fi\par\addvspace{0.5\baselineskip}\egroup} + + +% V1.7 Computer Society "diamond line" which follows index terms for nonconference papers +% V1.8a full width diamond line for single column use +\def\@IEEEcompsocdiamondlinei{\vrule depth 0pt height 0.5pt width 4cm\nobreak\hspace{7.5pt}\nobreak +\raisebox{-3.5pt}{\fontfamily{pzd}\fontencoding{U}\fontseries{m}\fontshape{n}\fontsize{11}{12}\selectfont\char70}\nobreak +\hspace{7.5pt}\nobreak\vrule depth 0pt height 0.5pt width 4cm\relax} +% V1.8a narrower width diamond line for double column use +\def\@IEEEcompsocdiamondlineii{\vrule depth 0pt height 0.5pt width 2.5cm\nobreak\hspace{7.5pt}\nobreak +\raisebox{-3.5pt}{\fontfamily{pzd}\fontencoding{U}\fontseries{m}\fontshape{n}\fontsize{11}{12}\selectfont\char70}\nobreak +\hspace{7.5pt}\nobreak\vrule depth 0pt height 0.5pt width 2.5cm\relax} +% V1.8a bare core without rules to base a last resort on for very narrow linewidths +\def\@IEEEcompsocdiamondlineiii{\mbox{}\nobreak\hspace{7.5pt}\nobreak +\raisebox{-3.5pt}{\fontfamily{pzd}\fontencoding{U}\fontseries{m}\fontshape{n}\fontsize{11}{12}\selectfont\char70}\nobreak +\hspace{7.5pt}\nobreak\mbox{}\relax} + +% V1.8a allow \IEEEcompsocdiamondline to adjust for different linewidths. +% Use \@IEEEcompsocdiamondlinei if its width is less than 0.66\linewidth (0.487 nominal for single column) +% if not, fall back to \@IEEEcompsocdiamondlineii if its width is less than 0.75\linewidth (0.659 nominal for double column) +% if all else fails, try to make a custom diamondline based on the abnormally narrow linewidth +\def\IEEEcompsocdiamondline{\settowidth{\@IEEEtrantmpdimenA}{\@IEEEcompsocdiamondlinei}\relax +\ifdim\@IEEEtrantmpdimenA<0.66\linewidth\relax\@IEEEcompsocdiamondlinei\relax +\else +\settowidth{\@IEEEtrantmpdimenA}{\@IEEEcompsocdiamondlineii}\relax +\ifdim\@IEEEtrantmpdimenA<0.75\linewidth\relax\@IEEEcompsocdiamondlineii\relax +\else +\settowidth{\@IEEEtrantmpdimenA}{\@IEEEcompsocdiamondlineiii}\relax +\@IEEEtrantmpdimenB=\linewidth\relax +\addtolength{\@IEEEtrantmpdimenB}{-1\@IEEEtrantmpdimenA}\relax +\vrule depth 0pt height 0.5pt width 0.33\@IEEEtrantmpdimenB\@IEEEcompsocdiamondlineiii\vrule depth 0pt height 0.5pt width 0.33\@IEEEtrantmpdimenB\relax +\fi\fi} + + +% V1.7 standard LateX2e \thanks, but with \itshape under compsoc. Also make it a \long\def +% We also need to trigger the one-shot footnote rule +\def\@IEEEtriggeroneshotfootnoterule{\global\@IEEEenableoneshotfootnoteruletrue} + + +\long\def\thanks#1{\footnotemark + \protected@xdef\@thanks{\@thanks + \protect\footnotetext[\the\c@footnote]{\@IEEEcompsoconly{\itshape + \protect\@IEEEtriggeroneshotfootnoterule\relax}\ignorespaces#1}}} +\let\@thanks\@empty + + +% V1.7 allow \author to contain \par's. This is needed to allow \thanks to contain \par. +\long\def\author#1{\gdef\@author{#1}} + + +% in addition to setting up IEEEitemize, we need to remove a baselineskip space above and +% below it because \list's \pars introduce blank lines because of the footnote struts. +\def\@IEEEsetupcompsocitemizelist{\def\labelitemi{$\bullet$}% +\setlength{\IEEElabelindent}{0pt}\setlength{\labelsep}{1.2em}\setlength{\parskip}{0pt}% +\setlength{\partopsep}{0pt}\setlength{\topsep}{0.5\baselineskip}\vspace{-1\baselineskip}\relax} + + +% flag for fake non-compsoc \IEEEcompsocthanksitem - prevents line break on very first item +\newif\if@IEEEbreakcompsocthanksitem \@IEEEbreakcompsocthanksitemfalse + +\ifCLASSOPTIONcompsoc +% V1.7 compsoc bullet item \thanks +% also, we need to redefine this to destroy the argument in \IEEEquantizevspace +\long\def\IEEEcompsocitemizethanks#1{\relax\@IEEEbreakcompsocthanksitemfalse\footnotemark + \protected@xdef\@thanks{\@thanks + \protect\footnotetext[\the\c@footnote]{\itshape\protect\@IEEEtriggeroneshotfootnoterule + {\let\IEEEiedlistdecl\relax\protect\begin{IEEEitemize}[\protect\@IEEEsetupcompsocitemizelist]\ignorespaces#1\relax + \protect\end{IEEEitemize}}\protect\vspace{-1\baselineskip}}}} +\DeclareRobustCommand*{\IEEEcompsocthanksitem}{\item} +\else +% non-compsoc, allow for dual compilation via rerouting to normal \thanks +\long\def\IEEEcompsocitemizethanks#1{\thanks{#1}} +% redirect to "pseudo-par" \hfil\break\indent after swallowing [] from \IEEEcompsocthanksitem[] +\DeclareRobustCommand{\IEEEcompsocthanksitem}{\@ifnextchar [{\@IEEEthanksswallowoptionalarg}% +{\@IEEEthanksswallowoptionalarg[\relax]}} +% be sure and break only after first item, be sure and ignore spaces after optional argument +\def\@IEEEthanksswallowoptionalarg[#1]{\relax\if@IEEEbreakcompsocthanksitem\hfil\break +\indent\fi\@IEEEbreakcompsocthanksitemtrue\ignorespaces} +\fi + + +% V1.6b define the \IEEEpeerreviewmaketitle as needed +\ifCLASSOPTIONpeerreview +\def\IEEEpeerreviewmaketitle{\@IEEEcleardoublepage{empty}% +\ifCLASSOPTIONtwocolumn +\twocolumn[{\IEEEquantizevspace{\@IEEEpeerreviewmaketitle}[\IEEEquantizedisabletitlecmds]{0pt}[-\topskip]{\baselineskip}{\@IEEENORMtitlevspace}{\@IEEEMINtitlevspace}}] +\else +\newpage\@IEEEpeerreviewmaketitle\@IEEEstatictitlevskip +\fi +\thispagestyle{IEEEtitlepagestyle}} +\else +% \IEEEpeerreviewmaketitle does nothing if peer review option has not been selected +\def\IEEEpeerreviewmaketitle{\relax} +\fi + +% peerreview formats the repeated title like the title in journal papers. +\def\@IEEEpeerreviewmaketitle{\bgroup\par\addvspace{0.5\baselineskip}\centering\@IEEEcompsoconly{\sffamily}% +\normalfont\normalsize\vskip0.2em{\Huge\@title\par}\vskip1.0em\par +\par\addvspace{0.5\baselineskip}\egroup} + + + +% V1.6 +% this is a static rubber spacer between the title/authors and the main text +% used for single column text, or when the title appears in the first column +% of two column text (technotes). +\def\@IEEEstatictitlevskip{{\normalfont\normalsize +% adjust spacing to next text +% v1.6b handle peer review papers +\ifCLASSOPTIONpeerreview +% for peer review papers, the same value is used for both title pages +% regardless of the other paper modes + \vskip 1\baselineskip plus 0.375\baselineskip minus 0.1875\baselineskip +\else + \ifCLASSOPTIONconference% conference + \vskip 1\baselineskip plus 0.375\baselineskip minus 0.1875\baselineskip% + \else% + \ifCLASSOPTIONtechnote% technote + \vskip 1\baselineskip plus 0.375\baselineskip minus 0.1875\baselineskip% + \else% journal uses more space + \vskip 2.5\baselineskip plus 0.75\baselineskip minus 0.375\baselineskip% + \fi + \fi +\fi}} + + +% set the nominal and minimum values for the quantized title spacer +% the quantization algorithm will not allow the spacer size to +% become less than \@IEEEMINtitlevspace - instead it will be lengthened +% default to journal values +\def\@IEEENORMtitlevspace{2.5\baselineskip} +\def\@IEEEMINtitlevspace{2\baselineskip} +% conferences and technotes need tighter spacing +\ifCLASSOPTIONconference% conference + \def\@IEEENORMtitlevspace{1\baselineskip} + \def\@IEEEMINtitlevspace{0.75\baselineskip} +\fi +\ifCLASSOPTIONtechnote% technote + \def\@IEEENORMtitlevspace{1\baselineskip} + \def\@IEEEMINtitlevspace{0.75\baselineskip} +\fi + + +% V1.8a +\def\IEEEquantizevspace{\begingroup\@ifstar{\@IEEEquantizevspacestarformtrue\@IEEEquantizevspace}{\@IEEEquantizevspacestarformfalse\@IEEEquantizevspace}} +% \IEEEquantizevspace[output dimen register]{object}[object decl] +% {top baselineskip} +% [offset][prevdepth][lineskip limit][lineskip] +% {unit height}{nominal vspace}{minimum vspace} +% +% Calculates and creates the vspace needed to make the combined height with +% the given object an integer multiple of the given unit height. This command +% is more general than the older \@IEEEdynamictitlevspace it replaces. +% +% The star form has no effect at present, but is reserved for future use. +% +% If the optional argument [output dimen register] is given, the calculated +% vspace height is stored in the given output dimen (or skip) register +% and no other action is taken, otherwise the object followed by a vspace* +% of the appropriate height is evaluated/output. +% +% The optional object decl (declarations) is code that is evaluated just +% before the object's height is evaluated. Its intented purpose is to allow +% for the alteration or disabling of code within the object during internal +% height evaluation (e.g., \long\def\thanks#1{\relax} ). +% This special code is not invoked if/when the object is rendered at the end. +% +% The nominal vspace is the target value of the added vspace and the minimum +% vspace is the lower allowed limit. The vspacer will be the value that achieves +% integral overall height, in terms of the given unit height, that is closest +% to the nominal vspace and that is not less than the specified minimum vspace. +% +% The line spacing algorithm of TeX is somewhat involved and requires special +% care with regard to the first line of a vertical list (which is indicated +% when \prevdepth is -1000pt or less). top baselineskip specifies the +% baselineskip or topskip used prior to the object. If the height of the +% first line of the object is greater than the given top baselineskip, then +% the top baselineskip is subtracted from the height of the first line and +% that difference is considered along with the rest of the object height +% (because the object will be shifted down by an amount = +% top line height - top baselineskip). Otherwise, the height of the first line +% of the object is ignored as far as the calculations are concerned. +% This algorithm is adequate for objects that appear at the top of a page +% (e.g., titles) where \topskip spacing is used. +% +% However, as explained on page 78 of the TeXbook, interline spacing is more +% complex when \baselineskip is being used (indicated by \prevdepth > +% -1000pt). The four optional parameters offset, prevdepth, lineskip limit and +% lineskip are assumed to be equal to be 0pt, \prevdepth, \lineskiplimit and +% \lineskip, respectively, if they are omitted. +% +% The prevdepth is the depth of the line before the object, the lineskip limit +% specifies how close the top of the object can come to the bottom of the +% previous line before \baselineskip is ignored and \lineskip is inserted +% between the object and the line above it. Lineskip does not come into +% play unless the first line of the object is high enough to "get too close" +% (as specified by lineskiplimit) to the line before it. The the prevdepth, +% lineskip limit, and lineskip optional parameters are not needed for the +% first object/line on a page (i.e., prevdepth <= -1000pt) where the simplier +% \topskip spacing rules are in effect. +% +% Offset is a manual adjustment that is added to the height calculations of +% object irrespective of the value of \prevdepth. It is useful when the top +% baselineskip will result in a noninteger unit height object placement even +% if the object itself has integral height. e.g., a footnotesize baselineskip +% is used before the object, thus an offset of, say -3pt, can be given as a +% correction. + +% Common combinations of these parameters include: +% +% top baselineskip: (and default values for offset, prevdepth, etc.) +% \topskip % for objects that appear at the top of a page +% \maxdimen % always ignore the height of the top line +% 0pt % always consider any positive height of the top line +% +% for objects to appear inline in normal text: +% top baselineskip = \baselineskip +% +% set prevdepth = -1000pt and top baselineskip = 0pt to consider the +% overall height of the object without any other external skip +% consideration + +\newif\if@IEEEquantizevspacestarform % flag to indicate star form +\newif\if@IEEEquantizevspaceuseoutdimenreg % flag to indicate output dimen register is to be used +% Use our own private registers because the object could contain a +% structure that uses the existing tmp scratch pad registers +\newdimen\@IEEEquantizeheightA +\newdimen\@IEEEquantizeheightB +\newdimen\@IEEEquantizeheightC +\newdimen\@IEEEquantizeprevdepth % need to save this early as can change +\newcount\@IEEEquantizemultiple +\newbox\@IEEEquantizeboxA + + +\def\@IEEEquantizevspace{\@ifnextchar [{\@IEEEquantizevspaceuseoutdimenregtrue\@@IEEEquantizevspace}{\@IEEEquantizevspaceuseoutdimenregfalse\@@IEEEquantizevspace[]}} + + +\long\def\@@IEEEquantizevspace[#1]#2{\relax +% acquire and store +% #1 optional output dimen register +% #2 object +\edef\@IEEEquantizeoutdimenreg{#1}\relax +% allow for object specifications that contain parameters +\@IEEEtrantmptoksA={#2}\relax +\long\edef\@IEEEquantizeobject{\the\@IEEEtrantmptoksA}\relax +\@ifnextchar [{\@@@IEEEquantizevspace}{\@@@IEEEquantizevspace[\relax]}} + +\long\def\@@@IEEEquantizevspace[#1]#2{\relax +% acquire and store +% [#1] optional object decl, is \relax if not given by user +% #2 top baselineskip +% allow for object decl specifications that have parameters +\@IEEEtrantmptoksA={#1}\relax +\long\edef\@IEEEquantizeobjectdecl{\the\@IEEEtrantmptoksA}\relax +\edef\@IEEEquantizetopbaselineskip{#2}\ivIEEEquantizevspace} + +% acquire optional argument set and store +% [offset][prevdepth][lineskip limit][lineskip] +\def\ivIEEEquantizevspace{\@ifnextchar [{\@vIEEEquantizevspace}{\@vIEEEquantizevspace[0pt]}} +\def\@vIEEEquantizevspace[#1]{\edef\@IEEEquantizeoffset{#1}\@ifnextchar [{\@viIEEEquantizevspace}{\@viIEEEquantizevspace[\prevdepth]}} +\def\@viIEEEquantizevspace[#1]{\@IEEEquantizeprevdepth=#1\relax\@ifnextchar [{\@viiIEEEquantizevspace}{\@viiIEEEquantizevspace[\lineskiplimit]}} +\def\@viiIEEEquantizevspace[#1]{\edef\@IEEEquantizelineskiplimit{#1}\@ifnextchar [{\@viiiIEEEquantizevspace}{\@viiiIEEEquantizevspace[\lineskip]}} +\def\@viiiIEEEquantizevspace[#1]{\edef\@IEEEquantizelineskip{#1}\@ixIEEEquantizevspace} + +% main routine +\def\@ixIEEEquantizevspace#1#2#3{\relax +\edef\@IEEEquantizeunitheight{#1}\relax +\edef\@IEEEquantizenomvspace{#2}\relax +\edef\@IEEEquantizeminvspace{#3}\relax +% \@IEEEquantizeoutdimenreg +% \@IEEEquantizeobject +% \@IEEEquantizeobjectdecl +% \@IEEEquantizetopbaselineskip +% \@IEEEquantizeoffset +% \@IEEEquantizeprevdepth +% \@IEEEquantizelineskiplimit +% \@IEEEquantizelineskip +% \@IEEEquantizeunitheight +% \@IEEEquantizenomvspace +% \@IEEEquantizeminvspace +% get overall height of object +\setbox\@IEEEquantizeboxA\vbox{\begingroup\@IEEEquantizeobjectdecl\@IEEEquantizeobject\relax\endgroup}\relax +\@IEEEquantizeheightA\ht\@IEEEquantizeboxA\relax +% get height of first line of object +\setbox\@IEEEquantizeboxA\vtop{\begingroup\@IEEEquantizeobjectdecl\@IEEEquantizeobject\relax\endgroup}\relax +\@IEEEquantizeheightB\ht\@IEEEquantizeboxA\relax +\ifdim\@IEEEquantizeprevdepth>-1000pt\relax % prevdepth > -1000pf means full baselineskip\lineskip rules in effect +% lineskip spacing rule takes effect if height of top line > baselineskip - prevdepth - lineskiplimit, +% otherwise the baselineskip rule is in effect and the height of the first line does not matter at all. +\@IEEEquantizeheightC=\@IEEEquantizetopbaselineskip\relax +\advance\@IEEEquantizeheightC-\@IEEEquantizeprevdepth\relax +\advance\@IEEEquantizeheightC-\@IEEEquantizelineskiplimit\relax % this works even though \@IEEEquantizelineskiplimit is a macro because TeX allows --10pt notation +\ifdim\@IEEEquantizeheightB>\@IEEEquantizeheightC\relax +% lineskip spacing rule is in effect i.e., the object is going to be shifted down relative to the +% baselineskip set position by its top line height (already a part of the total height) + prevdepth + lineskip - baselineskip +\advance\@IEEEquantizeheightA\@IEEEquantizeprevdepth\relax +\advance\@IEEEquantizeheightA\@IEEEquantizelineskip\relax +\advance\@IEEEquantizeheightA-\@IEEEquantizetopbaselineskip\relax +\else +% height of first line <= \@IEEEquantizetopbaselineskip - \@IEEEquantizeprevdepth - \@IEEEquantizelineskiplimit +% standard baselineskip rules are in effect, so don't consider height of first line +\advance\@IEEEquantizeheightA-\@IEEEquantizeheightB\relax +\fi +% +\else % prevdepth <= -1000pt, simplier \topskip type rules in effect +\ifdim\@IEEEquantizeheightB>\@IEEEquantizetopbaselineskip +% height of top line (already included in the total height) in excess of +% baselineskip is the amount it will be downshifted +\advance\@IEEEquantizeheightA-\@IEEEquantizetopbaselineskip\relax +\else +% height of first line is irrelevant, remove it +\advance\@IEEEquantizeheightA-\@IEEEquantizeheightB\relax +\fi +\fi % prevdepth <= -1000pt +% +% adjust height for any manual offset +\advance\@IEEEquantizeheightA\@IEEEquantizeoffset\relax +% add in nominal spacer +\advance\@IEEEquantizeheightA\@IEEEquantizenomvspace\relax +% check for nonzero unitheight +\@IEEEquantizeheightB=\@IEEEquantizeunitheight\relax +\ifnum\@IEEEquantizeheightB=0\relax +\@IEEEclspkgerror{IEEEquantizevspace unit height cannot be zero. Assuming 10pt.}% +{Division by zero is not allowed.} +\@IEEEquantizeheightB=10pt\relax +\fi +% get integer number of lines +\@IEEEquantizemultiple=\@IEEEquantizeheightA\relax +\divide\@IEEEquantizemultiple\@IEEEquantizeheightB\relax +% set A to contain the excess height over the \@IEEEquantizemultiple of lines +% A = height - multiple*unitheight +\@IEEEquantizeheightC\@IEEEquantizeheightB\relax +\multiply\@IEEEquantizeheightC\@IEEEquantizemultiple\relax +\advance\@IEEEquantizeheightA-\@IEEEquantizeheightC\relax +% set B to contain the height short of \@IEEEquantizemultiple+1 of lines +% B = unitheight - A +\advance\@IEEEquantizeheightB-\@IEEEquantizeheightA\relax +% choose A or B based on which is closer +\@IEEEquantizeheightC\@IEEEquantizenomvspace\relax +\ifdim\@IEEEquantizeheightA<\@IEEEquantizeheightB\relax +% C = nomvspace - A, go with lower +\advance\@IEEEquantizeheightC-\@IEEEquantizeheightA\relax +\else +% C = nomvspace + B, go with upper +\advance\@IEEEquantizeheightC\@IEEEquantizeheightB\relax +\fi +% if violate lower bound, use next integer bound +\ifdim\@IEEEquantizeheightC<\@IEEEquantizeminvspace\relax +% A + B = unitheight +\advance\@IEEEquantizeheightC\@IEEEquantizeheightA\relax +\advance\@IEEEquantizeheightC\@IEEEquantizeheightB\relax +\fi +% export object and spacer outside of group +\global\let\@IEEEquantizeobjectout\@IEEEquantizeobject\relax +\global\@IEEEquantizeheightC\@IEEEquantizeheightC\relax +\endgroup +\if@IEEEquantizevspaceuseoutdimenreg +\@IEEEquantizeoutdimenreg=\@IEEEquantizeheightC\relax +\else +\@IEEEquantizeobjectout\relax +\vskip\@IEEEquantizeheightC\relax +\fi} + + +% user command to disable all global assignments, possible use within object decl +\def\IEEEquantizedisableglobal{\let\global\relax +\let\gdef\def +\let\xdef\edef} +% user command to allow for the disabling of \thanks and other commands, possible use within object decl +\def\IEEEquantizedisabletitlecmds{\long\def\thanks##1{\relax}\relax +\long\def\IEEEcompsocitemizethanks##1{\relax}\def\newpage{\relax}} + + + + + +% V1.6 +% we allow the user access to the last part of the title area +% useful in emergencies such as when a different spacing is needed +% This text is NOT compensated for in the dynamic sizer. +\let\@IEEEaftertitletext=\relax +\long\def\IEEEaftertitletext#1{\def\@IEEEaftertitletext{#1}} + + +% V1.7 provide a way for users to enter abstract and keywords +% into the onecolumn title are. This text is compensated for +% in the dynamic sizer. +\let\@IEEEtitleabstractindextext=\relax +\long\def\IEEEtitleabstractindextext#1{\def\@IEEEtitleabstractindextext{#1}} + +% V1.7 provide a way for users to get the \@IEEEtitleabstractindextext if +% not in compsoc or transmag journal mode - this way abstract and keywords +% can still be placed in their conventional position if not in those modes. +\def\IEEEdisplaynontitleabstractindextext{% +% display for all conference formats +\ifCLASSOPTIONconference\@IEEEtitleabstractindextext\relax +\else% non-conferences + % V1.8a display for all technotes + \ifCLASSOPTIONtechnote\@IEEEtitleabstractindextext\relax + % V1.8a add diamond line after abstract and index terms for compsoc technotes + \@IEEEcompsoconly{\noindent\hfill\IEEEcompsocdiamondline\hfill\hbox{}\par}\relax + \else % non-conferences and non-technotes + \ifCLASSOPTIONcompsoc% display if not compsoc and not transmag + \else + \ifCLASSOPTIONtransmag + \else% not compsoc journal nor transmag journal + \@IEEEtitleabstractindextext\relax + \fi + \fi + \fi +\fi} + + +% command to allow alteration of baselinestretch, but only if the current +% baselineskip is unity. Used to tweak the compsoc abstract and keywords line spacing. +\def\@IEEEtweakunitybaselinestretch#1{{\def\baselinestretch{1}\selectfont +\global\@tempskipa\baselineskip}\ifnum\@tempskipa=\baselineskip% +\def\baselinestretch{#1}\selectfont\fi\relax} + + +% abstract and keywords are in \small, except +% for 9pt docs in which they are in \footnotesize +% Because 9pt docs use an 8pt footnotesize, \small +% becomes a rather awkward 8.5pt +\def\@IEEEabskeysecsize{\small} +\ifx\CLASSOPTIONpt\@IEEEptsizenine + \def\@IEEEabskeysecsize{\footnotesize} +\fi + +% compsoc journals use \footnotesize, compsoc conferences use normalsize +\@IEEEcompsoconly{\def\@IEEEabskeysecsize{\footnotesize}} +\@IEEEcompsocconfonly{\def\@IEEEabskeysecsize{\small}} + + +% V1.6 have abstract and keywords strip leading spaces, pars and newlines +% so that spacing is more tightly controlled. +\def\abstract{\normalfont + \if@twocolumn + \@IEEEabskeysecsize\bfseries\textit{\abstractname}---\relax + \else + \bgroup\par\addvspace{0.5\baselineskip}\centering\vspace{-1.78ex}\@IEEEabskeysecsize\textbf{\abstractname}\par\addvspace{0.5\baselineskip}\egroup\quotation\@IEEEabskeysecsize + \fi\@IEEEgobbleleadPARNLSP} +% V1.6 The IEEE wants only 1 pica from end of abstract to introduction heading when in +% conference mode (the heading already has this much above it) +\def\endabstract{\relax\ifCLASSOPTIONconference\vspace{0ex}\else\vspace{1.34ex}\fi\par\if@twocolumn\else\endquotation\fi + \normalfont\normalsize} + +\def\IEEEkeywords{\normalfont + \if@twocolumn + \@IEEEabskeysecsize\bfseries\textit{\IEEEkeywordsname}---\relax + \else + \bgroup\par\addvspace{0.5\baselineskip}\centering\@IEEEabskeysecsize\textbf{\IEEEkeywordsname}\par\addvspace{0.5\baselineskip}\egroup\quotation\@IEEEabskeysecsize + \fi\@IEEEgobbleleadPARNLSP} +\def\endIEEEkeywords{\relax\ifCLASSOPTIONtechnote\vspace{1.34ex}\else\vspace{0.67ex}\fi + \par\if@twocolumn\else\endquotation\fi% + \normalfont\normalsize} + +% V1.7 compsoc keywords index terms +\ifCLASSOPTIONcompsoc + \ifCLASSOPTIONconference% compsoc conference +\def\abstract{\normalfont\@IEEEtweakunitybaselinestretch{1.15}\bfseries + \if@twocolumn + \@IEEEabskeysecsize\noindent\textit{\abstractname}---\relax + \else + \bgroup\par\addvspace{0.5\baselineskip}\centering\vspace{-1.78ex}\@IEEEabskeysecsize\textbf{\abstractname}\par\addvspace{0.5\baselineskip}\egroup\quotation\@IEEEabskeysecsize% + \fi\@IEEEgobbleleadPARNLSP} +\def\IEEEkeywords{\normalfont\@IEEEtweakunitybaselinestretch{1.15}\bfseries + \if@twocolumn + \@IEEEabskeysecsize\vskip 0.5\baselineskip plus 0.25\baselineskip minus 0.25\baselineskip\noindent + \textit{\IEEEkeywordsname}---\relax + \else + \bgroup\par\addvspace{0.5\baselineskip}\centering\@IEEEabskeysecsize\textbf{\IEEEkeywordsname}\par\addvspace{0.5\baselineskip}\egroup\quotation\@IEEEabskeysecsize% + \fi\@IEEEgobbleleadPARNLSP} + \else% compsoc not conference +\def\abstract{\normalfont\@IEEEtweakunitybaselinestretch{1.15}\sffamily + \if@twocolumn + \@IEEEabskeysecsize\noindent\textbf{\abstractname}---\relax + \else + \bgroup\par\addvspace{0.5\baselineskip}\centering\vspace{-1.78ex}\@IEEEabskeysecsize\textbf{\abstractname}\par\addvspace{0.5\baselineskip}\egroup\quotation\@IEEEabskeysecsize% + \fi\@IEEEgobbleleadPARNLSP} +\def\IEEEkeywords{\normalfont\@IEEEtweakunitybaselinestretch{1.15}\sffamily + \if@twocolumn + \@IEEEabskeysecsize\vskip 0.5\baselineskip plus 0.25\baselineskip minus 0.25\baselineskip\noindent + \textbf{\IEEEkeywordsname}---\relax + \else + \bgroup\par\addvspace{0.5\baselineskip}\centering\@IEEEabskeysecsize\textbf{\IEEEkeywordsname}\par\addvspace{0.5\baselineskip}\egroup\quotation\@IEEEabskeysecsize% + \fi\@IEEEgobbleleadPARNLSP} + \fi +\fi + +% V1.8 transmag keywords index terms +% no abstract name, use indentation +\ifCLASSOPTIONtransmag +\def\abstract{\normalfont\parindent 1em\relax + \if@twocolumn + \@IEEEabskeysecsize\bfseries\indent + \else + \bgroup\par\addvspace{0.5\baselineskip}\centering\vspace{-1.78ex}\@IEEEabskeysecsize + \textbf{\abstractname}\par\addvspace{0.5\baselineskip}\egroup\quotation\@IEEEabskeysecsize + \fi\@IEEEgobbleleadPARNLSP} + +\def\IEEEkeywords{\normalfont\parindent 1em\relax + \if@twocolumn + \@IEEEabskeysecsize\vspace{1\baselineskip}\bfseries\indent\textit{\IEEEkeywordsname}---\relax + \else + \bgroup\par\vspace{1\baselineskip}\centering\@IEEEabskeysecsize + \textbf{\IEEEkeywordsname}\par\addvspace{0.5\baselineskip}\egroup\quotation\@IEEEabskeysecsize + \fi\@IEEEgobbleleadPARNLSP} +\fi + + + +% gobbles all leading \, \\ and \par, upon finding first token that +% is not a \ , \\ or a \par, it ceases and returns that token +% +% used to strip leading \, \\ and \par from the input +% so that such things in the beginning of an environment will not +% affect the formatting of the text +\long\def\@IEEEgobbleleadPARNLSP#1{\let\@IEEEswallowthistoken=0% +\let\@IEEEgobbleleadPARNLSPtoken#1% +\let\@IEEEgobbleleadPARtoken=\par% +\let\@IEEEgobbleleadNLtoken=\\% +\let\@IEEEgobbleleadSPtoken=\ % +\def\@IEEEgobbleleadSPMACRO{\ }% +\ifx\@IEEEgobbleleadPARNLSPtoken\@IEEEgobbleleadPARtoken% +\let\@IEEEswallowthistoken=1% +\fi% +\ifx\@IEEEgobbleleadPARNLSPtoken\@IEEEgobbleleadNLtoken% +\let\@IEEEswallowthistoken=1% +\fi% +\ifx\@IEEEgobbleleadPARNLSPtoken\@IEEEgobbleleadSPtoken% +\let\@IEEEswallowthistoken=1% +\fi% +% a control space will come in as a macro +% when it is the last one on a line +\ifx\@IEEEgobbleleadPARNLSPtoken\@IEEEgobbleleadSPMACRO% +\let\@IEEEswallowthistoken=1% +\fi% +% if we have to swallow this token, do so and taste the next one +% else spit it out and stop gobbling +\ifx\@IEEEswallowthistoken 1\let\@IEEEnextgobbleleadPARNLSP=\@IEEEgobbleleadPARNLSP\else% +\let\@IEEEnextgobbleleadPARNLSP=#1\fi% +\@IEEEnextgobbleleadPARNLSP}% + + + + +% TITLING OF SECTIONS +\def\@IEEEsectpunct{:\ \,} % Punctuation after run-in section heading (headings which are + % part of the paragraphs), need little bit more than a single space + % spacing from section number to title +% compsoc conferences use regular period/space punctuation +\ifCLASSOPTIONcompsoc +\ifCLASSOPTIONconference +\def\@IEEEsectpunct{.\ } +\fi\fi + + +\def\@seccntformat#1{\csname the#1dis\endcsname\hskip 0.5em\relax} + +\ifCLASSOPTIONcompsoc +% compsoc journals need extra spacing +\ifCLASSOPTIONconference\else +\def\@seccntformat#1{\csname the#1dis\endcsname\hskip 1em\relax} +\fi\fi + +%v1.7 put {} after #6 to allow for some types of user font control +%and use \@@par rather than \par +\def\@sect#1#2#3#4#5#6[#7]#8{% + \ifnum #2>\c@secnumdepth + \let\@svsec\@empty + \else + \refstepcounter{#1}% + % load section label and spacer into \@svsec + \protected@edef\@svsec{\@seccntformat{#1}\relax}% + \fi% + \@tempskipa #5\relax + \ifdim \@tempskipa>\z@% tempskipa determines whether is treated as a high + \begingroup #6{\relax% or low level heading + \noindent % subsections are NOT indented + % print top level headings. \@svsec is label, #8 is heading title + % The IEEE does not block indent the section title text, it flows like normal + {\hskip #3\relax\@svsec}{\interlinepenalty \@M #8\@@par}}% + \endgroup + \addcontentsline{toc}{#1}{\ifnum #2>\c@secnumdepth\relax\else + \protect\numberline{\csname the#1\endcsname}\fi#7}% + \else % printout low level headings + % svsechd seems to swallow the trailing space, protect it with \mbox{} + % got rid of sectionmark stuff + \def\@svsechd{#6{\hskip #3\relax\@svsec #8\@IEEEsectpunct\mbox{}}% + \addcontentsline{toc}{#1}{\ifnum #2>\c@secnumdepth\relax\else + \protect\numberline{\csname the#1\endcsname}\fi#7}}% + \fi%skip down + \@xsect{#5}} + + +% section* handler +%v1.7 put {} after #4 to allow for some types of user font control +%and use \@@par rather than \par +\def\@ssect#1#2#3#4#5{\@tempskipa #3\relax + \ifdim \@tempskipa>\z@ + %\begingroup #4\@hangfrom{\hskip #1}{\interlinepenalty \@M #5\par}\endgroup + % The IEEE does not block indent the section title text, it flows like normal + \begingroup \noindent #4{\relax{\hskip #1}{\interlinepenalty \@M #5\@@par}}\endgroup + % svsechd swallows the trailing space, protect it with \mbox{} + \else \def\@svsechd{#4{\hskip #1\relax #5\@IEEEsectpunct\mbox{}}}\fi + \@xsect{#3}} + + +%% SECTION heading spacing and font +%% +% arguments are: #1 - sectiontype name +% (for \@sect) #2 - section level +% #3 - section heading indent +% #4 - top separation (absolute value used, neg indicates not to indent main text) +% If negative, make stretch parts negative too! +% #5 - (absolute value used) positive: bottom separation after heading, +% negative: amount to indent main text after heading +% Both #4 and #5 negative means to indent main text and use negative top separation +% #6 - font control +% You've got to have \normalfont\normalsize in the font specs below to prevent +% trouble when you do something like: +% \section{Note}{\ttfamily TT-TEXT} is known to ... +% The IEEE sometimes REALLY stretches the area before a section +% heading by up to about 0.5in. However, it may not be a good +% idea to let LaTeX have quite this much rubber. +\ifCLASSOPTIONconference% +% The IEEE wants section heading spacing to decrease for conference mode +\def\section{\@startsection{section}{1}{\z@}{1.5ex plus 1.5ex minus 0.5ex}% +{0.7ex plus 1ex minus 0ex}{\normalfont\normalsize\centering\scshape}}% +\def\subsection{\@startsection{subsection}{2}{\z@}{1.5ex plus 1.5ex minus 0.5ex}% +{0.7ex plus .5ex minus 0ex}{\normalfont\normalsize\itshape}}% +\else % for journals +\def\section{\@startsection{section}{1}{\z@}{3.0ex plus 1.5ex minus 1.5ex}% V1.6 3.0ex from 3.5ex +{0.7ex plus 1ex minus 0ex}{\normalfont\normalsize\centering\scshape}}% +\def\subsection{\@startsection{subsection}{2}{\z@}{3.5ex plus 1.5ex minus 1.5ex}% +{0.7ex plus .5ex minus 0ex}{\normalfont\normalsize\itshape}}% +\fi + +% for both journals and conferences +% decided to put in a little rubber above the section, might help somebody +\def\subsubsection{\@startsection{subsubsection}{3}{\parindent}{0ex plus 0.1ex minus 0.1ex}% +{0ex}{\normalfont\normalsize\itshape}}% +\def\paragraph{\@startsection{paragraph}{4}{2\parindent}{0ex plus 0.1ex minus 0.1ex}% +{0ex}{\normalfont\normalsize\itshape}}% + + +% compsoc +\ifCLASSOPTIONcompsoc +\ifCLASSOPTIONconference +% compsoc conference +\def\section{\@startsection{section}{1}{\z@}{1\baselineskip plus 0.25\baselineskip minus 0.25\baselineskip}% +{1\baselineskip plus 0.25\baselineskip minus 0.25\baselineskip}{\normalfont\large\bfseries}}% +\def\subsection{\@startsection{subsection}{2}{\z@}{1\baselineskip plus 0.25\baselineskip minus 0.25\baselineskip}% +{1\baselineskip plus 0.25\baselineskip minus 0.25\baselineskip}{\normalfont\sublargesize\bfseries}}% +\def\subsubsection{\@startsection{subsubsection}{3}{\z@}{1\baselineskip plus 0.25\baselineskip minus 0.25\baselineskip}% +{0ex}{\normalfont\normalsize\bfseries}}% +\def\paragraph{\@startsection{paragraph}{4}{2\parindent}{0ex plus 0.1ex minus 0.1ex}% +{0ex}{\normalfont\normalsize}}% +\else% compsoc journals +% use negative top separation as compsoc journals do not indent paragraphs after section titles +\def\section{\@startsection{section}{1}{\z@}{-3.5ex plus -2ex minus -1.5ex}% +{0.7ex plus 1ex minus 0ex}{\normalfont\sublargesize\sffamily\bfseries\scshape}}% +% Note that subsection and smaller may not be correct for the Computer Society, +% I have to look up an example. +\def\subsection{\@startsection{subsection}{2}{\z@}{-3.5ex plus -1.5ex minus -1.5ex}% +{0.7ex plus .5ex minus 0ex}{\normalfont\normalsize\sffamily\bfseries}}% +\def\subsubsection{\@startsection{subsubsection}{3}{\z@}{-2.5ex plus -1ex minus -1ex}% +{0.5ex plus 0.5ex minus 0ex}{\normalfont\normalsize\sffamily\itshape}}% +\def\paragraph{\@startsection{paragraph}{4}{2\parindent}{-0ex plus -0.1ex minus -0.1ex}% +{0ex}{\normalfont\normalsize}}% +\fi\fi + +% transmag +\ifCLASSOPTIONtransmag +\def\subsection{\@startsection{subsection}{2}{0.75\parindent}{3.5ex plus 1.5ex minus 1.5ex}% +{0.7ex plus .5ex minus 0ex}{\normalfont\normalsize\itshape}}% +\def\subsubsection{\@startsection{subsubsection}{3}{1.25\parindent}{0.1ex plus 0.1ex minus 0.1ex}% +{0.1ex}{\normalfont\normalsize\itshape}}% +\fi + + +% V1.8a provide for a raised line Introduction section for use with Computer +% Society papers. We have to remove any spacing glue after the section +% heading and then remove the blank line for the new paragraph after it. +% LaTeX's section handler alters \everypar and we need to propogate those +% changes outside of the \parbox lest there be spacing problems at the top +% of the next section. +\def\IEEEraisesectionheading#1{\noindent\raisebox{1.5\baselineskip}[0pt][0pt]{\parbox[b]{\columnwidth}{#1\unskip\global\everypar=\everypar}}\vspace{-1\baselineskip}\vspace{-\parskip}\par} + + + +%% ENVIRONMENTS +% "box" symbols at end of proofs +\def\IEEEQEDclosed{\mbox{\rule[0pt]{1.3ex}{1.3ex}}} % for a filled box +% V1.6 some journals use an open box instead that will just fit around a closed one +\def\IEEEQEDopen{{\setlength{\fboxsep}{0pt}\setlength{\fboxrule}{0.2pt}\fbox{\rule[0pt]{0pt}{1.3ex}\rule[0pt]{1.3ex}{0pt}}}} +\ifCLASSOPTIONcompsoc +\def\IEEEQED{\IEEEQEDopen} % default to open for compsoc +\else +\def\IEEEQED{\IEEEQEDclosed} % otherwise default to closed +\fi + +%V1.8 flag to indicate if QED symbol is to be shown +\newif\if@IEEEQEDshow \@IEEEQEDshowtrue +\def\IEEEproofindentspace{2\parindent}% V1.8 allow user to change indentation amount if desired +% v1.7 name change to avoid namespace collision with amsthm. Also add support +% for an optional argument. +\def\IEEEproof{\@ifnextchar[{\@IEEEproof}{\@IEEEproof[\IEEEproofname]}} +\def\@IEEEproof[#1]{\@IEEEQEDshowtrue\par\noindent\hspace{\IEEEproofindentspace}{\itshape #1: }} +\def\endIEEEproof{\if@IEEEQEDshow\hspace*{\fill}\nobreakspace\IEEEQED\fi\par} +% qedhere for equation environments, similar to AMS \qedhere +\def\IEEEQEDhereeqn{\global\@IEEEQEDshowfalse\eqno\let\eqno\relax\let\leqno\relax + \let\veqno\relax\hbox{\IEEEQED}} +% IEEE style qedhere for IEEEeqnarray and other environments +\def\IEEEQEDhere{\global\@IEEEQEDshowfalse\IEEEQED} +% command to disable QED at end of IEEEproof +\def\IEEEQEDoff{\global\@IEEEQEDshowfalse} + + +%\itemindent is set to \z@ by list, so define new temporary variable +\newdimen\@IEEEtmpitemindent + +\ifCLASSOPTIONcompsoc +% V1.8a compsoc uses bold theorem titles, a period instead of a colon, vertical spacing, and hanging indentation +% V1.8 allow long theorem names to break across lines. +% Thanks to Miquel Payaro for reporting this. +\def\@begintheorem#1#2{\@IEEEtmpitemindent\itemindent\relax + \topsep 0.2\@IEEEnormalsizeunitybaselineskip plus 0.26\@IEEEnormalsizeunitybaselineskip minus 0.05\@IEEEnormalsizeunitybaselineskip + \rmfamily\trivlist\hangindent\parindent% + \item[]\textit{\bfseries\noindent #1\ #2.} \itemindent\@IEEEtmpitemindent\relax} +\def\@opargbegintheorem#1#2#3{\@IEEEtmpitemindent\itemindent\relax +\topsep 0.2\@IEEEnormalsizeunitybaselineskip plus 0.26\@IEEEnormalsizeunitybaselineskip minus 0.05\@IEEEnormalsizeunitybaselineskip +\rmfamily\trivlist\hangindent\parindent% +% V1.6 The IEEE is back to using () around theorem names which are also in italics +% Thanks to Christian Peel for reporting this. + \item[]\textit{\bfseries\noindent #1\ #2\ (#3).} \itemindent\@IEEEtmpitemindent\relax} +% V1.7 remove bogus \unskip that caused equations in theorems to collide with +% lines below. +\def\@endtheorem{\endtrivlist\vskip 0.25\@IEEEnormalsizeunitybaselineskip plus 0.26\@IEEEnormalsizeunitybaselineskip minus 0.05\@IEEEnormalsizeunitybaselineskip} +\else +% +% noncompsoc +% +% V1.8 allow long theorem names to break across lines. +% Thanks to Miquel Payaro for reporting this. +\def\@begintheorem#1#2{\@IEEEtmpitemindent\itemindent\relax\topsep 0pt\rmfamily\trivlist% + \item[]\textit{\indent #1\ #2:} \itemindent\@IEEEtmpitemindent\relax} +\def\@opargbegintheorem#1#2#3{\@IEEEtmpitemindent\itemindent\relax\topsep 0pt\rmfamily \trivlist% +% V1.6 The IEEE is back to using () around theorem names which are also in italics +% Thanks to Christian Peel for reporting this. + \item[]\textit{\indent #1\ #2\ (#3):} \itemindent\@IEEEtmpitemindent\relax} +% V1.7 remove bogus \unskip that caused equations in theorems to collide with +% lines below. +\def\@endtheorem{\endtrivlist} +\fi + + + +% V1.6 +% display command for the section the theorem is in - so that \thesection +% is not used as this will be in Roman numerals when we want arabic. +% LaTeX2e uses \def\@thmcounter#1{\noexpand\arabic{#1}} for the theorem number +% (second part) display and \def\@thmcountersep{.} as a separator. +% V1.7 intercept calls to the section counter and reroute to \@IEEEthmcounterinsection +% to allow \appendix(ices} to override as needed. +% +% special handler for sections, allows appendix(ices) to override +\gdef\@IEEEthmcounterinsection#1{\arabic{#1}} +% string macro +\edef\@IEEEstringsection{section} + +% redefine the #1#2[#3] form of newtheorem to use a hook to \@IEEEthmcounterinsection +% if section in_counter is used +\def\@xnthm#1#2[#3]{% + \expandafter\@ifdefinable\csname #1\endcsname + {\@definecounter{#1}\@newctr{#1}[#3]% + \edef\@IEEEstringtmp{#3} + \ifx\@IEEEstringtmp\@IEEEstringsection + \expandafter\xdef\csname the#1\endcsname{% + \noexpand\@IEEEthmcounterinsection{#3}\@thmcountersep + \@thmcounter{#1}}% + \else + \expandafter\xdef\csname the#1\endcsname{% + \expandafter\noexpand\csname the#3\endcsname \@thmcountersep + \@thmcounter{#1}}% + \fi + \global\@namedef{#1}{\@thm{#1}{#2}}% + \global\@namedef{end#1}{\@endtheorem}}} + + + +%% SET UP THE DEFAULT PAGESTYLE +\pagestyle{headings} +\pagenumbering{arabic} + +% normally the page counter starts at 1 +\setcounter{page}{1} +% however, for peerreview the cover sheet is page 0 or page -1 +% (for duplex printing) +\ifCLASSOPTIONpeerreview + \if@twoside + \setcounter{page}{-1} + \else + \setcounter{page}{0} + \fi +\fi + +% standard book class behavior - let bottom line float up and down as +% needed when single sided +\ifCLASSOPTIONtwoside\else\raggedbottom\fi +% if two column - turn on twocolumn, allow word spacings to stretch more and +% enforce a rigid position for the last lines +\ifCLASSOPTIONtwocolumn +% the peer review option delays invoking twocolumn + \ifCLASSOPTIONpeerreview\else + \twocolumn + \fi +\sloppy +\flushbottom +\fi + + + + +% \APPENDIX and \APPENDICES definitions + +% This is the \@ifmtarg command from the LaTeX ifmtarg package +% by Peter Wilson (CUA) and Donald Arseneau +% \@ifmtarg is used to determine if an argument to a command +% is present or not. +% For instance: +% \@ifmtarg{#1}{\typeout{empty}}{\typeout{has something}} +% \@ifmtarg is used with our redefined \section command if +% \appendices is invoked. +% The command \section will behave slightly differently depending +% on whether the user specifies a title: +% \section{My appendix title} +% or not: +% \section{} +% This way, we can eliminate the blank lines where the title +% would be, and the unneeded : after Appendix in the table of +% contents +\begingroup +\catcode`\Q=3 +\long\gdef\@ifmtarg#1{\@xifmtarg#1QQ\@secondoftwo\@firstoftwo\@nil} +\long\gdef\@xifmtarg#1#2Q#3#4#5\@nil{#4} +\endgroup +% end of \@ifmtarg defs + + +% V1.7 +% command that allows the one time saving of the original definition +% of section to \@IEEEappendixsavesection for \appendix or \appendices +% we don't save \section here as it may be redefined later by other +% packages (hyperref.sty, etc.) +\def\@IEEEsaveoriginalsectiononce{\let\@IEEEappendixsavesection\section +\let\@IEEEsaveoriginalsectiononce\relax} + +% neat trick to grab and process the argument from \section{argument} +% we process differently if the user invoked \section{} with no +% argument (title) +% note we reroute the call to the old \section* +\def\@IEEEprocessthesectionargument#1{% +\@ifmtarg{#1}{% +\@IEEEappendixsavesection*{\appendixname\nobreakspace\thesectiondis}% +\addcontentsline{toc}{section}{\appendixname\nobreakspace\thesection}}{% +\@IEEEappendixsavesection*{\appendixname\nobreakspace\thesectiondis\\* #1}% +\addcontentsline{toc}{section}{\appendixname\nobreakspace\thesection: #1}}} + +% we use this if the user calls \section{} after +% \appendix-- which has no meaning. So, we ignore the +% command and its argument. Then, warn the user. +\def\@IEEEdestroythesectionargument#1{\typeout{** WARNING: Ignoring useless +\protect\section\space in Appendix (line \the\inputlineno).}} + + +% remember \thesection forms will be displayed in \ref calls +% and in the Table of Contents. +% The \sectiondis form is used in the actual heading itself + +% appendix command for one single appendix +% normally has no heading. However, if you want a +% heading, you can do so via the optional argument: +% \appendix[Optional Heading] +\def\appendix{\relax} +\renewcommand{\appendix}[1][]{\@IEEEsaveoriginalsectiononce\par + % v1.6 keep hyperref's identifiers unique + \gdef\theHsection{Appendix.A}% + % v1.6 adjust hyperref's string name for the section + \xdef\Hy@chapapp{appendix}% + \setcounter{section}{0}% + \setcounter{subsection}{0}% + \setcounter{subsubsection}{0}% + \setcounter{paragraph}{0}% + \gdef\thesection{A}% + \gdef\thesectiondis{}% + \gdef\thesubsection{\Alph{subsection}}% + \gdef\@IEEEthmcounterinsection##1{A} + \refstepcounter{section}% update the \ref counter + \@ifmtarg{#1}{\@IEEEappendixsavesection*{\appendixname}% + \addcontentsline{toc}{section}{\appendixname}}{% + \@IEEEappendixsavesection*{\appendixname\nobreakspace\\* #1}% + \addcontentsline{toc}{section}{\appendixname: #1}}% + % redefine \section command for appendix + % leave \section* as is + \def\section{\@ifstar{\@IEEEappendixsavesection*}{% + \@IEEEdestroythesectionargument}}% throw out the argument + % of the normal form +} + + + +% appendices command for multiple appendices +% user then calls \section with an argument (possibly empty) to +% declare the individual appendices +\def\appendices{\@IEEEsaveoriginalsectiononce\par + % v1.6 keep hyperref's identifiers unique + \gdef\theHsection{Appendix.\Alph{section}}% + % v1.6 adjust hyperref's string name for the section + \xdef\Hy@chapapp{appendix}% + \setcounter{section}{-1}% we want \refstepcounter to use section 0 + \setcounter{subsection}{0}% + \setcounter{subsubsection}{0}% + \setcounter{paragraph}{0}% + \ifCLASSOPTIONromanappendices% + \gdef\thesection{\Roman{section}}% + \gdef\thesectiondis{\Roman{section}}% + \@IEEEcompsocconfonly{\gdef\thesectiondis{\Roman{section}.}}% + \gdef\@IEEEthmcounterinsection##1{A\arabic{##1}} + \else% + \gdef\thesection{\Alph{section}}% + \gdef\thesectiondis{\Alph{section}}% + \@IEEEcompsocconfonly{\gdef\thesectiondis{\Alph{section}.}}% + \gdef\@IEEEthmcounterinsection##1{\Alph{##1}} + \fi% + \refstepcounter{section}% update the \ref counter + \setcounter{section}{0}% NEXT \section will be the FIRST appendix + % redefine \section command for appendices + % leave \section* as is + \def\section{\@ifstar{\@IEEEappendixsavesection*}{% process the *-form + \refstepcounter{section}% or is a new section so, + \@IEEEprocessthesectionargument}}% process the argument + % of the normal form +} + + + +% V1.7 compoc uses nonbold drop cap and small caps word style +\ifCLASSOPTIONcompsoc + \def\IEEEPARstartFONTSTYLE{\mdseries} + \def\IEEEPARstartWORDFONTSTYLE{\scshape} + \def\IEEEPARstartWORDCAPSTYLE{\relax} +\fi +% +% +% \IEEEPARstart +% Definition for the big two line drop cap letter at the beginning of the +% first paragraph of journal papers. The first argument is the first letter +% of the first word, the second argument is the remaining letters of the +% first word which will be rendered in upper case. +% In V1.6 this has been completely rewritten to: +% +% 1. no longer have problems when the user begins an environment +% within the paragraph that uses \IEEEPARstart. +% 2. auto-detect and use the current font family +% 3. revise handling of the space at the end of the first word so that +% interword glue will now work as normal. +% 4. produce correctly aligned edges for the (two) indented lines. +% +% We generalize things via control macros - playing with these is fun too. +% +% V1.7 added more control macros to make it easy for IEEEtrantools.sty users +% to change the font style. +% +% the number of lines that are indented to clear it +% may need to increase if using decenders +\providecommand{\IEEEPARstartDROPLINES}{2} +% minimum number of lines left on a page to allow a \@IEEEPARstart +% Does not take into consideration rubber shrink, so it tends to +% be overly cautious +\providecommand{\IEEEPARstartMINPAGELINES}{2} +% V1.7 the height of the drop cap is adjusted to match the height of this text +% in the current font (when \IEEEPARstart is called). +\providecommand{\IEEEPARstartHEIGHTTEXT}{T} +% the depth the letter is lowered below the baseline +% the height (and size) of the letter is determined by the sum +% of this value and the height of the \IEEEPARstartHEIGHTTEXT in the current +% font. It is a good idea to set this value in terms of the baselineskip +% so that it can respond to changes therein. +\providecommand{\IEEEPARstartDROPDEPTH}{1.1\baselineskip} +% V1.7 the font the drop cap will be rendered in, +% can take zero or one argument. +\providecommand{\IEEEPARstartFONTSTYLE}{\bfseries} +% V1.7 any additional, non-font related commands needed to modify +% the drop cap letter, can take zero or one argument. +\providecommand{\IEEEPARstartCAPSTYLE}{\MakeUppercase} +% V1.7 the font that will be used to render the rest of the word, +% can take zero or one argument. +\providecommand{\IEEEPARstartWORDFONTSTYLE}{\relax} +% V1.7 any additional, non-font related commands needed to modify +% the rest of the word, can take zero or one argument. +\providecommand{\IEEEPARstartWORDCAPSTYLE}{\MakeUppercase} +% This is the horizontal separation distance from the drop letter to the main text. +% Lengths that depend on the font (e.g., ex, em, etc.) will be referenced +% to the font that is active when \IEEEPARstart is called. +\providecommand{\IEEEPARstartSEP}{0.15em} +% V1.7 horizontal offset applied to the left of the drop cap. +\providecommand{\IEEEPARstartHOFFSET}{0em} +% V1.7 Italic correction command applied at the end of the drop cap. +\providecommand{\IEEEPARstartITLCORRECT}{\/} + +% width of the letter output, set globally. Can be used in \IEEEPARstartSEP +% or \IEEEPARstartHOFFSET, but not the height lengths. +\newdimen\IEEEPARstartletwidth +\IEEEPARstartletwidth 0pt\relax + +% definition of \IEEEPARstart +% THIS IS A CONTROLLED SPACING AREA, DO NOT ALLOW SPACES WITHIN THESE LINES +% +% The token \@IEEEPARstartfont will be globally defined after the first use +% of \IEEEPARstart and will be a font command which creates the big letter +% The first argument is the first letter of the first word and the second +% argument is the rest of the first word(s). +\def\IEEEPARstart#1#2{\par{% +% if this page does not have enough space, break it and lets start +% on a new one +\@IEEEtranneedspace{\IEEEPARstartMINPAGELINES\baselineskip}{\relax}% +% V1.7 move this up here in case user uses \textbf for \IEEEPARstartFONTSTYLE +% which uses command \leavevmode which causes an unwanted \indent to be issued +\noindent +% calculate the desired height of the big letter +% it extends from the top of \IEEEPARstartHEIGHTTEXT in the current font +% down to \IEEEPARstartDROPDEPTH below the current baseline +\settoheight{\@IEEEtrantmpdimenA}{\IEEEPARstartHEIGHTTEXT}% +\addtolength{\@IEEEtrantmpdimenA}{\IEEEPARstartDROPDEPTH}% +% extract the name of the current font in bold +% and place it in \@IEEEPARstartFONTNAME +\def\@IEEEPARstartGETFIRSTWORD##1 ##2\relax{##1}% +{\IEEEPARstartFONTSTYLE{\selectfont\edef\@IEEEPARstartFONTNAMESPACE{\fontname\font\space}% +\xdef\@IEEEPARstartFONTNAME{\expandafter\@IEEEPARstartGETFIRSTWORD\@IEEEPARstartFONTNAMESPACE\relax}}}% +% define a font based on this name with a point size equal to the desired +% height of the drop letter +\font\@IEEEPARstartsubfont\@IEEEPARstartFONTNAME\space at \@IEEEtrantmpdimenA\relax% +% save this value as a counter (integer) value (sp points) +\@IEEEtrantmpcountA=\@IEEEtrantmpdimenA% +% now get the height of the actual letter produced by this font size +\settoheight{\@IEEEtrantmpdimenB}{\@IEEEPARstartsubfont\IEEEPARstartCAPSTYLE{#1}}% +% If something bogus happens like the first argument is empty or the +% current font is strange, do not allow a zero height. +\ifdim\@IEEEtrantmpdimenB=0pt\relax% +\typeout{** WARNING: IEEEPARstart drop letter has zero height! (line \the\inputlineno)}% +\typeout{ Forcing the drop letter font size to 10pt.}% +\@IEEEtrantmpdimenB=10pt% +\fi% +% and store it as a counter +\@IEEEtrantmpcountB=\@IEEEtrantmpdimenB% +% Since a font size doesn't exactly correspond to the height of the capital +% letters in that font, the actual height of the letter, \@IEEEtrantmpcountB, +% will be less than that desired, \@IEEEtrantmpcountA +% we need to raise the font size, \@IEEEtrantmpdimenA +% by \@IEEEtrantmpcountA / \@IEEEtrantmpcountB +% But, TeX doesn't have floating point division, so we have to use integer +% division. Hence the use of the counters. +% We need to reduce the denominator so that the loss of the remainder will +% have minimal affect on the accuracy of the result +\divide\@IEEEtrantmpcountB by 200% +\divide\@IEEEtrantmpcountA by \@IEEEtrantmpcountB% +% Then reequalize things when we use TeX's ability to multiply by +% floating point values +\@IEEEtrantmpdimenB=0.005\@IEEEtrantmpdimenA% +\multiply\@IEEEtrantmpdimenB by \@IEEEtrantmpcountA% +% \@IEEEPARstartfont is globaly set to the calculated font of the big letter +% We need to carry this out of the local calculation area to to create the +% big letter. +\global\font\@IEEEPARstartfont\@IEEEPARstartFONTNAME\space at \@IEEEtrantmpdimenB% +% Now set \@IEEEtrantmpdimenA to the width of the big letter +% We need to carry this out of the local calculation area to set the +% hanging indent +\settowidth{\global\@IEEEtrantmpdimenA}{\@IEEEPARstartfont +\IEEEPARstartCAPSTYLE{#1\IEEEPARstartITLCORRECT}}}% +% end of the isolated calculation environment +\global\IEEEPARstartletwidth\@IEEEtrantmpdimenA\relax% +% add in the extra clearance we want +\advance\@IEEEtrantmpdimenA by \IEEEPARstartSEP\relax% +% add in the optional offset +\advance\@IEEEtrantmpdimenA by \IEEEPARstartHOFFSET\relax% +% V1.7 don't allow negative offsets to produce negative hanging indents +\@IEEEtrantmpdimenB\@IEEEtrantmpdimenA +\ifnum\@IEEEtrantmpdimenB < 0 \@IEEEtrantmpdimenB 0pt\fi +% \@IEEEtrantmpdimenA has the width of the big letter plus the +% separation space and \@IEEEPARstartfont is the font we need to use +% Now, we make the letter and issue the hanging indent command +% The letter is placed in a box of zero width and height so that other +% text won't be displaced by it. +\hangindent\@IEEEtrantmpdimenB\hangafter=-\IEEEPARstartDROPLINES% +\makebox[0pt][l]{\hspace{-\@IEEEtrantmpdimenA}% +\raisebox{-\IEEEPARstartDROPDEPTH}[0pt][0pt]{\hspace{\IEEEPARstartHOFFSET}% +\@IEEEPARstartfont\IEEEPARstartCAPSTYLE{#1\IEEEPARstartITLCORRECT}% +\hspace{\IEEEPARstartSEP}}}% +{\IEEEPARstartWORDFONTSTYLE{\IEEEPARstartWORDCAPSTYLE{\selectfont#2}}}} + + + + +% determines if the space remaining on a given page is equal to or greater +% than the specified space of argument one +% if not, execute argument two (only if the remaining space is greater than zero) +% and issue a \newpage +% +% example: \@IEEEtranneedspace{2in}{\vfill} +% +% Does not take into consideration rubber shrinkage, so it tends to +% be overly cautious +% Based on an example posted by Donald Arseneau +% Note this macro uses \@IEEEtrantmpdimenB internally for calculations, +% so DO NOT PASS \@IEEEtrantmpdimenB to this routine +% if you need a dimen register, import with \@IEEEtrantmpdimenA instead +\def\@IEEEtranneedspace#1#2{\penalty-100\begingroup%shield temp variable +\@IEEEtrantmpdimenB\pagegoal\advance\@IEEEtrantmpdimenB-\pagetotal% space left +\ifdim #1>\@IEEEtrantmpdimenB\relax% not enough space left +\ifdim\@IEEEtrantmpdimenB>\z@\relax #2\fi% +\newpage% +\fi\endgroup} + + + +% IEEEbiography ENVIRONMENT +% Allows user to enter biography leaving place for picture (adapts to font size) +% As of V1.5, a new optional argument allows you to have a real graphic! +% V1.5 and later also fixes the "colliding biographies" which could happen when a +% biography's text was shorter than the space for the photo. +% MDS 7/2001 +% V1.6 prevent multiple biographies from making multiple TOC entries +\newif\if@IEEEbiographyTOCentrynotmade +\global\@IEEEbiographyTOCentrynotmadetrue + +% biography counter so hyperref can jump directly to the biographies +% and not just the previous section +\newcounter{IEEEbiography} +\setcounter{IEEEbiography}{0} + +% photo area size +\def\@IEEEBIOphotowidth{1.0in} % width of the biography photo area +\def\@IEEEBIOphotodepth{1.25in} % depth (height) of the biography photo area +% area cleared for photo +\def\@IEEEBIOhangwidth{1.14in} % width cleared for the biography photo area +\def\@IEEEBIOhangdepth{1.25in} % depth cleared for the biography photo area + % actual depth will be a multiple of + % \baselineskip, rounded up +\def\@IEEEBIOskipN{4\baselineskip}% nominal value of the vskip above the biography + +\newenvironment{IEEEbiography}[2][]{\normalfont\@IEEEcompsoconly{\sffamily}\footnotesize% +\unitlength 1in\parskip=0pt\par\parindent 1em\interlinepenalty500% +% we need enough space to support the hanging indent +% the nominal value of the spacer +% and one extra line for good measure +\@IEEEtrantmpdimenA=\@IEEEBIOhangdepth% +\advance\@IEEEtrantmpdimenA by \@IEEEBIOskipN% +\advance\@IEEEtrantmpdimenA by 1\baselineskip% +% if this page does not have enough space, break it and lets start +% with a new one +\@IEEEtranneedspace{\@IEEEtrantmpdimenA}{\relax}% +% nominal spacer can strech, not shrink use 1fil so user can out stretch with \vfill +\vskip \@IEEEBIOskipN plus 1fil minus 0\baselineskip% +% the default box for where the photo goes +\def\@IEEEtempbiographybox{{\setlength{\fboxsep}{0pt}\framebox{% +\begin{minipage}[b][\@IEEEBIOphotodepth][c]{\@IEEEBIOphotowidth}\centering PLACE\\ PHOTO\\ HERE \end{minipage}}}}% +% +% detect if the optional argument was supplied, this requires the +% \@ifmtarg command as defined in the appendix section above +% and if so, override the default box with what they want +\@ifmtarg{#1}{\relax}{\def\@IEEEtempbiographybox{\mbox{\begin{minipage}[b][\@IEEEBIOphotodepth][c]{\@IEEEBIOphotowidth}% +\centering% +#1% +\end{minipage}}}}% end if optional argument supplied +% Make an entry into the table of contents only if we have not done so before +\if@IEEEbiographyTOCentrynotmade% +% link labels to the biography counter so hyperref will jump +% to the biography, not the previous section +\setcounter{IEEEbiography}{-1}% +\refstepcounter{IEEEbiography}% +\addcontentsline{toc}{section}{Biographies}% +\global\@IEEEbiographyTOCentrynotmadefalse% +\fi% +% one more biography +\refstepcounter{IEEEbiography}% +% Make an entry for this name into the table of contents +\addcontentsline{toc}{subsection}{#2}% +% V1.6 properly handle if a new paragraph should occur while the +% hanging indent is still active. Do this by redefining \par so +% that it will not start a new paragraph. (But it will appear to the +% user as if it did.) Also, strip any leading pars, newlines, or spaces. +\let\@IEEEBIOORGparCMD=\par% save the original \par command +\edef\par{\hfil\break\indent}% the new \par will not be a "real" \par +\settoheight{\@IEEEtrantmpdimenA}{\@IEEEtempbiographybox}% get height of biography box +\@IEEEtrantmpdimenB=\@IEEEBIOhangdepth% +\@IEEEtrantmpcountA=\@IEEEtrantmpdimenB% countA has the hang depth +\divide\@IEEEtrantmpcountA by \baselineskip% calculates lines needed to produce the hang depth +\advance\@IEEEtrantmpcountA by 1% ensure we overestimate +% set the hanging indent +\hangindent\@IEEEBIOhangwidth% +\hangafter-\@IEEEtrantmpcountA% +% reference the top of the photo area to the top of a capital T +\settoheight{\@IEEEtrantmpdimenB}{\mbox{T}}% +% set the photo box, give it zero width and height so as not to disturb anything +\noindent\makebox[0pt][l]{\hspace{-\@IEEEBIOhangwidth}\raisebox{\@IEEEtrantmpdimenB}[0pt][0pt]{% +\raisebox{-\@IEEEBIOphotodepth}[0pt][0pt]{\@IEEEtempbiographybox}}}% +% now place the author name and begin the bio text +\noindent\textbf{#2\ }\@IEEEgobbleleadPARNLSP}{\relax\let\par=\@IEEEBIOORGparCMD\par% +% 7/2001 V1.5 detect when the biography text is shorter than the photo area +% and pad the unused area - preventing a collision from the next biography entry +% MDS +\ifnum \prevgraf <\@IEEEtrantmpcountA\relax% detect when the biography text is shorter than the photo + \advance\@IEEEtrantmpcountA by -\prevgraf% calculate how many lines we need to pad + \advance\@IEEEtrantmpcountA by -1\relax% we compensate for the fact that we indented an extra line + \@IEEEtrantmpdimenA=\baselineskip% calculate the length of the padding + \multiply\@IEEEtrantmpdimenA by \@IEEEtrantmpcountA% + \noindent\rule{0pt}{\@IEEEtrantmpdimenA}% insert an invisible support strut +\fi% +\par\normalfont} + + + +% V1.6 +% added biography without a photo environment +\newenvironment{IEEEbiographynophoto}[1]{% +% Make an entry into the table of contents only if we have not done so before +\if@IEEEbiographyTOCentrynotmade% +% link labels to the biography counter so hyperref will jump +% to the biography, not the previous section +\setcounter{IEEEbiography}{-1}% +\refstepcounter{IEEEbiography}% +\addcontentsline{toc}{section}{Biographies}% +\global\@IEEEbiographyTOCentrynotmadefalse% +\fi% +% one more biography +\refstepcounter{IEEEbiography}% +% Make an entry for this name into the table of contents +\addcontentsline{toc}{subsection}{#1}% +\normalfont\@IEEEcompsoconly{\sffamily}\footnotesize\interlinepenalty500% +\vskip 4\baselineskip plus 1fil minus 0\baselineskip% +\parskip=0pt\par% +\noindent\textbf{#1\ }\@IEEEgobbleleadPARNLSP}{\relax\par\normalfont} + + +% provide the user with some old font commands +% got this from article.cls +\DeclareOldFontCommand{\rm}{\normalfont\rmfamily}{\mathrm} +\DeclareOldFontCommand{\sf}{\normalfont\sffamily}{\mathsf} +\DeclareOldFontCommand{\tt}{\normalfont\ttfamily}{\mathtt} +\DeclareOldFontCommand{\bf}{\normalfont\bfseries}{\mathbf} +\DeclareOldFontCommand{\it}{\normalfont\itshape}{\mathit} +\DeclareOldFontCommand{\sl}{\normalfont\slshape}{\@nomath\sl} +\DeclareOldFontCommand{\sc}{\normalfont\scshape}{\@nomath\sc} +\DeclareRobustCommand*\cal{\@fontswitch\relax\mathcal} +\DeclareRobustCommand*\mit{\@fontswitch\relax\mathnormal} + + +% SPECIAL PAPER NOTICE COMMANDS +% +% holds the special notice text +\def\@IEEEspecialpapernotice{\relax} + +% for special papers, like invited papers, the user can do: +% \IEEEspecialpapernotice{(Invited Paper)} before \maketitle +\def\IEEEspecialpapernotice#1{\ifCLASSOPTIONconference% +\def\@IEEEspecialpapernotice{{\sublargesize\textit{#1}\vspace*{1em}}}% +\else% +\def\@IEEEspecialpapernotice{{\\*[1.5ex]\sublargesize\textit{#1}}\vspace*{-2ex}}% +\fi} + + + + +% PUBLISHER ID COMMANDS +% to insert a publisher's ID footer +% V1.6 \IEEEpubid has been changed so that the change in page size and style +% occurs in \maketitle. \IEEEpubid must now be issued prior to \maketitle +% use \IEEEpubidadjcol as before - in the second column of the title page +% These changes allow \maketitle to take the reduced page height into +% consideration when dynamically setting the space between the author +% names and the maintext. +% +% the amount the main text is pulled up to make room for the +% publisher's ID footer +% The IEEE uses about 1.3\baselineskip for journals, +% dynamic title spacing will clean up the fraction +\def\@IEEEpubidpullup{1.3\baselineskip} +\ifCLASSOPTIONtechnote +% for technotes it must be an integer of baselineskip as there can be no +% dynamic title spacing for two column mode technotes (the title is in the +% in first column) and we should maintain an integer number of lines in the +% second column +% There are some examples (such as older issues of "Transactions on +% Information Theory") in which the IEEE really pulls the text off the ID for +% technotes - about 0.55in (or 4\baselineskip). We'll use 2\baselineskip +% and call it even. +\def\@IEEEpubidpullup{2\baselineskip} +\fi + +% V1.7 compsoc does not use a pullup +\ifCLASSOPTIONcompsoc +\def\@IEEEpubidpullup{0pt} +\fi + +% holds the ID text +\def\@IEEEpubid{\relax} + +% flag so \maketitle can tell if \IEEEpubid was called +\newif\if@IEEEusingpubid +\global\@IEEEusingpubidfalse +% issue this command in the page to have the ID at the bottom +% V1.6 use before \maketitle +\def\IEEEpubid#1{\def\@IEEEpubid{#1}\global\@IEEEusingpubidtrue} + + +% command which will pull up (shorten) the column it is executed in +% to make room for the publisher ID. Place in the second column of +% the title page when using \IEEEpubid +% Is smart enough not to do anything when in single column text or +% if the user hasn't called \IEEEpubid +% currently needed in for the second column of a page with the +% publisher ID. If not needed in future releases, please provide this +% command and define it as \relax for backward compatibility +% v1.6b do not allow command to operate if the peer review option has been +% selected because \IEEEpubidadjcol will not be on the cover page. +% V1.7 do nothing if compsoc +\def\IEEEpubidadjcol{\ifCLASSOPTIONcompsoc\else\ifCLASSOPTIONpeerreview\else +\if@twocolumn\if@IEEEusingpubid\enlargethispage{-\@IEEEpubidpullup}\fi\fi\fi\fi} + +% Special thanks to Peter Wilson, Daniel Luecking, and the other +% gurus at comp.text.tex, for helping me to understand how best to +% implement the IEEEpubid command in LaTeX. + + + +%% Lockout some commands under various conditions + +% general purpose bit bucket +\newsavebox{\@IEEEtranrubishbin} + +% flags to prevent multiple warning messages +\newif\if@IEEEWARNthanks +\newif\if@IEEEWARNIEEEPARstart +\newif\if@IEEEWARNIEEEbiography +\newif\if@IEEEWARNIEEEbiographynophoto +\newif\if@IEEEWARNIEEEpubid +\newif\if@IEEEWARNIEEEpubidadjcol +\newif\if@IEEEWARNIEEEmembership +\newif\if@IEEEWARNIEEEaftertitletext +\@IEEEWARNthankstrue +\@IEEEWARNIEEEPARstarttrue +\@IEEEWARNIEEEbiographytrue +\@IEEEWARNIEEEbiographynophototrue +\@IEEEWARNIEEEpubidtrue +\@IEEEWARNIEEEpubidadjcoltrue +\@IEEEWARNIEEEmembershiptrue +\@IEEEWARNIEEEaftertitletexttrue + + +%% Lockout some commands when in various modes, but allow them to be restored if needed +%% +% save commands which might be locked out +% so that the user can later restore them if needed +\let\@IEEESAVECMDthanks\thanks +\let\@IEEESAVECMDIEEEPARstart\IEEEPARstart +\let\@IEEESAVECMDIEEEbiography\IEEEbiography +\let\@IEEESAVECMDendIEEEbiography\endIEEEbiography +\let\@IEEESAVECMDIEEEbiographynophoto\IEEEbiographynophoto +\let\@IEEESAVECMDendIEEEbiographynophoto\endIEEEbiographynophoto +\let\@IEEESAVECMDIEEEpubid\IEEEpubid +\let\@IEEESAVECMDIEEEpubidadjcol\IEEEpubidadjcol +\let\@IEEESAVECMDIEEEmembership\IEEEmembership +\let\@IEEESAVECMDIEEEaftertitletext\IEEEaftertitletext + + +% disable \IEEEPARstart when in draft mode +% This may have originally been done because the pre-V1.6 drop letter +% algorithm had problems with a non-unity baselinestretch +% At any rate, it seems too formal to have a drop letter in a draft +% paper. +\ifCLASSOPTIONdraftcls +\def\IEEEPARstart#1#2{#1#2\if@IEEEWARNIEEEPARstart\typeout{** ATTENTION: \noexpand\IEEEPARstart + is disabled in draft mode (line \the\inputlineno).}\fi\global\@IEEEWARNIEEEPARstartfalse} +\fi +% and for technotes +\ifCLASSOPTIONtechnote +\def\IEEEPARstart#1#2{#1#2\if@IEEEWARNIEEEPARstart\typeout{** WARNING: \noexpand\IEEEPARstart + is locked out for technotes (line \the\inputlineno).}\fi\global\@IEEEWARNIEEEPARstartfalse} +\fi + + +% lockout unneeded commands when in conference mode +\ifCLASSOPTIONconference +% when locked out, \thanks, \IEEEbiography, \IEEEbiographynophoto, \IEEEpubid, +% \IEEEmembership and \IEEEaftertitletext will all swallow their given text. +% \IEEEPARstart will output a normal character instead +% warn the user about these commands only once to prevent the console screen +% from filling up with redundant messages +\def\thanks#1{\if@IEEEWARNthanks\typeout{** WARNING: \noexpand\thanks + is locked out when in conference mode (line \the\inputlineno).}\fi\global\@IEEEWARNthanksfalse} +\def\IEEEPARstart#1#2{#1#2\if@IEEEWARNIEEEPARstart\typeout{** WARNING: \noexpand\IEEEPARstart + is locked out when in conference mode (line \the\inputlineno).}\fi\global\@IEEEWARNIEEEPARstartfalse} + + +% LaTeX treats environments and commands with optional arguments differently. +% the actual ("internal") command is stored as \\commandname +% (accessed via \csname\string\commandname\endcsname ) +% the "external" command \commandname is a macro with code to determine +% whether or not the optional argument is presented and to provide the +% default if it is absent. So, in order to save and restore such a command +% we would have to save and restore \\commandname as well. But, if LaTeX +% ever changes the way it names the internal names, the trick would break. +% Instead let us just define a new environment so that the internal +% name can be left undisturbed. +\newenvironment{@IEEEbogusbiography}[2][]{\if@IEEEWARNIEEEbiography\typeout{** WARNING: \noexpand\IEEEbiography + is locked out when in conference mode (line \the\inputlineno).}\fi\global\@IEEEWARNIEEEbiographyfalse% +\setbox\@IEEEtranrubishbin\vbox\bgroup}{\egroup\relax} +% and make biography point to our bogus biography +\let\IEEEbiography=\@IEEEbogusbiography +\let\endIEEEbiography=\end@IEEEbogusbiography + +\renewenvironment{IEEEbiographynophoto}[1]{\if@IEEEWARNIEEEbiographynophoto\typeout{** WARNING: \noexpand\IEEEbiographynophoto + is locked out when in conference mode (line \the\inputlineno).}\fi\global\@IEEEWARNIEEEbiographynophotofalse% +\setbox\@IEEEtranrubishbin\vbox\bgroup}{\egroup\relax} + +\def\IEEEpubid#1{\if@IEEEWARNIEEEpubid\typeout{** WARNING: \noexpand\IEEEpubid + is locked out when in conference mode (line \the\inputlineno).}\fi\global\@IEEEWARNIEEEpubidfalse} +\def\IEEEpubidadjcol{\if@IEEEWARNIEEEpubidadjcol\typeout{** WARNING: \noexpand\IEEEpubidadjcol + is locked out when in conference mode (line \the\inputlineno).}\fi\global\@IEEEWARNIEEEpubidadjcolfalse} +\def\IEEEmembership#1{\if@IEEEWARNIEEEmembership\typeout{** WARNING: \noexpand\IEEEmembership + is locked out when in conference mode (line \the\inputlineno).}\fi\global\@IEEEWARNIEEEmembershipfalse} +\def\IEEEaftertitletext#1{\if@IEEEWARNIEEEaftertitletext\typeout{** WARNING: \noexpand\IEEEaftertitletext + is locked out when in conference mode (line \the\inputlineno).}\fi\global\@IEEEWARNIEEEaftertitletextfalse} +\fi + + +% provide a way to restore the commands that are locked out +\def\IEEEoverridecommandlockouts{% +\typeout{** ATTENTION: Overriding command lockouts (line \the\inputlineno).}% +\let\thanks\@IEEESAVECMDthanks% +\let\IEEEPARstart\@IEEESAVECMDIEEEPARstart% +\let\IEEEbiography\@IEEESAVECMDIEEEbiography% +\let\endIEEEbiography\@IEEESAVECMDendIEEEbiography% +\let\IEEEbiographynophoto\@IEEESAVECMDIEEEbiographynophoto% +\let\endIEEEbiographynophoto\@IEEESAVECMDendIEEEbiographynophoto% +\let\IEEEpubid\@IEEESAVECMDIEEEpubid% +\let\IEEEpubidadjcol\@IEEESAVECMDIEEEpubidadjcol% +\let\IEEEmembership\@IEEESAVECMDIEEEmembership% +\let\IEEEaftertitletext\@IEEESAVECMDIEEEaftertitletext} + + + +% need a backslash character for typeout output +{\catcode`\|=0 \catcode`\\=12 +|xdef|@IEEEbackslash{\}} + + +% hook to allow easy disabling of all legacy warnings +\def\@IEEElegacywarn#1#2{\typeout{** ATTENTION: \@IEEEbackslash #1 is deprecated (line \the\inputlineno). +Use \@IEEEbackslash #2 instead.}} + + +% provide some legacy IEEEtran commands +\def\IEEEcompsoctitleabstractindextext{\@IEEElegacywarn{IEEEcompsoctitleabstractindextext}{IEEEtitleabstractindextext}\IEEEtitleabstractindextext} +\def\IEEEdisplaynotcompsoctitleabstractindextext{\@IEEElegacywarn{IEEEdisplaynotcompsoctitleabstractindextext}{IEEEdisplaynontitleabstractindextext}\IEEEdisplaynontitleabstractindextext} +% provide some legacy IEEEtran environments + + +% V1.8a no more support for these legacy commands +%\def\authorblockA{\@IEEElegacywarn{authorblockA}{IEEEauthorblockA}\IEEEauthorblockA} +%\def\authorblockN{\@IEEElegacywarn{authorblockN}{IEEEauthorblockN}\IEEEauthorblockN} +%\def\authorrefmark{\@IEEElegacywarn{authorrefmark}{IEEEauthorrefmark}\IEEEauthorrefmark} +%\def\PARstart{\@IEEElegacywarn{PARstart}{IEEEPARstart}\IEEEPARstart} +%\def\pubid{\@IEEElegacywarn{pubid}{IEEEpubid}\IEEEpubid} +%\def\pubidadjcol{\@IEEElegacywarn{pubidadjcol}{IEEEpubidadjcol}\IEEEpubidadjcol} +%\def\specialpapernotice{\@IEEElegacywarn{specialpapernotice}{IEEEspecialpapernotice}\IEEEspecialpapernotice} +% and environments +%\def\keywords{\@IEEElegacywarn{keywords}{IEEEkeywords}\IEEEkeywords} +%\def\endkeywords{\endIEEEkeywords} +% V1.8 no more support for legacy IED list commands +%\let\labelindent\IEEElabelindent +%\def\calcleftmargin{\@IEEElegacywarn{calcleftmargin}{IEEEcalcleftmargin}\IEEEcalcleftmargin} +%\def\setlabelwidth{\@IEEElegacywarn{setlabelwidth}{IEEEsetlabelwidth}\IEEEsetlabelwidth} +%\def\usemathlabelsep{\@IEEElegacywarn{usemathlabelsep}{IEEEusemathlabelsep}\IEEEusemathlabelsep} +%\def\iedlabeljustifyc{\@IEEElegacywarn{iedlabeljustifyc}{IEEEiedlabeljustifyc}\IEEEiedlabeljustifyc} +%\def\iedlabeljustifyl{\@IEEElegacywarn{iedlabeljustifyl}{IEEEiedlabeljustifyl}\IEEEiedlabeljustifyl} +%\def\iedlabeljustifyr{\@IEEElegacywarn{iedlabeljustifyr}{IEEEiedlabeljustifyr}\IEEEiedlabeljustifyr} +% V1.8 no more support for QED and proof stuff +%\def\QED{\@IEEElegacywarn{QED}{IEEEQED}\IEEEQED} +%\def\QEDclosed{\@IEEElegacywarn{QEDclosed}{IEEEQEDclosed}\IEEEQEDclosed} +%\def\QEDopen{\@IEEElegacywarn{QEDopen}{IEEEQEDopen}\IEEEQEDopen} +%\AtBeginDocument{\def\proof{\@IEEElegacywarn{proof}{IEEEproof}\IEEEproof}\def\endproof{\endIEEEproof}} +% V1.8 no longer support biography or biographynophoto +%\def\biography{\@IEEElegacywarn{biography}{IEEEbiography}\IEEEbiography} +%\def\biographynophoto{\@IEEElegacywarn{biographynophoto}{IEEEbiographynophoto}\IEEEbiographynophoto} +%\def\endbiography{\endIEEEbiography} +%\def\endbiographynophoto{\endIEEEbiographynophoto} +% V1.7 and later no longer supports \overrideIEEEmargins +%\def\overrideIEEEmargins{% +%\typeout{** WARNING: \string\overrideIEEEmargins \space no longer supported (line \the\inputlineno).}% +%\typeout{** Use the \string\CLASSINPUTinnersidemargin, \string\CLASSINPUToutersidemargin \space controls instead.}} + +\endinput + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%% End of IEEEtran.cls %%%%%%%%%%%%%%%%%%%%%%%%%%%% +% That's all folks! + diff --git a/paper/exoskeleton/IEEEtran/main2.tex b/paper/exoskeleton/IEEEtran/main2.tex new file mode 100644 index 0000000..df25a6e --- /dev/null +++ b/paper/exoskeleton/IEEEtran/main2.tex @@ -0,0 +1,2944 @@ + +%% bare_conf.tex +%% V1.4b +%% 2015/08/26 +%% by Michael Shell +%% See: +%% http://www.michaelshell.org/ +%% for current contact information. +%% +%% This is a skeleton file demonstrating the use of IEEEtran.cls +%% (requires IEEEtran.cls version 1.8b or later) with an IEEE +%% conference paper. +%% +%% Support sites: +%% http://www.michaelshell.org/tex/ieeetran/ +%% http://www.ctan.org/pkg/ieeetran +%% and +%% http://www.ieee.org/ + +%%************************************************************************* +%% Legal Notice: +%% This code is offered as-is without any warranty either expressed or +%% implied; without even the implied warranty of MERCHANTABILITY or +%% FITNESS FOR A PARTICULAR PURPOSE! +%% User assumes all risk. +%% In no event shall the IEEE or any contributor to this code be liable for +%% any damages or losses, including, but not limited to, incidental, +%% consequential, or any other damages, resulting from the use or misuse +%% of any information contained here. +%% +%% All comments are the opinions of their respective authors and are not +%% necessarily endorsed by the IEEE. +%% +%% This work is distributed under the LaTeX Project Public License (LPPL) +%% ( http://www.latex-project.org/ ) version 1.3, and may be freely used, +%% distributed and modified. A copy of the LPPL, version 1.3, is included +%% in the base LaTeX documentation of all distributions of LaTeX released +%% 2003/12/01 or later. +%% Retain all contribution notices and credits. +%% ** Modified files should be clearly indicated as such, including ** +%% ** renaming them and changing author support contact information. ** +%%************************************************************************* + + +% *** Authors should verify (and, if needed, correct) their LaTeX system *** +% *** with the testflow diagnostic prior to trusting their LaTeX platform *** +% *** with production work. The IEEE's font choices and paper sizes can *** +% *** trigger bugs that do not appear when using other class files. *** *** +% The testflow support page is at: +% http://www.michaelshell.org/tex/testflow/ + + + +\documentclass[conference]{IEEEtran} +% Some Computer Society conferences also require the compsoc mode option, +% but others use the standard conference format. +% +% If IEEEtran.cls has not been installed into the LaTeX system files, +% manually specify the path to it like: +% \documentclass[conference]{../sty/IEEEtran} + + + + + +% Some very useful LaTeX packages include: +% (uncomment the ones you want to load) + +\usepackage{amsmath} +\usepackage{amssymb} +\usepackage{booktabs} +\usepackage{graphicx} +\usepackage{siunitx} +\sisetup{detect-all} +\usepackage[caption=false,font=footnotesize]{subfig} +\usepackage{float} +\usepackage{tikz} +\usepackage{algorithm} +\usepackage{algpseudocode} +\usepackage{tabularx} +\usepackage{url} +\newtheorem{proposition}{Proposition} +\usetikzlibrary{fit} +\usetikzlibrary{arrows.meta, positioning, calc} +% *** MISC UTILITY PACKAGES *** +% +%\usepackage{ifpdf} +% Heiko Oberdiek's ifpdf.sty is very useful if you need conditional +% compilation based on whether the output is pdf or dvi. +% usage: +% \ifpdf +% % pdf code +% \else +% % dvi code +% \fi +% The latest version of ifpdf.sty can be obtained from: +% http://www.ctan.org/pkg/ifpdf +% Also, note that IEEEtran.cls V1.7 and later provides a builtin +% \ifCLASSINFOpdf conditional that works the same way. +% When switching from latex to pdflatex and vice-versa, the compiler may +% have to be run twice to clear warning/error messages. + + + + + + +% *** CITATION PACKAGES *** +% +%\usepackage{cite} +% cite.sty was written by Donald Arseneau +% V1.6 and later of IEEEtran pre-defines the format of the cite.sty package +% \cite{} output to follow that of the IEEE. Loading the cite package will +% result in citation numbers being automatically sorted and properly +% "compressed/ranged". e.g., [1], [9], [2], [7], [5], [6] without using +% cite.sty will become [1], [2], [5]--[7], [9] using cite.sty. cite.sty's +% \cite will automatically add leading space, if needed. Use cite.sty's +% noadjust option (cite.sty V3.8 and later) if you want to turn this off +% such as if a citation ever needs to be enclosed in parenthesis. +% cite.sty is already installed on most LaTeX systems. Be sure and use +% version 5.0 (2009-03-20) and later if using hyperref.sty. +% The latest version can be obtained at: +% http://www.ctan.org/pkg/cite +% The documentation is contained in the cite.sty file itself. + + + + + + +% *** GRAPHICS RELATED PACKAGES *** +% +\ifCLASSINFOpdf + % \usepackage[pdftex]{graphicx} + % declare the path(s) where your graphic files are + % \graphicspath{{../pdf/}{../jpeg/}} + % and their extensions so you won't have to specify these with + % every instance of \includegraphics + % \DeclareGraphicsExtensions{.pdf,.jpeg,.png} +\else + % or other class option (dvipsone, dvipdf, if not using dvips). graphicx + % will default to the driver specified in the system graphics.cfg if no + % driver is specified. + % \usepackage[dvips]{graphicx} + % declare the path(s) where your graphic files are + % \graphicspath{{../eps/}} + % and their extensions so you won't have to specify these with + % every instance of \includegraphics + % \DeclareGraphicsExtensions{.eps} +\fi +% graphicx was written by David Carlisle and Sebastian Rahtz. It is +% required if you want graphics, photos, etc. graphicx.sty is already +% installed on most LaTeX systems. The latest version and documentation +% can be obtained at: +% http://www.ctan.org/pkg/graphicx +% Another good source of documentation is "Using Imported Graphics in +% LaTeX2e" by Keith Reckdahl which can be found at: +% http://www.ctan.org/pkg/epslatex +% +% latex, and pdflatex in dvi mode, support graphics in encapsulated +% postscript (.eps) format. pdflatex in pdf mode supports graphics +% in .pdf, .jpeg, .png and .mps (metapost) formats. Users should ensure +% that all non-photo figures use a vector format (.eps, .pdf, .mps) and +% not a bitmapped formats (.jpeg, .png). The IEEE frowns on bitmapped formats +% which can result in "jaggedy"/blurry rendering of lines and letters as +% well as large increases in file sizes. +% +% You can find documentation about the pdfTeX application at: +% http://www.tug.org/applications/pdftex + + + + + +% *** MATH PACKAGES *** +% +%\usepackage{amsmath} +% A popular package from the American Mathematical Society that provides +% many useful and powerful commands for dealing with mathematics. +% +% Note that the amsmath package sets \interdisplaylinepenalty to 10000 +% thus preventing page breaks from occurring within multiline equations. Use: +%\interdisplaylinepenalty=2500 +% after loading amsmath to restore such page breaks as IEEEtran.cls normally +% does. amsmath.sty is already installed on most LaTeX systems. The latest +% version and documentation can be obtained at: +% http://www.ctan.org/pkg/amsmath + + + + + +% *** SPECIALIZED LIST PACKAGES *** +% +%\usepackage{algorithmic} +% algorithmic.sty was written by Peter Williams and Rogerio Brito. +% This package provides an algorithmic environment fo describing algorithms. +% You can use the algorithmic environment in-text or within a figure +% environment to provide for a floating algorithm. Do NOT use the algorithm +% floating environment provided by algorithm.sty (by the same authors) or +% algorithm2e.sty (by Christophe Fiorio) as the IEEE does not use dedicated +% algorithm float types and packages that provide these will not provide +% correct IEEE style captions. The latest version and documentation of +% algorithmic.sty can be obtained at: +% http://www.ctan.org/pkg/algorithms +% Also of interest may be the (relatively newer and more customizable) +% algorithmicx.sty package by Szasz Janos: +% http://www.ctan.org/pkg/algorithmicx + + + + +% *** ALIGNMENT PACKAGES *** +% +%\usepackage{array} +% Frank Mittelbach's and David Carlisle's array.sty patches and improves +% the standard LaTeX2e array and tabular environments to provide better +% appearance and additional user controls. As the default LaTeX2e table +% generation code is lacking to the point of almost being broken with +% respect to the quality of the end results, all users are strongly +% advised to use an enhanced (at the very least that provided by array.sty) +% set of table tools. array.sty is already installed on most systems. The +% latest version and documentation can be obtained at: +% http://www.ctan.org/pkg/array + + +% IEEEtran contains the IEEEeqnarray family of commands that can be used to +% generate multiline equations as well as matrices, tables, etc., of high +% quality. + + + + +% *** SUBFIGURE PACKAGES *** +%\ifCLASSOPTIONcompsoc +% \usepackage[caption=false,font=normalsize,labelfont=sf,textfont=sf]{subfig} +%\else +% \usepackage[caption=false,font=footnotesize]{subfig} +%\fi +% subfig.sty, written by Steven Douglas Cochran, is the modern replacement +% for subfigure.sty, the latter of which is no longer maintained and is +% incompatible with some LaTeX packages including fixltx2e. However, +% subfig.sty requires and automatically loads Axel Sommerfeldt's caption.sty +% which will override IEEEtran.cls' handling of captions and this will result +% in non-IEEE style figure/table captions. To prevent this problem, be sure +% and invoke subfig.sty's "caption=false" package option (available since +% subfig.sty version 1.3, 2005/06/28) as this is will preserve IEEEtran.cls +% handling of captions. +% Note that the Computer Society format requires a larger sans serif font +% than the serif footnote size font used in traditional IEEE formatting +% and thus the need to invoke different subfig.sty package options depending +% on whether compsoc mode has been enabled. +% +% The latest version and documentation of subfig.sty can be obtained at: +% http://www.ctan.org/pkg/subfig + + + + +% *** FLOAT PACKAGES *** +% +%\usepackage{fixltx2e} +% fixltx2e, the successor to the earlier fix2col.sty, was written by +% Frank Mittelbach and David Carlisle. This package corrects a few problems +% in the LaTeX2e kernel, the most notable of which is that in current +% LaTeX2e releases, the ordering of single and double column floats is not +% guaranteed to be preserved. Thus, an unpatched LaTeX2e can allow a +% single column figure to be placed prior to an earlier double column +% figure. +% Be aware that LaTeX2e kernels dated 2015 and later have fixltx2e.sty's +% corrections already built into the system in which case a warning will +% be issued if an attempt is made to load fixltx2e.sty as it is no longer +% needed. +% The latest version and documentation can be found at: +% http://www.ctan.org/pkg/fixltx2e + + +%\usepackage{stfloats} +% stfloats.sty was written by Sigitas Tolusis. This package gives LaTeX2e +% the ability to do double column floats at the bottom of the page as well +% as the top. (e.g., "\begin{figure*}[!b]" is not normally possible in +% LaTeX2e). It also provides a command: +%\fnbelowfloat +% to enable the placement of footnotes below bottom floats (the standard +% LaTeX2e kernel puts them above bottom floats). This is an invasive package +% which rewrites many portions of the LaTeX2e float routines. It may not work +% with other packages that modify the LaTeX2e float routines. The latest +% version and documentation can be obtained at: +% http://www.ctan.org/pkg/stfloats +% Do not use the stfloats baselinefloat ability as the IEEE does not allow +% \baselineskip to stretch. Authors submitting work to the IEEE should note +% that the IEEE rarely uses double column equations and that authors should try +% to avoid such use. Do not be tempted to use the cuted.sty or midfloat.sty +% packages (also by Sigitas Tolusis) as the IEEE does not format its papers in +% such ways. +% Do not attempt to use stfloats with fixltx2e as they are incompatible. +% Instead, use Morten Hogholm'a dblfloatfix which combines the features +% of both fixltx2e and stfloats: +% +% \usepackage{dblfloatfix} +% The latest version can be found at: +% http://www.ctan.org/pkg/dblfloatfix + + + + +% *** PDF, URL AND HYPERLINK PACKAGES *** +% +%\usepackage{url} +% url.sty was written by Donald Arseneau. It provides better support for +% handling and breaking URLs. url.sty is already installed on most LaTeX +% systems. The latest version and documentation can be obtained at: +% http://www.ctan.org/pkg/url +% Basically, \url{my_url_here}. + + + + +% *** Do not adjust lengths that control margins, column widths, etc. *** +% *** Do not use packages that alter fonts (such as pslatex). *** +% There should be no need to do such things with IEEEtran.cls V1.6 and later. +% (Unless specifically asked to do so by the journal or conference you plan +% to submit to, of course. ) + + +% correct bad hyphenation here +\hyphenation{op-tical net-works semi-conduc-tor} + + +\begin{document} +% +% paper title +% Titles are generally capitalized except for words such as a, an, and, as, +% at, but, by, for, in, nor, of, on, or, the, to and up, which are usually +% not capitalized unless they are the first or last word of the title. +% Linebreaks \\ can be used within to get better formatting as desired. +% Do not put math or special symbols in the title. +\title{Power-Dual SEW Retargeting and Haptic-Energy Supervision for Heterogeneous 7-DoF Exoskeleton Teleoperation} + + +% author names and affiliations +% use a multiple column layout for up to three different +% affiliations +\author{\IEEEauthorblockN{Xiangtian Kuang} +\IEEEauthorblockA{College of Mechanical and Vehicle Engineering\\ +Chongqing University\\ +Chongqing, China 400714\\ +Email: xiangtiankuang@gmail.com}} +% \and +% \IEEEauthorblockN{Homer Simpson} +% \IEEEauthorblockA{Twentieth Century Fox\\ +% Springfield, USA\\ +% Email: homer@thesimpsons.com} +% \and +% \IEEEauthorblockN{James Kirk\\ and Montgomery Scott} +% \IEEEauthorblockA{Starfleet Academy\\ +% San Francisco, California 96678--2391\\ +% Telephone: (800) 555--1212\\ +% Fax: (888) 555--1212}} + +% conference papers do not typically use \thanks and this command +% is locked out in conference mode. If really needed, such as for +% the acknowledgment of grants, issue a \IEEEoverridecommandlockouts +% after \documentclass + +% for over three affiliations, or if they all won't fit within the width +% of the page, use this alternative format: +% +%\author{\IEEEauthorblockN{Michael Shell\IEEEauthorrefmark{1}, +%Homer Simpson\IEEEauthorrefmark{2}, +%James Kirk\IEEEauthorrefmark{3}, +%Montgomery Scott\IEEEauthorrefmark{3} and +%Eldon Tyrell\IEEEauthorrefmark{4}} +%\IEEEauthorblockA{\IEEEauthorrefmark{1}School of Electrical and Computer Engineering\\ +%Georgia Institute of Technology, +%Atlanta, Georgia 30332--0250\\ Email: see http://www.michaelshell.org/contact.html} +%\IEEEauthorblockA{\IEEEauthorrefmark{2}Twentieth Century Fox, Springfield, USA\\ +%Email: homer@thesimpsons.com} +%\IEEEauthorblockA{\IEEEauthorrefmark{3}Starfleet Academy, San Francisco, California 96678-2391\\ +%Telephone: (800) 555--1212, Fax: (888) 555--1212} +%\IEEEauthorblockA{\IEEEauthorrefmark{4}Tyrell Inc., 123 Replicant Street, Los Angeles, California 90210--4321}} + + + + +% use for special paper notices +%\IEEEspecialpapernotice{(Invited Paper)} + + + + +% make the title area +\maketitle + + +% As a general rule, do not put math, special symbols or citations +% in the abstract +\begin{abstract} +Motion retargeting and force reflection cannot be designed independently when +a wearable master and a remote robot have different link lengths, joint axes, +and reachable workspaces. This paper develops a coupled formulation for a +heterogeneous 7-DoF exoskeleton--robot pair. An analytic +Shoulder--Elbow--Wrist (SEW) construction first generates feasible Cartesian +elbow and wrist targets; a bounded, branch-seeded least-squares stage then +recovers the slave configuration. The local differential of this complete map +is evaluated by guarded central differences and its transpose defines the +virtual-work-consistent generalized-force map. Slave interaction torque is +obtained from a bias-corrected inverse-dynamics residual, while a damped +least-squares reconstruction provides an end-effector wrench for diagnostics +and Cartesian baselines. On the master side, the mapped feedback passes through +filtering, gain, rate, and torque limits before a final radial energy +projection. A one-step storage invariant is established for this haptic +feedback contribution; no claim is made for the independently added dynamics +compensation or for arbitrary delayed two-port stability. Because the custom +master exoskeleton is still under fabrication, this version reports no +physical performance result. Instead, it fixes a gated validation protocol +with independent pose, wrench, timing, energy, task, and human-subject +evidence, so that later claims can be evaluated without retrospective changes +to endpoints or exclusions. +\end{abstract} + +\begin{IEEEkeywords} +Bilateral teleoperation; Upper-limb exoskeleton; Heterogeneous retargeting; +Shoulder--elbow--wrist angle; Interaction wrench estimation; +Energy-aware haptics; Human--robot interaction. +\end{IEEEkeywords} + + +% no keywords + + + + +% For peer review papers, you can put extra information on the cover +% page as needed: +% \ifCLASSOPTIONpeerreview +% \begin{center} \bfseries EDICS Category: 3-BBND \end{center} +% \fi +% +% For peerreview papers, this IEEEtran command inserts a page break and +% creates the second title. It will be ignored for other modes. +\IEEEpeerreviewmaketitle + +\section{Introduction} + +Bilateral teleoperation enables a human operator to perform manipulation in +remote, hazardous, or inaccessible environments while retaining human +perception and decision-making in the control loop +~\cite{Rebelo2014Teleop,Jiang2019Telerehab}. Upper-limb exoskeletons +are attractive master interfaces because their joint sensing can capture +operator posture and their distributed actuators can return kinesthetic cues +~\cite{Gull2020ExoReview,Kim2017Harmony}. They are also relevant to emerging +robot-demonstration and manipulation-data pipelines, in which the fidelity and +continuity of the human motion representation directly affect the quality of +the commanded robot behavior~\cite{Wang2023Dexcap,Fang2023Airexo}. + +The bilateral case becomes substantially more difficult when the wearable +master and remote robot are kinematically heterogeneous. Direct joint +mirroring is generally unavailable, while a task-space inverse-kinematics +solution may change redundancy branches, approach joint limits, or become +sensitive to initialization near singular configurations +~\cite{Sinha2019IK,Brahmi2019IK}. Moreover, a motion mapping alone +does not determine how a slave-side contact wrench should be reflected to the +operator. If motion scaling, reach saturation, filtering, actuator limits, and +communication effects are ignored in the force path, the resulting feedback +may be geometrically plausible but energetically inconsistent. + +The force path introduces two additional difficulties. First, model-based +wrench reconstruction from joint torques is affected by friction, inertial +parameter error, acceleration estimation, torque bias, and Jacobian +conditioning~\cite{Mohammadi2013DO,Indri2020FrictionID}. +Second, active force reflection can deliver net energy to the operator, +particularly during stiff contact or in the presence of delay and +asynchronous measurements. Passivity-oriented and semi-active haptic devices +illustrate useful safety principles +~\cite{Porcini2020Passivity,Buongiorno2019Teleop}, but an energy claim must identify exactly +which torque contribution is supervised and which downstream commands remain +outside the accounting. + +\vspace{1mm} +\noindent\textbf{Research question.} +This work asks the following overarching question: +\emph{How should motion retargeting, interaction-wrench reconstruction, and +force reflection be formulated together so that a heterogeneous 7-DoF +exoskeleton--robot pair preserves meaningful arm geometry while exposing and +bounding the energy released by its haptic feedback channel?} +The question is decomposed into three testable subquestions: + +\begin{itemize} + \item \textbf{RQ1 -- geometric retargeting:} Which SEW quantities should + be transferred between dissimilar embodiments, and how should reach + limits, redundancy branches, and geometric degeneracies be handled? + + \item \textbf{RQ2 -- wrench reconstruction:} Under what modeling, + sensing, and conditioning assumptions can slave joint-torque residuals + provide a useful six-dimensional interaction-wrench estimate? + + \item \textbf{RQ3 -- energy-aware force reflection:} How should the + differential of the heterogeneous motion map, actuator limits, signal + processing, and communication conditions enter the slave-to-master force + map and its discrete-time energy budget? +\end{itemize} + +\vspace{1mm} +\noindent\textbf{Contributions.} +The contributions of this work are threefold: + +\begin{enumerate} + \item \textbf{A branch-qualified motion/force contract.} We factor + heterogeneous retargeting into an analytic SEW Cartesian target and a + bounded embodiment-specific joint recovery, compute the differential of + the complete map on a fixed local branch, and use its transpose to derive + the associated generalized-force map. The same contract declares when + clipping, fallback geometry, solver failure, or an active joint limit makes + the differential unusable. + + \item \textbf{An implementation-explicit interaction pathway.} A + bias-corrected inverse-dynamics residual supplies the generalized slave + interaction torque used by the proposed force map. A frame-consistent + damped least-squares solve separately reconstructs the end-effector wrench + for interpretation and for a Cartesian-Jacobian baseline, avoiding an + unreported wrench-to-torque round trip in the proposed pathway. + + \item \textbf{A final-stage haptic-energy projection with a bounded claim.} + The complete feedback pipeline is specified as filter, gain, rate limit, + torque saturation, and radial energy projection. We prove the resulting + one-step storage invariant for the haptic contribution and provide a gated + protocol that independently tests geometry, estimation, mapping, timing, + energy, task performance, and human factors. +\end{enumerate} + +The custom 7-DoF upper-limb exoskeleton described in this paper is the target +master platform for this framework. Because fabrication and physical +integration are ongoing, the present manuscript deliberately separates the +method formulation and experimental protocol from empirical outcome claims. + +The remainder of the paper is organized as follows. +Section~\ref{sec:related_work} reviews heterogeneous retargeting, wrench +estimation, and energy-aware teleoperation. +Section~\ref{sec:problem} defines the system variables, mappings, and research +hypotheses. Section~\ref{sec:hardware} describes the master-exoskeleton +architecture and its measurable design requirements. +Section~\ref{sec:framework} presents the geometry- and energy-aware +teleoperation framework. Section~\ref{sec:experiments} specifies the planned +evaluation protocol, and Section~\ref{sec:results} reserves the reporting +structure for subsequently acquired data. +Section~\ref{sec:discussion} discusses limitations and validity threats, and +Section~\ref{sec:conclusion} concludes the paper. + +\section{Related Work} +\label{sec:related_work} + +Research on bilateral teleoperation spans posture retargeting, exoskeleton design, interaction force estimation, and passivity-based haptic rendering. Modern teleoperation and tactile-robotics frameworks emphasize bidirectional exchange of motion and force between a human and a remote environment, with a strong focus on safety and haptic awareness. This section reviews the most relevant lines of work and highlights the remaining gaps that motivate our approach. + +\subsection{Isomorphic and Vision-Aided Teleoperation} +In isomorphic teleoperation, the master and slave share similar kinematic structures, so joint-space mirroring or simple task-space tracking is sufficient. Representative systems include rehabilitation and assistance-focused upper-limb exoskeletons~\cite{Rebelo2014Teleop} and proprioceptive master devices~\cite{Lee2014Teaching}. + +Recent low-cost teleoperation and data-collection platforms demonstrate the +feasibility of dexterous manipulation through several distinct interface +families. Vision- or tracking-driven systems include ACE, AnyTeleop, and +OpenTeach~\cite{Yang2024ACE,Qin2023AnyTeleop,Iyer2024OpenTeach}. They support +multiple embodiments and scalable demonstration collection, but pose +measurement and visual feedback do not by themselves define a +power-conjugate kinesthetic channel. Physical master platforms such as HOMIE, +Mobile ALOHA, and GELLO instead exploit an isomorphic cockpit or a +robot-matched low-cost controller +~\cite{HOMIE2025,Fu2024MobileALOHA,Wu2023Gello}. Their embodiment match +simplifies command generation, whereas it does not resolve force reflection +for a kinematically different wearable master. System-level analyses further +summarize challenges in exoskeleton-based teleoperation and rehabilitation +interfaces~\cite{Gull2020ExoReview}. + +\subsection{Heterogeneous 7-DoF Arm Retargeting} +Retargeting between kinematically heterogeneous master--slave arms typically relies on numerical inverse kinematics (IK) with redundancy resolution or null-space shaping. Classical IK approaches for redundant 7-DoF manipulators~\cite{Sinha2019IK,Brahmi2019IK} have been widely applied in human--robot retargeting and humanoid control. Their continuity, convergence, and computational cost depend on the solver, initialization, constraints, and treatment of redundancy, particularly near joint or workspace boundaries. + +Recent exoskeleton-based teleoperation systems explore posture-based +homo--hetero mapping, such as the upper-limb heteromorphic teleoperation +framework in~\cite{Cheng2024HomoHetero}. Geometric SEW constructions +provide an alternative representation of reach and arm-plane redundancy +~\cite{Elias2024SEW}. +However, an analytic SEW point construction does not by itself resolve +platform-specific joint recovery, joint limits, branch continuity, or the +dual force map induced by the retargeting. + +\subsection{Force Estimation in Teleoperation and Exoskeletons} +Accurate estimation of the environment interaction wrench remains central for high-quality haptic feedback. Model-based residual-dynamics estimators and nonlinear disturbance observers have been applied to torque-controlled manipulators~\cite{Mohammadi2013DO}. However, the approach is sensitive to friction, modeling error, and torque noise. + +Exoskeleton-based teleoperation architectures often rely on direct F/T sensing or joint-based observers, such as force-sensitive exoskeletons for elderly care robotics~\cite{Toedtheide2023ForceSensitive} and lightweight tactile interfaces~\cite{Forouhar2024TactileExo}. These systems demonstrate feasibility but remain affected by compliance, transmission losses, and bias handling. Vision-only pose teleoperation~\cite{Li2020VisionTeleop} can avoid robot inverse-dynamics estimation, but its pose signal does not itself provide a power-conjugate wrench for kinesthetic feedback. These observations motivate configuration-stratified validation of residual estimation rather than a general robustness claim for lightweight hardware. + +\subsection{Passivity-Based Haptic Rendering} +The transparency--stability trade-off in bilateral teleoperation has been well established, especially under communication delay and stiff contact. Passive and semi-active actuators, including MR clutches, offer hardware-level means to limit active energy injection~\cite{Pisetskiy2021MRclutch}. Passivity-oriented control has also been integrated into multi-DoF exoskeleton teleoperation~\cite{Buongiorno2019Teleop}. + +The passivity observer/controller construction provides a foundational +sampled-data energy-accounting mechanism~\cite{Hannaford2002TDPC}. +Time-domain passivity control has also been applied to exoskeleton-based +bilateral teleoperation with independently passivated slave devices +~\cite{Porcini2020Passivity}. Multi-sensory control strategies further improve +dynamic transparency in upper-limb exoskeletons +~\cite{Zimmermann2020Transparency}. These methods establish the relevant +stability--transparency trade-off, while the force coordinate induced by a +heterogeneous motion map and the exact torque included in discrete energy +accounting still have to be declared for each implementation. + +\subsection{Research Gap} +The literature reveals three coupled gaps that motivate the present work: + +\begin{itemize} + \item \textbf{Retargeting is often separated from force reflection.} + Geometry-preserving motion mappings are commonly evaluated through pose + error alone, without deriving the differential map required to relate + generalized power between heterogeneous embodiments. + + \item \textbf{Wrench-estimation assumptions are insufficiently isolated.} + Residual-dynamics estimators depend on model quality, torque sensing, + friction compensation, acceleration estimation, and Jacobian conditioning; + these effects require independent ground truth and paired evaluation. + + \item \textbf{Energy claims must be tied to a precisely defined port.} + Filtering, scaling, saturation, timing, and communication can alter the + torque and velocity used for power computation. An energy argument is + therefore meaningful only when it identifies the exact controlled torque, + every downstream operation, and the scope of the communication model. +\end{itemize} + +To address these gaps, the present framework connects an SEW target +construction, platform-specific joint recovery, model-based residual wrench +estimation, and an energy-aware master torque supervisor through a common +problem formulation. The framework is evaluated only after its geometric, +estimation, timing, and energy assumptions have been tested separately. +No novelty is claimed for the SEW coordinate~\cite{Elias2024SEW}, residual +estimation~\cite{Mohammadi2013DO}, DLS inversion, or time-domain energy +accounting~\cite{Hannaford2002TDPC} in isolation. The claimed methodological +advance is their branch-qualified coupling: the differential is taken through +the complete constrained retargeting map; its transpose acts on the same +generalized slave interaction quantity; tracking, transport, shaping, and +post-allocation errors are separated; and the energy statement is attached to +the actual applied haptic increment rather than to an upstream wrench. This +coupling, and not a list of standard modules, defines the unit tested by the +protocol. + +\begin{table*}[t] +\centering +\footnotesize +\renewcommand{\arraystretch}{1.18} +\begin{tabularx}{\textwidth}{p{2.8cm} X X X} +\toprule +\textbf{Open interface} & \textbf{Required mathematical contract} +& \textbf{Method element in this work} & \textbf{Evidence required before a claim}\\ +\midrule +Heterogeneous motion +& Feasible targets, branch state, limits, and nonsmooth events +& SEW Cartesian construction plus bounded joint recovery +& Pose ground truth, failure/discontinuity rates, and timing against matched baselines\\ +Motion-to-force coupling +& Differential of the complete fixed-branch map and its virtual-work dual +& Guarded finite-difference $A$ and generalized-force map $A^\top$ +& Differential convergence, validity rate, and actual/reference power mismatch\\ +Interaction reconstruction +& Torque residual, frame, reference point, spatial order, and observability +& Offline bias correction and DLS end-effector wrench reconstruction +& Independent six-axis wrench reference, singular-value stratification, and perturbations\\ +Haptic energy +& Exact candidate/applied torque, velocity sample, timing, and downstream operations +& Final-stage radial projection of the shaped haptic contribution +& Independent energy reconstruction, intervention cost, and finite contact/network envelope\\ +\bottomrule +\end{tabularx} +\caption{Claim-to-evidence structure used in place of categorical +``yes/no'' comparisons. Each row identifies an interface that must be defined +mathematically and tested independently before an end-to-end conclusion.} +\label{tab:claim_evidence} +\end{table*} + +\section{System Overview and Problem Formulation} +\label{sec:problem} + +\subsection{System Boundary and Notation} + +The system comprises a human operator, a 7-DoF wearable master exoskeleton, a +communication channel, a 7-DoF robot arm, and a remote environment. Let +$q_m,\dot q_m\in\mathbb{R}^{7}$ denote the measured master joint position and +velocity, and let $q_s,\dot q_s\in\mathbb{R}^{7}$ denote the slave state. The +master and slave SEW points are denoted by +$p_{S_i},p_{E_i},p_{W_i}\in\mathbb{R}^{3}$, where $i\in\{m,s\}$. +Twists use the ordering $[v^\top\ \omega^\top]^\top$ and wrenches use +$[f^\top\ m^\top]^\top$, both at the end-effector point. The mathematical +formulation expresses their axes in a common task frame $\{C\}$. The current +URDF simulation realizes this as an identity alignment between the fixed +master and slave base axes; physical experiments require measured base-to-$C$ +calibrations and a calibrated tool-center point. + +The commanded slave configuration is written as +\begin{equation} + q_s^{\mathrm{ref}}[k] + = \mathcal{R}\!\left(q_m[k];b_k,\theta_R\right), + \label{eq:retarget_map} +\end{equation} +where $b_k$ is the numerical branch seed and $\theta_R$ collects link lengths, +frame calibrations, tolerances, and joint limits. Thus, $\mathcal{R}$ is not +assumed to be a globally single-valued smooth map. To avoid conflating two +distinct operations, we factor it as +\begin{equation} + y_s^{\mathrm{ref}} = \mathcal{G}(y_m;\theta_G),\qquad + q_s^{\mathrm{ref}} + = \mathcal{K}_s(y_s^{\mathrm{ref}};b_k,\theta_K). + \label{eq:retarget_factorization} +\end{equation} +Here, $\mathcal{G}$ is the analytic Cartesian SEW target construction and +$\mathcal{K}_s$ is the embodiment-specific joint recovery. The SEW descriptor +$y_i$ contains reach direction, reach magnitude, swivel angle, and a terminal +orientation objective. This factorization permits the geometric construction +to be evaluated independently of the numerical or analytic solver used by a +particular slave arm. + +\subsection{Differential Mapping and Power Objective} + +At configurations where $\mathcal{R}$ is differentiable and $b_k$ identifies +one fixed branch, define the local retargeting differential +\begin{equation} + A(q_m;b_k) = + \frac{\partial\mathcal{R}(q_m;b_k,\theta_R)}{\partial q_m}, + \qquad + \dot q_s^{\mathrm{map}} = A(q_m;b_k)\dot q_m. + \label{eq:retarget_differential} +\end{equation} +Reach clipping, branch changes, and joint-limit projections can make this map +nonsmooth; those events are logged explicitly and are not covered by an +unqualified smoothness assumption. Let $\bar A[k]$ denote the most recent +valid differential, updated at 50\,Hz and held between updates. Two velocities +must be distinguished in the current multirate simulator. At every 500-Hz +haptic sample, the unmodified algebraic velocity +$\dot q_s^{\mathrm{map}}[k]=\bar A[k]\dot q_m[k]$ is recomputed for the +virtual-work contract. At a 50-Hz mapping instant $k_u$, the slave-controller +feedforward +\begin{equation} + \dot q_s^{\mathrm{ctrl}}[k_u] + =\mathrm{clip}_{0.8\dot q_{s,\max}}\! + \left(\bar A[k_u]\dot q_m[k_u]\right) + \label{eq:controller_reference_velocity} +\end{equation} +is computed once and held until the next update. Thus, +$\dot q_s^{\mathrm{map}}$, $\dot q_s^{\mathrm{ctrl}}$, and the actual +$\dot q_s$ are conceptually separate signals. The current run-level log stores +the actual velocity and the resulting algebraic power scalar but not both +reference vectors; explicit vector logging is a G0a prerequisite. + +The present simulator colocates retargeting and slave control, so it has no +forward network delay. For the G3 network tests, mapping update $i$ creates the +forward packet +$u_f[i]=(q_s^{\mathrm{ref}}[i],\dot q_s^{\mathrm{ctrl}}[i],i)$, while the +corresponding $\bar A[i]$ is retained in the synchronized master log. If +$\kappa_f(k)$ is the most recent forward-packet index accepted by slave sample +$k$, the physical controller uses +\begin{equation} + q_{s,\mathrm{net}}^{\mathrm{ref}}[k] + =q_s^{\mathrm{ref}}[\kappa_f(k)],\qquad + \dot q_{s,\mathrm{net}}^{\mathrm{ctrl}}[k] + =\dot q_s^{\mathrm{ctrl}}[\kappa_f(k)]. + \label{eq:forward_held_reference} +\end{equation} +The slave does not require $A$ for tracking. Instead, each return packet echoes +the active forward-map identifier +$\mu(j)=\kappa_f(j)$ at its slave source sample $j$. This identifier binds a +returned residual and velocity to the exact held reference and historical +$\bar A[\mu(j)]$ that generated them. + +Let $\hat\tau_{s,\mathrm{int}}$ be the estimated generalized slave interaction +torque under the environment-on-robot sign convention. Virtual work motivates +the corresponding environment-on-master reaction +\begin{equation} + \tau_{m,\mathrm{dual}} + = \sigma_f A(q_m;b_k)^\top\hat\tau_{s,\mathrm{int}}, + \label{eq:power_dual_map} +\end{equation} +where $\sigma_f>0$ is a declared force scale. No additional minus sign is +introduced after fixing this reaction convention. On a valid branch, +\begin{equation} + \tau_{m,\mathrm{dual}}^\top\dot q_m + = + \sigma_f\hat\tau_{s,\mathrm{int}}^\top + \dot q_s^{\mathrm{map}}. + \label{eq:power_dual_identity} +\end{equation} +\begin{proposition}[Fixed-branch virtual-work dual] +If $\delta q_s=A\,\delta q_m$ on a certified local branch, then +$\tau_{m,\mathrm{dual}}=\sigma_fA^\top\tau_s$ is the unique generalized +master force satisfying +$\tau_{m,\mathrm{dual}}^\top\delta q_m +=\sigma_f\tau_s^\top\delta q_s$ for every $\delta q_m$. +\end{proposition} +\begin{IEEEproof} +Substitution gives +$\sigma_f\tau_s^\top\delta q_s +=\sigma_f\tau_s^\top A\delta q_m +=(\sigma_fA^\top\tau_s)^\top\delta q_m$. Equality for every virtual +displacement fixes the generalized-force coefficient uniquely. +\end{IEEEproof} +This is an algebraic identity, not actual bilateral power equality. Before +delay or master-side shaping, slave tracking error contributes +\begin{equation} + \Delta P_{\mathrm{trk}} + = \sigma_f\hat\tau_{s,\mathrm{int}}^\top + \left(A\dot q_m-\dot q_s\right). + \label{eq:tracking_power_error} +\end{equation} +Delay, filtering, rate limits, saturation, and energy projection introduce +additional measurable differences and are reported separately. + +\subsection{Controlled Haptic Contribution and Storage} + +All nominal filtering, gain, rate limiting, and haptic-torque saturation +precede the energy projection. Let $\tau_{h,\mathrm{app}}[k]$ denote the final +haptic feedback contribution returned by that projection. Using the convention +that positive power is delivered by this contribution to the operator, +\begin{equation} + P_h[k] + = \tau_{h,\mathrm{app}}[k]^\top\dot q_m[k], + \label{eq:applied_port_power} +\end{equation} +and the unbounded discrete storage update is +\begin{equation} + E^+[k+1] = E[k] - P_h[k]\Delta t_k, + \label{eq:applied_port_energy} +\end{equation} +where $\Delta t_k=t_{k+1}-t_k>0$ is the measured interval of the applied +output sample. The fixed-rate simulation is the special case +$\Delta t_k=T_s$. +The complete motor command may additionally contain independently designed +master dynamics compensation, +$\tau_{m,\mathrm{cmd}}=\tau_{m,\mathrm{comp}}+\tau_{h,\mathrm{app}}$. +The present storage does not account for $\tau_{m,\mathrm{comp}}$ or the +mechanical energy of the exoskeleton. Consequently, the analytical claim is +limited to the haptic contribution. No later operation may modify +$\tau_{h,\mathrm{app}}$ except an emergency zero-output action. On physical +hardware this requirement must be enforced either by reserving verified +actuator headroom for the haptic component or by relocating the projection +and accounting to the actual post-allocation torque. A combined low-level +saturation that changes the haptic increment invalidates the stated invariant +unless that actual increment is reconstructed and used for accounting. +Communication-delay robustness and total human--device passivity are not +inferred from \eqref{eq:applied_port_energy}. + +\subsection{Testable Hypotheses and Scope} + +The evaluation is organized around four hypotheses: +\begin{itemize} + \item \textbf{H1:} the SEW target construction reduces the locked + trajectory-level composite rate of solver/tolerance failure or observable + joint/swivel discontinuity relative to the primary IK baseline at + comparable task-space accuracy; + \item \textbf{H2:} on the preregistered noisy and near-singular subset, + regularized residual-torque inversion yields lower force and moment RMSE + than an undamped solve, with a measurable bias--variance trade-off; + \item \textbf{H3:} for a matched wrench input, the differential-dual map + $A^\top J_s^\top F$ reduces cross-embodiment power mismatch relative to + the mapping-agnostic $J_m^\top F$ reflection; + \item \textbf{H4:} when reconstructed from the actual post-allocation + haptic increment, the final energy projection prevents lower-budget + violations beyond numerical tolerance relative to projection bypass, with + a measurable transparency cost over the tested envelope. +\end{itemize} +The virtual-work identity and the one-step storage inequality are deterministic +contracts verified at G0a; they are not treated as statistically discovered +effects. +These hypotheses deliberately avoid universal claims about arbitrary stiffness +or delay. Their acceptance criteria, statistical tests, and failure reporting +are defined before physical data are collected. + +\begin{figure*}[!ht] + \centering + \includegraphics[width=0.32\linewidth]{assets/overview.jpg}% + \includegraphics[width=0.32\linewidth]{assets/explain.jpg}% + \includegraphics[width=0.32\linewidth]{assets/joint_distribution.jpg} + \caption{Overall hardware design of the proposed 7-DoF upper-limb exoskeleton. + (Left) Intended wearable arrangement and adjustable human--exoskeleton interface. + (Middle) Joint arrangement and kinematic routing used in the current mechanical design. + (Right) Distribution of the seven rotational joints ($q_0$--$q_6$) across the shoulder, elbow, and wrist modules.} + \label{fig:exo_overview} +\end{figure*} + +\section{Target Master Platform and Design Requirements} +\label{sec:hardware} + +Prior upper-limb exoskeletons have explored anatomically aligned actuation and precise joint-center matching to improve motion capture accuracy and wearer comfort. Representative shoulder--arm systems emphasize accurate glenohumeral alignment and low-distortion rotational layouts~\cite{Yan2014ShoulderExo}. Wrist and distal-arm devices similarly prioritize compactness and minimal reflected inertia to preserve natural hand motion~\cite{Buongiorno2018WRES}. + +The target master device is a custom 7-DoF upper-limb exoskeleton currently +under fabrication for bilateral teleoperation. Unlike a load-assistance +exoskeleton, its design objectives are posture-measurement fidelity, +backdrivability, bounded kinesthetic feedback, and compatibility with the SEW +representation in Section~\ref{sec:sew}. These objectives are not treated as +measured properties at the present stage. They are converted into explicit +characterization requirements in Section~\ref{sec:experiments}. An overview of +the current mechanical design is provided in Fig.~\ref{fig:exo_overview}. + +\subsection{Three-DoF Orthogonal Shoulder Architecture} +The shoulder module design (Fig.~\ref{fig:shoulder_arch}) places three nominally +orthogonal rotational axes near the human glenohumeral joint, consistent with +prior shoulder-exoskeleton design principles~\cite{Yan2014ShoulderExo}. +Fabrication tolerance, soft-tissue motion, and subject-specific anatomy will +prevent perfect alignment; the resulting misalignment must therefore be +measured rather than assumed. The proposed actuator layout is: + +\begin{itemize} + \item \textbf{(1) Back-mounted flexion/extension axis.} + The first DoF actuator is placed behind the scapular region to keep bulk + away from the upper arm and to approximate a flexion--extension axis. + + \item \textbf{(2) Lateral shoulder abduction axis.} + The second actuator is positioned laterally to approximate an + abduction--adduction axis; alignment error and parasitic motion remain + quantities to be measured. + + \item \textbf{(3) Humeral-roll ring gear.} + A lightweight annular ring gear encircles the upper arm and is driven by a + compact motor to approximate a coaxial humeral-roll axis. Its reflected + inertia and friction will be established experimentally rather than inferred + from the layout. +\end{itemize} + +\begin{figure}[t] + \centering + \includegraphics[width=0.75\linewidth]{assets/shoulder.jpg} + \caption{Intended three-DoF shoulder architecture; physical joint-center + alignment remains a characterization outcome.} + \label{fig:shoulder_arch} +\end{figure} + +\subsection{Forearm and Wrist Module with Symmetric 3-DoF Design} +The forearm--wrist assembly (Fig.~\ref{fig:wrist_module}) follows the same +geometric design objective. Three rotational DoFs---forearm roll, wrist pitch, +and wrist yaw---are realized through compact transmission mechanisms informed +by wrist-exoskeleton designs~\cite{Buongiorno2018WRES}. The present design does +not assume magnetorheological actuation. Actuator type, reduction ratio, +continuous torque, peak torque, friction, and torque bandwidth will be reported +from the fabricated system. + +\begin{figure}[t] + \centering + \includegraphics[width=0.78\linewidth]{assets/wrist.jpg} + \caption{Current symmetric three-DoF wrist-module design with nominal + yaw--pitch--roll axes.} + \label{fig:wrist_module} +\end{figure} + +\subsection{Structure and Human-Fit Requirements} +A hybrid frame combining aluminum or composite links with polymer interface +components is intended to balance structural stiffness, distal mass, and +wearability. These choices create testable requirements rather than guaranteed +outcomes: structural deflection must be separated from encoder motion, +backdriving torque must be measured throughout the workspace, and fitting +adjustments must cover a declared anthropometric range. Comfort and alignment +will be assessed independently of task performance. + +\subsection{Interface to the Teleoperation Framework} +The mechanical design exposes the following interfaces to the teleoperation +architecture: +\begin{itemize} + \item joint positions and synchronized velocities for master-side SEW + reconstruction; + \item calibrated joint-torque commands and measured actuator states for + kinesthetic feedback; + \item declared torque, velocity, position, and thermal limits for nominal + haptic shaping and independent low-level safety; + \item hardware timestamps and safety-state logs for independent power and + timing reconstruction. +\end{itemize} +The experiment protocol treats sensing accuracy, actuator bandwidth, +backdrivability, structural stiffness, alignment, and safety thresholds as +prerequisites for system-level evaluation. + +% ---------------- Geometry- and Energy-Aware Teleoperation Framework ---------------- + +\section{Geometry- and Energy-Aware Teleoperation Framework} +\label{sec:framework} + +The framework contains four stages. First, master joint measurements are +converted to a master SEW descriptor. Second, an +analytic geometric construction produces a feasible slave SEW target, after +which a bounded platform-specific solver recovers the slave joint reference +and a guarded finite difference estimates the differential $A$. Third, a +bias-corrected inverse-dynamics residual estimates generalized slave +interaction torque; a DLS solve provides a Cartesian wrench for diagnostics +and the direct-Jacobian baseline. Fourth, $A^\top$ maps the generalized +interaction to the master, followed in strict order by a torque filter, gain, +rate limit, saturation, and final haptic-energy projection. The current +repository implementation binds these stages to \texttt{master\_7dof.urdf} and +\texttt{real\_slave\_7dof.urdf}. Physical frame calibration, timing, and +behavior remain validation requirements in Section~\ref{sec:experiments}. + +\begin{figure*}[t] +\centering +\begin{tikzpicture}[ + >=Latex, + block/.style={draw, rounded corners, align=center, minimum height=8mm, + text width=25mm, font=\scriptsize}, + wide/.style={block, text width=31mm}, + line/.style={->, thick}, + base/.style={->, dashed, semithick} +] +\node[block] (master) {Master state\\$q_m,\dot q_m$}; +\node[block, right=6mm of master] (sew) {Analytic SEW\\target $\mathcal G$}; +\node[wide, right=6mm of sew] (recover) {Bounded recovery\\$\mathcal K_s(\cdot;b_k)$}; +\node[block, right=6mm of recover] (slave) {Slave tracking\\and environment}; + +\node[wide, below=12mm of slave] (residual) {Bias-corrected residual\\ +$\hat\tau_{s,\mathrm{int}}$}; +\node[block, left=6mm of residual] (dual) {Power-dual map\\$A^\top$}; +\node[wide, left=6mm of dual] (shape) {Filter $\rightarrow$ gain $\rightarrow$ rate\\ +$\rightarrow$ saturation}; +\node[wide, left=6mm of shape] (energy) {Final haptic-energy\\projection}; +\node[block, left=6mm of energy] (output) {Applied haptic\\$\tau_{h,\mathrm{app}}$}; + +\node[block, below=9mm of residual] (dls) {DLS wrench\\${}^C\hat F_{\mathrm{int}}$}; +\node[wide, left=6mm of dls] (direct) {Cartesian baseline\\ +$({}^CJ_{E_m})^\top{}^C\hat F_{\mathrm{int}}$}; + +\draw[line] (master) -- (sew); +\draw[line] (sew) -- (recover); +\draw[line] (recover) -- node[above,font=\scriptsize] {$q_s^{\mathrm{ref}}$} (slave); +\draw[line] (slave) -- (residual); +\draw[line] (residual) -- (dual); +\draw[line] (dual) -- (shape); +\draw[line] (shape) -- (energy); +\draw[line] (energy) -- (output); +\draw[line] (recover.south) |- node[pos=0.35,left,font=\scriptsize] + {guarded FD $A$} (dual.north); +\draw[base] (residual) -- (dls); +\draw[base] (dls) -- (direct); +\draw[base] (direct.west) -| node[pos=0.25,below,font=\scriptsize] + {baseline only} (shape.south); +\end{tikzpicture} +\caption{Executable information flow. The proposed feedback path maps the +bias-corrected generalized residual directly through $A^\top$. DLS wrench +reconstruction feeds diagnostics and the dashed Cartesian baseline. All +nominal shaping precedes the final projection, which supervises only the +applied haptic contribution.} +\label{fig:method_architecture} +\end{figure*} + +\subsection{Heterogeneous SEW Target Construction} +\label{sec:sew} + +\begin{figure*}[t] + \centering + \subfloat[SEW master and slave geometry\label{fig:sew_geometry_all_a}]{ + \includegraphics[width=0.31\linewidth]{assets/sew_geometry.png} + }\hfill + \subfloat[Swivel angle definition\label{fig:sew_geometry_all_b}]{ + \includegraphics[width=0.31\linewidth]{assets/swivel_angle.png} + }\hfill + \subfloat[Two-sphere intersection for elbow\label{fig:sew_geometry_all_c}]{ + \includegraphics[width=0.31\linewidth]{assets/two_sphere_intersection.png} + } + \caption{Geometric interpretation of the proposed SEW retargeting: + (a) master and slave SEW triangles remain geometrically consistent + despite different link lengths; + (b) the swivel angle $\phi_m$ is defined as the rotation from the + reference normal $n_{\mathrm{ref}}$ to the upper-arm normal $n_m$ + around the reach direction $\hat{x}_m$; + (c) the slave elbow lies on the intersection circle of two spheres + centered at $S_s$ and $W_s^{\mathrm{ref}}$ with radii $L_1$ and $L_2$, + with $\phi_m$ selecting a unique point on this circle.} + \label{fig:sew_geometry_all} +\end{figure*} + +Bilateral teleoperation between heterogeneous 7-DoF arms requires a posture +representation that does not assume identical joint coordinates. Motivated by +the conventional SEW redundancy coordinate for 7-DoF arms +~\cite{Elias2024SEW}, we use a Shoulder--Elbow--Wrist descriptor to construct a +feasible Cartesian target. +The target construction is analytic; recovery of a complete slave joint vector +is treated separately because it depends on the slave joint axes, limits, and +available kinematic solver. Geometry-preserving retargeting is also used in +vision-based pipelines, where pose or keypoint measurements may feed a broader +optimization-based robot mapping~\cite{Wu2023Gello,Rong2020FrankMocap}. + +% ========================================================== +\subsubsection{SEW Descriptor and Validity Domain} +SEW makes the redundant arm-plane coordinate explicit, but it does not by +itself guarantee temporal continuity or remove algorithmic singularities +~\cite{Elias2024SEW}. Fig.~\ref{fig:sew_geometry_all} illustrates the +geometric structure used here. Let +$p_{S_m},p_{E_m},p_{W_m}\in\mathbb{R}^3$ be the master shoulder, elbow, and +wrist positions expressed in the common chest frame $\{C\}$. + +The shoulder--wrist direction and reach are +\begin{equation} + r_m = p_{W_m}-p_{S_m},\qquad + d_m=\|r_m\|,\qquad + \hat{x}_m = \frac{r_m}{\|r_m\|}. +\end{equation} + +The master upper-arm plane normal is +\begin{equation} + n_m=\frac{(p_{E_m}-p_{S_m})\times(p_{W_m}-p_{S_m})} + {\|(p_{E_m}-p_{S_m})\times(p_{W_m}-p_{S_m})\|}. +\end{equation} +These normalized quantities are valid only when +\begin{equation} + d_m>\epsilon_r,\qquad + \left\|(p_{E_m}-p_{S_m})\times(p_{W_m}-p_{S_m})\right\|>\epsilon_n. + \label{eq:sew_validity} +\end{equation} +A violation marks the pose invalid for differential feedback. The current +defaults are $\epsilon_r=10^{-9}$\,m and +$\epsilon_n=10^{-8}$\,m$^2$; these are numerical degeneracy guards, not +physical accuracy thresholds, and G0c includes threshold sensitivity. +A geometric placeholder may be returned for software continuity, but it is not +treated as a valid SEW measurement. + +A chest-frame reference normal is obtained by projecting +the chest vertical axis $e_z=[0\ 0\ 1]^\top$ onto the plane +orthogonal to $\hat{x}_m$: +\begin{equation} + n_{\mathrm{ref}} + =\frac{ e_z-(e_z^\top\hat{x}_m)\hat{x}_m } + { \| e_z-(e_z^\top\hat{x}_m)\hat{x}_m \| }. +\end{equation} +The implemented reference-axis fallback is activated when the norm of the +projected unit axis in the denominator is below $10^{-6}$. + +The swivel angle is the signed rotation from $n_{\mathrm{ref}}$ to $n_m$ +about~$\hat{x}_m$ (see Fig.~\ref{fig:sew_geometry_all}(b)): +\begin{equation} + \phi_m=\mathrm{atan2}\!\left( + \hat{x}_m^\top(n_{\mathrm{ref}}\!\times n_m),~~ + n_{\mathrm{ref}}^\top n_m + \right), +\end{equation} +where the current realization uses the principal value. Because the +Rodrigues construction below is $2\pi$-periodic, this choice does not create a +Cartesian discontinuity solely at the angle wrap; branch and fallback +continuity are addressed separately. + +% ========================================================== +\subsubsection{Closed-form Wrist and Elbow Reconstruction} + +Let $p_{S_s}$ denote the slave shoulder position, and $L_1,L_2$ its upper-arm and +forearm link lengths. A feasible slave wrist distance must satisfy the triangle +inequality with a small safety margin $\varepsilon>0$: +\begin{equation} + d_{\min}=|L_1-L_2|+\varepsilon,\qquad + d_{\max}=L_1+L_2-\varepsilon. +\end{equation} +The present software uses $\varepsilon=10^{-3}$\,m; the reach margin and +degeneracy thresholds are varied in the same G0c sensitivity study. +We clip the master reach into this feasible interval: +\begin{equation} + d_s=\mathrm{clip}(d_m;\,d_{\min},d_{\max}),\qquad + p_{W_s}^{\mathrm{ref}}=p_{S_s}+d_s \hat{x}_m. +\end{equation} +This is the absolute-reach policy implemented in the current software. +Normalized reach and a calibrated affine reach scale are retained as +preregistered ablations because absolute transfer may overuse clipping when +master and slave link-length sums differ substantially. + +The slave elbow $p_{E_s}$ lies on the circle defined by the intersection of two +spheres centered at $p_{S_s}$ and $p_{W_s}^{\mathrm{ref}}$: +\[ +\|p_{E_s}-p_{S_s}\|=L_1,\qquad +\|p_{E_s}-p_{W_s}^{\mathrm{ref}}\|=L_2, +\] +as shown in Fig.~\ref{fig:sew_geometry_all}(c). Let +\[ +d=\|p_{W_s}^{\mathrm{ref}}-p_{S_s}\|,\qquad +\hat{e}_1 = \frac{p_{W_s}^{\mathrm{ref}}-p_{S_s}}{d}. +\] + +Using Rodrigues' rotation operator, +\begin{equation} + \mathcal{R}(\hat{e}_1,\phi_m) + =I+\sin\phi_m[\hat{e}_1]_\times + +(1-\cos\phi_m)[\hat{e}_1]^2_\times, +\end{equation} +we form an orthonormal basis of the circle plane as +\begin{equation} + \hat{e}_3=\mathcal{R}(\hat{e}_1,\phi_m)n_{\mathrm{ref}},\qquad + \hat{e}_2=\frac{\hat{e}_1\times \hat{e}_3} + {\|\hat{e}_1\times\hat{e}_3\|}. +\end{equation} + +From the cosine law, +\begin{equation} + \cos\theta=\frac{L_1^2+d^2-L_2^2}{2L_1d},\qquad + \sin\theta=\sqrt{1-\cos^2\theta}, +\end{equation} +the elbow target is obtained as +\begin{equation} + p_{E_s}^{\mathrm{ref}} + =p_{S_s}+L_1(\cos\theta\,\hat{e}_1+\sin\theta\,\hat{e}_2). + \label{eq:elbow_target} +\end{equation} + +\begin{proposition}[Feasible SEW target] +For $L_1,L_2>0$, $0<\varepsilon<\min(L_1,L_2)$, and +$d_s\in[d_{\min},d_{\max}]$, the targets in +\eqref{eq:elbow_target} satisfy +$\|p_{E_s}^{\mathrm{ref}}-p_{S_s}\|=L_1$ and +$\|p_{E_s}^{\mathrm{ref}}-p_{W_s}^{\mathrm{ref}}\|=L_2$, provided the SEW +basis is valid. +\end{proposition} +\begin{IEEEproof} +The clipped interval strictly satisfies the two triangle inequalities. +The cosine-law expression therefore yields +$\cos\theta\in[-1,1]$. Orthonormality of +$(\hat e_1,\hat e_2)$ gives the first distance directly; substituting +$p_{W_s}^{\mathrm{ref}}=p_{S_s}+d_s\hat e_1$ and the cosine law gives the +second. +\end{IEEEproof} + +This construction makes explicit how $p_{E_s}$ is selected on the SEW circle +by the chosen swivel branch. It provides an analytic Cartesian elbow target; +continuity of the resulting joint trajectory +still depends on reference-normal handling, numerical branch state, and the +platform-specific joint recovery described next. + +% ========================================================== +\subsubsection{Temporal Continuity Requirements} + +The current implementation obtains temporal branch preference by warm-starting +the bounded recovery from the preceding valid slave solution. It does not +maintain a separate swivel-angle filter, an unwrapped swivel state, or a +last-valid reference normal. Reach clipping, reference-axis fallback, a +degenerate master arm plane, and an active slave joint limit therefore mark the +sample as nonsmooth and invalidate $A$. Pose continuity and elbow-flip rate are +measured from trajectories rather than inferred from the geometry. Stateful +normal blending or angle unwrapping may be evaluated later, but is not part of +the method claimed here. + +% ========================================================== +\subsubsection{Embodiment-Specific Joint Recovery} + +The terminal orientation is transferred as +\begin{equation} + R_{s,\mathrm{EE}}^{\mathrm{ref}} + = R_{m,\mathrm{EE}}R_\Delta, + \label{eq:orientation_transfer} +\end{equation} +where $R_\Delta$ is a fixed neutral-pose calibration. The present aligned-URDF +simulation uses $R_\Delta=I$ and the current code path hard-wires that identity; +it does not yet expose $R_\Delta$ as a mapper parameter. Ingestion of the +measured orientation offset and its regression test are G0a requirements before +hardware use. + +Given the Cartesian targets and \eqref{eq:orientation_transfer}, the current +slave recovery solves the bounded residual problem +\begin{equation} + q_s^{\mathrm{ref}} + =\arg\min_{\underline q_s\le q_s\le\overline q_s} + \left\|r_K(q_s)\right\|_2^2, +\label{eq:joint_recovery} +\end{equation} +\begin{equation} +r_K(q_s)= +\begin{bmatrix} +s_E\!\left(p_{E_s}(q_s)-p_{E_s}^{\mathrm{ref}}\right)\\ +s_W\!\left(p_{W_s}(q_s)-p_{W_s}^{\mathrm{ref}}\right)\\ +s_R\,\mathrm{Log}\!\left(R_{s,\mathrm{EE}}(q_s)^\top +R_{s,\mathrm{EE}}^{\mathrm{ref}}\right)\\ +s_q\,\mathrm{wrap}(q_s-q_{\mathrm{seed}}) +\end{bmatrix}. +\label{eq:joint_recovery_residual} +\end{equation} +The scale factors make position and angular residuals explicit. The current +software fixes $s_E=s_W=5$, $s_R=1$, and $s_q=10^{-5}$ in SI units and uses +the URDF lower and upper joint bounds; $\mathrm{wrap}$ acts componentwise on +$[-\pi,\pi)$. These values are implementation +parameters, not empirically optimal constants, and are included in the +sensitivity protocol. + +A trust-region reflective least-squares solver is warm-started from the +previous solution during sequential operation. At initialization, a staged +shoulder--elbow--wrist seed and the joint-range midpoint are both evaluated, +and the lower task-residual solution is retained. The solver uses at most +400 function evaluations and +$\mathrm{ftol}=\mathrm{xtol}=\mathrm{gtol}=10^{-12}$. A recovery is accepted +only when the solver succeeds, all values are finite, the master geometry is +valid, no URDF limit is violated, both elbow and wrist errors are at most +$2\times10^{-4}$\,m, and the orientation error is at most +$2\times10^{-3}$\,rad. Accordingly, \emph{analytic} refers only to the +Cartesian SEW target, not the complete map +$q_m\mapsto q_s^{\mathrm{ref}}$. + +% ========================================================== +\subsubsection{SEW Degenerate Cases and Fallback Construction} +\label{sec:sew_degenerate} + +When $\hat{x}_m$ becomes nearly collinear with $e_z$, the projected reference +normal becomes ill-conditioned. Separately, a nearly zero reach or nearly +collinear shoulder--elbow--wrist points violates +\eqref{eq:sew_validity}. The latter two cases are invalid master geometry and +are never certified for differential feedback. + +To keep the reference construction defined, we introduce a fallback +orthogonal direction. +Let $e_x = [1\ 0\ 0]^\top$ and $e_y = [0\ 1\ 0]^\top$. The deterministic +fallback selects the candidate with the larger projected norm, +\begin{equation} + e_f=\arg\max_{e\in\{e_y,e_x\}} + \left\|e-(e^\top\hat x_m)\hat x_m\right\|. +\end{equation} +It is then projected onto the plane orthogonal to $\hat{x}_m$: +\[ +\tilde{n}_{\mathrm{ref}} = e_f - (e_f^\top \hat{x}_m)\hat{x}_m,\qquad +n_{\mathrm{ref}} = \frac{\tilde{n}_{\mathrm{ref}}}{\|\tilde{n}_{\mathrm{ref}}\|}. +\] +This construction keeps the reference normal defined when $\hat{x}_m$ is +nearly vertical, but activation is explicitly marked as nonsmooth. The current +implementation neither blends the threshold nor reuses a preceding normal. +Deliberate passages through this region are therefore retained in the failure +and continuity evaluation. + +The SEW module therefore provides an analytic construction of feasible reach, +direction, and swivel targets. Full-map continuity, joint-limit feasibility, +and real-time performance remain properties to be established for the selected +slave-joint recovery implementation. + +\subsubsection{Guarded Local Retargeting Differential} +\label{sec:retarget_differential} + +The complete bounded map has no analytic Jacobian in the current +implementation. Let $q_s^0=\mathcal R(q_m;b_k)$ be a valid, smooth base +solution and $e_j$ the $j$th master-joint basis vector. Its local differential +is approximated by +\begin{equation} + A_{:,j}\approx + \frac{\mathrm{wrap}\!\left( + \mathcal R(q_m+h e_j;q_s^0) + -\mathcal R(q_m-h e_j;q_s^0)\right)}{2h}, + \label{eq:finite_difference_A} +\end{equation} +with $h=10^{-4}$\,rad. All positive and negative perturbations are initialized +from the same bounded base solution so that the calculation targets one local +branch. + +A recovered pose is marked smooth only if it meets the stated task tolerances +and has no reach clipping, reference-axis fallback, degenerate master geometry, +joint-limit violation, or joint clearance below the configured +$10^{-4}$-rad limit margin. For column $j$, define +$d_j^+=\mathrm{wrap}(q_s^+-q_s^0)/h$ and +$d_j^-=\mathrm{wrap}(q_s^0-q_s^-)/h$. A column is certified only when both +perturbation solves succeed, all three poses are marked smooth, the reach-clip +region is unchanged, the largest wrapped joint jump from the base is no greater +than $0.25$\,rad, and +\begin{equation} + \eta_j = + \frac{\|d_j^+-d_j^-\|_2} + {1+\max(\|d_j^+\|_2,\|d_j^-\|_2)} + \le 0.05 . + \label{eq:differential_consistency} +\end{equation} +The finite-difference step and all three guard thresholds are current software +defaults, not universal constants; G0c varies them in a locked convergence and +sensitivity study. The active-limit gate currently refers to recovered +\emph{slave} joints. The mapper does not yet reject a master perturbation +$q_m\pm he_j$ outside the master URDF limits; G0a must add that domain check and +mark the corresponding differential invalid rather than silently using an +out-of-domain central sample. A preregistered one-sided boundary rule may be +evaluated separately. The complete $A$ is valid only if all seven columns are +finite and certified. Invalid columns are represented as non-finite values for +diagnostics; they are never used for feedback. In the closed-loop software, a +failed pose update holds the preceding pose reference, whereas an invalid +differential sets the new mapped-feedback target and reference velocity to zero +so that the upstream rate limit returns the haptic output toward zero. One +differential evaluation requires one base recovery and fourteen perturbed +recoveries. The present convenience wrapper first performs a separate nominal +pose recovery and then calls that evaluator, for a total of two nominal and +fourteen perturbed solves per 50-Hz update; reusing the first nominal solution +is an implementation optimization still to be made. High-percentile runtime +and validity rate are therefore mandatory evaluation outcomes. + + +\subsection{Locked Slave Tracking Interface} +\label{sec:slave_tracking} + +Slave tracking is not claimed as a contribution, but it is part of the +experimental plant and cannot be left implicit. The current numerical fixture +uses the fixed computed-torque law +\begin{equation} + \begin{aligned} + \tau_{s,\mathrm{ctrl}} + &=\mathrm{sat}_{\alpha_s\tau_{s,\max}}\!\left[ + h_s+M_s a_s^{\mathrm{cmd}}\right],\\ + a_s^{\mathrm{cmd}} + &=K_p\,\mathrm{diff}(q_s,q_s^{\mathrm{ref}}) + +K_d(\dot q_s^{\mathrm{ctrl}}-\dot q_s)+a_{\mathrm{lim}} . + \end{aligned} + \label{eq:slave_tracking_controller} +\end{equation} +where $a_{\mathrm{lim}}$ is a continuous acceleration-domain soft joint-limit +guard and $\alpha_s=0.70$ in the present simulation. The gain vectors, the +0.12-rad soft-limit buffer, the 500-Hz update, and all effort and numerical +acceleration limits are archived with the fixture configuration. Physical +hardware may require a different certified low-level controller; whichever +controller is selected will be locked and shared by every paired mapping and +haptic condition so that it is not an unreported experimental factor. + +\subsection{Inverse-Dynamics Residual Wrench Estimation} +\label{sec:interaction} + +The slave-side contact wrench is reconstructed from joint-torque measurements +without assuming that the residual is an unbiased measurement of contact. +The formulation follows model-based residual methods used for contact +detection and interaction estimation +~\cite{Mohammadi2013DO,DeLuca2006Collision,Wang2022InteractionTorque}. +Unlike a generalized-momentum observer, the present estimator explicitly uses +the instantaneous inverse-dynamics residual. Its performance therefore depends +on acceleration estimation, friction compensation, model quality, torque +sensing, and the spatial directions observable through the tool Jacobian. + +% ========================================================== +\subsubsection{Joint-Space Inverse-Dynamics Residual} + +Let $\tau_s^{\mathrm{meas}}$ denote a \emph{calibrated load-side-equivalent} +joint-torque signal, not an unqualified raw motor-current estimate. Under the +environment-on-robot convention used here, its required measurement balance is +\begin{equation} + \tau_s^{\mathrm{meas}} + =\tau_s^{\mathrm{dyn}}+\tau_{s,\mathrm{int}}+b_\tau+\eta_\tau, + \label{eq:load_side_torque_balance} +\end{equation} +where $b_\tau$ and $\eta_\tau$ are bias and measurement noise. If the physical +drive reports actuator-on-link torque under the usual manipulator dynamics +$M\ddot q+h=\tau_{\mathrm{act}}+\tau_{\mathrm{ext}}$, its sign and +transmission mapping must first be converted to +\eqref{eq:load_side_torque_balance}; the conversion will be verified against +the independent wrist force/torque sensor. +The model-predicted torque from rigid-body dynamics is +\begin{equation} + \tau_s^{\mathrm{dyn}} + = M_s(q_s)\ddot q_s + h_s(q_s,\dot q_s) + + \tau_{\mathrm{fric}}^{\mathrm{ff}}, +\end{equation} +where $h_s$ contains Coriolis, centrifugal, and gravity terms and +$\tau_{\mathrm{fric}}^{\mathrm{ff}}$ is an optional identified friction +feedforward term. It is zero in the present rigid-body simulation and must not +be described as identified until physical calibration is complete. + +We define the \emph{residual torque} +\begin{equation} + r_s = \tau_s^{\mathrm{meas}} - \tau_s^{\mathrm{dyn}}, +\end{equation} +which contains external joint torque together with modeling error, friction +error, sensor bias, and measurement noise. The estimate used by the current +formulation is +\begin{equation} + \hat\tau_{s,\mathrm{int}} = r_s-\hat b_\tau, + \label{eq:residual_torque_estimate} +\end{equation} +where $\hat b_\tau$ is identified from a declared, independently confirmed +no-contact calibration sequence. Static contact cannot be distinguished from +bias using low velocity alone; consequently, online bias updates are disabled +unless an independent contact gate is available. A true momentum-observer +extension may be introduced later, but it must then be implemented and +evaluated as a separate estimator rather than attributed to +\eqref{eq:residual_torque_estimate}. +For physical data, the causal acceleration estimator, its cutoff and order, +initialization, group delay, and timestamp alignment with torque measurements +will be fixed from calibration data and reported; the current rigid-body +simulation supplies acceleration directly and cannot validate that pipeline. + +% ========================================================== +\subsubsection{Mapping to Chest-Frame Wrench} + +Let $J_{E_s}^{\mathrm{LWA}}(q_s)\in\mathbb{R}^{6\times n_s}$ be the +local-world-aligned geometric Jacobian at the slave end-effector point. The +axes are rotated into $\{C\}$ without changing that reference point: +\begin{equation} + {}^C\!J_{E_s} + =\operatorname{blkdiag}(R_{CW},R_{CW}) + J_{E_s}^{\mathrm{LWA}}. + \label{eq:chest_jacobian} +\end{equation} +No translational adjoint term appears because the twist and wrench remain +referenced at the same end-effector point. With the +$[\mathrm{linear};\mathrm{angular}]$ and $[\mathrm{force};\mathrm{moment}]$ +orders, virtual work gives +\begin{equation} + \tau_{s,\mathrm{int}} + = \left({}^{C}\!J_{E_s}\right)^{\!\top} {}^{C}\!F_{\mathrm{int}}, +\end{equation} +where ${}^{C}\!F_{\mathrm{int}}\in\mathbb{R}^6$ is the interaction wrench at +the slave end-effector. The implemented DLS estimate is the regularized +least-squares solution only after the translational and rotational coordinates +have been given a common physical scale. Let the characteristic length +$\ell_c>0$ be selected from calibration data and frozen before the locked +test set, and define +\begin{equation} + \begin{aligned} + S_{\ell}&=\operatorname{diag}(\ell_c^{-1}I_3,I_3),\\ + \widetilde J_s&=S_{\ell}\,{}^C\!J_{E_s},\\ + \widetilde F&=S_{\ell}^{-\top}{}^C\!F + =[\ell_c f^\top\;m^\top]^\top . + \end{aligned} + \label{eq:spatial_scaling} +\end{equation} +Then $\widetilde J_s^\top\widetilde F +=({}^C\!J_{E_s})^\top{}^C\!F$, so the virtual-work pairing is unchanged +while all entries of $\widetilde F$ have torque units. The dimensionally +scaled DLS estimate is +\begin{equation} + \widetilde F_{\mathrm{int}}^\star + =\arg\min_{\widetilde F} + \left\|\widetilde J_s^\top\widetilde F + -\hat\tau_{s,\mathrm{int}}\right\|_2^2 + +\lambda_{\ell}^{\,2}\|\widetilde F\|_2^2, + \label{eq:dls_objective} +\end{equation} +namely +\begin{equation} + {}^C\!\hat F_{\mathrm{int}} + =S_{\ell}^{\top} + \left(\widetilde J_s\widetilde J_s^\top + +\lambda_{\ell}^{\,2}I_6\right)^{-1} + \widetilde J_s\hat\tau_{s,\mathrm{int}}. + \label{eq:dls_wrench} +\end{equation} + +For a singular value $\widetilde\sigma_i$ of $\widetilde J_s$, the +scaled residual-to-wrench gain is +$\widetilde\sigma_i/(\widetilde\sigma_i^2+\lambda_{\ell}^{\,2})$; damping +limits near-singular amplification but introduces projection bias. The +current simulation code predates \eqref{eq:spatial_scaling}: it applies an +unweighted six-dimensional solve to force and moment coordinates and uses the +numerical default $\lambda=10^{-3}$. Its reconstructed wrench and unscaled +condition number are therefore diagnostic only, because they depend on the +chosen length and unit convention. Before any physical accuracy claim, G0a +requires implementing \eqref{eq:spatial_scaling}--\eqref{eq:dls_wrench}, +selecting $(\ell_c,\lambda_{\ell})$ only on the calibration set, and using the +same frozen scaling for singularity stratification and all DLS baselines. The +rotation in \eqref{eq:chest_jacobian} does not improve the scaled Jacobian +singular values. + +% ========================================================== +\subsubsection{Signal Conditioning and Identifiability} + +The current estimator stores the raw residual, bias-corrected residual, and +unfiltered DLS wrench. It does not apply a separate wrench filter. Nominal +low-pass filtering is instead applied once to the mapped master generalized +torque in Section~\ref{sec:haptics}. Acceleration-estimation filtering, +friction identification, and any future wrench filter must be calibrated and +logged separately. Force and moment errors are reported separately because +their units and sensor characteristics differ; a combined norm requires a +declared characteristic length. + +% ========================================================== +\subsubsection{Estimator Scope} + +The residual estimator is expected to degrade under inertial-parameter error, +unmodeled payload, drivetrain friction, acceleration noise, torque bias, and +poor Jacobian conditioning. Damping can trade variance for bias but cannot +recover wrench components that are not observable from joint torque. These +effects are therefore experimental factors, not assumed robustness +properties. An independent wrist force/torque sensor supplies ground truth, +and trials near singular configurations remain in the analysis. + +The proposed differential-dual pathway applies $A^\top$ directly to the +bias-corrected generalized residual in +\eqref{eq:residual_torque_estimate}. The DLS wrench in +\eqref{eq:dls_wrench} is used for physical interpretation, comparison with +independent six-axis ground truth, and the direct +$({}^C\!J_{E_m})^\top{}^C\!\hat F_{\mathrm{int}}$ baseline. In general, +$({}^C\!J_{E_s})^\top{}^C\!\hat F_{\mathrm{int}} +\ne\hat\tau_{s,\mathrm{int}}$ because DLS introduces a regularized +projection; the method therefore does not silently map through this round +trip. + + + +\subsection{Power-Dual Haptic Rendering and Feedback-Energy Supervision} +\label{sec:haptics} + +Time-domain passivity and energy-supervision methods have been studied for +multi-DoF exoskeleton teleoperation +~\cite{Buongiorno2019Teleop,Porcini2020Passivity}. The present formulation +distinguishes whether the generalized-force map is dual to the heterogeneous +motion map from whether the shaped haptic contribution respects a discrete +energy budget. The latter is not a passivity claim for the total actuator port +or an arbitrary delayed bilateral network. + +% ========================================================== +\subsubsection{Differential-Dual Generalized-Force Mapping} + +For a valid held differential, let $\kappa_r(k)\le k$ denote the source index +of the most recent return-channel sample accepted at master sample $k$, and +let $\chi_r[k]\in\{0,1\}$ indicate that the held return sample has not exceeded +its timeout. The proposed current-map pathway applies the declared force scale +to that delayed, bias-corrected joint residual: +\begin{equation} + \tau_{h,\mathrm{raw}}[k] + =\chi_r[k]\sigma_f \bar A[k]^\top + \hat\tau_{s,\mathrm{int}}[\kappa_r(k)]. + \label{eq:raw_master_torque} +\end{equation} +Equation~\eqref{eq:raw_master_torque} states the timestamp policy of the +current implementation: the most recently arrived slave residual is +re-expressed through the \emph{current} held map. It therefore satisfies a +receiver-sample algebraic duality with +$\dot q_s^{\mathrm{map}}[k]=\bar A[k]\dot q_m[k]$, but it is not a claim of +source-time power equality at configuration $\kappa_r(k)$. For the present +fixed-delay queue, $\kappa_r(k)=k-d$ with integer $d\ge0$. The protocol +separately reports the resulting transport/configuration error and compares +this policy with source-stamped +$\bar A[\mu(\kappa_r(k))]^\top +\hat\tau_{s,\mathrm{int}}[\kappa_r(k)]$. +Here $\mu(\kappa_r(k))$ is the active forward-map identifier echoed by that +return packet, as defined in \eqref{eq:forward_held_reference}; using +$\bar A[\kappa_r(k)]$ without this binding would be incorrect under asymmetric +forward delay. +The current implementation sets $\sigma_f=1$ and uses $\gamma$ below as the +single feedback-strength parameter; the two gains are not tuned +simultaneously. The present fixture uses $\gamma=0.50$ and $a_\tau=0.222$. +The constructor currently checks finiteness but does not reject every +out-of-contract lower-bound value; strict validation of +$\gamma\ge0$ and $00 . + \end{cases} + \label{eq:energy_projection} +\end{equation} +The final haptic contribution and storage update are +\begin{equation} + \tau_{h,\mathrm{app}}[k] + =\rho[k]\tau_{h,\mathrm{cand}}[k], + \label{eq:applied_torque} +\end{equation} +\begin{equation} + E[k+1]=\mathrm{clip}\!\left( + E[k]-\tau_{h,\mathrm{app}}[k]^\top + \dot q_m[k]\Delta t_k,\, + E_{\min},E_{\max}\right). + \label{eq:bounded_energy_update} +\end{equation} +Charging above $E_{\max}$ is discarded as virtual dissipation. The numerical +lower clip is a finite-precision guard; the projection itself supplies the +lower-bound invariant. The initial value $E[0]\in[E_{\min},E_{\max}]$ and +$E_{\min}$, $E_{\max}$, $\bar\rho$, $a_\tau$, and $\gamma$ will be selected +from fixture-safe calibration runs and locked before confirmatory comparisons; +the present simulation values are software-test parameters rather than +validated human-use settings. + +\begin{proposition}[One-step haptic-storage invariant] +Assume $E[k]\in[E_{\min},E_{\max}]$, $\Delta t_k>0$, finite +$\tau_{h,\mathrm{cand}}[k]$ and $\dot q_m[k]$, and no downstream modification +of $\tau_{h,\mathrm{app}}[k]$. Equations +\eqref{eq:energy_projection}--\eqref{eq:bounded_energy_update} imply first that +the \emph{unclipped} update in \eqref{eq:applied_port_energy} satisfies +$E^+[k+1]\ge E_{\min}$, and consequently +$E[k+1]\in[E_{\min},E_{\max}]$. +\end{proposition} +\begin{IEEEproof} +If $P_{\mathrm{cand}}\le0$, the applied haptic power is nonpositive and the +unclipped storage cannot decrease. If $P_{\mathrm{cand}}>0$, +\eqref{eq:energy_projection} gives +$\rho P_{\mathrm{cand}}\Delta t_k\le E[k]-E_{\min}$, so the unclipped update is at +least $E_{\min}$. The final clip enforces the upper capacity and preserves the +lower bound. +\end{IEEEproof} +Applying the proposition inductively gives the same storage bound over any +finite sequence of valid samples; this still does not establish closed-loop +stability. More specifically, define discarded charge +\begin{equation} + D[k]=\max\!\left(0,E[k]-P_h[k]\Delta t_k-E_{\max}\right). +\end{equation} +Then, over $N$ valid samples, +\begin{equation} + \sum_{k=0}^{N-1}P_h[k]\Delta t_k + =E[0]-E[N]-\sum_{k=0}^{N-1}D[k] + \le E[0]-E_{\min}. + \label{eq:net_haptic_energy_bound} +\end{equation} +This bound concerns signed net released energy, not the cumulative positive +work after repeated recharge events. + +% ========================================================== +\subsubsection{Scope of the Energy Claim} + +Equations~\eqref{eq:energy_projection}--\eqref{eq:applied_torque} constrain +energy released by the sampled haptic feedback contribution under the stated +timing and sign conventions. They do not constrain independently added +dynamics compensation and do not establish stability of the human, robot, +environment, or communication channel as arbitrary dynamical systems. A +stronger delayed bilateral claim requires an explicit two-port treatment and +an analysis including sampling, filters, saturation, and delay. + +% ========================================================== +\subsubsection{Executable Update and Logging Contract} + +\begin{figure}[t] +\begin{algorithmic}[1] +\Require synchronized master/slave states, $\tau_s^{\mathrm{meas}}$, $E[k]$ +\If{retargeting update is due} + \State construct SEW target and solve bounded recovery + \State compute guarded $A$ using \eqref{eq:finite_difference_A} +\EndIf +\State compute $\hat\tau_{s,\mathrm{int}}$ and diagnostic +${}^C\!\hat F_{\mathrm{int}}$ +\State accept the declared return-channel sample and retain its source index +$\kappa_r(k)$ and echoed forward-map identifier $\mu(\kappa_r(k))$ +\State set $\tau_{h,\mathrm{raw}}\gets +\chi_r[k]\sigma_f\bar A[k]^\top +\hat\tau_{s,\mathrm{int}}[\kappa_r(k)]$ if the held +differential and return channel are currently valid; otherwise $0$ +\State apply \eqref{eq:haptic_filter}--\eqref{eq:candidate_torque} +\State project and account exactly once using +\eqref{eq:energy_projection}--\eqref{eq:bounded_energy_update} +\State output $\tau_{h,\mathrm{app}}$ unchanged +\end{algorithmic} +\caption{One executable update of the proposed motion, estimation, and haptic +path. Retargeting may run at a slower declared rate than the dynamics and +haptic loop; its output and validity flag are then held between updates.} +\label{alg:framework_update} +\end{figure} + +The present simulation stores time series for: +\begin{itemize} + \item the bias-corrected slave residual and DLS wrench; + \item raw, candidate, and applied haptic torque, the post-update storage + $E[k+1]$, and $\rho[k]$. +\end{itemize} +Retargeting success, differential validity, and fallback are currently stored +only as aggregate run-level rates or counts, as are rate-limit and +haptic-torque-saturation events. They have no per-sample reason codes. The +numerical supervisor likewise exposes a fail-safe flag internally, but the +current run-level simulator does not persist it. G0a requires per-sample flags, +reason codes, and the reference-vector logs identified above before such +evidence is reported. Physical tests additionally require +source timestamps, packet age, measured period and jitter, packet loss, +emergency-stop state, actuator derating/limit state, and the actual accepted +low-level torque. A quantity not recorded cannot support a timing or +energy-accounting claim. + +% ========================================================== +The haptic module therefore defines a power-dual raw reaction and a +final-stage storage projection for the haptic contribution. Whether the +implementation improves transparency and remains usable over a finite contact +and network envelope is determined by the protocol below. + + +\section{Experimental Design and Prospective Validation Protocol} +\label{sec:experiments} + +\noindent\textit{Protocol status:} +The experiments described in this section constitute a prospective validation +protocol. Physical data collection under this protocol has not yet begun, and +no experimental outcome is reported or implied. Numerical safety limits, +factor levels, and sample sizes that depend on fabricated hardware will be +fixed after calibration but before the corresponding locked outcomes are +inspected. Calibration and pilot data used for tuning will remain separate +from the confirmatory test set. The present repository supports deterministic +software checks and a closed-loop numerical smoke test (G0a--G0b below). +The multi-run simulation study (G0c) and all physical stages (G1--G4) remain +prospective; this distinction is maintained in every reported table and +figure. + +\subsection{Evaluation Questions and Staged Design} + +Table~\ref{tab:rq_traceability} prevents the hardware prerequisite, +deterministic contracts, empirical hypotheses, and end-to-end external +validation from being conflated. + +\begin{table*}[t] +\centering +\footnotesize +\renewcommand{\arraystretch}{1.16} +\begin{tabularx}{\textwidth}{p{1.8cm} p{3.1cm} X X p{1.0cm}} +\toprule +\textbf{Role} & \textbf{Claim or hypothesis} & \textbf{Locked comparison} +& \textbf{Primary evidence} & \textbf{Gate}\\ +\midrule +Prerequisite +& Master and slave hardware satisfy their declared operating envelopes +& Measured performance versus preregistered requirements +& Calibration, backdrive, bandwidth, latency, headroom, and safety limits +& G1\\ +RQ1 / H1 +& Branch-qualified SEW retargeting reduces the locked composite event rate +& SEW pathway versus matched bounded and task-priority IK +& Paired trajectory-level failure-or-discontinuity rate +& G0c/G2\\ +RQ2 / H2 +& Regularization improves noisy/near-singular wrench reconstruction +& DLS versus undamped solve, with bias/friction/model ablations +& Force RMSE and moment RMSE against an independent six-axis sensor +& G0c/G2\\ +RQ3 / H3 +& Differential-dual mapping reduces cross-embodiment power mismatch +& $A^\top J_s^\top F$ versus $J_m^\top F$ with the same $F$ +& Paired $\epsilon_{P,c}^{\mathrm{act}}$ and transport-error decomposition +& G0c/G3\\ +RQ3 / H4 +& Actual-applied haptic supervision enforces its budget at a transparency cost +& Projection on/off plus a locked time-domain PO/PC baseline +& Paired floor deficit, pre-clip audit, and torque-level projection distortion +& G0a/G3\\ +External validity +& Integrated usefulness after all component gates pass +& Proposed, PO/PC, and no-kinesthetic conditions +& Task success; human surface-force RMSE; safety events +& G4\\ +\bottomrule +\end{tabularx} +\caption{Traceability from research questions to claims, comparisons, primary +evidence, and validation gates. Hardware characterization is a prerequisite, +not a separate algorithmic research claim.} +\label{tab:rq_traceability} +\end{table*} + +All algorithm parameters will be tuned on a separate calibration set. +Trajectories, random seeds, failure definitions, exclusion rules, primary +outcomes, and statistical models will be frozen before evaluating the locked +test set. Compared methods will receive identical inputs, initial conditions, +joint and torque limits, network traces, and computing resources. + +\begin{table*}[t] +\centering +\footnotesize +\renewcommand{\arraystretch}{1.18} +\begin{tabularx}{\textwidth}{p{1.0cm} p{3.0cm} X X} +\toprule +\textbf{Gate} & \textbf{Stage and purpose} & \textbf{Required evidence} +& \textbf{Exit criterion before advancing}\\ +\midrule +G0a & Deterministic software contracts +& URDF/frame binding, bounded recovery, finite-difference checks, +DLS algebra, invalid-sample fail-safe, output ordering, and independent +energy reconstruction +& Versioned tests pass; no unexplained non-finite value; tolerances and +failure semantics frozen\\ +G0b & Current closed-loop numerical smoke test +& Declared master/slave models, one scripted free-space/contact trajectory, +three implemented feedback paths, and invariant/event logging +& All runs terminate normally; algebraic and storage invariants satisfy +frozen numerical tolerances; no performance claim is made\\ +G0c & Locked multi-run simulation study +& Paired trajectories and seeds, stratified kinematic cases, contact and +delay factors, robustness perturbations, matched-input baselines, and +per-sample protocol-state logs +& Runner, dataset, primary numerical endpoints, and analysis script frozen +before the locked batch\\ +G1 & Hardware bench characterization +& Master sensing, torque/backdrive, bandwidth, limits, temperature, +deflection and emergency stop; slave tracking, TCP/F/T, residual, and +accepted-command calibration +& Documented safe operating envelope and automatic termination limits\\ +G2 & Component validation +& SEW/solver comparison on a locked workspace and wrench estimation against +an independent six-axis sensor +& Primary component endpoints and failure definitions pass their +preregistered feasibility criteria\\ +G3 & Non-human bilateral fixture +& Power mismatch, haptic storage, delay/contact factors, transparency, +oscillation, and all single-factor ablations +& No unresolved safety event; human-safe conditions selected without viewing +human outcomes\\ +G4 & Integrated and human evaluation +& Locked manipulation tasks followed by ethics-approved, counterbalanced +within-participant testing +& Complete reporting of successes, failures, withdrawals, and protocol +deviations\\ +\bottomrule +\end{tabularx} +\caption{Gated evidence plan. A later stage cannot be used to compensate for +an unresolved component or safety failure at an earlier gate.} +\label{tab:validation_gates} +\end{table*} + +\subsection{Current-Code Simulation and Numerical Verification} +\label{sec:current_code_validation} + +\subsubsection{G0a: deterministic software contracts} + +The present regression suite exercises the declared URDF, joint, and frame +contract; bounded SEW recovery and invalid-domain handling; an independent +directional finite-difference check of $A$; the fixed-branch virtual-work +identity; DLS recovery of a synthetic known wrench; frame-mismatch rejection; +the filter--rate-limit--saturation--projection order; exact storage-floor +projection; the unilateral virtual-wall sign convention; and finite execution +of a short closed-loop run. These tests are treated as executable +specifications, not as samples in a statistical performance comparison. + +Before the locked G0c batch, the suite and logger will additionally cover +calibrated $R_\Delta$ and TCP/F/T transforms, convergence over multiple +finite-difference steps, explicit master-domain checks, source-stamped held +differentials, independent packet age and timeout state, and per-sample +validity and failure-reason codes. The logger will separately persist +$\dot q_s^{\mathrm{map}}$, $\dot q_s^{\mathrm{ctrl}}$, actual $\dot q_s$, +$E[k]$, the unclipped $E^+[k+1]$, clipped $E[k+1]$, and the numerical +fail-safe state. Random seeds, solver tolerances, loop rates, delay queues, and +test configurations will be archived. + +\subsubsection{G0b: current closed-loop smoke test} + +The executable smoke test loads \texttt{master\_7dof.urdf} and +\texttt{real\_slave\_7dof.urdf}. It integrates both rigid-body models at +\SI{500}{\hertz}, updates and holds the retargeting differential at +\SI{50}{\hertz}, and applies the currently implemented fixed +\SI{80}{\milli\second} return delay. A computed-torque human proxy drives the +master and a computed-torque controller drives the slave through a scripted +approach--contact--return trajectory against a unilateral spring--damper wall. +The only three presently executable paired conditions are: +\begin{enumerate} + \item $A(q_m)^\top\hat\tau_{s,\mathrm{int}}$ with final applied-port + energy supervision; + \item $J_m^\top{}^C\!\hat F_{\mathrm{int}}$ with the same filter, rate + limit, saturation, and energy supervision; + \item $A(q_m)^\top\hat\tau_{s,\mathrm{int}}$ with only the energy + projection bypassed while all other guards remain active. +\end{enumerate} +The run stores joint/reference trajectories, synthetic residual and DLS-wrench +signals, raw/candidate/applied master torque, contact force and penetration, +power-identity errors, applied power, $\rho$, supervised and shadow storage, +tracking errors, mapping runtime, and aggregate mapping/limit events. +Consequently it can expose integration, sign, ordering, non-finite-state, and +storage-accounting defects. + +It cannot establish physical wrench accuracy, transparency, human +performance, or stability over an operating envelope. The simulated +measurement is generated from the same nominal dynamics used by the plant, +with prescribed bias and noise; the virtual TCP is not yet a calibrated +physical TCP; and the present output contains one trajectory, one seed, one +contact impedance, and one fixed return delay. Moreover, condition 1 uses a +generalized residual whereas condition 2 uses a DLS wrench. Their contrast is +therefore an end-to-end feedback-chain smoke test, not the matched-input +mapping comparison required by H3. + +\begin{table*}[t] +\centering +\footnotesize +\renewcommand{\arraystretch}{1.15} +\begin{tabularx}{\textwidth}{p{2.1cm} p{4.0cm} X X} +\toprule +\textbf{Subsystem} & \textbf{Available in the current repository} +& \textbf{Evidence it may support now} +& \textbf{Required before the next claim}\\ +\midrule +Model contract +& Both seven-DoF URDFs, declared joint/frame names, limits, and +master/common/slave frame checks +& Reproducible numerical model binding +& Measured $R_\Delta$, master alignment, and slave TCP/F/T calibration\\ +Retargeting +& Bounded SEW target/recovery, fallback logic, finite-difference $A$, and +aggregate success/validity counts +& Local algebra and deterministic regression checks +& Locked IK baselines, stratified trajectory generator, source stamps, and +per-sample failure reasons\\ +Interaction estimate +& Bias-corrected generalized residual and fixed-damping DLS wrench with +synthetic bias/noise +& Estimator algebra under the shared nominal model +& Dimensionally scaled Jacobian, frozen damping, model/friction +perturbations, and independent six-axis ground truth\\ +Haptic path +& $A^\top\hat\tau_s$, direct $J_m^\top\hat F$, output shaping, applied-port +energy projection, and shadow storage +& Virtual-work, execution-order, and storage-invariant checks +& Matched $A^\top J_s^\top\hat F$ baseline, locked PO/PC, source-stamped $A$, +and actuator-accepted torque audit\\ +Closed loop and network +& One numerical contact trajectory, one seed, fixed return-delay queues, and +CSV/NPZ/JSON output +& G0b smoke testing only +& Multi-trajectory runner, asymmetric time-varying channels, jitter/loss and +timeouts, complete per-sample state, and independent physical logs\\ +\bottomrule +\end{tabularx} +\caption{Code-to-evidence boundary. ``Available'' describes executable +capability, not a measured result. Items in the last column are completion +criteria rather than assumed properties.} +\label{tab:code_evidence_boundary} +\end{table*} + +\subsubsection{G0c: locked multi-run simulation design} + +G0c converts the single-run smoke test into a reproducible paired experiment +without elevating numerical output to hardware evidence. Four master-motion +families will be generated: free-space workspace sweeps, repeated +approach--contact--retreat motion, multi-axis contact probes, and trajectories +that deliberately approach joint limits, low manipulability, or SEW +reference-normal degeneracy. Every feedback method receives the same initial +state, trajectory identifier, random seed, and environment/network trace. + +The core software-only development grid uses fixed return delays of +\SIlist{0;40;80;120}{\milli\second} and three virtual impedance pairs +$(K,B)=\{(200,15),(500,30),(800,45)\}$ in +\si{\newton\per\metre} and \si{\newton\second\per\metre}, respectively. +The three implemented conditions above are run first. After their interfaces +are frozen, the matched-input +$A^\top J_s^\top{}^C\!\hat F_{\mathrm{int}}$ condition and the locked PO/PC +baseline are added. A preliminary minimum of 20 paired +trajectory--seed realizations per core cell is used to estimate variability; +the final locked count is set by confidence-interval precision on the +trajectory-level primary endpoint, not by treating time samples as independent +replicates. The extended generator must make these distinct master +trajectories as well as distinct perturbation realizations; changing only the +current synthetic sensor-noise seed does not create an independent task +trajectory. These numerical factor levels are a development grid and do not +define safe hardware settings. + +A separate one-factor-at-a-time robustness block varies measurement-noise and +bias magnitudes, finite-difference step, dynamics/inertial and friction-model +perturbations, and dynamics-to-mapping rate ratio. Jitter, packet loss, +asymmetric delay, out-of-order rejection, and timeout/recovery traces enter +only after the corresponding two-channel state machine and per-sample logger +pass G0a. This separation prevents an incomplete network mock-up from being +presented as delay robustness. + +The G0c experimental unit is one complete trajectory--seed realization. +Deterministic primary gates are maximum virtual-work-identity error, maximum +independently reconstructed pre-clip storage deficit, and absence of +unexplained non-finite states. Paired trajectory-level numerical outcomes are +the composite SEW event $C_r$, mapping-valid fraction, runtime tail +percentiles, normalized power mismatch +\eqref{eq:normalized_actual_power_mismatch}, projection distortion +\eqref{eq:projection_distortion}, contact-force and motion-tracking errors, +limit/saturation events, and shadow-budget deficit +\eqref{eq:shadow_floor_deficit}. The present single-run artifacts are retained +as development records but are excluded from the locked batch and from every +confirmatory confidence interval. + +\subsection{Instrumentation, Synchronization, and Safety} + +The final manuscript will report the manufacturer, model, measurement range, +resolution, sampling rate, calibration method, and calibration date of each +external instrument. The minimum independent instrumentation is: +\begin{itemize} + \item a calibrated six-axis force/torque sensor at the slave wrist; + \item an external pose-reference system or metrology procedure for + end-effector and exoskeleton alignment measurements; + \item calibrated load cells or torque transducers for actuator and + backdrivability characterization; + \item a common clock or measured clock-offset process for master, slave, + network, and reference sensors. +\end{itemize} + +Raw signals will be recorded before filtering. Logged variables will include +joint position, velocity, measured and commanded torque, reference wrench, +estimated wrench, SEW state, mapping differential, raw/candidate/applied haptic +torque, independently added compensation torque, total low-level command, +the command accepted by the drive, active current/thermal derating limits, +storage level, projection factor, packet delay, controller period, and safety +events. Before G3, componentwise command allocation must demonstrate that the +declared haptic increment reaches the actuator unchanged; otherwise the +post-allocation increment, rather than the pre-allocation software value, will +be used in \eqref{eq:applied_port_energy}. The nominal loop rate will not be +inferred from a configuration value; the measured period distribution, missed +deadlines, and 99th-percentile jitter will be reported. + +Joint position, velocity, torque, temperature, and workspace limits will be +derived from measured hardware ratings and a documented risk assessment. +High-stiffness, high-delay, and controller-ablation tests will first use a +rigidly mounted robotic input fixture or recorded-motion replay without a +person wearing the device. Automatic termination thresholds will be locked +before testing. Terminated trials will be retained and reported as safety +events rather than silently discarded. + +\subsection{Hardware Characterization} +\label{sec:hardware_characterization} + +Hardware characterization will precede teleoperation experiments. Each joint +of the wearable master will be evaluated in both motion directions at no fewer +than three representative arm configurations and with repeated trials per +condition. The slave bench will independently characterize its tracking, +residual-estimation, TCP/F/T, and command-acceptance chain rather than +inheriting the nominal URDF properties. The following properties will be +measured: +\begin{enumerate} + \item range of motion, encoder resolution, absolute accuracy, + repeatability, zero-offset drift, and human-joint alignment error; + \item commanded-to-measured torque gain, linearity, hysteresis, + continuous and peak torque, saturation, and thermal behavior; + \item static breakaway torque, velocity-dependent friction, passive + backdriving torque, and observable reflected inertia; + \item closed-loop torque bandwidth and phase lag using a safely bounded + chirp or multisine excitation; + \item structural stiffness and end-point deflection under calibrated + loads; + \item total mass, moving mass by segment, fitting range, donning time, and + emergency-stop latency; + \item sensor-to-actuator latency, control-period jitter, and packet-loss + behavior. +\end{enumerate} + +Motor current alone will not be used as torque ground truth. Backdrivability +will be reported as a function of joint angle and velocity. Aggregate torque +norms will be accompanied by per-joint values and percentages of measured +continuous and peak ratings. + +\subsection{SEW Retargeting Evaluation} +\label{sec:sew_protocol} + +The complete retargeting pipeline will be compared with: +\begin{enumerate} + \item anthropometrically scaled joint-space mapping; + \item damped least-squares IK with warm starting and joint-limit + regularization; + \item task-priority IK with an explicit elbow-swivel or manipulability + objective. +\end{enumerate} +These three baseline runners are not part of the present single-trajectory +simulator. They must share the same input and output contract, expose the same +validity/failure fields, and pass G0a before their parameters are frozen for +G0c and G2. +Baseline parameters will be selected on calibration trajectories under the same +joint limits, task weights, and compute-time budget as the proposed method. +The analytic SEW target construction and the platform-specific joint recovery +will also be timed and evaluated separately. + +The locked configuration set will stratify samples across the nominal +workspace, reach boundaries, joint-limit neighborhoods, low-manipulability +postures, and deliberate reference-normal degeneracies. The trajectory set +will include workspace sweeps, repeated reach--retract motion, overhead motion, +and passages through fallback regions. Dataset sizes +$N_q$ and $N_{\mathrm{traj}}$ will be set by a precision analysis so that the +bootstrap 95\% confidence-interval half-width for the paired +composite-event-rate difference is below a preregistered tolerance +$\Delta_{\mathrm{SEW}}$. The tolerance and resulting sizes will be frozen +before executing the locked set. + +Because master and slave link lengths differ, position error will be measured +relative to the feasible slave target rather than directly between physical +wrists: +\begin{equation} + e_p=\|p_{W_s}-p_{W_s}^{\mathrm{ref}}\|. +\end{equation} +Additional outcomes are +\begin{align} + e_{\mathrm{dir}} + &=\cos^{-1}\!\left( + \operatorname{clip}_{[-1,1]}(\hat{x}_s^\top\hat{x}_m)\right),\\ + e_\phi + &=\left|\operatorname{wrap}_{[-\pi,\pi)} + (\phi_s-\phi_m)\right|,\\ + e_R + &=\cos^{-1}\!\left( + \operatorname{clip}_{[-1,1]}\!\left[ + \frac{\operatorname{tr}((R_{s,\mathrm{EE}}^{\mathrm{ref}})^\top + R_{s,\mathrm{EE}})-1}{2}\right] + \right). +\end{align} +For locked trajectory $r$, let $v_k=1$ denote a valid bounded recovery, +$\Delta q_s^{\,w}[k]=\operatorname{wrap}(q_s[k]-q_s[k-1])$, and +$\Delta\phi_s^{\,w}[k]= +\operatorname{wrap}(\phi_s[k]-\phi_s[k-1])$. These increments are evaluated +between adjacent accepted 50-Hz retargeting updates. Define +\begin{align} + F_r&=\mathbf{1}\!\left\{\exists k: + v_k=0\ \vee\ e_p[k]>\bar e_p\right.\nonumber\\[-1mm] + &\hspace{37mm}\left.\vee\ e_R[k]>\bar e_R\right\},\\ + D_r&=\mathbf{1}\!\left\{\exists k: + |\Delta\phi_s^{\,w}[k]|>\bar\delta_\phi\right.\nonumber\\[-1mm] + &\hspace{31mm}\left.\vee\ + \|\Delta q_s^{\,w}[k]\|_\infty>\bar\delta_q\right\},\\ + C_r&=\max(F_r,D_r). + \label{eq:sew_composite_event} +\end{align} +The discontinuity thresholds apply only between adjacent, valid master +samples whose increment is below a frozen input-step threshold; commanded +resets and preregistered degeneracy transitions are labeled separately rather +than silently excluded. All tolerances in +\eqref{eq:sew_composite_event} are selected on calibration trajectories and +frozen before the locked set. Thus $D_r$, rather than an unobservable latent +branch label, is the operational definition of a branch-discontinuity event. +The single primary H1 endpoint is the paired +trajectory-level rate of $C_r=1$. Solver/tolerance failure $F_r$, +discontinuity $D_r$, elbow-position error, joint-limit violations, +reach-clipping rate, differential validity, trajectory jerk, and runtime are +secondary or diagnostic endpoints. Runtime on the deployed processor will be +reported using median, 95th-percentile, 99th-percentile, and maximum values. A +real-time claim will be made only if the locked timing criterion is satisfied. +Absolute, normalized, and calibrated reach-transfer policies are compared as +a separate ablation. + +\subsection{Interaction-Wrench Estimation} +\label{sec:wrench_protocol} + +The current shared-model simulation can verify residual/DLS algebra and +sensitivity to prescribed numerical perturbations, but it cannot supply +independent wrench ground truth or support H2. Physical H2 evidence begins +only after the following sensor and model-calibration protocol passes G1. + +The wrist force/torque sensor will provide independent ground truth after tool +gravity, payload, coordinate-transform, and timestamp calibration. The test +matrix will contain: +\begin{enumerate} + \item unloaded free-space motion for bias and false-contact assessment; + \item quasi-static forces and moments in positive and negative directions + along all six wrench axes; + \item bounded multiaxial dynamic loading and contact transients; + \item configurations stratified by the minimum singular value and + condition number of the frozen scaled Jacobian $\widetilde J_s$; + \item payload, inertial-parameter, friction, and torque-noise perturbations + within the characterized uncertainty envelope. +\end{enumerate} +Each load, pose, direction, and motion condition will be repeated in randomized +order. The repeat count will be chosen so that the 95\% interval half-width for +each co-primary RMSE is below preregistered force and moment tolerances. + +Comparisons will include an undamped residual pseudoinverse, fixed-damping +residual inversion, removal of identified friction compensation, and removal +of bias correction. Damping and all observer parameters will be selected only +from calibration data. If a wrench filter is added in an exploratory analysis, +it will be reported as a distinct, non-confirmatory method rather than folded +into the current estimator. + +Force and moment will be analyzed separately. Outcomes will include RMSE, MAE, +signed bias, 95th-percentile absolute error, direction error, correlation, +frequency-response gain and phase, contact-onset latency, free-space +false-positive rate, and peak error. Error will also be plotted against +Jacobian singular value, speed, acceleration, payload perturbation, and torque +noise. Near-singular trials will not be removed or hidden inside a +workspace-wide average. +Force RMSE and moment RMSE over the locked matrix are the co-primary estimator +endpoints; the remaining quantities diagnose when and why errors occur. + +\subsection{Mapping Consistency, Energy, Delay, and Transparency} +\label{sec:energy_protocol} + +The non-human fixture study will compare: +\begin{enumerate} + \item the differential-dual force map with haptic-energy projection; + \item the same force map without the energy projection; + \item a direct master-Jacobian-transpose wrench reflection with matched + scale, shaping, and saturation; + \item an $A^\top J_s^\top\hat F$ variant using the same DLS wrench as the + direct baseline; + \item a discrete-time passivity observer/controller (PO/PC) baseline + following Hannaford and Ryu~\cite{Hannaford2002TDPC}, implemented at the + same master haptic port. +\end{enumerate} +Only items 1--3 are executable in the current simulator, and even their +item~1--item~3 contrast is the end-to-end chain comparison identified in +Table~\ref{tab:code_evidence_boundary}. Item 4 is required before any pure +$A^\top$-versus-$J_m^\top$ mapping claim, and item 5 is required before the +fixture and human comparisons; both must pass G0a and the locked G0c runner +before G3. +The PO/PC baseline will receive the same raw mapping, upstream filter, nominal +gain, and actuator limits; only its passivity-intervention law differs, and its +parameters are selected from the calibration set. The primary H3 mapping +contrast is item 4 versus item 3 because both receive +the same reconstructed wrench; it will also be repeated offline with the +independent force/torque reference. The full proposed path in item 1 uses the +unprojected bias-corrected joint residual, whereas item 3 uses the DLS wrench. +Their comparison is therefore labeled an end-to-end chain comparison, not a +pure mapping ablation. +Signal filtering, torque limits, environment trajectories, and network traces +will be identical across paired comparisons. Single-factor ablations will +separately remove the generalized-torque filter, rate limiting, torque +saturation when fixture safety permits, and energy projection; multiple +elements will not be removed under one ablation label. ``Without energy +projection'' retains all other limits and is not described as an unprotected +controller. + +The environment will include free space and preregistered stiffness and damping +levels within the verified fixture and actuator safety envelope. A network +emulator will impose constant, asymmetric, and time-varying delays, bounded +jitter, and packet loss. The matrix will include a zero-delay reference, the +measured nominal network condition, at least two fixed-delay levels, and one +recorded jitter/loss trace within the G3 safety envelope. Exact levels and +repeat counts will be locked before confirmatory execution. +The present implementation contains only a fixed return-delay queue; it does +not yet implement independent forward/return channels, packet sequencing, +jitter/loss injection, or timeout transitions. The semantics below are +therefore requirements to be implemented and deterministically tested before +G0c/G3, not descriptions of the current executable. + +Network semantics are fixed independently of those traces. Let +$\kappa_f(k)$ and $\kappa_r(k)$ be the newest accepted source indices at slave +and master sample $k$ for the forward reference packet $u_f$ in +\eqref{eq:forward_held_reference} and the return residual packet, +respectively. The return packet contains the residual, reconstructed wrench, +$({}^{C}\!J_{E_s})^\top{}^C\!\hat F_{\mathrm{int}}$, actual slave velocity, +active forward-map identifier $\mu$, source timestamp, and sequence number; +this payload supports both direct and matched-wrench baselines. +Packets use synchronized clocks and monotonically increasing sequence numbers. +Duplicates, corrupted packets, and packets no newer than the last accepted +sequence number are discarded; no interpolation or retransmission is +introduced by the experiment. The last accepted sample is held until its +preregistered channel timeout. A forward timeout invokes the same bounded +zero-velocity safe hold in every condition. A return timeout sets the raw +haptic target to zero, after which the declared filter, rate limit, saturation, +and energy projection produce the controlled fade. This sets $\chi_r[k]=0$; +accepting a fresh in-time packet sets it to one. Every arrival, rejection, loss, +held-sample age, active $\mu$, and timeout transition is logged. Constant +return delay on the 500-Hz channel is the special case +$\kappa_r(k)=k-d$. Because forward packets exist only on the 50-Hz update set +$\mathcal I_f$, a constant forward delay $d_f$ is instead +\begin{equation} + \kappa_f(k)=\max\{i\in\mathcal I_f:t_i^{f}+d_f\le t_k^{s}\}, +\end{equation} +when that set is nonempty. Thus asymmetric and time-varying traces change the +held references and their echoed map identifiers without changing controller +semantics. + +The fixed-branch algebraic identity will first be checked using +\begin{equation} + e_P^{\mathrm{map}}[k]= + \tau_{h,\mathrm{raw}}[k]^\top\dot q_m[k] + -\chi_r[k]\sigma_f + \hat\tau_{s,\mathrm{int}}[\kappa_r(k)]^\top + \dot q_s^{\mathrm{map}}[k]. +\end{equation} +where the residual in the second term is the same delayed sample +$\hat\tau_{s,\mathrm{int}}[\kappa_r(k)]$ used in +\eqref{eq:raw_master_torque}. This is a numerical contract check rather than a +performance endpoint. Errors +$\|\dot q_s^{\mathrm{ctrl}}-\dot q_s^{\mathrm{map}}\|$ and +$\|\dot q_s-\dot q_s^{\mathrm{ctrl}}\|$ separately quantify limiting/hold and +tracking effects. The current-map and source-stamped delay policies are +compared using +\begin{equation} + e_{\tau,A}[k]= + \chi_r[k]\sigma_f\left\|\left(\bar A[k] + -\bar A[\mu(\kappa_r(k))]\right)^\top + \hat\tau_{s,\mathrm{int}}[\kappa_r(k)]\right\|_2 . +\end{equation} +The primary H3 endpoint uses actual slave velocity and aligned source/arrival +timestamps. For condition $c$, let $\tau_{s,c}$ be the source generalized +torque actually supplied to its mapping: the bias-corrected residual for the +full proposed chain and +$({}^{C}\!J_{E_s})^\top{}^C\!\hat F_{\mathrm{int}}$ for the two matched-wrench +mapping conditions. Then +\begin{align} + P_{m,c}^{\mathrm{raw}}[k] + &=\tau_{m,c}^{\mathrm{raw}}[k]^\top\dot q_m[k],\\ + P_{s,c}[\kappa_r(k)] + &=\chi_r[k]\tau_{s,c}[\kappa_r(k)]^\top + \dot q_s[\kappa_r(k)]. +\end{align} +\begin{equation} + \epsilon_{P,c}^{\mathrm{act}}= + \frac{\sum_k|P_{m,c}^{\mathrm{raw}}[k] + -\sigma_fP_{s,c}[\kappa_r(k)]|\Delta t_k} + {\frac{1}{2}\sum_k\left( + |P_{m,c}^{\mathrm{raw}}[k]| + +|\sigma_fP_{s,c}[\kappa_r(k)]|\right)\Delta t_k+\epsilon_P}. + \label{eq:normalized_actual_power_mismatch} +\end{equation} +Here $\epsilon_P>0$ has energy units and is a locked numerical regularizer +used only for near-zero-power trials. +To prevent the stored lower clip from hiding an accounting defect, every +sample will independently reconstruct +\begin{align} + E_{\mathrm{audit}}^+[k+1] + &=E[k]-\tau_{h,\mathrm{app}}[k]^\top + \dot q_m[k]\Delta t_k,\\ + \delta_E^-[k] + &=\max(0,E_{\min}-E_{\mathrm{audit}}^+[k+1]). +\end{align} +The budget component of H4 is a deterministic gate: all independently +reconstructed samples must satisfy +$\delta_E^-[k]\le\bar\delta_E$, where $\bar\delta_E$ is the frozen numerical +roundoff tolerance. To make the projection-bypass comparison explicit, each +paired trajectory $r$ also carries an independently reconstructed shadow +budget for condition $c$: +\begin{align} + E_{c,r}^{\mathrm{sh}}[0]&=E[0],\\ + E_{c,r}^{\mathrm{sh}}[k+1] + &=\min\!\left(E_{\max}, + E_{c,r}^{\mathrm{sh}}[k] + -P_{h,c}[k]\Delta t_k\right),\\ + B_{c,r}&=\max_k\max\!\left( + 0,E_{\min}-E_{c,r}^{\mathrm{sh}}[k]\right). + \label{eq:shadow_floor_deficit} +\end{align} +For the confirmatory enforcement contrast, $c=\mathrm{proj}$ uses the actual +projected-run output, while $c=\mathrm{shadow}$ is an offline counterfactual +that substitutes that same run's logged $\tau_h^{\mathrm{cand}}$ for the +applied torque without feeding it back into the filter, rate limiter, robot, or +trajectory; explicitly, +$P_{h,\mathrm{proj}}=\tau_{h,\mathrm{app}}^\top\dot q_m$ and +$P_{h,\mathrm{shadow}}=(\tau_h^{\mathrm{cand}})^\top\dot q_m$ on the same +logged samples. This avoids falsely assuming that two live closed-loop +conditions retain an identical candidate sequence after intervention. The primary +floor-deficit reduction is +$\Delta B_r=B_{\mathrm{shadow},r}-B_{\mathrm{proj},r}$ on a +calibration-selected, energy-challenging fixture subset. The live projection- +bypass run remains an end-to-end secondary comparison with its own +condition-specific candidate and trajectory. + +The single primary transparency-cost scalar is the trajectory-level, +torque-space projection distortion +\begin{equation} + D_{\mathrm{proj},r}= + \frac{\sum_k\|\tau_{h,\mathrm{app}}[k] + -\tau_h^{\mathrm{cand}}[k]\|_2\Delta t_k} + {\sum_k\|\tau_h^{\mathrm{cand}}[k]\|_2\Delta t_k+\epsilon_\tau}, + \label{eq:projection_distortion} +\end{equation} +where $\epsilon_\tau>0$ has units of \si{\newton\metre\second} and is frozen +for near-zero-torque trials. This metric quantifies transparency only at the +generalized-torque projection level. Lower is better, and an acceptable upper +limit $\bar D_{\mathrm{proj}}$ is set from calibration/fixture data before +confirmatory trials. H4 is accepted only if the deterministic audit passes, +the one-sided 95\% confidence interval for the paired mean +$\Delta B_r$ lies above a frozen nonnegative superiority margin, and the +one-sided 95\% upper confidence bound for mean $D_{\mathrm{proj},r}$ does not +exceed $\bar D_{\mathrm{proj}}$. Haptic-storage diagnostics will include +$\min_k(E_{\mathrm{audit}}^+[k]-E_{\min})$, +$\max_k\delta_E^-[k]$, discrepancy between the reconstructed pre-clip value +and the software pre-clip value, number and duration of violations beyond a +fixed numerical tolerance, and signed net released energy from +\eqref{eq:net_haptic_energy_bound}. +Secondary transparency outcomes will include motion-tracking error, +force-reflection gain and phase, contact-force error, time with +$\rho<\bar\rho$, and rendered-impedance error. Oscillation amplitude, settling +time, spectral energy, torque-limit events, safety stops, and divergent trials +will be reported as diagnostics. + +Any passivity or stability statement will be limited to its analytical +assumptions and experimentally tested envelope. The absence of instability in +a finite dataset will not be described as proof for arbitrary stiffness or +delay. + +\subsection{End-to-End Teleoperation Tasks} +\label{sec:end_to_end_protocol} + +Integrated testing will progress through: +\begin{enumerate} + \item three-dimensional free-space path tracking; + \item force-regulated following of an instrumented surface; + \item constrained insertion with randomized initial pose and clearance; + \item manipulation of an instrumented compliant or fragile object. +\end{enumerate} +Targets, paths, object parameters, timeouts, and termination rules will be +sampled from a locked set. The complete method will be compared with no +kinesthetic feedback and the locked PO/PC baseline. Unsafe +unregulated conditions will remain fixture-only. + +The primary integrated-task outcome is success probability under a +preregistered success definition. Completion time, Cartesian and force +tracking error, peak unintended contact force, contact impulse, unintended +contacts, insertion retries, and safety stops are secondary outcomes. A +timeout or safety stop counts as failure; completion time among successful +trials and failure probability are analyzed separately. + +\subsection{Human-Subject Protocol} +\label{sec:user_protocol} + +Participant recruitment will begin only after approval by the appropriate +institutional ethics committee. The approval identifier, informed-consent +procedure, inclusion and exclusion criteria, anthropometric fitting range, +compensation, and withdrawal procedure will be reported. The confirmatory +sample size will be determined by an a priori repeated-measures power analysis +for the primary contrast, with $\alpha=0.05$, power of at least 0.80, +anticipated attrition declared separately, and an effect-size assumption +supported by pilot data or relevant literature. The calculation and resulting +integer sample size will be preregistered before confirmatory recruitment. +Pilot participants used for tuning will not enter the confirmatory analysis. + +A randomized, counterbalanced, within-participant design will compare: +\begin{enumerate} + \item the proposed energy-supervised kinesthetic feedback; + \item the locked PO/PC kinesthetic baseline; + \item visual teleoperation without kinesthetic force feedback. +\end{enumerate} +Direct unregulated force reflection will not be applied to human participants. +Participants will receive standardized fitting, training, and +criterion-based familiarization. Condition and task order will be balanced, +and mandatory rest intervals will reduce fatigue. + +The primary human-performance outcome is surface-following force RMSE; any +change requires a dated protocol amendment before confirmatory recruitment. +Secondary outcomes include success, completion time, path error, peak +unintended force, contact impulse, corrective motions, and safety +interventions. Subjective outcomes include NASA-TLX workload +~\cite{Hart1988TLX}, perceived force +fidelity, comfort, and preference. A separate psychophysical stiffness- +discrimination block may be included only if it is fixed before recruitment; +it will not replace the preregistered robotic-task endpoint. + +\subsection{Statistical Analysis} +\label{sec:statistics_protocol} + +The configuration or trajectory is the experimental unit for component tests; +the participant is the experimental unit for the human study. Repeated trials +from one participant will not be treated as independent human samples. +Algorithm comparisons on identical inputs will use paired analyses. + +Component-level outcomes will be summarized using mean, standard deviation, +median, interquartile range, bootstrap 95\% confidence interval, and relevant +tail percentiles. Human continuous outcomes will be analyzed with +mixed-effects models containing feedback condition, task, delay when +applicable, and their preregistered interactions as fixed effects and +participant as a random effect. Binary success will use a logistic +mixed-effects model; ordinal responses will use an ordinal model when +appropriate. + +Assumptions, residuals, and heteroscedasticity will be checked. Robust, +transformed, or preregistered nonparametric alternatives will be used when +needed. The four confirmatory algorithmic outcome families are fixed as: +H1, the single composite event $C_r$ in +\eqref{eq:sew_composite_event}; H2, force RMSE and moment RMSE as two +co-primary estimator endpoints; H3, normalized actual power mismatch +$\epsilon_{P,c}^{\mathrm{act}}$; and H4, the deterministic storage gate plus +paired floor deficit $\Delta B_r$ and the single torque-level transparency-cost +endpoint $D_{\mathrm{proj},r}$. + +All confirmatory directions and margins are frozen from calibration and +engineering tolerances before the locked data are inspected. H1 uses the +matched task-priority IK as its primary comparator and passes when the +one-sided 95\% upper confidence bound for +$p(C_r=1)_{\mathrm{SEW}}-p(C_r=1)_{\mathrm{IK}}$ is below +$-\Delta_{\mathrm{H1}}$. H2 passes only when the corresponding upper bounds +for both +$\mathrm{RMSE}_{F,\mathrm{DLS}}-\mathrm{RMSE}_{F,\mathrm{undamped}}$ and +$\mathrm{RMSE}_{M,\mathrm{DLS}}-\mathrm{RMSE}_{M,\mathrm{undamped}}$ are below +their negative superiority margins. H3 passes when the upper bound for the +paired mean difference +$\epsilon_{P,A^\top J_s^\top F}^{\mathrm{act}} +-\epsilon_{P,J_m^\top F}^{\mathrm{act}}$ is below +$-\Delta_{\mathrm{H3}}$. H4 uses the three conjunctive rules stated after +\eqref{eq:projection_distortion}; failure of any one rejects H4. Every margin +is nonnegative and has zero as the fallback only when calibration cannot +justify a larger practically relevant value. + +These are one-sided superiority or acceptability decisions; two-sided 95\% +intervals and effect sizes will still be reported. Additional pairwise method +comparisons will use Holm correction within the corresponding H1--H4 family. +Integrated-task and human outcomes form separately declared external-validity +families; all remaining measures are labeled secondary or diagnostic. Exact +$p$ values will be reported. Missing data, aborted trials, sensor dropouts, +outlier rules, and protocol deviations will be disclosed. + +\subsection{Reproducibility and Data Availability} +\label{sec:reproducibility_protocol} + +The replication package will contain the exact URDF and calibration files, +controller parameters, test trajectories, network-emulator traces, random +seeds, anonymized trial-level data, and analysis and figure-generation scripts. +The operating system, processor, real-time settings, software versions, +execution thread, and timing method will be reported. Raw and filtered signals +will remain distinguishable, and coordinate transforms, spatial-vector order, +units, and sign conventions will be machine-readable. + +All parameter sweeps, failed trials, safety stops, and protocol deviations will +be retained. Each final table and figure will be generated from one versioned +analysis entry point, and the released code and data will be archived at an +immutable commit and DOI subject to participant privacy and hardware-vendor +restrictions. + +\section{Results} +\label{sec:results} + +No quantitative result is reported in the present working draft. After the +protocol has been executed, this section will report outcomes in the following +fixed order: (i) deterministic software contracts and the locked multi-run +simulation, explicitly labeled numerical, (ii) hardware characterization and +timing, (iii) component-level retargeting and wrench estimation, +(iv) non-human mapping-consistency, energy, and delay tests, +(v) end-to-end task performance, and (vi) human-subject outcomes. Failed and +terminated trials will be included according to the rules above. + + +\section{Discussion and Limitations} +\label{sec:discussion} + +\subsection{Methodological Trade-offs} + +\textbf{SEW degeneracy and branch continuity.} +The reference-normal construction becomes ill-conditioned when the +shoulder--wrist direction approaches the reference axis. A fallback direction +keeps the equations defined but does not automatically make the threshold +transition smooth. Similarly, an analytic Cartesian elbow target does not +guarantee a continuous slave joint trajectory. The current implementation uses +warm-started recovery and validity gates rather than swivel hysteresis or +normal blending; failure behavior must therefore be evaluated explicitly. + +\textbf{Reach feasibility and differential validity.} +Reach clipping makes the target feasible but changes the local motion scale and +may introduce a nonsmooth boundary. The differential map in +\eqref{eq:retarget_differential} is therefore valid only on a fixed smooth +branch. Clipping and joint-limit events must be excluded from a smooth-map +proof or handled by a hybrid formulation, while remaining included in the +empirical failure analysis. + +\textbf{Residual-wrench identifiability.} +The inverse-dynamics residual contains contact torque together with inertial, +friction, bias, and sensing errors. Damped inversion reduces amplification but +introduces bias and cannot recover unobservable wrench components. Independent +force/torque ground truth and configuration-stratified analysis are necessary +before describing the estimator as robust. + +\textbf{Energy constraint versus bilateral stability.} +The energy projection constrains only the sampled haptic feedback contribution +under a specific timing and sign convention. Dynamics compensation, device +mechanics, the communication channel, and the slave port lie outside this +storage. A stronger total-port or bilateral claim requires an explicit +two-port treatment and a proof whose assumptions include filters, saturation, +sampling, compensation, and delay. + +\textbf{Transparency versus intervention.} +Any reduction of candidate torque used to preserve an energy reserve alters the +rendered impedance. The relevant question is therefore not whether the method +is simultaneously ``fully transparent'' and constrained, but how much force +fidelity, phase, and task performance are lost for a measured reduction in +released energy or oscillatory behavior. + +\subsection{Current Limitations and Validity Threats} + +\begin{itemize} + \item \textbf{Prototype status.} The master exoskeleton is under + fabrication, so mass, torque capability, backdrivability, bandwidth, + alignment, comfort, timing, and safety performance are not yet empirical + contributions. + + \item \textbf{Implementation and calibration status.} The software core now + includes bounded recovery, guarded $A$, the declared spatial-vector order, + direct residual mapping, and final haptic-energy projection. The current + simulation nevertheless assumes aligned base axes, a simulated TCP, + synchronous clocks, fixed delay, and simplified rigid-body contact. + Calibration ingestion for the master/common/slave transforms and + $R_\Delta$ is not yet implemented; timestamps, torque calibration, and + total-port energy measurements also remain pending. + + \item \textbf{Computational burden.} The current wrapper executes two + nominal recoveries and fourteen perturbed recoveries per differential + update. The present multirate implementation does not establish real-time + feasibility on the target processor; validity rate and tail latency may + require solve reuse, an analytic or differentiated map, or a reduced-order + alternative. + + \item \textbf{Model dependence.} Wrench reconstruction depends on the + slave rigid-body model, payload identification, friction compensation, and + measured joint torque. Conclusions may not transfer to position-controlled + robots without comparable sensing. + + \item \textbf{Finite evaluation envelope.} Contact stiffness, delay, + jitter, packet loss, motion speed, subject anthropometry, and tasks will + cover finite preregistered ranges. Claims must remain within those ranges. + + \item \textbf{Human-study generalizability.} A laboratory within-subject + study cannot by itself establish long-term comfort, training effects, or + performance in field environments. Participant diversity and device-fit + exclusions must be disclosed. +\end{itemize} + +These limitations are incorporated into the prospective protocol rather than +being inferred after observing favorable or unfavorable results. + +\section{Conclusion} +\label{sec:conclusion} + +This paper formulates a geometry- and energy-aware architecture for +heterogeneous 7-DoF exoskeleton teleoperation. The central distinction is +between an analytic SEW Cartesian target, platform-specific joint recovery, and +the differential map that defines a virtual-work-consistent force-reflection +objective. The proposed feedback path maps a bias-corrected generalized +slave residual directly through $A^\top$; DLS wrench reconstruction is retained +for physical interpretation and Cartesian baselines. Final-stage radial +projection supplies a one-step energy invariant for the shaped haptic +contribution, not for the complete bilateral system. + +The present working draft intentionally makes no quantitative hardware, +real-time, accuracy, passivity, stability, transparency, or usability claim. +Instead, it fixes the evidence required to evaluate those claims: measured +exoskeleton characterization, paired retargeting baselines, independent +six-axis wrench ground truth, finite contact and network envelopes, +single-factor ablations, end-to-end tasks, and a powered human-subject study. +After the prototype and software implementation are complete, the protocol will +be executed without replacing failed trials or retrospectively changing the +primary outcomes. The abstract, results, discussion, and conclusion will then +be updated using measured effect sizes and uncertainty intervals. + +% conference papers do not normally have an appendix + +% Add an acknowledgment section only after funding and contributor information +% have been confirmed. +% \section*{Acknowledgment} + +% trigger a \newpage just before the given reference +% number - used to balance the columns on the last page +% adjust value as needed - may need to be readjusted if +% the document is modified later +% The "triggered" command can be changed if desired: +%\IEEEtriggercmd{\enlargethispage{-5in}} +\IEEEtriggeratref{24} + +% references section + +% can use a bibliography generated by BibTeX as a .bbl file +% BibTeX documentation can be easily obtained at: +% http://mirror.ctan.org/biblio/bibtex/contrib/doc/ +% The IEEEtran BibTeX style support page is at: +% http://www.michaelshell.org/tex/ieeetran/bibtex/ +%\bibliographystyle{IEEEtran} +% argument is your BibTeX string definitions and bibliography database(s) +%\bibliography{IEEEabrv,../bib/paper} +% +% manually copy in the resultant .bbl file +% set second argument of \begin to the number of references +% (used to reserve space for the reference number labels box) + +\begin{thebibliography}{99} + +\bibitem{Rebelo2014Teleop} +J. Rebelo, T. Sednaoui, E. B. den Exter, T. Krueger, and A. Schiele, +``Bilateral robot teleoperation: A wearable arm exoskeleton featuring an intuitive user interface,'' +\textit{IEEE Robotics and Automation Magazine}, vol.~21, no.~4, pp.~62--69, 2014. + +\bibitem{Jiang2019Telerehab} +J.~Chen, D.~Hu, W.~Sun, X.~Tu, and J.~He, +``A novel telerehabilitation system based on bilateral upper limb exoskeleton robot,'' +in \emph{Proc. IEEE Int. Conf. Real-time Computing and Robotics (RCAR)}, 2019, pp.~391--396. + +\bibitem{Gull2020ExoReview} +M.~A.~Gull, S.~Bai, and T.~Bak, +``A review on design of upper limb exoskeletons,'' +\emph{Robotics}, vol.~9, no.~1, p.~16, 2020. + +\bibitem{Kim2017Harmony} +B.~Kim and A.~D.~Deshpande, +``An upper-body rehabilitation exoskeleton Harmony with an anatomical shoulder mechanism: Design, modeling, control, and performance evaluation,'' +\emph{Int. J. Robotics Research}, vol.~36, no.~4, pp.~414--435, 2017. + +\bibitem{Pisetskiy2021MRclutch} +S.~Pisetskiy and M.~Kermani, +``High-performance magneto-rheological clutches for direct-drive actuation: Design and development,'' +\emph{J. Intelligent Material Systems and Structures}, vol.~32, no.~20, pp.~2582--2600, Oct.~2021. + +\bibitem{Wang2023Dexcap} +C.~Wang, H.~Shi, W.~Wang, R.~Zhang, L.~Fei-Fei, and K.~Liu, +``DexCap: Scalable and portable mocap data collection system for dexterous manipulation,'' +in \emph{Proc. Robotics: Science and Systems (RSS)}, 2024, +doi: 10.15607/RSS.2024.XX.043. + +\bibitem{Fang2023Airexo} +H.~Fang, H.-S.~Fang, Y.~Wang, J.~Ren, J.~Chen, R.~Zhang, W.~Wang, and C.~Lu, +``Airexo: Low-cost exoskeletons for learning whole-arm manipulation in the wild,'' +in \emph{Proc. IEEE Int. Conf. Robotics and Automation (ICRA)}, 2024, pp.~15031--15038. + +\bibitem{Zimmermann2020Transparency} +Y.~Zimmermann, E.~B.~Kucuktabak, F.~Farshidian, R.~Riener, and M.~Hutter, +``Towards dynamic transparency: Robust interaction force tracking using multi-sensory control on an arm exoskeleton,'' +in \emph{Proc. IEEE/RSJ Int. Conf. Intelligent Robots and Systems (IROS)}, 2020, pp.~7417--7424. + +\bibitem{Indri2020FrictionID} +M.~Indri and S.~Trapani, +``Framework for static and dynamic friction identification for industrial manipulators,'' +\emph{IEEE/ASME Trans. Mechatronics}, vol.~25, no.~3, pp.~1589--1599, 2020. + +\bibitem{Buongiorno2018WRES} +D.~Buongiorno, E.~Sotgiu, D.~Leonardis, S.~Marcheschi, M.~Solazzi, and A.~Frisoli, +``WRES: A novel 3-DoF wrist exoskeleton with tendon-driven differential transmission for neuro-rehabilitation and teleoperation,'' +\emph{IEEE Robotics and Automation Letters}, vol.~3, no.~3, pp.~2152--2159, Jul.~2018. + +\bibitem{Yan2014ShoulderExo} +H.~Yan, C.~Yang, Y.~Zhang, and Y.~Wang, +``Design and validation of a compatible 3-degrees-of-freedom shoulder exoskeleton with an adaptive center of rotation,'' +\emph{J. Mechanical Design}, vol.~136, no.~7, p.~071006, 2014. + +\bibitem{Lee2014Teaching} +H. Lee, J. Kim, and T. Kim, +``A robot teaching framework for a redundant dual arm manipulator with teleoperation from exoskeleton motion data,'' +in \textit{2014 IEEE-RAS International Conference on Humanoid Robots (Humanoids)}, 2014, pp.~1057--1062. + +\bibitem{Yang2024ACE} +S.~Yang, M.~Liu, Y.~Qin, R.~Ding, J.~Li, X.~Cheng, R.~Yang, S.~Yi, and X.~Wang, +``ACE: A cross-platform and visual-exoskeletons system for low-cost dexterous teleoperation,'' +in \emph{Proc. 8th Conf. Robot Learning (CoRL)}, ser. PMLR, +vol.~270, pp.~4895--4911, 2025. + +\bibitem{HOMIE2025} +Qingwei Ben, Fang Jia, Jiang Zeng, Jiahui Dong, Dahua Lin, and Jiangmiao Pang, +``HOMIE: Humanoid loco-manipulation with isomorphic exoskeleton cockpit,'' +arXiv preprint arXiv:2502.13013, 2025. + +\bibitem{Qin2023AnyTeleop} +Y.~Qin, W.~Yang, B.~Huang, K.~Van Wyk, H.~Su, X.~Wang, Y.-W.~Chao, and D.~Fox, +``AnyTeleop: A general vision-based dexterous robot arm-hand teleoperation system,'' +in \emph{Proc. Robotics: Science and Systems (RSS)}, 2023, +doi: 10.15607/RSS.2023.XIX.015. + +\bibitem{Fu2024MobileALOHA} +Z.~Fu, T.~Z.~Zhao, and C.~Finn, +``Mobile ALOHA: Learning bimanual mobile manipulation using low-cost whole-body teleoperation,'' +in \emph{Proc. 8th Conf. Robot Learning (CoRL)}, ser. PMLR, +vol.~270, pp.~4066--4083, 2025. + +\bibitem{Iyer2024OpenTeach} +A.~Iyer, Z.~Peng, Y.~Dai, I.~Guzey, S.~Haldar, S.~Chintala, and L.~Pinto, +``OPEN TEACH: A versatile teleoperation system for robotic manipulation,'' +in \emph{Proc. 8th Conf. Robot Learning (CoRL)}, ser. PMLR, +vol.~270, pp.~2372--2395, 2025. + +\bibitem{Sinha2019IK} +A. Sinha and N. Chakraborty, +``Geometric search-based inverse kinematics of 7-DoF redundant manipulator with multiple joint offsets,'' +in \textit{2019 International Conference on Robotics and Automation (ICRA)}, 2019, pp.~5592--5598. + +\bibitem{Brahmi2019IK} +Bilel Brahmi, Maarouf Saad, M. H. Rahman, and C. Ochoa-Luna, +``Cartesian trajectory tracking of a 7-DOF exoskeleton robot based on human inverse kinematics,'' +\textit{IEEE Transactions on Systems, Man, and Cybernetics: Systems}, vol.~49, no.~3, pp.~600--611, 2019. + +\bibitem{Cheng2024HomoHetero} +Cheng Cheng, Wen Dai, Tianyu Wu, Xinyu Chen, Ming Wu, Jia Yu, Jiahui Jiang, and Hongqiang Lu, +``Efficient and precise homo-hetero teleoperation based on an optimized upper limb exoskeleton,'' +\textit{IEEE/ASME Transactions on Mechatronics}, vol.~30, no.~5, +pp.~3722--3734, 2025, doi: 10.1109/TMECH.2024.3479873. + +\bibitem{Mohammadi2013DO} +Amir Mohammadi, M. Tavakoli, Humberto J. Marquez, and F. Hashemzadeh, +``Nonlinear disturbance observer design for robotic manipulators,'' +\textit{Control Engineering Practice}, vol.~21, no.~3, pp.~253--267, 2013. + +\bibitem{Toedtheide2023ForceSensitive} +Alexander Toedtheide, Xuanchen Chen, Hamed Sadeghian, Amir Naceri, and Sami Haddadin, +``A Force-Sensitive Exoskeleton for Teleoperation: An Application in Elderly Care Robotics,'' +in \textit{2023 IEEE International Conference on Robotics and Automation (ICRA)}, 2023, pp.~12624--12630. + +\bibitem{Forouhar2024TactileExo} +Mohammad Forouhar, Hamed Sadeghian, Diego Pablo Suay, Amir Naceri, and Sami Haddadin, +``A tactile lightweight exoskeleton for teleoperation: Design and control performance,'' +in \textit{2024 IEEE/RSJ International Conference on Intelligent Robots and Systems (IROS)}, 2024, pp.~178--183. + +\bibitem{Li2020VisionTeleop} +Shihui Li, Xuguang Ma, Hongyi Liang, Matthias G\"{o}rner, Philipp Ruppel, Bowen Fang, Fuchun Sun, and Jianwei Zhang, +``Vision-based teleoperation of Shadow dexterous hand using end-to-end deep neural network,'' +in \textit{2019 International Conference on Robotics and Automation (ICRA)}, 2019, pp.~416--422. + +\bibitem{Hannaford2002TDPC} +B.~Hannaford and J.-H.~Ryu, +``Time-domain passivity control of haptic interfaces,'' +\emph{IEEE Trans. Robotics and Automation}, vol.~18, no.~1, pp.~1--10, +2002, doi: 10.1109/70.988969. + +\bibitem{Buongiorno2019Teleop} +Dario Buongiorno, Davide Chiaradia, Simone Marcheschi, Marco Solazzi, and Antonio Frisoli, +``Multi-DOFs exoskeleton-based bilateral teleoperation with the time-domain passivity approach,'' +\textit{Robotica}, vol.~37, no.~9, pp.~1641--1662, 2019. + +\bibitem{Porcini2020Passivity} +Francesco Porcini, Davide Chiaradia, Simone Marcheschi, Marco Solazzi, and Antonio Frisoli, +``Evaluation of an exoskeleton-based bimanual teleoperation architecture with independently passivated slave devices,'' +in \textit{2020 IEEE International Conference on Robotics and Automation (ICRA)}, 2020, pp.~10205--10211. + +\bibitem{Elias2024SEW} +A.~J.~Elias and J.~T.~Wen, +``Redundancy parameterization and inverse kinematics of 7-DoF revolute manipulators,'' +\textit{Mechanism and Machine Theory}, vol.~204, art.~no.~105824, 2024, +doi: 10.1016/j.mechmachtheory.2024.105824. + +\bibitem{Wu2023Gello} +P.~Wu, Y.~Shentu, Z.~Yi, X.~Lin, and P.~Abbeel, +``Gello: A general, low-cost, and intuitive teleoperation framework for robot manipulators,'' +in \emph{Proc. IEEE/RSJ Int. Conf. Intelligent Robots and Systems (IROS)}, +2024, pp.~12156--12163. + +\bibitem{Rong2020FrankMocap} +Yifei Rong, Takeo Shiratori, and Hanbyul Joo, +``FrankMocap: Fast monocular 3D hand and body motion capture by regression and integration,'' +arXiv preprint arXiv:2008.08324, 2020. + +\bibitem{DeLuca2006Collision} +Alessandro De~Luca, Alin Albu-Schaffer, Sami Haddadin, and Gerd Hirzinger, +``Collision detection and safe reaction with the DLR-III lightweight manipulator arm,'' +in \textit{2006 IEEE/RSJ International Conference on Intelligent Robots and Systems (IROS)}, +IEEE, 2006, pp.~1623--1630. + +\bibitem{Wang2022InteractionTorque} +Yuan Wang, Arvin Zahedi, Yichao Zhao, and Dapeng Zhang, +``Extracting human-exoskeleton interaction torque for cable-driven upper-limb exoskeleton equipped with torque sensors,'' +\textit{IEEE/ASME Transactions on Mechatronics}, vol.~27, no.~6, pp.~4269--4280, 2022. + +\bibitem{Hart1988TLX} +S.~G.~Hart and L.~E.~Staveland, +``Development of NASA-TLX (Task Load Index): Results of empirical and +theoretical research,'' +in \emph{Human Mental Workload}, P.~A.~Hancock and N.~Meshkati, Eds. +Amsterdam, The Netherlands: North-Holland, 1988, pp.~139--183. + +\end{thebibliography} + + +% that's all folks +\end{document} diff --git a/paper/exoskeleton/assets/explain.jpg b/paper/exoskeleton/assets/explain.jpg new file mode 100644 index 0000000..21d788c Binary files /dev/null and b/paper/exoskeleton/assets/explain.jpg differ diff --git a/paper/exoskeleton/assets/joint_distribution.jpg b/paper/exoskeleton/assets/joint_distribution.jpg new file mode 100644 index 0000000..56b23ac Binary files /dev/null and b/paper/exoskeleton/assets/joint_distribution.jpg differ diff --git a/paper/exoskeleton/assets/overview.jpg b/paper/exoskeleton/assets/overview.jpg new file mode 100644 index 0000000..b583965 Binary files /dev/null and b/paper/exoskeleton/assets/overview.jpg differ diff --git a/paper/exoskeleton/assets/sew_geometry.png b/paper/exoskeleton/assets/sew_geometry.png new file mode 100644 index 0000000..1fc0375 Binary files /dev/null and b/paper/exoskeleton/assets/sew_geometry.png differ diff --git a/paper/exoskeleton/assets/shoulder.jpg b/paper/exoskeleton/assets/shoulder.jpg new file mode 100644 index 0000000..9dc891f Binary files /dev/null and b/paper/exoskeleton/assets/shoulder.jpg differ diff --git a/paper/exoskeleton/assets/swivel_angle.png b/paper/exoskeleton/assets/swivel_angle.png new file mode 100644 index 0000000..279c330 Binary files /dev/null and b/paper/exoskeleton/assets/swivel_angle.png differ diff --git a/paper/exoskeleton/assets/two_sphere_intersection.png b/paper/exoskeleton/assets/two_sphere_intersection.png new file mode 100644 index 0000000..a66d139 Binary files /dev/null and b/paper/exoskeleton/assets/two_sphere_intersection.png differ diff --git a/paper/exoskeleton/assets/wrist.jpg b/paper/exoskeleton/assets/wrist.jpg new file mode 100644 index 0000000..7ad0629 Binary files /dev/null and b/paper/exoskeleton/assets/wrist.jpg differ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1e883e3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,39 @@ +[build-system] +requires = ["setuptools>=75", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "exoskeleton-teleoperation-evidence" +version = "0.1.0" +description = "Pre-prototype evidence pipeline for heterogeneous 7-DoF bilateral teleoperation" +requires-python = ">=3.10,<3.11" +dependencies = [ + "cmeel-tinyxml2==10.0.0", + "cmeel-urdfdom==4.0.1", + "matplotlib==3.10.7", + "mujoco==3.5.0", + "numpy==2.2.6", + "omegaconf==2.3.0", + "pin==3.8.0", + "PyYAML==6.0.3", + "scipy==1.15.3", +] + +[project.scripts] +exo-experiment = "experiments.cli:main" +exo-paper-artifacts = "analysis.make_paper_artifacts:main" + +[tool.setuptools] +package-dir = {"" = "code"} + +[tool.setuptools.packages.find] +where = ["code"] +include = ["analysis*", "core*", "experiments*", "utils*"] + +[tool.ruff] +line-length = 88 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "I", "W"] +ignore = ["E501"] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..88ac6a2 --- /dev/null +++ b/uv.lock @@ -0,0 +1,657 @@ +version = 1 +revision = 3 +requires-python = "==3.10.*" + +[[package]] +name = "absl-py" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/4f/d79676ab82f2e42fc3611618139f13a9c4c31d0cff4b486982047679a802/absl_py-2.5.0.tar.gz", hash = "sha256:0c996f25c0490700fadabe6351630f6111534fa0ae252cc6d2014ea3b141135f", size = 118119, upload-time = "2026-07-03T10:57:48.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/0a/a10b45aab35b175aded078a462dc8d0c698f5b13946e7cb0869097b78bb6/absl_py-2.5.0-py3-none-any.whl", hash = "sha256:0f17b89f2a4eaaedc4f28c622998aa690564b3012a396a4ffad0821007fe03ba", size = 137410, upload-time = "2026-07-03T10:57:46.735Z" }, +] + +[[package]] +name = "antlr4-python3-runtime" +version = "4.9.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/38/7859ff46355f76f8d19459005ca000b6e7012f2f1ca597746cbcd1fbfe5e/antlr4-python3-runtime-4.9.3.tar.gz", hash = "sha256:f224469b4168294902bb1efa80a8bf7855f24c99aef99cbefc1bcd3cce77881b", size = 117034, upload-time = "2021-11-06T17:52:23.524Z" } + +[[package]] +name = "cmeel" +version = "0.60.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/46/ddc7df697e49cae32b1a97b3e1a3b47b815238f9059312f987bc62a2e756/cmeel-0.60.1.tar.gz", hash = "sha256:3e0b92eb933a3693ad3f1da8aae31defcbee5f25969daaf20e59c57d6a9474cf", size = 14972, upload-time = "2026-05-11T17:13:57.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/39/f2db2ff475d42222c70fe25c737028aeaafdd9c0aeba04e6b2dc66e7f93f/cmeel-0.60.1-py3-none-any.whl", hash = "sha256:3f92b68353a58b4d6b5a664ea96bf58a3fef0891a8ed570d3c153361bbbb94b7", size = 20612, upload-time = "2026-05-11T17:13:55.812Z" }, +] + +[[package]] +name = "cmeel-assimp" +version = "6.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, + { name = "cmeel-zlib" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/57/444bedd35567a8d6b61850b03aadf6b0d2d20737ee79c620c0e40f9a56dc/cmeel_assimp-6.0.5-0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:840ab4bd398abbec5a612a731bb6f7fdc4e21b2708ae0dc0f99d7ff6d9992624", size = 10057855, upload-time = "2026-05-20T16:36:19.775Z" }, + { url = "https://files.pythonhosted.org/packages/6f/96/d4d128e074f59b79e90c78b8379127bd8953c72d1ad8a65229c23a09814f/cmeel_assimp-6.0.5-0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d81f913e9efc8526a335900325906b85dc57891eeb7ece463714a107f14f63f", size = 9216472, upload-time = "2026-05-20T16:36:22.369Z" }, + { url = "https://files.pythonhosted.org/packages/77/37/11de8f263a5cde3cde4796e344ff034674bdbb7a2381f6b5303d49b24def/cmeel_assimp-6.0.5-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:db6fe58f8bd633cb05ab9f390c576a4e774927ed8c343083fad4f939e834bf6e", size = 13709230, upload-time = "2026-05-20T16:36:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/62/66/7f12b2f003671828a46ab35964aed0a34632fcba5638b4049bdd918cf342/cmeel_assimp-6.0.5-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:7b5ee36f3027de9b1bd73bd4294b60fed1bbc6c1524f94f64f69f38c5a49bf03", size = 14840275, upload-time = "2026-05-20T16:36:28.311Z" }, +] + +[[package]] +name = "cmeel-boost" +version = "1.89.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/7c/4d9bbc00d4f9286a48d38ffdcf030fe50c99fe00d3601303270740f22424/cmeel_boost-1.89.0.tar.gz", hash = "sha256:e28d4aa61f4b8dbcb6cb83e732e1076fe4f5a3a0d338d73d1c0821944b37a332", size = 4158, upload-time = "2025-08-22T22:29:36.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/6b/36e51770bfa546bb182eacc0a9c88cfb9817aa2914305cfd8d31ff7d5ae5/cmeel_boost-1.89.0-0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:509729c9a3549753df5b219773fef76b1be90e052089e40a6193d1fea4861f80", size = 30666822, upload-time = "2025-08-22T22:28:19.58Z" }, + { url = "https://files.pythonhosted.org/packages/40/35/89b58a680189f511d7543a89a7e647943d0058e81639badde448b8deaa23/cmeel_boost-1.89.0-0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ad20fdf62d41eb74c3cba4c0f8d32d49f40bc2bf0b1fb508d97f63f911f7cece", size = 30565392, upload-time = "2025-08-22T22:28:23.378Z" }, + { url = "https://files.pythonhosted.org/packages/45/10/f59c72182391176fc18f76cab503df57bf10234d5b13856dd1f44237679c/cmeel_boost-1.89.0-0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:82aaaed1bb6703664f87bc1ec666703ef917ff67baa61491c8796e13853bbd91", size = 35676696, upload-time = "2025-08-22T22:28:27.258Z" }, + { url = "https://files.pythonhosted.org/packages/79/85/7f61c694bf55a239a3821c65767e5d104adaf0faa890c9c63d8ed4dc44b1/cmeel_boost-1.89.0-0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:78fd13be11d7c570e67400f5e734cf00787fe268cee7fd6bdde466ffd13e12be", size = 36013432, upload-time = "2025-08-22T22:28:32.225Z" }, + { url = "https://files.pythonhosted.org/packages/82/39/4ff562a699082dfc93a521418655653fe6223a61480cc58aab4333093864/cmeel_boost-1.89.0-1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7b7f0c174c7a9216cf0221b36bb0bac81e62f9cc1c25a6082474b1dedee9c62e", size = 30658750, upload-time = "2025-10-15T23:59:22.997Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e6/e468de28bcb1140d8a61d45f6eb88c8cd79b1fa3ccdd538f58204dbae681/cmeel_boost-1.89.0-1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e76274a984c3f69bd57bbb8923e8acd68b1e65fe7d73df69393e2a5bca601094", size = 30551561, upload-time = "2025-10-15T23:59:26.451Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d8/129cd530587cbcf032120f80176d67f06592e9dc62dddd7dde5f8ce80edc/cmeel_boost-1.89.0-1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:849308a26b07c268f4b2b68a2a8730ffa82e3dc8569bb016088c542c89117580", size = 35676698, upload-time = "2025-10-15T23:59:29.852Z" }, + { url = "https://files.pythonhosted.org/packages/74/96/3b6a784577ff6e69a32b65c970d27ad1a8705645c7cdef181fa475fafd0d/cmeel_boost-1.89.0-1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:2d35dd5a8bc06f75d3c0c1d2db38e700693bf87c7f40a7cbd52af29a51df64c0", size = 36013428, upload-time = "2025-10-15T23:59:33.402Z" }, +] + +[[package]] +name = "cmeel-console-bridge" +version = "1.0.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/13/2e9e9d23db8548aef975564055bdb4fb6da8a397a1e7df8cb61f5afebefb/cmeel_console_bridge-1.0.2.3.tar.gz", hash = "sha256:3b2837da7ab408e9d1a775c83c0a7772356062b3a3672e4ce247f2da71a8ecd9", size = 262061, upload-time = "2025-03-19T18:22:06.845Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/a7/527fa060e5881acb3b0a07bf1d803ccb831cb87739abb62b6bcd14f5aed3/cmeel_console_bridge-1.0.2.3-0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:7aa19b2d006073a1fad55d32968c7d0c7136749e06f98405f4f73a71038a5c41", size = 21341, upload-time = "2025-03-19T18:21:56.834Z" }, + { url = "https://files.pythonhosted.org/packages/bb/db/f8643a8766e8909e0dbfcda6191ca92454cf9a3fadd89be417db261601a1/cmeel_console_bridge-1.0.2.3-0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c47d8c97cb120feed1c01f30845d16c67e4e8205941e3977951018972b9b8721", size = 21286, upload-time = "2025-03-19T18:21:57.984Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b4/9c79177152a220ab2e4ffa0140722165035f6a5c2abbed2912352bd7e7b9/cmeel_console_bridge-1.0.2.3-0-py3-none-manylinux_2_17_i686.whl", hash = "sha256:cad9723ac44ab563cd23bf361b604733623d11847c4edf2a2b4ebd1d984ade09", size = 23740, upload-time = "2025-03-19T18:21:59.683Z" }, + { url = "https://files.pythonhosted.org/packages/92/65/5741de6f550fe701d0780546d97b283306676315a3e1f379a6038e8c0ab0/cmeel_console_bridge-1.0.2.3-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:372942e9c44f681bfff377fba25b348801283aa6f3826a00e4195089bda9737a", size = 25762, upload-time = "2025-03-19T18:22:01.055Z" }, + { url = "https://files.pythonhosted.org/packages/50/a5/70e23c5570506bb39b56aa4d0f3a4a414e38082ddb33e86a48b546620121/cmeel_console_bridge-1.0.2.3-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:5bb1115ed38441b2396e732e10ec63d1e68445674f9f5d321f7985eb10e9aeef", size = 24477, upload-time = "2025-03-19T18:22:02.091Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/bfd5a255348902e39243ccc6eba693bce714b891cd3be5603a9bd50c6de5/cmeel_console_bridge-1.0.2.3-0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2b8d084b797f592942208c2040b08e06b82f8832aa6c5e582ba6f1a4a653505b", size = 24970, upload-time = "2025-03-19T18:22:03.075Z" }, + { url = "https://files.pythonhosted.org/packages/b3/02/3ae074e9acb9e150a4d5d97f341c2064573cd5fe9e5af20ab58bf8c0020a/cmeel_console_bridge-1.0.2.3-0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:fb6753a9864217d969c4965389d66a476ac978136c03eadf1063b1619c359220", size = 24689, upload-time = "2025-03-19T18:22:04.084Z" }, + { url = "https://files.pythonhosted.org/packages/69/d0/321f74b7d4167a6c59bb7714a6899ba402d9fad611f62573b9d646107320/cmeel_console_bridge-1.0.2.3-0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9d446c0fc541413d8d2ceea3c1cfb9cbfd57938d6659c113121eca6c245caafe", size = 24404, upload-time = "2025-03-19T18:22:05.232Z" }, +] + +[[package]] +name = "cmeel-octomap" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/ab/2fed2dbee13e4b39949591685419f1dbb691295e32a6bbbaf87edc005922/cmeel_octomap-1.10.0.tar.gz", hash = "sha256:bd79d1d17adede534de242e42e13ef0d9f04bdd27daf7d56c57f7c43670c9b05", size = 1694189, upload-time = "2025-01-06T17:57:05.477Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/22/ea67d35df31ec4bb2ed6e594b173c572c72dbd2a87e96906eac67b4af930/cmeel_octomap-1.10.0-4-py3-none-macosx_14_0_arm64.whl", hash = "sha256:c116eb151920d26ee2b2c1f656cd7526862006739817205f11f9366ab0ef6cb4", size = 639956, upload-time = "2025-01-06T17:56:56.826Z" }, + { url = "https://files.pythonhosted.org/packages/b2/da/07725a8c11224881f536ad252e97a3d9801b48e5e776017d5f00fb39b17f/cmeel_octomap-1.10.0-4-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:76cc42553f54bae97584aaf0c7bc33753ff287e2738aa2ecac4820121101dd46", size = 1044402, upload-time = "2025-01-06T17:56:58.553Z" }, + { url = "https://files.pythonhosted.org/packages/a2/15/9617b7039afd6d17d3148f6f970d953f5e265d7736f8fdbca09c86e976a0/cmeel_octomap-1.10.0-4-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:5fdb04546fff3accac5f8626c3fc15c3b99e94ab887793565e0b92cedaf96468", size = 1105037, upload-time = "2025-01-06T17:57:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/51/69/88c1d1eca1abf2387ee8263ac7e12708c8b1b5b70b46a0bd9f43b485165b/cmeel_octomap-1.10.0-4-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:84a7376cfced954bb7e3e347afbd02bdc1c83066b995afbdd0fb1e2d9f57ebec", size = 1108359, upload-time = "2025-01-06T17:57:04.085Z" }, + { url = "https://files.pythonhosted.org/packages/f5/59/57b3b38cf7a382855902b9d24266c283c29d977706438e6b7af62df74e2b/cmeel_octomap-1.10.0-5-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:042b4a21b5e5e19ee78a9a7db78e1b06fb8a287c832031788aec0d3fcabbfecd", size = 748832, upload-time = "2025-02-12T11:57:34.252Z" }, + { url = "https://files.pythonhosted.org/packages/55/8b/f5ec7676808a48c0185e216c0da700e34cb13ba233f13a4557a5ec56324a/cmeel_octomap-1.10.0-5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:79c15a0ece5ca3746170088ef2a377dfb3df8326fafde9bdba688852219758b9", size = 706924, upload-time = "2025-02-12T11:57:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/09/50/56de5a4d9f8ca58100146f16f42c4e2fbb49c0957bfe40d3fd2bc910afe4/cmeel_octomap-1.10.0-5-py3-none-manylinux_2_17_i686.whl", hash = "sha256:e2923bf593ebdafed86b6f3890a122c62fbd9cc9f325d60dbecb72b6b60d78fb", size = 1073973, upload-time = "2025-02-12T11:57:37.914Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f5/8dddf5cdd31176288acd85cc8bf0262b7c3de81d5cb2cb33aa6646f44eb1/cmeel_octomap-1.10.0-5-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d9e6f9c826905e8de632e9df8cc20e59ce2eb5d1e0b368d8d4abbbc5c0829c1a", size = 1044533, upload-time = "2025-02-12T11:57:39.672Z" }, + { url = "https://files.pythonhosted.org/packages/d6/14/b85bd33bb05c9bb7e87b9ac8401793c12a80a6d594b3ca4bcb5e971a24b7/cmeel_octomap-1.10.0-5-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:b0b54fac180dce4f483afe7029c29cc55f6f2b21be8413e8e2275845b0c204d7", size = 1105199, upload-time = "2025-02-12T11:57:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/fe3360441159974ebdbb4c013a92ad0425d5f8bf414868d5161060e40660/cmeel_octomap-1.10.0-5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c8691e665bab7c12b6f51e6c5fbbb83ee6f91dce9d15d9d0387553950e7fb5ee", size = 1092962, upload-time = "2025-02-12T11:57:43.297Z" }, + { url = "https://files.pythonhosted.org/packages/82/a6/074166544cc0ce3a5d7844f97dfd13d1b3ec7bff6a6e2cfb18d66a671a7f/cmeel_octomap-1.10.0-5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:735c0ad84dacbbcc8c4237f127c57244c236b7d6c7500b5c45a4c225e19daac1", size = 1083321, upload-time = "2025-02-12T11:57:46.121Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a3/b19ea0d30837369091141b248936b0757ee17f58b809007399bad0b398e4/cmeel_octomap-1.10.0-5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5f86a83f6bd60de290cd327f0374d525328369e76591e3ab2ad1bc0b183678c4", size = 1109207, upload-time = "2025-02-12T11:57:48.709Z" }, +] + +[[package]] +name = "cmeel-qhull" +version = "8.0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/dd/8d0bcfb18771b2ea02bf85dfbbc587c97b274496fb5419b72134eb69430b/cmeel_qhull-8.0.2.1.tar.gz", hash = "sha256:68e8d41d95f61830f2d460af1e4d760f0dbe4d46413d7c736f0ed701153ebe52", size = 1308055, upload-time = "2023-11-17T14:21:06.003Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/b4/d72ebd5e9ee711b68ad466e7bd4c0edcb45b0c2c8a358fdcdb64b092666a/cmeel_qhull-8.0.2.1-0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:39f5183a6e026754c3c043239bac005bf1825240d72e1d8fdf090a0f3ea27307", size = 2804225, upload-time = "2023-11-17T14:15:39.958Z" }, + { url = "https://files.pythonhosted.org/packages/29/dc/4bfb8d51a09401cf740e66d10bdb388eacd7c73bae12ef78149cbbc93e83/cmeel_qhull-8.0.2.1-0-py3-none-macosx_12_0_x86_64.whl", hash = "sha256:f135c5a4f4c8ed53f061bc86b794aaca2c0c34761c9269c06b71329c9da56f82", size = 2972481, upload-time = "2023-11-17T14:20:58.418Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7c/74b5c781cbfc8e4a9bb73b71659cc595bc0163223fd700b18133dbcf2831/cmeel_qhull-8.0.2.1-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:17f519106df79aed9fc5ec92833d4958d132d23021f02a78a9564cdf83a36c7c", size = 3078962, upload-time = "2023-11-17T14:21:00.183Z" }, + { url = "https://files.pythonhosted.org/packages/b4/16/ef7b6201835ba2492753c9c91b266d047b6664507be42ec858e2b24673b5/cmeel_qhull-8.0.2.1-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:c513abafa40e2b8eb7cd3640e3f92d5391fbd6ec0f4182dbf9536934d8a8ea3e", size = 3194917, upload-time = "2023-11-17T14:21:01.879Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ae/200bdf257507e2c95d0656bf02278cd666d49f0a9e2e6d281ea76d7d085c/cmeel_qhull-8.0.2.1-0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:20a69cb34b6250aee1f018412989734c9ddcad6ff66717a7c5516fc19f55d5ff", size = 3290068, upload-time = "2023-11-17T14:21:03.828Z" }, + { url = "https://files.pythonhosted.org/packages/01/1b/de3fa6091ef58ab40f02653e777c8943acf7cec486184d6007885123571d/cmeel_qhull-8.0.2.1-1-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:b5d47b113c1cb8f519bc813cf015d0d01f8ce5b08912733a24a6018f7caa6e96", size = 2902499, upload-time = "2025-02-12T11:51:16.999Z" }, + { url = "https://files.pythonhosted.org/packages/05/0c/5e5d9a033c683eb272508ccf560c03ac6bf5d397b038fe05f896a2283eaf/cmeel_qhull-8.0.2.1-1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:33a0169f4ee37d093c450195b0ef73d4fe0d9d62abb7899ebe79f778b36e1f36", size = 2773563, upload-time = "2025-02-12T11:51:19.893Z" }, + { url = "https://files.pythonhosted.org/packages/52/9b/00c73069348e60fbbdf6a5a10de046083f7d1ad36844958bbf12163ac688/cmeel_qhull-8.0.2.1-1-py3-none-manylinux_2_17_i686.whl", hash = "sha256:a577e76ac94d128f2966b137ead9f088749513df63749728e2b588f4564b7fdf", size = 3228684, upload-time = "2025-02-12T11:51:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/c0/4a/81b8c88b444935a64d8c83b41e662f696c36dd5937c3ca687113ac4778d0/cmeel_qhull-8.0.2.1-1-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:fd0b2d4ce749b102c3cdead4588249befd34f1a660628f6bfc090ce942925aac", size = 3156051, upload-time = "2025-02-12T11:51:24.594Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c1/44874cd8bfc1e3f7cb15678c836c7a1d5537f34f5a727a0207e01f395598/cmeel_qhull-8.0.2.1-1-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:2371a7c80a14f3e874876359ae3e3094861f081fcdd7a03987c3e880d14e07b9", size = 3262508, upload-time = "2025-02-12T11:51:27.147Z" }, + { url = "https://files.pythonhosted.org/packages/54/0e/425d9ce1f2a831025d39fa5b6479b856bd4d73614c9caa690ac72bbfca04/cmeel_qhull-8.0.2.1-1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:197c14c2006dbeba8f5a5771700a7afea72c1a441aab7cdeaaf10b4ed8c1137d", size = 3172646, upload-time = "2025-02-12T11:51:28.967Z" }, + { url = "https://files.pythonhosted.org/packages/00/c1/e973e287a7d793911b8e6497b17586e601a678f2379ba2c615f72bd76480/cmeel_qhull-8.0.2.1-1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:886d1be24b31842286ae42755af5c312a43a4199632826e4110185ec36dc5c6a", size = 3530837, upload-time = "2025-02-12T11:51:31.651Z" }, + { url = "https://files.pythonhosted.org/packages/fd/65/c6cd54f04b5fcaa4ec52f5b57692c1dcef812ff9ee86545e5607369d365e/cmeel_qhull-8.0.2.1-1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1a49ce7f8492c9a8b49f930e34cce75b5e9b9843b015033dd0a25421441159fc", size = 3301908, upload-time = "2025-02-12T11:51:34.53Z" }, +] + +[[package]] +name = "cmeel-tinyxml2" +version = "10.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/9f/030eca702c485f7a641f975f167fa93164911b3329f005fb0730ff5e793f/cmeel_tinyxml2-10.0.0.tar.gz", hash = "sha256:00252aefc1c94a55b89f25ad08ee79fda2da8d1d94703e051598ddb52a9088fe", size = 645297, upload-time = "2025-02-06T10:29:00.106Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/5d/bc3a932eb7996a0a789979426a9bb8a3948bf57f3f17bab87dddbef62433/cmeel_tinyxml2-10.0.0-0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:924499bb1b60b9a17bd001d12a9af88ddbee4ca888638ae684ba7f0f3ce49e87", size = 111913, upload-time = "2025-02-06T10:28:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/92/bf/67d11e123313c034712896e94038291fe506bb099bdb75a136392002ffd0/cmeel_tinyxml2-10.0.0-0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:26a1eb30c2a00bfc172e89ed015a18b8efb2b383546252ca8859574aed684686", size = 109487, upload-time = "2025-02-06T10:28:47.546Z" }, + { url = "https://files.pythonhosted.org/packages/ca/48/d8c81ce19b4b278ed0e8f81f93ae8670209bf3a9ac20141b9c386bb40cc7/cmeel_tinyxml2-10.0.0-0-py3-none-manylinux_2_17_i686.whl", hash = "sha256:53d86e02864c712f51f9a9adfcd8b6046b2ed51d44a0c34a8438d93b72b48325", size = 160118, upload-time = "2025-02-06T10:28:49.627Z" }, + { url = "https://files.pythonhosted.org/packages/87/4e/62193e27c9581f8ba7aeaeca7805632a64f2f4a824b1db37ad02ee953e8a/cmeel_tinyxml2-10.0.0-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:74112e2e9473afbf6ee2d25c9942553e9f6a40465e714533db72db48bc7658e1", size = 158477, upload-time = "2025-02-06T10:28:51.667Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/d0420c39e9ade99beeec61cd3abc68880fe6e14d85e9df292af8fabe65c8/cmeel_tinyxml2-10.0.0-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:ecd6e99caa2a06ac0d4b333b740c20fca526d0ca426f99eb5c0a0039117afdb6", size = 147025, upload-time = "2025-02-06T10:28:53.944Z" }, + { url = "https://files.pythonhosted.org/packages/66/9e/df63147fc162ab487217fa5596778ab7a81a82d9b3ce4236fd3a1e48cecb/cmeel_tinyxml2-10.0.0-0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:30993fffb7032a45d5d3b1e5670cb879dad667a13144cd68c8f4e0371a8a3d2e", size = 150958, upload-time = "2025-02-06T10:28:55.301Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a8/b03567275fd83f5af33ddb61de942689dec72c5b21bec01e6a5b11101aa5/cmeel_tinyxml2-10.0.0-0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8c09ede51784af54211a6225884dc7ddbb02ea1681656d173060c7ad2a5b9a3c", size = 160300, upload-time = "2025-02-06T10:28:57.189Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ec/2781635b66c1059ca1243ae0f5a0410e171a5d8b8a71be3e34cb172f9f2d/cmeel_tinyxml2-10.0.0-0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3bd511d6d0758224efdebc23d3ead6e94f0755b04141ebf7d5493377829e8332", size = 149184, upload-time = "2025-02-06T10:28:58.734Z" }, +] + +[[package]] +name = "cmeel-urdfdom" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, + { name = "cmeel-console-bridge" }, + { name = "cmeel-tinyxml2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/09/be81a5e7db56f34b6ccdbe7afe855c95a18c8439e173519e0146e9276a8c/cmeel_urdfdom-4.0.1.tar.gz", hash = "sha256:2e3f41e8483889e195b574acb326a4464cf11a3c0a8724031ac28bcda2223efc", size = 291511, upload-time = "2025-02-12T12:07:09.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/d0/20147dd6bb723afc44a58d89ea624df2bad1bed7b898a2df112aaca4a479/cmeel_urdfdom-4.0.1-0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:2fe56939c6b47f6ec57021aac154123da47ecdcd79a217f3a5e3c4b705a07dee", size = 300860, upload-time = "2025-02-12T12:06:58.536Z" }, + { url = "https://files.pythonhosted.org/packages/8e/98/f832bca347e2d987c6b0ebb6930caf7b2c402535324aeed466b6aa2c4513/cmeel_urdfdom-4.0.1-0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:00a0aba78b68c428b27abeed1db58d73e65319ed966911a0e97b37367442e756", size = 300616, upload-time = "2025-02-12T12:07:00.556Z" }, + { url = "https://files.pythonhosted.org/packages/cf/10/bf5765b6f388037cff166a754a0958ac2fee34ca3c0975ef64d0324e4647/cmeel_urdfdom-4.0.1-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:a701a8f9671331f11b18ecf37a6537db546a21e6a0e5d0ff53341fea0693ed7f", size = 385951, upload-time = "2025-02-12T12:07:02.556Z" }, + { url = "https://files.pythonhosted.org/packages/c3/82/cb3f8f587d293a17bdbea15b50cdaa4a1e28e04583eb4cb4821685b89466/cmeel_urdfdom-4.0.1-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:12e39fc388c077d79fc9b3841d3d972a1da90b90de754d3363194c1540e18abf", size = 399619, upload-time = "2025-02-12T12:07:04.388Z" }, + { url = "https://files.pythonhosted.org/packages/24/77/322d7ac92c692d8dfaeda9de2d937087d15e2b564dc457d656e5fde3991d/cmeel_urdfdom-4.0.1-0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c4a83925df1d5923c4485c3eb2b80b3a61b14f119ab724fb5bd04cec494690ee", size = 373969, upload-time = "2025-02-12T12:07:06.222Z" }, + { url = "https://files.pythonhosted.org/packages/9f/63/bdc6b55cc8bd99bb9dce6be801b30feffaa1c3841ecb7f4fe4d137424518/cmeel_urdfdom-4.0.1-0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:4c4f44270971b3d05c45a4e21b1fb2df7e05a750363ae918f59532bff0bfe0e1", size = 388237, upload-time = "2025-02-12T12:07:08.326Z" }, + { url = "https://files.pythonhosted.org/packages/1d/2d/8463fc23230612daf4da1e31d3229f47708381f3ae4d1500f0f007ac0f92/cmeel_urdfdom-4.0.1-1-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:f7535158f45992eb2ba79e90d9db1bf9adc3846d9c7ed3e7a8c1c4d5343afa37", size = 301006, upload-time = "2025-02-13T11:42:08.8Z" }, + { url = "https://files.pythonhosted.org/packages/0f/d5/c8cdf500e49300d85624cbc3ef804107ddcdc9c541b1d3f726bfb58a9fc1/cmeel_urdfdom-4.0.1-1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fef2a01a00d61d41b3d35dd4958bba973e9025c26eea1d3c9880932f4dba89a5", size = 300758, upload-time = "2025-02-13T11:42:10.449Z" }, + { url = "https://files.pythonhosted.org/packages/cf/b3/2f7bac1544113a7f8e0f6d8b1fab5e75c6a3d27ffbb584b03267251b2165/cmeel_urdfdom-4.0.1-1-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:7a52eb36950ce982014d99a55717ca29985da056e3705f20746f15d3244c1f7a", size = 386043, upload-time = "2025-02-13T11:42:11.923Z" }, + { url = "https://files.pythonhosted.org/packages/86/03/8bdeb36ba6a3e8125d523ecfc010403049e463fe589f9896858d4bdcaf1e/cmeel_urdfdom-4.0.1-1-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:9f3b9c80b10d7246821ff61c2573f799e3da23d483e6f7367ddcad8a48baf58f", size = 399719, upload-time = "2025-02-13T11:42:14.325Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ed/43f99e7512460294cd8acc5753ba25f8a20bdf28d62e143eaf3ec7a28bb6/cmeel_urdfdom-4.0.1-1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2de69f47e8312cc09157624802d5bdaad6406443f863fb4b9ec62a19b4de3c72", size = 374073, upload-time = "2025-02-13T11:42:17.907Z" }, + { url = "https://files.pythonhosted.org/packages/17/c6/2e9bde6d7c02c1cf203ea896f8ce1afd441412f09b44830f1ee4a96d77de/cmeel_urdfdom-4.0.1-1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7708c1402de450fbeab21f7ca264a9a4676ed4c1cdf8d84d840bc5d057aac920", size = 388337, upload-time = "2025-02-13T11:42:19.657Z" }, +] + +[[package]] +name = "cmeel-zlib" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/85/9c6b77d5c49b363b17ba974271d58730bd26cbe00b1c576596339bb624ea/cmeel_zlib-1.3.2.tar.gz", hash = "sha256:6e31f2956c334de9a7e19a4e4f8eed030ed8c8016c841ea57226ce8bed443712", size = 3200, upload-time = "2026-05-20T16:24:37.87Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/e7/c60533fb6f638fe92d56b79edbcff70ed74a7f19b3ace981532efab5c3dd/cmeel_zlib-1.3.2-0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:88a89882340391aa263ed1f7448fe0177e6fb9b93740cad381228437879f16a9", size = 1035696, upload-time = "2026-05-20T16:24:28.538Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/dfcc8b9c2989e7342aaac1781d147b06b31197337d1968029216be26ee43/cmeel_zlib-1.3.2-0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:94a2f1adcc811617eab849b5771e608fa20e2fc44fb8cbd2e56a3e4d496461c1", size = 957505, upload-time = "2026-05-20T16:24:30.151Z" }, + { url = "https://files.pythonhosted.org/packages/3c/e9/6a3d6732e23fcf7072973f9ca8251eaef3e6738e46d6846064dd92e13331/cmeel_zlib-1.3.2-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:3b07fb7e28aaa917e6accb59a29dd0f5c1d272d42988f32ba2c232f5f455be4c", size = 1055975, upload-time = "2026-05-20T16:24:31.561Z" }, + { url = "https://files.pythonhosted.org/packages/70/a4/d345605b6f8c9d665baa1dd2f839ad6aee0d771e174be12c81fdd53067ed/cmeel_zlib-1.3.2-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:4658000c5531273d14ca8f0250abcd2a2ad85b336f10a273a77ecb38611a5f5a", size = 1052274, upload-time = "2026-05-20T16:24:33.078Z" }, + { url = "https://files.pythonhosted.org/packages/9b/9d/83737c38cc106e1b4959393da7744d5180b9ab5eac0369fc6ba0ea6c6428/cmeel_zlib-1.3.2-0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:06cbf60a361ca18e8a4c46a238149cebcaf955047a60830705e130aa397b5116", size = 1057248, upload-time = "2026-05-20T16:24:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/cb/28/b378182b0f33ed9e9c091b3d1e8d86a6f2d69add107087b2e09adcb12137/cmeel_zlib-1.3.2-0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2a3425a65a0adf0ddbb48d41204a0d9c13197bd693f7821d1450e8a969d77352", size = 1055409, upload-time = "2026-05-20T16:24:36.219Z" }, +] + +[[package]] +name = "coal" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, + { name = "cmeel-assimp" }, + { name = "cmeel-boost" }, + { name = "cmeel-octomap" }, + { name = "cmeel-qhull" }, + { name = "eigenpy" }, + { name = "libcoal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/4f/9b1f2cb921827aa877c09f6e727215fb633e4e3671682bd2a6559cd42d09/coal-3.0.2.tar.gz", hash = "sha256:7ca3f961fe72962b543894492efb33ee71bdc1091d93b87dc6988cdf0d4dedca", size = 1463955, upload-time = "2025-10-16T00:52:33.982Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/da/8b4758f8183d6808e542f97b5719b191ceda8f23e5958a1c3324535b9049/coal-3.0.2-1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eab5b68f1e25babd10a5d788bdce2ae61196c3e548c900ff8d060462e60e5194", size = 1612332, upload-time = "2025-10-16T00:51:56.269Z" }, + { url = "https://files.pythonhosted.org/packages/5e/14/21ba9435ce088452f903cc54312e04fd337d00f63f1a5cc90ceb37511dba/coal-3.0.2-1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bb21e74d9071f87629026c1177b3c346145630869dc136cc4704899f3dfbf9db", size = 1505686, upload-time = "2025-10-16T00:51:57.948Z" }, + { url = "https://files.pythonhosted.org/packages/51/6c/68c42fe06b1ee8c5962edb4c9cecd9e8a042ebc5f850510d76dcb5beea0b/coal-3.0.2-1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6d97c0137b22a41e03090d044824596f76ccc065407f6fc538af7aedb2995306", size = 2218478, upload-time = "2025-10-16T00:51:59.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/6977e63ca97451b6888f69531d26513b64ce94235aa06ea49b24b0e2bb12/coal-3.0.2-1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:89af2fcc4f74474487e8e42ced4a2222db81ec50d27f0f9482fec9ca6309cad4", size = 2216338, upload-time = "2025-10-16T00:52:01.211Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/a3/da4153ec8fe25d263aa48c1a4cbde7f49b59af86f0b6f7862788c60da737/contourpy-1.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba38e3f9f330af820c4b27ceb4b9c7feee5fe0493ea53a8720f4792667465934", size = 268551, upload-time = "2025-04-15T17:34:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6c/330de89ae1087eb622bfca0177d32a7ece50c3ef07b28002de4757d9d875/contourpy-1.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc41ba0714aa2968d1f8674ec97504a8f7e334f48eeacebcaa6256213acb0989", size = 253399, upload-time = "2025-04-15T17:34:51.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/bd/20c6726b1b7f81a8bee5271bed5c165f0a8e1f572578a9d27e2ccb763cb2/contourpy-1.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9be002b31c558d1ddf1b9b415b162c603405414bacd6932d031c5b5a8b757f0d", size = 312061, upload-time = "2025-04-15T17:34:55.961Z" }, + { url = "https://files.pythonhosted.org/packages/22/fc/a9665c88f8a2473f823cf1ec601de9e5375050f1958cbb356cdf06ef1ab6/contourpy-1.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2e74acbcba3bfdb6d9d8384cdc4f9260cae86ed9beee8bd5f54fee49a430b9", size = 351956, upload-time = "2025-04-15T17:35:00.992Z" }, + { url = "https://files.pythonhosted.org/packages/25/eb/9f0a0238f305ad8fb7ef42481020d6e20cf15e46be99a1fcf939546a177e/contourpy-1.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e259bced5549ac64410162adc973c5e2fb77f04df4a439d00b478e57a0e65512", size = 320872, upload-time = "2025-04-15T17:35:06.177Z" }, + { url = "https://files.pythonhosted.org/packages/32/5c/1ee32d1c7956923202f00cf8d2a14a62ed7517bdc0ee1e55301227fc273c/contourpy-1.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad687a04bc802cbe8b9c399c07162a3c35e227e2daccf1668eb1f278cb698631", size = 325027, upload-time = "2025-04-15T17:35:11.244Z" }, + { url = "https://files.pythonhosted.org/packages/83/bf/9baed89785ba743ef329c2b07fd0611d12bfecbedbdd3eeecf929d8d3b52/contourpy-1.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cdd22595308f53ef2f891040ab2b93d79192513ffccbd7fe19be7aa773a5e09f", size = 1306641, upload-time = "2025-04-15T17:35:26.701Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cc/74e5e83d1e35de2d28bd97033426b450bc4fd96e092a1f7a63dc7369b55d/contourpy-1.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4f54d6a2defe9f257327b0f243612dd051cc43825587520b1bf74a31e2f6ef2", size = 1374075, upload-time = "2025-04-15T17:35:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/0c/42/17f3b798fd5e033b46a16f8d9fcb39f1aba051307f5ebf441bad1ecf78f8/contourpy-1.3.2-cp310-cp310-win32.whl", hash = "sha256:f939a054192ddc596e031e50bb13b657ce318cf13d264f095ce9db7dc6ae81c0", size = 177534, upload-time = "2025-04-15T17:35:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/54/ec/5162b8582f2c994721018d0c9ece9dc6ff769d298a8ac6b6a652c307e7df/contourpy-1.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c440093bbc8fc21c637c03bafcbef95ccd963bc6e0514ad887932c18ca2a759a", size = 221188, upload-time = "2025-04-15T17:35:50.064Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/b26e3c6ecc05f349ee0013f0bb850a761016d89cec528a98193a48c34033/contourpy-1.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fd93cc7f3139b6dd7aab2f26a90dde0aa9fc264dbf70f6740d498a70b860b82c", size = 265681, upload-time = "2025-04-15T17:44:59.314Z" }, + { url = "https://files.pythonhosted.org/packages/2b/25/ac07d6ad12affa7d1ffed11b77417d0a6308170f44ff20fa1d5aa6333f03/contourpy-1.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:107ba8a6a7eec58bb475329e6d3b95deba9440667c4d62b9b6063942b61d7f16", size = 315101, upload-time = "2025-04-15T17:45:04.165Z" }, + { url = "https://files.pythonhosted.org/packages/8f/4d/5bb3192bbe9d3f27e3061a6a8e7733c9120e203cb8515767d30973f71030/contourpy-1.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ded1706ed0c1049224531b81128efbd5084598f18d8a2d9efae833edbd2b40ad", size = 220599, upload-time = "2025-04-15T17:45:08.456Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "eigenpy" +version = "3.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, + { name = "cmeel-boost" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/a5/7ec1dc873df269332c84e5b79b033fe53d55c5fd6517bd6d8bb5fb24e707/eigenpy-3.12.0.tar.gz", hash = "sha256:e9d07219df1e61e45db6e42001697c5637743b3ad3e0bfcf069fc94c5fab218d", size = 6556548, upload-time = "2025-08-23T17:54:34.041Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/6d/25e69e262ec336c3b51eebfae9da536f57e93970b434dc7d8506ed3ee3f7/eigenpy-3.12.0-0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:27525792572d6d2cdc7dc407b253280b7f52b9b4dda900ad9cc4b27879e251f2", size = 5635279, upload-time = "2025-08-23T17:53:58.956Z" }, + { url = "https://files.pythonhosted.org/packages/05/cf/fc8729917859ce949d7ab07f0853fe4df45555322e56765f98152e5f2bf1/eigenpy-3.12.0-0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a3e993c2adc4029673d0578dadec20c5a683c3de5b7768abf98129767e57c40a", size = 4865885, upload-time = "2025-08-23T17:54:01.012Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b0/651af0c2db36f1ad62b2a40d439b65c13da8b38bd0e0a1bf67f3af6d0034/eigenpy-3.12.0-0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:291c21c38a1faeb1823e6f821be1910b7751e0fecf12047217f98c3ab748183b", size = 6200013, upload-time = "2025-08-23T17:54:03.107Z" }, + { url = "https://files.pythonhosted.org/packages/c1/90/c0fc4227cf3f02b60cbdcf36946eb1239683273057d242b97e9af7a44ee3/eigenpy-3.12.0-0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:5d94b52d087d9f317e1029a48cdf170bfca7f0e1c10581b4ce79b1d067fc5e1a", size = 6004382, upload-time = "2025-08-23T17:54:04.667Z" }, + { url = "https://files.pythonhosted.org/packages/f4/53/d6c7ef75acd8ac099b1ae9e2209936fb0ada9c675dfd6762c4392be19df5/eigenpy-3.12.0-1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6d660cd9ebdff808f4e9d49027e9ae5621d19de069deef67ef226e5d48cdcfb2", size = 5415254, upload-time = "2025-10-15T20:13:13.051Z" }, + { url = "https://files.pythonhosted.org/packages/5d/31/a7358942489a31edbded67e00771ff18261c67efddb9f37bdb353633e493/eigenpy-3.12.0-1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37c05f8431f9edbd5db2ab04a01d004b894929b70c6faea0f80004f97ddb2e1f", size = 4226332, upload-time = "2025-10-15T20:13:15.092Z" }, + { url = "https://files.pythonhosted.org/packages/2e/38/4d06a01b1fe3efb9c9bc10521d9718d5a263b316306429220bd2da8e134e/eigenpy-3.12.0-1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:c5cbbee1f043baed9900a99918fb4326151be0145f793f8129fd1b0b938f2974", size = 6200413, upload-time = "2025-10-15T20:13:16.952Z" }, + { url = "https://files.pythonhosted.org/packages/fe/38/5aff8d72ebaf891a07f1368176508f75cc9e7bf8967d07f7fa1baedc6ac7/eigenpy-3.12.0-1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:f579f3b23754f2156b43f27906481bc00fd46030c4e061e8e770cd02aacc3d88", size = 6037835, upload-time = "2025-10-15T20:13:18.826Z" }, +] + +[[package]] +name = "etils" +version = "1.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/a0/522bbff0f3cdd37968f90dd7f26c7aa801ed87f5ba335f156de7f2b88a48/etils-1.13.0.tar.gz", hash = "sha256:a5b60c71f95bcd2d43d4e9fb3dc3879120c1f60472bb5ce19f7a860b1d44f607", size = 106368, upload-time = "2025-07-15T10:29:10.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/98/87b5946356095738cb90a6df7b35ff69ac5750f6e783d5fbcc5cb3b6cbd7/etils-1.13.0-py3-none-any.whl", hash = "sha256:d9cd4f40fbe77ad6613b7348a18132cc511237b6c076dbb89105c0b520a4c6bb", size = 170603, upload-time = "2025-07-15T10:29:09.076Z" }, +] + +[package.optional-dependencies] +epath = [ + { name = "fsspec" }, + { name = "importlib-resources" }, + { name = "typing-extensions" }, + { name = "zipp" }, +] + +[[package]] +name = "exoskeleton-teleoperation-evidence" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "cmeel-tinyxml2" }, + { name = "cmeel-urdfdom" }, + { name = "matplotlib" }, + { name = "mujoco" }, + { name = "numpy" }, + { name = "omegaconf" }, + { name = "pin" }, + { name = "pyyaml" }, + { name = "scipy" }, +] + +[package.metadata] +requires-dist = [ + { name = "cmeel-tinyxml2", specifier = "==10.0.0" }, + { name = "cmeel-urdfdom", specifier = "==4.0.1" }, + { name = "matplotlib", specifier = "==3.10.7" }, + { name = "mujoco", specifier = "==3.5.0" }, + { name = "numpy", specifier = "==2.2.6" }, + { name = "omegaconf", specifier = "==2.3.0" }, + { name = "pin", specifier = "==3.8.0" }, + { name = "pyyaml", specifier = "==6.0.3" }, + { name = "scipy", specifier = "==1.15.3" }, +] + +[[package]] +name = "fonttools" +version = "4.63.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/c9/4141c90a90db20f807c7e10bfd689fe53eb8f7f4caff58ee4d4dfe46919f/fonttools-4.63.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e3297a6a4059b4acc3a1e9a8b04741f240a80044eef08ebd32e8b5bcdddce75b", size = 2884632, upload-time = "2026-05-14T12:02:38.56Z" }, + { url = "https://files.pythonhosted.org/packages/b8/46/ad12b5c10eae602d7ef814b02afa08aacbf89da917fed5b071282b7eadc2/fonttools-4.63.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b1cd75a03ad8cb5bc40c90bfde68c0c47de423aa19e5c0f362b43520645eea94", size = 2429441, upload-time = "2026-05-14T12:02:41.162Z" }, + { url = "https://files.pythonhosted.org/packages/90/8f/bdca24a84c81d56fffed052229cdcff368f6e05882e526f4558891481f65/fonttools-4.63.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0425b277a59cff3d80ca42162a8de360f318438a2ac83570842a678d826d579", size = 4946346, upload-time = "2026-05-14T12:02:43.41Z" }, + { url = "https://files.pythonhosted.org/packages/04/59/a639c0e136441ee91a65b56fdf89e5d075927e7a09c559d1b0f5276577db/fonttools-4.63.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d7e5c9973aa04c95650c96e5f5ad865fbf42d62079163ecfab1e01cbc2504c22", size = 4903184, upload-time = "2026-05-14T12:02:45.742Z" }, + { url = "https://files.pythonhosted.org/packages/e6/53/91b7e0cb45b536f3da1b29ba8cbab89f27e8b986809e0b1982303a3f4eca/fonttools-4.63.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cb014d58140a38135f16064c74c652ed57aa0b75cbf8bb59cac821f7edb5334e", size = 4922967, upload-time = "2026-05-14T12:02:48.386Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b7/87439bf44e6b97c5538cd29d0b7e366a5b8ce2cc132a4134fb67fa3f2fa2/fonttools-4.63.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:032038247a96c1690f9f31e377c389383c902531b085aa4e4dabd6f57f870e69", size = 5042799, upload-time = "2026-05-14T12:02:50.424Z" }, + { url = "https://files.pythonhosted.org/packages/ad/7c/8b96c3263b89ef99cded544c0f0636686f85dbd3c211c4dceef0231fca23/fonttools-4.63.0-cp310-cp310-win32.whl", hash = "sha256:a8b33a82979e0a6a34ff435cc81317be1f95ec1ebb7a3a2d1c8a6a54f02ae44e", size = 1519704, upload-time = "2026-05-14T12:02:52.523Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4d/2c2f0069970b6907de8fb5b05c5c0193cc22f717df151d1c7aef1c738f58/fonttools-4.63.0-cp310-cp310-win_amd64.whl", hash = "sha256:0c18358a155d75034911c5ee397a5b44cd19dd325dbb8b35fb60bf421d6a72ac", size = 1568666, upload-time = "2026-05-14T12:02:54.917Z" }, + { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/10/a1/ae4e3e5003468d6391d2c77b6fa1cd73bd5d13511d81c642d7b28ac90ed4/fsspec-2026.6.0.tar.gz", hash = "sha256:f5bac145310fe30e16e1471bd6840b2d990d609e872251d7e674241822abf01a", size = 313646, upload-time = "2026-06-16T01:57:28.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/22/4222d7ddf3da30f363edaa98e329c2bce6c65497c9cb2810931c8b2c0fbc/fsspec-2026.6.0-py3-none-any.whl", hash = "sha256:02e0b71817df9b2169dc30a16832045764def1191b43dcff5bb85bdee212d2a1", size = 203949, upload-time = "2026-06-16T01:57:26.358Z" }, +] + +[[package]] +name = "glfw" +version = "2.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/62/096058bcb4b4fb28f7ecd28fb048f07969d90b243c417af5f6d09d45a0c2/glfw-2.10.2.tar.gz", hash = "sha256:5d2cf97c66bc42a6b583be0e307eae5a3945438322e2ed0c5e4f14dc251d693d", size = 36307, upload-time = "2026-07-21T14:42:36.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/80/5e575ec14f54dc0a644e7215985b9ee7c5044fcc3594d6b83dfdeb04ccd4/glfw-2.10.2-py2.py3-none-macosx_10_6_intel.whl", hash = "sha256:a04d25bb535a3a29e173ea1abd658e161681b5d3816e00bc51c122f74cd7fa3a", size = 110295, upload-time = "2026-07-21T14:42:25.625Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2e/22216db429690aa51fa87d2e2b5e6b610c5a37b9df7768da2927f52f8704/glfw-2.10.2-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:c000b8a5e5fb4374b2c18d12347255ccb92001b9bcf80ef74c56072acbb98fe3", size = 107146, upload-time = "2026-07-21T14:42:26.854Z" }, + { url = "https://files.pythonhosted.org/packages/75/a4/9ccee9d5c3b9c5d6bafce3e8d53ab8318eff30124e3c19b95580bebaa6d4/glfw-2.10.2-py2.py3-none-manylinux2014_aarch64.whl", hash = "sha256:0ffe2b48b51f37b05e8f027f57c0b8cd213affaf22fc83401bd9621692d4c6c8", size = 235005, upload-time = "2026-07-21T14:42:27.955Z" }, + { url = "https://files.pythonhosted.org/packages/77/44/37aeed50c581c76e1c5bb83b696a06f98f8ecc979ed8564dde3eef908e8b/glfw-2.10.2-py2.py3-none-manylinux2014_x86_64.whl", hash = "sha256:82daf1f87b2a48815637dc6f6720d4a55a57ba1d3683322dc20dc28065754f97", size = 246951, upload-time = "2026-07-21T14:42:29.107Z" }, + { url = "https://files.pythonhosted.org/packages/52/fc/29b4f1a8c8e33d8934781330a0845458b01da7f389d9fe6a3350e752b7f3/glfw-2.10.2-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:7604425ec95665cc6a25c3ed7d07e9660d394a34472ceffaec0843c844e7b649", size = 236018, upload-time = "2026-07-21T14:42:30.623Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/1b98671314ea5f40397586b6cd14db913de3196333be558fdc177e61708f/glfw-2.10.2-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:b860de3ca0686182483f98f3ddd12e660acf25b3e0d521450ec9a3f999f72a65", size = 248490, upload-time = "2026-07-21T14:42:32.377Z" }, + { url = "https://files.pythonhosted.org/packages/12/d9/bd2e6c8dbadcf69fcd2289705e75881258b46221c196903a52e9f6e97322/glfw-2.10.2-py2.py3-none-win32.whl", hash = "sha256:a94aefe8c48886fd83cd6e11e936846166a4b5b94abf1f4bf599d984774b0b1b", size = 557654, upload-time = "2026-07-21T14:42:33.903Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6c/4ca5f3ab85a8d7f612ce857208726e9b834e54e559751e3d4f98cc2f7509/glfw-2.10.2-py2.py3-none-win_amd64.whl", hash = "sha256:15fd0666cd8f1b0ecb535c3abb712b8ddda053bf8d16de2353b1c6ced0e65402", size = 564433, upload-time = "2026-07-21T14:42:35.465Z" }, +] + +[[package]] +name = "importlib-resources" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/06/b56dfa750b44e86157093bc8fca0ab81dccbf5260510de4eaf1cb69b5b99/importlib_resources-7.1.0.tar.gz", hash = "sha256:0722d4c6212489c530f2a145a34c0a7a3b4721bc96a15fada5930e2a0b760708", size = 44985, upload-time = "2026-04-12T16:36:09.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/db/55a262f3606bebcae07cc14095338471ad7c0bbcaa37707e6f0ee49725b7/importlib_resources-7.1.0-py3-none-any.whl", hash = "sha256:1bd7b48b4088eddb2cd16382150bb515af0bd2c70128194392725f82ad2c96a1", size = 37232, upload-time = "2026-04-12T16:36:08.219Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/f8/06549565caa026e540b7e7bab5c5a90eb7ca986015f4c48dace243cd24d9/kiwisolver-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:32cc0a5365239a6ea0c6ed461e8838d053b57e397443c0ca894dcc8e388d4374", size = 122802, upload-time = "2026-03-09T13:12:37.515Z" }, + { url = "https://files.pythonhosted.org/packages/84/eb/8476a0818850c563ff343ea7c9c05dcdcbd689a38e01aa31657df01f91fa/kiwisolver-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cc0b66c1eec9021353a4b4483afb12dfd50e3669ffbb9152d6842eb34c7e29fd", size = 66216, upload-time = "2026-03-09T13:12:38.812Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/f9c8a6b4c21aed4198566e45923512986d6cef530e7263b3a5f823546561/kiwisolver-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:86e0287879f75621ae85197b0877ed2f8b7aa57b511c7331dce2eb6f4de7d476", size = 63917, upload-time = "2026-03-09T13:12:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0e/ba4ae25d03722f64de8b2c13e80d82ab537a06b30fc7065183c6439357e3/kiwisolver-1.5.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:62f59da443c4f4849f73a51a193b1d9d258dcad0c41bc4d1b8fb2bcc04bfeb22", size = 1628776, upload-time = "2026-03-09T13:12:41.976Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e4/3f43a011bc8a0860d1c96f84d32fa87439d3feedf66e672fef03bf5e8bac/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9190426b7aa26c5229501fa297b8d0653cfd3f5a36f7990c264e157cbf886b3b", size = 1228164, upload-time = "2026-03-09T13:12:44.002Z" }, + { url = "https://files.pythonhosted.org/packages/4b/34/3a901559a1e0c218404f9a61a93be82d45cb8f44453ba43088644980f033/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c8277104ded0a51e699c8c3aff63ce2c56d4ed5519a5f73e0fd7057f959a2b9e", size = 1246656, upload-time = "2026-03-09T13:12:45.557Z" }, + { url = "https://files.pythonhosted.org/packages/87/9e/f78c466ea20527822b95ad38f141f2de1dcd7f23fb8716b002b0d91bbe59/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8f9baf6f0a6e7571c45c8863010b45e837c3ee1c2c77fcd6ef423be91b21fedb", size = 1295562, upload-time = "2026-03-09T13:12:47.562Z" }, + { url = "https://files.pythonhosted.org/packages/0a/66/fd0e4a612e3a286c24e6d6f3a5428d11258ed1909bc530ba3b59807fd980/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cff8e5383db4989311f99e814feeb90c4723eb4edca425b9d5d9c3fefcdd9537", size = 2178473, upload-time = "2026-03-09T13:12:50.254Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8e/6cac929e0049539e5ee25c1ee937556f379ba5204840d03008363ced662d/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ebae99ed6764f2b5771c522477b311be313e8841d2e0376db2b10922daebbba4", size = 2274035, upload-time = "2026-03-09T13:12:51.785Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d3/9d0c18f1b52ea8074b792452cf17f1f5a56bd0302a85191f405cfbf9da16/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:d5cd5189fc2b6a538b75ae45433140c4823463918f7b1617c31e68b085c0022c", size = 2443217, upload-time = "2026-03-09T13:12:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/45/2a/6e19368803a038b2a90857bf4ee9e3c7b667216d045866bf22d3439fd75e/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f42c23db5d1521218a3276bb08666dcb662896a0be7347cba864eca45ff64ede", size = 2249196, upload-time = "2026-03-09T13:12:55.057Z" }, + { url = "https://files.pythonhosted.org/packages/75/2b/3f641dfcbe72e222175d626bacf2f72c3b34312afec949dd1c50afa400f5/kiwisolver-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:94eff26096eb5395136634622515b234ecb6c9979824c1f5004c6e3c3c85ccd2", size = 73389, upload-time = "2026-03-09T13:12:56.496Z" }, + { url = "https://files.pythonhosted.org/packages/da/88/299b137b9e0025d8982e03d2d52c123b0a2b159e84b0ef1501ef446339cf/kiwisolver-1.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:dd952e03bfbb096cfe2dd35cd9e00f269969b67536cb4370994afc20ff2d0875", size = 64782, upload-time = "2026-03-09T13:12:57.609Z" }, + { url = "https://files.pythonhosted.org/packages/17/6f/6fd4f690a40c2582fa34b97d2678f718acf3706b91d270c65ecb455d0a06/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:295d9ffe712caa9f8a3081de8d32fc60191b4b51c76f02f951fd8407253528f4", size = 59606, upload-time = "2026-03-09T13:15:40.81Z" }, + { url = "https://files.pythonhosted.org/packages/82/a0/2355d5e3b338f13ce63f361abb181e3b6ea5fffdb73f739b3e80efa76159/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:51e8c4084897de9f05898c2c2a39af6318044ae969d46ff7a34ed3f96274adca", size = 57537, upload-time = "2026-03-09T13:15:42.071Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b9/1d50e610ecadebe205b71d6728fd224ce0e0ca6aba7b9cbe1da049203ac5/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b83af57bdddef03c01a9138034c6ff03181a3028d9a1003b301eb1a55e161a3f", size = 79888, upload-time = "2026-03-09T13:15:43.317Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ee/b85ffcd75afed0357d74f0e6fc02a4507da441165de1ca4760b9f496390d/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf4679a3d71012a7c2bf360e5cd878fbd5e4fcac0896b56393dec239d81529ed", size = 77584, upload-time = "2026-03-09T13:15:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/6b/dd/644d0dde6010a8583b4cd66dd41c5f83f5325464d15c4f490b3340ab73b4/kiwisolver-1.5.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:41024ed50e44ab1a60d3fe0a9d15a4ccc9f5f2b1d814ff283c8d01134d5b81bc", size = 73390, upload-time = "2026-03-09T13:15:45.832Z" }, +] + +[[package]] +name = "libcoal" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, + { name = "cmeel-assimp" }, + { name = "cmeel-boost" }, + { name = "cmeel-octomap" }, + { name = "cmeel-qhull" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/51/cb68b16abd786e3ebb5e7e64036894a6f69ea8fe45c04a433e6d5462d60e/libcoal-3.0.2.tar.gz", hash = "sha256:1d48cfdce1157d4b89cf6a7215fc1b1e120d54c4a8d975cd9f45f2c8cedec275", size = 1464086, upload-time = "2025-10-15T22:53:34.811Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/ea/6aa65497d00ec494bf1c5e121b59ad8cf6da308e0cf01271a9d7c614752c/libcoal-3.0.2-0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:16702fdd13942080c42c9565eb1b692618ce4456192a5bc497c585f9142138e5", size = 1683950, upload-time = "2025-10-15T22:53:28.361Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b0/3480197ba40cf9c6de71ca7f7a81a7504a40ca77a2b8604cbcc068f8f7ca/libcoal-3.0.2-0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:527c710c6215936f1a4b99ca1d01b4bb15c6b52980fa96cfa5f1fd1a7ef12393", size = 1484168, upload-time = "2025-10-15T22:53:29.933Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e5/5b9605496e48a0437152196e5f200433d3904e59c899cab799a3c27bcd4f/libcoal-3.0.2-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:ed45722c07a3d23a346211f837856549dce11167928743eb6c73bcf17a369dd6", size = 2257523, upload-time = "2025-10-15T22:53:31.214Z" }, + { url = "https://files.pythonhosted.org/packages/3c/49/c3bec783144c226b5ef3728ed66d7fc2d08c553922a3892591958284801a/libcoal-3.0.2-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:c4ca3fec02386e5c8ccc81030c44e74a546b06059886ce04bb6c16fe4628e9ba", size = 2285635, upload-time = "2025-10-15T22:53:33.142Z" }, +] + +[[package]] +name = "libpinocchio" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, + { name = "cmeel-boost" }, + { name = "cmeel-urdfdom" }, + { name = "libcoal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/b4431f1acdce04300d798a87b98b064c1bb56061848abd9476c7b7e9dac2/libpinocchio-3.8.0.tar.gz", hash = "sha256:687442a8316d03cbe1a5c66e20499bf3fadb59439d6207e36118eef34f73d8c8", size = 4001141, upload-time = "2025-10-16T06:34:02.405Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/71/b17ca7f4c0cb0f216441222e22c3fb8d905ba038ec5ac7c120790340da95/libpinocchio-3.8.0-0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:b8266d37482c35b5aa27240f3a0274447cd038aa219bdd6413c0bafcad822e2b", size = 4663536, upload-time = "2025-10-16T06:33:55.707Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a9/a4842e056d3f7d07c3f96f90c8f7fe7ef7e543c725f1c9498e5f4d58c47c/libpinocchio-3.8.0-0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0320c471bd4e78226cc266ad7927432f884709104fa8a253e565adbed7da8aac", size = 3781718, upload-time = "2025-10-16T06:33:57.483Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f5/950cd3be129766d6f847cb0702f73ad5f6ed2d2b5775e073f9f017d923b4/libpinocchio-3.8.0-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:b70bc23fb9f53d0a65929c92bac8c0df836bef064225a54d009214cdd778bdb7", size = 4582702, upload-time = "2025-10-16T06:33:58.887Z" }, + { url = "https://files.pythonhosted.org/packages/28/0d/5deebded1fa71a381c9efd3ea69103a38f64d804da704148e92f4886762d/libpinocchio-3.8.0-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:b52ca3520635f551ab2c8c9bf5e8e555b54e92c4bb948020eb4e4dc1b3f9eb0b", size = 4803646, upload-time = "2025-10-16T06:34:00.662Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.10.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/e2/d2d5295be2f44c678ebaf3544ba32d20c1f9ef08c49fe47f496180e1db15/matplotlib-3.10.7.tar.gz", hash = "sha256:a06ba7e2a2ef9131c79c49e63dad355d2d878413a0376c1727c8b9335ff731c7", size = 34804865, upload-time = "2025-10-09T00:28:00.669Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/87/3932d5778ab4c025db22710b61f49ccaed3956c5cf46ffb2ffa7492b06d9/matplotlib-3.10.7-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:7ac81eee3b7c266dd92cee1cd658407b16c57eed08c7421fa354ed68234de380", size = 8247141, upload-time = "2025-10-09T00:26:06.023Z" }, + { url = "https://files.pythonhosted.org/packages/45/a8/bfed45339160102bce21a44e38a358a1134a5f84c26166de03fb4a53208f/matplotlib-3.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:667ecd5d8d37813a845053d8f5bf110b534c3c9f30e69ebd25d4701385935a6d", size = 8107995, upload-time = "2025-10-09T00:26:08.669Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3c/5692a2d9a5ba848fda3f48d2b607037df96460b941a59ef236404b39776b/matplotlib-3.10.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc1c51b846aca49a5a8b44fbba6a92d583a35c64590ad9e1e950dc88940a4297", size = 8680503, upload-time = "2025-10-09T00:26:10.607Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a0/86ace53c48b05d0e6e9c127b2ace097434901f3e7b93f050791c8243201a/matplotlib-3.10.7-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a11c2e9e72e7de09b7b72e62f3df23317c888299c875e2b778abf1eda8c0a42", size = 9514982, upload-time = "2025-10-09T00:26:12.594Z" }, + { url = "https://files.pythonhosted.org/packages/a6/81/ead71e2824da8f72640a64166d10e62300df4ae4db01a0bac56c5b39fa51/matplotlib-3.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f19410b486fdd139885ace124e57f938c1e6a3210ea13dd29cab58f5d4bc12c7", size = 9566429, upload-time = "2025-10-09T00:26:14.758Z" }, + { url = "https://files.pythonhosted.org/packages/65/7d/954b3067120456f472cce8fdcacaf4a5fcd522478db0c37bb243c7cb59dd/matplotlib-3.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:b498e9e4022f93de2d5a37615200ca01297ceebbb56fe4c833f46862a490f9e3", size = 8108174, upload-time = "2025-10-09T00:26:17.015Z" }, + { url = "https://files.pythonhosted.org/packages/1e/6c/a9bcf03e9afb2a873e0a5855f79bce476d1023f26f8212969f2b7504756c/matplotlib-3.10.7-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5c09cf8f2793f81368f49f118b6f9f937456362bee282eac575cca7f84cda537", size = 8241204, upload-time = "2025-10-09T00:27:48.806Z" }, + { url = "https://files.pythonhosted.org/packages/5b/fd/0e6f5aa762ed689d9fa8750b08f1932628ffa7ed30e76423c399d19407d2/matplotlib-3.10.7-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:de66744b2bb88d5cd27e80dfc2ec9f0517d0a46d204ff98fe9e5f2864eb67657", size = 8104607, upload-time = "2025-10-09T00:27:50.876Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a9/21c9439d698fac5f0de8fc68b2405b738ed1f00e1279c76f2d9aa5521ead/matplotlib-3.10.7-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:53cc80662dd197ece414dd5b66e07370201515a3eaf52e7c518c68c16814773b", size = 8682257, upload-time = "2025-10-09T00:27:52.597Z" }, +] + +[[package]] +name = "mujoco" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "absl-py" }, + { name = "etils", extra = ["epath"] }, + { name = "glfw" }, + { name = "numpy" }, + { name = "pyopengl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/0d/005f0d49ad5878f0611a7c018550b8504d480a7a17ad7e6773ff47d8627a/mujoco-3.5.0.tar.gz", hash = "sha256:5c85a6fc7560ab5fa4534f35ff459e12dc3609681f307e457dbb49b6217f4d73", size = 912543, upload-time = "2026-02-13T01:02:51.554Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/20/9e0595e653543df3e4233bc3ad7e50b371b81dbe48d45ffbc867ed7c379d/mujoco-3.5.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:c4324161cb4f334dd984fbb4a4f7d7db9f914f40d06174b02dcf05463d8275e4", size = 7088320, upload-time = "2026-02-13T01:02:06.745Z" }, + { url = "https://files.pythonhosted.org/packages/8d/6b/fdac8ed97086e12ac930fb44e419eda1626e339010df73678cb1f22527d7/mujoco-3.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5f3803ff0dd7bc04d6c47d53a794343843bde06f0aeefeac28bb62b4cf2baab3", size = 7093261, upload-time = "2026-02-13T01:02:09.857Z" }, + { url = "https://files.pythonhosted.org/packages/19/ce/abcd9cc6ee7802f97c729ae0ccd517c68f04882f5db755b178e199511dc2/mujoco-3.5.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e13560991c779a139b53151733a0a6f3420ef09459b32d90302c2661c1b20992", size = 6637850, upload-time = "2026-02-13T01:02:11.808Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d6/a5a7b615b257867b7c97db6b3ce07dec9351d5d9d5a5aca881cbb583d7a3/mujoco-3.5.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01b12896ae906f157e18d8b1b7c24a8b72d2576fffa09869047150f186e92b33", size = 7079429, upload-time = "2026-02-13T01:02:13.738Z" }, + { url = "https://files.pythonhosted.org/packages/7e/91/d82dd3c16892e1b0e27a2f537eec8aad54d91d939cb3cd37db2e8c09ecc2/mujoco-3.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:2328358d2f0031175897092560dd6d04b14bab1cc22caa145ce99b843c17daa2", size = 5624454, upload-time = "2026-02-13T01:02:15.714Z" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "omegaconf" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "antlr4-python3-runtime" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/48/6388f1bb9da707110532cb70ec4d2822858ddfb44f1cdf1233c20a80ea4b/omegaconf-2.3.0.tar.gz", hash = "sha256:d5d4b6d29955cc50ad50c46dc269bcd92c6e00f5f90d23ab5fee7bfca4ba4cc7", size = 3298120, upload-time = "2022-12-08T20:59:22.753Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/94/1843518e420fa3ed6919835845df698c7e27e183cb997394e4a670973a65/omegaconf-2.3.0-py3-none-any.whl", hash = "sha256:7b4df175cdb08ba400f45cae3bdcae7ba8365db4d165fc65fd04b050ab63b46b", size = 79500, upload-time = "2022-12-08T20:59:19.686Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/c2/669d88644cddb1485bd9534e63e8cf476c8e51cb3c3a1297677023505c0e/pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a", size = 5392418, upload-time = "2026-07-01T11:53:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ba/3762f376a2948e3036488d773a146e0ae6ecc2ca03ac20e2615bd0b2ba02/pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7", size = 4785287, upload-time = "2026-07-01T11:53:29.761Z" }, + { url = "https://files.pythonhosted.org/packages/07/50/b5d688cc9c52d4482f3d5bcab6ce20bc2a74a85d2343841c907444a3be2c/pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f", size = 6253754, upload-time = "2026-07-01T11:53:32.298Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/36f4cd76cf4baf05c50ababb976249153f18c959171c7f6ba09a6f217260/pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec", size = 6925605, upload-time = "2026-07-01T11:53:34.487Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c0/4de58cf6633b9e3a6061ef4be6fb91fc3c90b812ece886f531e3c523d777/pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468", size = 6327788, upload-time = "2026-07-01T11:53:36.433Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/14d53682a19550dbbaf3b598f807d5457646c510805a44c7d7891cd1cd1a/pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed", size = 7036288, upload-time = "2026-07-01T11:53:38.712Z" }, + { url = "https://files.pythonhosted.org/packages/38/1d/36279e3c77efe034e4cc2b0393ee74ffdb5a62391dacbf9b916154f5f0b8/pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1", size = 6472396, upload-time = "2026-07-01T11:53:40.781Z" }, + { url = "https://files.pythonhosted.org/packages/48/7c/8fa0039574c476d7c6fa57dd7c32a130436877c6ec1e5ce1cc8ec44878c1/pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb", size = 7226887, upload-time = "2026-07-01T11:53:42.764Z" }, + { url = "https://files.pythonhosted.org/packages/fa/17/e324be141d173c1c919428066c3259f21c1b8982e564e01a4a81e96dbdcf/pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f", size = 2568039, upload-time = "2026-07-01T11:53:45.372Z" }, +] + +[[package]] +name = "pin" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, + { name = "cmeel-boost" }, + { name = "cmeel-urdfdom" }, + { name = "coal" }, + { name = "libpinocchio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/99/4e7393e8035985405e89bc61dc0037f9bd1792c7a0295192aa3791bf4844/pin-3.8.0.tar.gz", hash = "sha256:f3889867d6fb968299696e94974138d6668600663b8650723a59fe062356fece", size = 4000900, upload-time = "2025-10-16T14:04:29.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/6b/0d280cc9753acb1bca1ffad8138f1c3939a797a336b9b058a051267b4aea/pin-3.8.0-0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:92046a8b0599d2396e0f5303f81f76ad306315d7a45cc44bb1ad8afacc59760c", size = 5634231, upload-time = "2025-10-16T14:03:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/c1/df/b7c9cbb484a0c096e7b4beb22fed4c5bf77c5bb042fe22702ce9c3757bb7/pin-3.8.0-0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2565eebc9dd2f84181cddc66c356f2896a64162ed1eadc7d3a60e6a034d6a5ae", size = 5420549, upload-time = "2025-10-16T14:03:51.642Z" }, + { url = "https://files.pythonhosted.org/packages/c3/2e/1cb2fc19cd5ee830a9bc992956d9ef83a3dcee347edbb56d8c35d069b374/pin-3.8.0-0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:4b2e0ae3f5b06538f78f84e385c9d5d2a8470828b108520a1cf0657f658521e8", size = 7242690, upload-time = "2025-10-16T14:03:53.369Z" }, + { url = "https://files.pythonhosted.org/packages/78/9a/8f93ca590dab6058283d0cc3ee776ba3a72f6d8662e3c7e3b6b9424faee0/pin-3.8.0-0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:d9a48b99f8d3b085575f88944f1537a048fcd262da3efc52fed732b220e1422f", size = 7402696, upload-time = "2025-10-16T14:03:55.133Z" }, +] + +[[package]] +name = "pyopengl" +version = "3.1.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/16/912b7225d56284859cd9a672827f18be43f8012f8b7b932bc4bd959a298e/pyopengl-3.1.10.tar.gz", hash = "sha256:c4a02d6866b54eb119c8e9b3fb04fa835a95ab802dd96607ab4cdb0012df8335", size = 1915580, upload-time = "2025-08-18T02:33:01.76Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/e4/1ba6f44e491c4eece978685230dde56b14d51a0365bc1b774ddaa94d14cd/pyopengl-3.1.10-py3-none-any.whl", hash = "sha256:794a943daced39300879e4e47bd94525280685f42dbb5a998d336cfff151d74f", size = 3194996, upload-time = "2025-08-18T02:32:59.902Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, +] + +[[package]] +name = "scipy" +version = "1.15.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/2f/4966032c5f8cc7e6a60f1b2e0ad686293b9474b65246b0c642e3ef3badd0/scipy-1.15.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:a345928c86d535060c9c2b25e71e87c39ab2f22fc96e9636bd74d1dbf9de448c", size = 38702770, upload-time = "2025-05-08T16:04:20.849Z" }, + { url = "https://files.pythonhosted.org/packages/a0/6e/0c3bf90fae0e910c274db43304ebe25a6b391327f3f10b5dcc638c090795/scipy-1.15.3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ad3432cb0f9ed87477a8d97f03b763fd1d57709f1bbde3c9369b1dff5503b253", size = 30094511, upload-time = "2025-05-08T16:04:27.103Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b1/4deb37252311c1acff7f101f6453f0440794f51b6eacb1aad4459a134081/scipy-1.15.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:aef683a9ae6eb00728a542b796f52a5477b78252edede72b8327a886ab63293f", size = 22368151, upload-time = "2025-05-08T16:04:31.731Z" }, + { url = "https://files.pythonhosted.org/packages/38/7d/f457626e3cd3c29b3a49ca115a304cebb8cc6f31b04678f03b216899d3c6/scipy-1.15.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:1c832e1bd78dea67d5c16f786681b28dd695a8cb1fb90af2e27580d3d0967e92", size = 25121732, upload-time = "2025-05-08T16:04:36.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/0a/92b1de4a7adc7a15dcf5bddc6e191f6f29ee663b30511ce20467ef9b82e4/scipy-1.15.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:263961f658ce2165bbd7b99fa5135195c3a12d9bef045345016b8b50c315cb82", size = 35547617, upload-time = "2025-05-08T16:04:43.546Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6d/41991e503e51fc1134502694c5fa7a1671501a17ffa12716a4a9151af3df/scipy-1.15.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2abc762b0811e09a0d3258abee2d98e0c703eee49464ce0069590846f31d40", size = 37662964, upload-time = "2025-05-08T16:04:49.431Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/3df8f83cb15f3500478c889be8fb18700813b95e9e087328230b98d547ff/scipy-1.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed7284b21a7a0c8f1b6e5977ac05396c0d008b89e05498c8b7e8f4a1423bba0e", size = 37238749, upload-time = "2025-05-08T16:04:55.215Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/b3257cf446f2a3533ed7809757039016b74cd6f38271de91682aa844cfc5/scipy-1.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5380741e53df2c566f4d234b100a484b420af85deb39ea35a1cc1be84ff53a5c", size = 40022383, upload-time = "2025-05-08T16:05:01.914Z" }, + { url = "https://files.pythonhosted.org/packages/d1/84/55bc4881973d3f79b479a5a2e2df61c8c9a04fcb986a213ac9c02cfb659b/scipy-1.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:9d61e97b186a57350f6d6fd72640f9e99d5a4a2b8fbf4b9ee9a841eab327dc13", size = 41259201, upload-time = "2025-05-08T16:05:08.166Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +]