Run logs¶
In this tutorial we record a run of the soft arm in detail, save it, load it back, replay its commands on a model that is slightly wrong, compare the two runs, and let a configuration file save every run it makes.
What a run records¶
vmc.sim.run returns a RunLog: every signal it recorded, one row per control step. We run the
controller of your first controller and ask record for everything it
can add:
import numpy as np
import matplotlib.pyplot as plt
import virtualmodelcontrol as vmc
from virtualmodelcontrol.robots import helyx
arm = helyx.add_dynamics(helyx.arm("145-145-145"))
goal = np.array([0.08, 0.0, 0.40]) # [m]
ctrl = vmc.Mechanism("ctrl")
ctrl.add("reach", vmc.LinearSpring(arm.point(s=1.0) - goal, 300.0))
ctrl.add("damp", vmc.LinearDamper(arm.point(s=1.0), 5.0))
ctrl.add("gravity", vmc.GravityCompensation(arm))
system = vmc.VirtualMechanismSystem(arm, ctrl)
controller = vmc.VMCController(vmc.compile(system))
plant = vmc.sim.ModelPlant(arm)
clock = vmc.sim.SimClock(dt=1 / 330) # [s]
log = vmc.sim.run(plant, controller, clock, T=2.0,
record=vmc.sim.RECORDS)
rows = log.arrays()
len(rows["t"])
660
Without record, a log holds the signals down to z in this list. record adds the others,
one group at a time: "params", "elements", "energy" and "robot". The signals are:
t: the time of the step [s].motor_position,motor_velocity: the measured motor angles [rad] and rates [rad/s].q,v: the configuration and velocity, when the plant reports them (a simulator does).motor_torque: the torques sent to the motors [N·m].law_torque: the torques the controller’s elements asked for, before the output stages [N·m].z: the controller’s virtual states, when it has any.param/<name>: a live Param, in its own shape ("params").element/<mechanism>.<element>/y,ydot,force: an element’s coordinate, its rate and its force, as inelement/ctrl.reach/force("elements").element/<mechanism>.<element>/torque: the element’s share of the motor torques [N·m], before the output stages ("elements").energy/stored,energy/kinetic: the controller’s stored and kinetic energy [J] ("energy").power/port,power/dissipation,power/source: the controller’s powers [W] ("energy").robot/<component>/y,ydot,force,torque: the same four for each spring, damper and contact of the simulated robot itself, as it feels them ("robot"; a real robot reports none).
The spring’s force is minus its stiffness times its coordinate y, the deflection. The shares
of the elements add up to the torque of the law. We follow the forces, and the torques on one
motor:
t = rows["t"].ravel()
spring = np.linalg.norm(rows["element/ctrl.reach/force"], axis=1)
damper = np.linalg.norm(rows["element/ctrl.damp/force"], axis=1)
fig, (top, bottom) = plt.subplots(2, 1, figsize=(6.4, 6.4), sharex=True)
top.plot(t, spring, label="spring")
top.plot(t, damper, "--", label="damper")
top.set_ylabel("force [N]")
top.legend(loc="upper right", fontsize=18)
for element in ("reach", "damp", "gravity"):
share = rows[f"element/ctrl.{element}/torque"][:, 2]
bottom.plot(t, 1000 * share, label=element)
bottom.plot(t, 1000 * rows["law_torque"][:, 2], "k:", label="sum")
bottom.set_xlabel("time [s]")
bottom.set_ylabel(r"motor 2 [mN$\cdot$m]")
bottom.legend(loc="lower right", fontsize=18);
The spring pulls with 26 N at the start and settles at 6.6 N: the arm’s own stiffness holds the tip short of the goal. The damper brakes the first moves, with up to 10.7 N after 6 ms. At the end, gravity compensation carries 10.1 of the 13.8 mN·m (in magnitude) that the law sends to motor 2. The shares add up to the law’s torque within 6e-17 N·m.
A step on which the guard stops the controller (a measurement is missing or not a number) logs
the zero torque it sent, and NaN for what the controller did not compute. A signal that
exists for part of a run only, such as the elements of a controller that takes over midway,
is NaN elsewhere. So every signal keeps one row per step. A loop of your own adds its steps
the same way, with log.step(t=..., q=...).
Save and load¶
save writes the log as one compressed NumPy file, and RunLog.load reads it back:
path = log.save("reach.npz", overwrite=True) # runs again without error
again = vmc.sim.RunLog.load(path)
again.meta["params"]["ctrl.reach.stiffness"]
{'value': 300.0, 'unit': 'N/m'}
The file holds the signals and meta, which says what the run was: the library’s version, the
start time, and the value every Param had at the start (the controller’s own, if set had
changed it). A plant that has a hardware profile as plant.profile adds it. A real-time run
also fills info with its statistics (steps, rate, overruns). save writes a temporary file
and renames it, so an interrupted save never leaves half a log. It refuses a name that is
taken, unless you pass overwrite=True, as the cell above does.
A saved log is a plain NumPy archive, so np.load("reach.npz")["q"] reads a signal without the
library.
Export to CSV¶
to_csv writes one line per step and one column per entry, named name_i for the entries of
a vector and name_i_j for those of a matrix, with all the digits of each number:
log.to_csv("reach.csv")
header = open("reach.csv").readline().strip().split(",")
header[:3], len(header)
(['t', 'motor_torque_0', 'motor_torque_1'], 245)
The CSV holds the steps only; meta and info stay in the .npz.
Replay and compare¶
A log of the real arm holds the commands it received and what its motors measured. replay
sends the same commands to a simulator, each held for as long as it was in the run, and returns
what the simulator did. compare says how far apart the two runs are. Replayed on the model that
made the log, the commands give the run back:
same = vmc.sim.replay(log, vmc.sim.ModelPlant(arm))
check = vmc.sim.compare(log, same, names=["motor_position", "q"])
max(entry["max"] for entry in check.values())
4.884981308350689e-15
Now the model is wrong: its joints are 30 % stiffer than the arm’s. We treat the log as the real arm’s:
stiff = helyx.add_dynamics(helyx.arm("145-145-145"),
stiffness=1.3 * helyx.SIM_STIFFNESS)
replayed = vmc.sim.replay(log, vmc.sim.ModelPlant(stiff))
vmc.sim.compare(log, replayed, names=["motor_position"])
{'motor_position': {'max': 0.5420545718770908, 'rms': 0.17024658148859892}}
compare gives the largest (max) and the root-mean-square (rms) difference of each signal
the two logs hold (names picks some), reading the second log at the first log’s times. A
log of a real arm has no q and v; replay then starts the simulator from the first motor
reading, through the transmission’s exact inverse.
fig, (top, bottom) = plt.subplots(2, 1, figsize=(6.4, 6.4), sharex=True)
angle = rows["motor_position"][:, 0]
model = replayed.arrays()["motor_position"][:, 0]
top.plot(t, angle, label="arm")
top.plot(t, model, "--", label="model")
top.set_ylabel("motor 0 [rad]")
top.legend(loc="lower right", fontsize=18)
bottom.plot(t, model - angle)
bottom.set_xlabel("time [s]")
bottom.set_ylabel("difference [rad]");
On the true model the largest difference is 5e-15. On the wrong one the stiffer joints hold the arm back. Under the same torques its motors turn up to 0.54 rad less than the arm’s (0.17 rad RMS), against a travel of 2.4 rad. The differences say what to correct in the model: the hanging arm fits its stiffness and damping from logged runs.
Runs from a configuration file¶
A configuration (Experiments in files) saves each of its runs when its
experiment section has run settings:
run:
name: walk
folder: logs
record: [params, elements]
name is the file name (by default the start time of the run), folder is relative to the
configuration file, and record lists what to add to the log. Download
reach.yaml
and run-settings.yaml
into the folder where you run the code. The cell adds these lines, indented by two spaces as in
the file, at the end of reach.yaml, where its experiment section ends, and runs the result.
Run it once: a second run is refused, as explained below it.
from pathlib import Path
settings = Path("run-settings.yaml").read_text()
Path("walk.yaml").write_text(Path("reach.yaml").read_text() + settings)
experiment = vmc.config.load("walk.yaml")
walk = experiment.run()
sorted(path.name for path in Path("logs").iterdir())
['walk.npz']
The run starts only if its name is free. A second run with the same name raises a
FileExistsError before anything moves, so a finished log is never replaced by mistake. With
overwrite: true in the settings, it replaces the file instead.
The log holds the configuration it started from, as YAML text in meta. Writing it to a file
brings the experiment back as it began, whatever has changed in the original file since:
Path("again.yaml").write_text(walk.meta["configuration"])
before = vmc.config.load("again.yaml") # the experiment of the log
This experiment moves its goal on a schedule and swaps to a gentler controller at 4 s, which takes over within a second. The log records the goal Param as it walked, and the elements of each controller, with NaN where a controller was not the one running:
saved = vmc.sim.RunLog.load("logs/walk.npz")
walked = saved.arrays()
time = walked["t"].ravel()
fig, (top, bottom) = plt.subplots(2, 1, figsize=(6.4, 6.4), sharex=True)
top.plot(time, 100 * walked["param/ctrl.reach.goal"][:, 0])
top.set_ylabel("goal $x$ [cm]")
for name in ("ctrl", "gentle"):
force = np.linalg.norm(walked[f"element/{name}.reach/force"], axis=1)
bottom.plot(time, force, label=name)
bottom.set_xlabel("time [s]")
bottom.set_ylabel("spring force [N]")
bottom.legend(loc="upper left", fontsize=18);
The log holds the first controller’s goal and elements until 5.0 s, when the swap is done, and the gentle controller’s from then on. Before the handover the first spring pulls with up to 14 N.