Doctoral Research · Space Robotics Inspection with a Free-Flying Space Manipulator
A Doctoral Research Journal Aerospace Engineering

Speed / Impedance Derating — What the live code actually does, and where a true speed-derate belongs

Generated: 2026-06-18, morning session (EDT) Mode: Investigator (report-only — no code was modified) Question: What are we actually doing about speed/impedance derating? How should we best implement it to be closer to the intended SPEED derating (i.e. within the guidance algorithm)? Companion note: GNC/equations/Inconsistencies_Note_Jun18.md Verification: every load-bearing claim below was independently re-read by an adversarial verifier (5/5 claims adjudicated; 2 of my prior framings were corrected — see Findings F4, F5).


Answer First


Scope

Question owner: the derating (“Pareto”) layer of singularity handling — explicitly not the deeper damped-inverse / cascade-floor layer (user: “Don’t go deeper into singularities”).

Inspected (in scope): - GNC/breve_controller.py — the live coordinated controller. - GNC/guidance/ee_guidance.py — the EE-guidance algorithm. - GNC/base_guidance.py — base/CoM reference derate. - GNC/com_controller.pyCC_Controller base class (the all_control_terms super-body). - GNC/guidance/analytic_feedforward.pyreduced_desired_twist (reference-only). - analysis/runner.py — the controller factory (make_controller). - Run-spec / baseline YAMLs and validation/test_baselines.py for the retirement dependency map.

External boundaries (stopped, inferred from call site): - utils/sampling.py:scale_by_svd and sigma_min_and_cond — the γ ramp and the SVD of Γ. Behavior taken from the call site: a monotone piecewise-linear ramp returning floor below low, 1.0 above high, linear between. - utils/robot.py — assembly of Γ and dyn.s_min_G. - utils/geometry.py:block9 — read only to confirm argument order (base = upper-left 3×3, arm = lower-right 6×6).

Not modified: everything. This is a report.


Flow Summary

γ is born once per step from the conditioning of Γ, then fans out to three consumers that each interpret it differently. The diagram is the whole story in one picture:

flowchart TD
    SMG["dyn.s_min_G\n(smallest singular value of Γ)"] --> SBS["scale_by_svd(s_min_G, conditioning.sigma)\n= γ ∈ [floor, 1]  (utils/sampling.py)"]

    SBS --> ARM["ARM (controller)\narm_breve_gains: γ·K_arm, γ·D_arm\nbreve_controller.py:393-395"]
    SBS --> BASEREF["BASE (guidance)\nderate_desired_by_gamma: v_c·γ, a_c·γ², ω_b·γ\nbase_guidance.py:135-142"]
    SBS --> TAUB["BASE wrench (controller)\ncompute_tau_b_oplus: τ_b·γ\nbreve_controller.py:332-333"]
    SBS --> WIND["ARM integral anti-windup\nfreeze if γ < scale_gate\nbreve_controller.py:243-245"]

    ARM --> RHS["RHS34b: K_breve, D_breve\n(base gains UNSCALED)\nbreve_controller.py:437-439"]
    BASEREF --> RHS
    RHS --> VDOT["v_breve_dot = M_breve⁻¹·RHS\n→ ω_b_dot, ν_e_oplus_dot\nbreve_controller.py:521-524"]
    VDOT --> STATE["live base + arm state\n(this is what flies)"]

    TAUB --> GSTACK["G = [f_c, τ_b, ω_e_oplus]\nF = Γᵀ·G  → LOGGED\ncom_controller.py:198-208"]
    GSTACK -. "logged, NOT integrated\n(superseded by v_breve_dot)" .-> STATE

    EE["EE desired twist ν_e\nee_guidance.py:252 / :304\n(NO γ — full speed)"] --> RHS

The two solid paths into STATE (arm gains + base reference) are the live derate. The dashed path (γτ_b^⊕) is the report’s described mechanism — it reaches the log, not the integrator. The EE twist enters STATE at full magnitude.


