"""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, )