Appearance
Python API
Everything is frozen dataclasses and plain functions. Units are SI at the joint output: rad, rad/s, N·m; kp in N·m/rad, kd in N·m·s/rad. On a linear axis (axis="linear") read m, m/s and N instead, and every "mrad" in a score as "mm".
Servo
One installed actuator: what it runs now, and how far a tuning session may move it.
text
@dataclass(frozen=True)
class Servo:
name: str
gains: Gains # what the drive runs now ("as found")
envelope: Envelope # what a session may do
torque_limit_nm: float
command_rate_hz: float = 100.0 # how often the host sends a new reference
can_id: int | None = None # optional bus metadata, copied into the proposal
part: str | None = None
gear_ratio: float = 1.0
priors: PlantModel | None = None # fallback model if identification is refused
feedforward: Feedforward = Feedforward() # the as-found feed-forward
notes: tuple[str, ...] = ()
axis: str = "rotary" # or "linear"
rotor_per_output: float | None = None
rotor_inertia_kg_m2: float | None = None # datasheet rotor + gearbox inertia at the outputservo.as_found is the current controller; servo.gain_bounds() gives the kp and kd ranges the guard allows.
Gains
text
@dataclass(frozen=True)
class Gains:
kp: float
kd: float
ki: float = 0.0
ilimit: float | None = None # integral clamp, N·m
def scaled(self, kp_scale=1.0, kd_scale=1.0) -> Gains
@classmethod
def from_moteus(cls, kp, kd, ki=0.0, ilimit=None) -> Gains # per-revolution units in
def to_moteus(self) -> dict[str, float] # per-revolution units out
def natural_frequency(self, inertia_kg_m2) -> float # sqrt(kp / J)
def damping_ratio(self, inertia_kg_m2, viscous_nm_s_rad=0.0) -> floatDrives such as moteus store position gains per revolution. from_moteus and to_moteus are the only places a 2π appears; everything inside works in SI.
python
from pidtuna import Gains
gains = Gains.from_moteus(kp=60.0, kd=1.5) # as read from the drive
print(f"kp {gains.kp:.3f} N·m/rad, kd {gains.kd:.4f} N·m·s/rad")
print(f"on a 0.01 kg·m² joint: wn {gains.natural_frequency(0.01):.1f} rad/s, zeta {gains.damping_ratio(0.01, 0.02):.2f}")
print(gains.scaled(2.0, 2.0).to_moteus())text
kp 9.549 N·m/rad, kd 0.2387 N·m·s/rad
on a 0.01 kg·m² joint: wn 30.9 rad/s, zeta 0.42
{'kp': 120.0, 'kd': 3.0, 'ki': 0.0}Envelope
Everything a session may do to the joint. Every tape the tuner plans stays inside it; a plant refuses what does not.
text
@dataclass(frozen=True)
class Envelope:
travel_rad: float = 0.3 # how far either side of the origin a tape may go
max_speed_rad_s: float = 1.0
max_torque_nm: float = 1.0
origin_rad: float | None = None # where tapes start and end
gain_scale_range: tuple[float, float] = (0.25, 2.0) # the gain guardThe gain guard bounds every kp and kd tried, relative to the as-found gains. A session never strays further than that from gains known to be safe. Writing a result and tuning again widens the next session's reach.
Goal
What "good" means for one joint. The defaults:
| field | default | meaning |
|---|---|---|
stop_allocation_mrad | None | stop-error budget (p90). None: only beat the as-found controller |
damping_ratio | 0.8 | ζ the closed form aims for |
gain_margin | 2.0 | deploy kp at most 1/margin of the kp where ringing started (6 dB) |
tracking_slack | 1.10 | a pick may track at most 10 % worse than as found |
ringing_mrad | None | ringing limit. None: 3× the joint's own hold noise, floor 0.05 mrad |
ringing_cap_mrad | 2.0 | a computed ringing limit never exceeds this |
max_saturation | 0.02 | share of rows allowed at the torque cap |
max_chatter_fraction | 0.02 | torque chatter allowed, as a share of the cap |
feedforward | True | try feed-forward candidates |
coulomb_fractions | (0.0, 0.25, 0.5, 0.75, 1.0) | Coulomb compensation levels tried |
integrator | False | not read anywhere yet: there is no ki search to switch on (why) |
repeats | 2 | plays per tape set (a simulator reseeds its noise) |
stop_weight, tracking_weight | 1.0, 1.0 | weights in the cost |
min_improvement | 0.10 | a change must win by 10 % or the incumbent stays |
Plant
The only seam that moves anything. The tuner never knows what is on the other side: a numpy simulation, a MuJoCo robot, or your hardware.
text
@runtime_checkable
class Plant(Protocol):
servo: Servo
def run(self, tape: Tape, controller: Controller) -> Trace: ...Optional attributes the tuner uses when present:
| attribute | default | meaning |
|---|---|---|
run_many(jobs) -> list[Trace] | one run per job | play a batch at once (a process pool, one hardware session) |
with_seed(seed) -> Plant | the same plant | a copy with fresh noise, for repeats |
feedforward_terms: frozenset[str] | all four | which of coulomb, viscous, inertia, gravity the plant can send |
torque_sample_phase: float | 0.5 | where in a command interval the logged torque was sampled (0 = right after the command latched) |
torque_delay_s: float | 0.0 | prior for the state/torque delay, used if calibration fails |
A Tape is a reference motion: minimum-jerk waypoints legs (target rad from the start, move s, dwell s) that return to the start, a constant-speed chirp, or a hold. tapes.render(tape, t) gives (q, qd, qdd) relative to the start. A Trace is what came back, one row per command:
text
@dataclass(frozen=True)
class Trace:
tape: Tape
controller: Controller
t: np.ndarray # s
q: np.ndarray # measured position
qd: np.ndarray # measured velocity
tau_nm: np.ndarray # torque the controller asked for, feed-forward included
q_target: np.ndarray # the reference that was commanded
qd_target: np.ndarray
tau_ff_nm: np.ndarray # the feed-forward part of tau_nm
torque_cap_nm: float
prelude_s: float = 0.0 # rows before this are a move to the origin, not the tape
reason: str = "complete" # anything else marks an aborted run
source: str = ""tau_nm is the requested torque, not a dynamometer reading: it is what a drive reports, and it is what identification fits. A run counts as complete only if reason starts with complete and every position, target and timestamp is finite.
A hardware plant renders the tape, streams the reference and gains to the drive at command_rate_hz, and logs each reply. This one does the same against a toy joint, so it runs anywhere:
python
import math
import numpy as np
from pidtuna import Envelope, Gains, Goal, Servo, Trace
from pidtuna.tapes import duration_s, render
from pidtuna.tune import tune
class ToyJoint:
"""A Plant. On hardware, replace the inner loop with: send (q_ref, qd_ref, ff, kp, kd), read the reply."""
def __init__(self, servo, inertia=0.02, viscous=0.05, coulomb=0.1, physics_hz=1000):
self.servo = servo
self.inertia, self.viscous, self.coulomb = inertia, viscous, coulomb
self.substeps = int(physics_hz / servo.command_rate_hz)
def run(self, tape, controller):
servo, gains = self.servo, controller.gains
cap = min(servo.envelope.max_torque_nm, servo.torque_limit_nm)
t = np.arange(0.0, duration_s(tape), 1.0 / servo.command_rate_hz)
q_ref, qd_ref, qdd_ref = render(tape, t)
q_ref = q_ref + (servo.envelope.origin_rad or 0.0)
ff = controller.feedforward.torque(q_ref, qd_ref, qdd_ref)
dt = 1.0 / (servo.command_rate_hz * self.substeps)
q, qd, rows = q_ref[0], 0.0, []
for k in range(len(t)):
sample, torque_sum = (q, qd), 0.0
for _ in range(self.substeps): # what the drive does between two commands
tau = gains.kp * (q_ref[k] - q) + gains.kd * (qd_ref[k] - qd) + ff[k]
tau = max(-cap, min(cap, tau))
torque_sum += tau
qd += dt * (tau - self.viscous * qd - self.coulomb * math.tanh(qd / 0.01)) / self.inertia
q += dt * qd
rows.append((*sample, torque_sum / self.substeps))
q_log, qd_log, tau_log = (np.array(column) for column in zip(*rows))
return Trace(tape=tape, controller=controller, t=t, q=q_log, qd=qd_log, tau_nm=tau_log,
q_target=q_ref, qd_target=qd_ref, tau_ff_nm=ff, torque_cap_nm=cap)
servo = Servo("toy", Gains(kp=2.0, kd=0.1),
Envelope(travel_rad=0.5, max_speed_rad_s=2.0, max_torque_nm=2.0, origin_rad=0.0), torque_limit_nm=2.0)
result = tune(servo, ToyJoint(servo), Goal())
print(f"identified J {result.model.inertia_kg_m2:.4f} kg·m² (true 0.02)")
print(f"stop p90 {result.before.stop_p90_mrad:.1f} -> {result.after.stop_p90_mrad:.1f} mrad")text
identified J 0.0207 kg·m² (true 0.02)
stop p90 40.3 -> 3.3 mradBuilt-in plants: plants.servo_sim.ServoSim(servo, truth, *, seed=0, workers=None), a fast single-axis joint behind a drive-like position loop (current-loop lag, quantized and delayed encoder, filtered velocity, stick-slip friction, optional compliant gearbox and backlash), and plants.mujoco_arm.ArmSim for whole robots (below). servo_sim.from_model(servo, model, *, template=None) builds a twin from an identified model.
tune
text
def tune(servo: Servo, plant: Plant, goal: Goal | None = None, *,
log: Callable[[str], None] = <quiet>, held_out_seed: int = 1,
seed: Controller | None = None) -> TuneResultRuns identify → sweep → refine → verify on one joint. seed is a starting controller, normally the analytic start: the sweep starts at its bandwidth, the search tries it, and it stays unless something beats it by min_improvement. Pass log=print to watch.
text
@dataclass
class TuneResult:
servo: Servo
model: PlantModel # the identified joint
baseline: Controller # as found
tuned: Controller # the pick (== baseline if nothing won)
before: Score # baseline on held-out tapes
after: Score # tuned on the same held-out tapes
sweep: list[dict] # one row per rung: wn, zeta, kp, kd, ringing, crossings, why it stopped
candidates: list[dict] # every controller tried: pass, cost, score, rejection reasons
verdict: dict # wn_onset, wn_max, sweep_stop, delay, thresholds, feed-forward choice
notes: list[str]
traces: dict[str, Sequence[Trace]] # identification, sweep, reference, before, after
improved: bool # property: tuned != baselineA Controller is Gains plus a Feedforward and a label. Feedforward adds torque from the reference (never the measurement): coulomb_nm·tanh(qd*/band_rad_s) + viscous_nm_s_rad·qd* + inertia_kg_m2·qdd* + gravity_sin_nm·sin q* + gravity_cos_nm·cos q* (on a linear axis gravity is the constant gravity_cos_nm). A PlantModel holds the same five terms as identified, with residual_nm, condition, rows, source and ci95.
analytic_controller
text
def analytic_controller(model: PlantModel, loop: LoopModel, goal: Goal | None = None, *,
feedforward: bool = True, coulomb_fraction: float = 1.0,
wn_cap: float | None = None) -> tuple[Controller, dict]The closed-form controller at the highest bandwidth five bounds allow, from the description alone. It measures nothing. LoopModel is how the loop is configured: delay_s, torque_bandwidth_hz, velocity_filter_hz, loop_hz, velocity_noise_rad_s, position_quantum_rad, torque_cap_nm, structural_hz, travel, gravity_load_nm (plus the fractions the bounds use). The returned dict holds wn, zeta, binding, gain_margin, kp, kd. Details: the analytic start.
python
import math
from pidtuna import Envelope, Gains, Goal, PlantModel, Servo
from pidtuna.analytic import LoopModel, analytic_controller
from pidtuna.plants.servo_sim import ServoSim, Truth
from pidtuna.tune import tune
if __name__ == "__main__": # the simulator plays tapes in a process pool
# What CAD and the datasheet say, a little off from the truth below.
model = PlantModel(inertia_kg_m2=0.028, viscous_nm_s_rad=0.05, coulomb_nm=0.2, gravity_sin_nm=1.1, source="CAD + datasheet")
loop = LoopModel(torque_cap_nm=6.0, travel=0.4, position_quantum_rad=2 * math.pi / 163840, gravity_load_nm=0.0)
seed, info = analytic_controller(model, loop, Goal())
print(seed.label)
servo = Servo("elbow", Gains(kp=8.0, kd=0.4), torque_limit_nm=6.0,
envelope=Envelope(travel_rad=0.4, max_speed_rad_s=2.0, max_torque_nm=6.0, origin_rad=0.0,
gain_scale_range=(0.25, 20.0))) # a wide guard: simulation only
truth = Truth(inertia_kg_m2=0.03, viscous_nm_s_rad=0.05, coulomb_nm=0.25, gravity_sin_nm=1.2,
gear_stiffness_nm_rad=3000.0, link_inertia_fraction=0.6)
result = tune(servo, ServoSim(servo, truth), Goal(), seed=seed)
print(f"stop p90 {result.before.stop_p90_mrad:.1f} -> {result.after.stop_p90_mrad:.1f} mrad, kp {result.tuned.gains.kp:.0f}")text
analytic (wn 228, zeta 0.80, bound by linear range)
stop p90 32.5 -> 1.2 mrad, kp 160The seed asked for kp 1450; the guard clipped it to 20× the factory kp (160), and the sweep found no ringing up to there.
tune_arm
text
def tune_arm(arm_plant: ArmPlant, goal: Goal | None = None, *,
goals: Mapping[str, Goal] | None = None, log: Callable[[str], None] = <quiet>,
held_out_seed: int = 101, seeds: Mapping[str, Controller] | None = None) -> ArmResultTunes every joint tip to base with the others holding home, then plays all joints together and backs off any joint the arm check flags. goals and seeds are per joint name. An ArmPlant has arm: Arm (joints as Servos, base to tip), joint(name, *, holds) -> Plant and run_arm(tapes_by_joint, controllers) -> dict[str, Trace].
text
@dataclass
class ArmResult:
arm: Arm
joints: dict[str, TuneResult] # each joint's before/after is the whole arm's run
before: dict[str, Score] # as found, all joints moving
after: dict[str, Score] # delivered, all joints moving
notes: list[str]
traces: dict[str, dict[str, Sequence[Trace]]]ArmSim(arm, truths, *, mjcf_path, payload_kg=0.0, seed=0, workers=None, loop_hz=4000.0, model_feedforward=True, payload_site="tool") runs any MJCF whose actuated joints are hinges or slides, including passive joints and closed chains. Ready-made robots: plants.arm_truths.ur5e_sim(), plants.gantry_truths.gantry_sim(), plants.five_bar_truths.five_bar_sim(), plants.hand_truths.hand_sim(motors) for 2, 3, 6 or 9 motors.
python
from pidtuna import Goal
from pidtuna.arm import tune_arm
from pidtuna.plants.hand_truths import hand_sim
if __name__ == "__main__":
sim = hand_sim(2) # two opposed fingers; hidden friction and rotor inertia
result = tune_arm(sim, Goal(repeats=1))
for name, joint in result.joints.items():
print(f"{name}: stop p90 {result.before[name].stop_p90_mrad:.0f} -> {result.after[name].stop_p90_mrad:.1f} mrad, "
f"{joint.verdict['feedforward']}")text
f1_j0: stop p90 435 -> 24.4 mrad, FF (Coulomb x1, viscous) + FB
f0_j0: stop p90 453 -> 23.5 mrad, FF (Coulomb x1, viscous) + FBThis starts from the factory gains. In the evaluation, seeded with each joint's analytic start and with the guard opened for simulation, the same hand ends at 0.47 mrad.
fine_tune
text
def fine_tune(servo: Servo, deployed: Controller, logs: Sequence[Trace],
twin: Callable[[PlantModel], Plant], goal: Goal | None = None, *,
commissioning: PlantModel | None = None, sample_phase: float = 0.5,
terms: frozenset[str] = frozenset({"coulomb", "viscous", "inertia", "gravity"}),
log: Callable[[str], None] = <quiet>) -> ProposalImproves a deployed joint from motion it already made, with no extra moves. It re-identifies the joint from the logs, learns what the feed-forward is missing from the feedback torque, rebuilds a twin, and searches a small trust region on the logged tapes. Proposal carries proposed, drift (per-term change against commissioning), learned_feedforward, fidelity (twin vs logs; no proposal above 30 %), predicted_gain, notes, and canary(after_logs), which says keep or roll back once the change has run. Set sample_phase to the plant's torque_sample_phase.
python
from dataclasses import replace
from pidtuna import Envelope, Gains, Goal, Servo, tapes
from pidtuna.finetune import fine_tune
from pidtuna.plants.servo_sim import ServoSim, Truth, from_model
from pidtuna.tune import tune
servo = Servo("elbow", Gains(kp=8.0, kd=0.4), torque_limit_nm=6.0,
envelope=Envelope(travel_rad=0.4, max_speed_rad_s=2.0, max_torque_nm=6.0, origin_rad=0.0))
truth = Truth(inertia_kg_m2=0.03, viscous_nm_s_rad=0.05, coulomb_nm=0.25, gravity_sin_nm=1.2,
gear_stiffness_nm_rad=3000.0, link_inertia_fraction=0.6)
if __name__ == "__main__":
commissioned = tune(servo, ServoSim(servo, truth), Goal())
# Months later: more friction, a heavier tool. Log a day of ordinary work under the deployed controller.
worn = ServoSim(servo, replace(truth, coulomb_nm=0.35, viscous_nm_s_rad=0.065, inertia_kg_m2=0.0375))
work = [tapes.within_torque(tape, servo, commissioned.model) for s in (10, 11, 12) for tape in tapes.evaluation(servo, seed=s)]
logs = [worn.run(tape, commissioned.tuned) for tape in work]
proposal = fine_tune(servo, commissioned.tuned, logs, twin=lambda model: from_model(servo, model),
commissioning=commissioned.model, sample_phase=ServoSim.torque_sample_phase)
print({term: f"{100 * change:+.0f} %" for term, change in proposal.drift.items()})
print(f"twin {100 * proposal.fidelity:.0f} % off the logs; predicted cost x{proposal.predicted_gain:.2f}; change: {proposal.changes}")text
{'inertia': '+26 %', 'viscous': '-14 %', 'coulomb': '+49 %', 'gravity': '+0 %'}
twin 11 % off the logs; predicted cost x0.71; change: TrueThe true wear was +25 % inertia, +40 % Coulomb and +30 % viscous: the fit caught the first two and missed the third. On fresh motion the proposal cut tracking error from 5.9 to 3.1 mrad and stop p90 from 6.1 to 5.1 mrad, and canary kept it.
Smaller pieces
config.proposal(result) -> dictandconfig.arm_proposal(arm_result) -> dict: the proposal.config.save_traces(path, traces)writes traces to one.npz.score.score(traces) -> Score: the scoring terms.tapes.identification(servo),tapes.steps(servo),tapes.evaluation(servo, seed=0),tapes.within_torque(tape, servo, model): the standard motions (pipeline).identify.identify(traces, *, priors=None, sample_phase=0.5, torque_delay_s=0.0, ...) -> PlantModel: least squares on any traces, deployment logs included.identify.Rlsis the same regressor, recursive, for live use.tune.plan_seconds(servo, goal=None): upper bound on motion time per stage, before anything moves.