Skip to content

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 output

servo.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) -> float

Drives 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 guard

The 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:

fielddefaultmeaning
stop_allocation_mradNonestop-error budget (p90). None: only beat the as-found controller
damping_ratio0.8ζ the closed form aims for
gain_margin2.0deploy kp at most 1/margin of the kp where ringing started (6 dB)
tracking_slack1.10a pick may track at most 10 % worse than as found
ringing_mradNoneringing limit. None: 3× the joint's own hold noise, floor 0.05 mrad
ringing_cap_mrad2.0a computed ringing limit never exceeds this
max_saturation0.02share of rows allowed at the torque cap
max_chatter_fraction0.02torque chatter allowed, as a share of the cap
feedforwardTruetry feed-forward candidates
coulomb_fractions(0.0, 0.25, 0.5, 0.75, 1.0)Coulomb compensation levels tried
integratorFalsenot read anywhere yet: there is no ki search to switch on (why)
repeats2plays per tape set (a simulator reseeds its noise)
stop_weight, tracking_weight1.0, 1.0weights in the cost
min_improvement0.10a 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:

attributedefaultmeaning
run_many(jobs) -> list[Trace]one run per jobplay a batch at once (a process pool, one hardware session)
with_seed(seed) -> Plantthe same planta copy with fresh noise, for repeats
feedforward_terms: frozenset[str]all fourwhich of coulomb, viscous, inertia, gravity the plant can send
torque_sample_phase: float0.5where in a command interval the logged torque was sampled (0 = right after the command latched)
torque_delay_s: float0.0prior 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 mrad

Built-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) -> TuneResult

Runs 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 != baseline

A 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 160

The 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) -> ArmResult

Tunes 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) + FB

This 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>) -> Proposal

Improves 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: True

The 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) -> dict and config.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.Rls is the same regressor, recursive, for live use.
  • tune.plan_seconds(servo, goal=None): upper bound on motion time per stage, before anything moves.

Source: coming soon.