Key Functions (in call order)

  1. make_controller (runner.py:104-117) — the factory. name: armBreveController + EEGuidance (the live mission path); name: baseBreveController_L.BaseController; name: comCC_Controller.
  2. BaseGuidance.build_..._for_step / derate_desired_by_gamma (base_guidance.py:119-142) — guidance scales the base reference by γ: v_c *= γ; a_c *= γ²; ω_b *= γ. Gated on controller.base.conditioning.enable. Reads dyn.s_min_Gguidance already holds dyn.
  3. blend_base_goal_by_conditioning (base_guidance.py:146-160) — a second reference-side base move: blends the desired base x-axis toward inward and scales ω_b by γ near singularity.
  4. EEGuidance.desired_nu_e_feedforward_consistent (FD) (ee_guidance.py:218-253) and analytic_desired_nu_e_feedforward (closed-form) (ee_guidance.py:267-305) — produce the EE desired twist. Only a fixed v_max clamp; no γ.
  5. CC_Controller.all_control_terms (com_controller.py:190-221) — the super-body every controller runs. Calls self.compute_tau_b_oplus(des) (dynamic dispatch → the override), stacks G = [f_c, τ_b, ω_e_oplus], maps F = Γᵀ·G, and logs forces.tau_b_oplus = τ_b.
  6. BreveController.compute_tau_b_oplus (breve_controller.py:326-334) — the override. τ_b *= conditioning_scale(s_min_G) when conditioning is on. This is the report’s γτ_b^⊕.
  7. conditioning_scale / arm_gain_scale / arm_breve_gains (breve_controller.py:313-322, :393-395) — conditioning_scale = scale_by_svd(s_min_G, base.conditioning.sigma); arm_breve_gains returns γ·g.arm.D, γ·g.arm.K.
  8. update_x_e_integral (breve_controller.py:240-254) — the EE integral. Freezes (return early) when conditioning_scale(s_min_G) < integral.scale_gate. A fourth consumer of γ.
  9. RHS34b (breve_controller.py:433-464) — assembles D_breve = block9(g.base.D, D_arm), K_breve = block9(g.base.K, K_arm). Base gains raw; arm gains γ-scaled. The EE reference nu_e.oplus.des enters here (velocity-FF and K_breve @ x_tilde) unscaled.
  10. v_breve_dotall_control_terms (override) → _apply_step_state_update (breve_controller.py:506-533) — solves M_breve · v̇ = RHS34b, splits into ω_b_dot (base) and ν_e_oplus_dot (arm), integrates the state. This is what flies — and it ignores the γτ_b^⊕ wrench from step 6.

The inconsistencies

Inconsistency 1 — Mirror-opposite mechanisms (arm gain-derate vs base reference-derate)

Frame γ multiplies What is preserved Physical effect Evidence
Arm the gains K_arm, D_arm the EE reference (full speed) softer impedance; arm still wants full velocity breve_controller.py:393-395, :437-439
Base the reference v_c, a_c, ω_b the base gains (full stiffness) slower commanded motion; base holds firm base_guidance.py:135-142; base gains raw at :438-439

These are not two flavors of one policy; they are opposite control-design choices applied to the two halves of the same plant. Verified: base-gains-unscaled confirmed, arm-gains-scaled confirmed.

Inconsistency 2 — The arm’s speed is never derated (the title is a misnomer)

The report section is titled “Speed Derating through Impedance,” but on the arm the only thing γ touches is K and D (impedance). The EE desired twist nu_e — the actual commanded speed — is produced with no γ anywhere (ee_guidance.py:218-305) and enters RHS34b raw (breve_controller.py:450-457). Verified: ee-twist-underated confirmed — a full regex sweep of ee_guidance.py for gamma|derate|s_min_G|scale_by_svd|conditioning returns nothing but a placeholder sigma_min=1.0.

This is the crux. Gain-derate changes the closed-loop impedance (how hard the loop fights error — a dynamics/stability change). Speed-derate changes the commanded trajectory (how fast you ask for motion — a reference change that leaves stability untouched). σ_crit,1 = v/x_max is a bound on commanded velocity, so the derivation justifies the second. The code applies the first to the arm — which is why the Jun 18 D-gain A/B saw the floor collapse (softer K/D let the arm overshoot into the cusp: min s_min_J 0.0255 → 0.0000).

Inconsistency 3 — Report-vs-code drift on the base wrench

flowchart LR
    CTB["compute_tau_b_oplus\nτ_b *= γ\nbreve_controller.py:332-333"] --> LOG["forces.tau_b_oplus (LOG)\ncom_controller.py:204"]
    CTB --> F["F = Γᵀ·[f_c, τ_b, ω_e]\n(logged generalized force)"]
    RHS2["RHS34b → v_breve_dot\nbreve_controller.py:521-524"] --> OMB["ω_b_dot integrates base state"]
    LOG -. "report says THIS drives the base" .-> OMB
    style LOG stroke-dasharray: 5 5

The report says the base derate is τ_b^⊕ → γτ_b^⊕. The live code does compute that (breve_controller.py:332-333) and log it, but the base attitude integrates from ω_b_dot (out of RHS34b), so γτ_b^⊕ is computed-but-inert for the live base state. Meanwhile the actual base derate (reference-scaling in guidance, Inconsistency 1) is undocumented in that report section. So the section is wrong twice: it names an inert mechanism and omits the live one. Verified: tau-b-oplus-dead-live was refuted as literally worded (the method is called every step via super()) — the correct statement is “called and logged, but superseded for integration.”

Inconsistency 4 — One base flag fans out to four effects

arm_gain_scale gates on base_cfg.conditioning.enable (breve_controller.py:320) and conditioning_scale reads ctrl_cfg.base.conditioning.sigma (:315). So a single base-side switch silently controls: (a) the arm gain-derate, (b) the base reference-derate, (c) the logged γτ_b^⊕, (d) the EE integral anti-windup freeze (:243). A reader toggling base.conditioning would not expect the arm impedance and the arm integrator to move. (Sub-note: the windup gate at :243 calls conditioning_scale unconditionally — it does not re-check the enable flag — so it computes a real γ even paths that nominally read “off.” Flagged for the change, F6.)


Where a true speed-derate belongs (the pinpoint)

Principle: to “derate the speed within the guidance algorithm,” scale the commanded task velocity by γ where it is finalized in guidance — exactly as the base already does in derate_desired_by_gamma. EEGuidance(BaseGuidance) already inherits that method (ee_guidance.py:82), and guidance already receives dyn (hence dyn.s_min_G) at the base-desired step (base_guidance.py:124). So this is a symmetric mirror, not a new subsystem.

Injection sites (verified): - FD path: ee_guidance.py:252des.nu_e = nu_des.copy(). Scale nu_des *= γ(s_min_G) before storing. - Analytic path: ee_guidance.py:304-305 — scale nu_local and the returned nu_oplus, nu_oplus_dot by γ (a partial scaling would desync the velocity-FF and accel-FF and shift the L1 equilibrium that RHS34b:447-453 constructs). - Then retire the arm gain-derate: make arm_gain_scale return 1.0 (or strip γ from arm_breve_gains, breve_controller.py:393-395), so the arm keeps full stiffness.

Three caveats that make this “meaty,” not a one-liner (each is a Finding below): 1. Plumbing (modest) — the EE-twist methods don’t currently take dyn/s_min_G; thread the same dyn guidance already holds into them (F3). 2. Anti-windup coupling (must change jointly) — the integral freeze at :243 uses γ as a proxy for “gains were shrunk.” If the gains no longer shrink, the freeze either fires spuriously (full-gain integrator frozen) or, if γ is also zeroed there, never fires (windup unguarded). The two uses of conditioning_scale cannot be changed independently (F6). 3. Feedforward purity (design choice)analytic_feedforward.py is deliberately “a function of the reference alone, never of the measured state” (analytic_feedforward.py:1-8). s_min_G is a function of the measured q. Scaling the analytic FF by a measured-state γ breaks that purity. Decide: a reference-evaluated s_min_G (γ at the desired pose) preserves purity; a measured-state γ is simpler but couples the FF to the plant (F7).


Findings

F1 — The arm derates impedance, the base derates reference (mirror-opposite). Evidence: breve_controller.py:393-395 (γ·gains) vs base_guidance.py:135-142 (γ·reference); base gains raw at :437-439. Both confirmed by adversarial re-read. Impact: indefensible asymmetry; the two halves of the plant follow opposite derate policies under one name. Next step: unify on reference/speed-derate (Proposed Task 1).

F2 — The arm’s commanded EE speed is never derated. Evidence: ee_guidance.py:218-305 (no γ; only v_max); reference enters RHS34b raw :450-457. Impact: “Speed Derating through Impedance” derates impedance, not speed, on the arm — the opposite of the σ_crit,1 = v/x_max intent; corroborated by the Jun 18 D-gain A/B (softer arm D worsened the floor). Next step: inject γ on nu_e (pinpoint above); A/B vs the current gain-derate (Proposed Task 1).

F3 — EEGuidance already inherits derate_desired_by_gamma and already has access to dyn. Evidence: ee_guidance.py:82 (class EEGuidance(BaseGuidance)); base_guidance.py:124 (sigma_gamma = dyn.s_min_G). Impact: the symmetric fix is low-friction — the same γ source and the same idiom the base uses. Next step: a derate_twist_by_gamma sibling that scales nu_e (+ nu_oplus, nu_oplus_dot).

F4 — compute_tau_b_oplus is called every step, but its γτ_b^⊕ is logged, not integrated. (corrects my prior “dead method” framing) Evidence: BreveController.all_control_termssuper() (breve_controller.py:518-519) → CC_Controller.all_control_terms calls self.compute_tau_b_oplus(des) (com_controller.py:195), dispatching to the override at :326. The result lands in forces.tau_b_oplus (com_controller.py:204); base state integrates from v_breve_dot (breve_controller.py:521-524). Impact: the report’s γτ_b^⊕ is real code that does not drive the live base; the actual base derate is the (undocumented) reference-scaling. Two-way drift. Next step: reconcile the report’s §“Speed Derating” with the reference-derate reality; decide whether the logged γτ_b^⊕ should be removed when BreveController_L is retired (it survives as a logged force field only).

F5 — BreveController_L is NOT safe to delete today; the live base mode depends on it. (corrects “retire it today” as a free delete) Evidence: make_controller case "base": from BreveController_L import BaseController (runner.py:104-108); BaseController/EEController are defined only in BreveController_L.py (:43, :259). Live, non-test consumers of name: base: the base pinned baseline (test_baselines.py:12), base_only_frame_check.yaml (your Jun 17 frame A/B), dispatch_smoke.yaml, determinism_smoke.yaml. Impact: deleting the file breaks the production base-only controller path and three live run-specs + a pinned baseline. The legacy arm class (EEController) is genuinely retire-able; the base-only BaseController is not — it must be relocated, not deleted. Next step: Proposed Task 2 (extract BaseController, repoint, re-pin).

F6 — The EE integral anti-windup is coupled to γ and gates unconditionally. Evidence: breve_controller.py:240-245scale = self.conditioning_scale(self.dyn.s_min_G); if scale < scale_gate: return (no enable re-check). Impact: changing the arm from gain-derate to speed-derate decouples the freeze from the mechanism it guards; it cannot be changed in isolation. Also worth confirming integral.scale_gate vs floor=0.25: if scale_gate ≤ floor the freeze can never fire (guard inert). Next step: fold the windup decision into the speed-derate design; read arm_cfg.integral.scale_gate and decide the new gate semantics.

F7 — Scaling the analytic feedforward by a measured-state γ breaks its reference-only purity. Evidence: analytic_feedforward.py:1-8 (reference-only by design); s_min_G is a function of measured q (base_guidance.py:124). Impact: a design fork — measured-state γ (simple, couples FF to plant) vs reference-pose γ (preserves purity, more work). Next step: user decision before the analytic-path injection (the FD path has no such purity claim).

F8 (peripheral, do-not-chase) — The shipped conditioning thresholds are not cascade-consistent. Evidence: live conditioning.sigma = {low: 0.005, high: 0.025, floor: 0.25} (parameters.yaml:87-92); the report’s cascade wants high = σ_crit,1 and low = σ_crit,1² — but 0.025 ≠ 0.005^0.5 and the report’s σ_crit,1 ≈ 0.143. The critical values are implicit (never stored as config; appear only as comments in cascade-sweep analysis YAMLs). Impact: belongs to the deeper cascade-floor thread the user is running in parallel — flagged, not investigated here (out of the Pareto-layer scope). Next step: leave to the σ_crit cascade track.


Proposed Next Tasks

Task 1 — Speed-derate-vs-gain-derate A/B (the safe new-vs-old comparison)

TITLE: Mirror the base’s reference-derate onto the arm; A/B it against the incumbent gain-derate on the full helix.

BODY: Add a guidance-side EE-twist derate (nu_e *= γ, plus nu_oplus/nu_oplus_dot on the analytic path) behind a default-OFF flag, and a switch that turns the arm gain-derate OFF. Run a 3-way comparison on the corrected base (omega_body_frame=true), targeting-OFF, full helix, dispatched across the two x86 boxes — reusing the exact dgain_study rig (same SMIN mask, pe_p99/pe_med/min s_min_J/frac_below/tau rms/z_b rms): (A) incumbent gain-derate, (B) speed-derate only, (C) neither (conditioning off, as a floor reference). Pre-register the win: speed-derate holds or lifts min s_min_J and does not worsen pe_p99 vs incumbent. The hypothesis (from F2 + the D-gain A/B) is that B holds the floor that A degrades.

Modify: GNC/guidance/ee_guidance.py (the two injection sites), GNC/breve_controller.py (arm_gain_scale switch + the windup gate per F6), YAMLs_by_domain/parameters.yaml (two default-OFF flags, byte-identical no-op). Inspect-not-modify: GNC/base_guidance.py (derate_desired_by_gamma as the template), analysis/dgain_study.py (the rig), the 5 validators. Create: analysis/analysis_YAMLs/speed_derate_{gain,speed,off}.yaml; analysis/speed_derate_study.py (facade-only verdict, dgain_study clone); a CLAIMS entry. Validation: default-OFF flags ⇒ the 5 baselines stay byte-identical (no re-pin) and the determinism gate stays green; the new flag’s effect shows in a mode/γ histogram (verify it actually engaged, per the “verify provenance” memory).

Task 2 — Retire the legacy controller without breaking the live base mode

TITLE: Split BreveController_L: delete the superseded arm class, relocate the still-live base class.

BODY: BreveController_L.py holds two classes with opposite fates. EEController (the legacy two-class arm controller) is superseded by the flattened BreveController (the byte-identical flatten A/B already served its purpose, and the two have since divergeddecisions.md:30), so it and its two legacy validators retire. BaseController (the base-only diagnostic controller behind name: base) is still live (frame A/B, smokes, the base baseline) and must be relocated, not deleted. Suggested: extract BaseController into GNC/base_controller.py unchanged, repoint runner.py:106, delete the rest of BreveController_L.py. Because the class body is unchanged, the base baseline should re-pin byte-identically (confirm, do not assume).

Modify: analysis/runner.py:106 (repoint case "base"); after relocation, delete GNC/BreveController_L.py; remove the now-dead base_window keyword from EEGuidance.add_ee_goal (ee_guidance.py:202-209) and its live call site (breve_controller.py:108-116); scrub the BreveController_L docstring/comment mentions. Inspect-not-modify: validation/test_baselines.py, validation/validate_base_baseline.py, analysis/analysis_YAMLs/{base_only_frame_check,dispatch_smoke,determinism_smoke,baseline_base}.yaml. Create: GNC/base_controller.py (relocated BaseController); delete validation/legacy/validate_breve_streamlined_ab.py and validation/legacy/validate_base_refactor_ab.py (confirm no conftest/pytest path collects validation/legacy/ first). Validation: re-run the 5 pinned baselines (the base one must stay max_abs_diff = 0.000e+00 after the move); python run_spec.py base_only_frame_check and the two smokes must still execute the relocated BaseController.

Sequencing: Task 1 is the science (and is independent of the cleanup). Task 2 is house-cleaning and can run in parallel or after, on a fresh branch (the user already OK’d a new branch). Neither depends on the deeper σ_crit cascade work (F8).


Verification provenance

This investigation traced six γ-threads in parallel, then ran a five-claim adversarial verification panel (each verifier re-read the source independently rather than trusting the trace). Outcomes:

Claim Verdict Note
base gains unscaled in RHS34b confirmed base = upper-left block9, raw
arm gains γ-scaled, arm reference unscaled confirmed impedance change, not speed
compute_tau_b_oplus dead in live path refuted called every step via super(); result logged, not integrated (F4)
EE desired twist underated confirmed only a fixed v_max; inject at :252/:304-305
BreveController_L safe to delete (tests only) refuted live name: base mode depends on it (F5)

The two refutations are why the report is more defensible than the opening note: the γτ_b^⊕ finding is sharper (“computed-but-inert” beats “uncalled”), and the retirement is now scoped to its real live dependency instead of an over-confident delete.