Using the core in Python¶
Scenarios and domain libraries compile down to the core: operators wired into a graph, and an engine that steps it. The core can also be used directly, for plants that no template describes or for experiments that drive the engine step by step.
Building a graph¶
import math
from homeostat.core import Engine, Event, Graph
from homeostat.core.operators import FOPDT, OU, PID, Lag, Schedule, Sum
g = Graph()
g.add(Schedule("L.sp", value=50.0))
g.add(Schedule("L.mode", value=1.0)) # AUTO
g.add(Schedule("L.man", value=math.nan)) # no manual output yet
g.add(
PID("L.pid", Kc=0.8, Ti=5.0),
inputs={"pv": "m", "sp": "L.sp", "mode": "L.mode", "man_out": "L.man"},
outputs={"out": "L.out"},
meta={"loop": "L"},
)
g.add(FOPDT("plant", K=1.0, tau=5.0, theta=1.0), inputs={"u": "L.out"})
g.add(OU("d", std=2.0, tau=60.0)) # a disturbance
g.add(Sum("y", weights=[1.0, 1.0]), inputs={"in0": "plant", "in1": "d"})
g.add(Lag("m", tau=1.0), inputs={"u": "y"}) # the instrument the controller reads
g.describe("y", unit="m3/h", doc="true flow")
add(operator, inputs, outputs, meta)adds an operator. Its output signal has the operator's name unlessoutputsrenames it;inputsmaps each input port to a signal.meta={"loop": "L"}names the loop a controller belongs to.describe(signal, unit, doc)adds a unit and a description to the signal table.modulate(path, signal, "scale" | "shift")makes a parameter follow a signal: its effective value is its base value times every scale signal, plus every shift signal. Degradations use it, for example fouling scales a heating gain.
The operator reference lists every operator with its equations, ports and parameters.
Compiling¶
compiled = g.compile(1.0, promote=["plant.K"]) # dt = 1 s; plant.K will change during the run
compiled.loops # [Loop(name='L', controller='L.pid', regulated='y', measured='m', reference='L.sp', ...)]
compiled.roles # {'y': [{'role': 'regulated', 'loop': 'L'}], 'd': [{'role': 'exogenous', ...}], ...}
Compiling checks the graph and fails with every problem at once: two writers for one signal, a missing input, an unknown signal (with a suggestion), an algebraic loop (with its cycle), a controller that reads a true value instead of a measurement. It then finds the control loops and every signal's roles, and orders the operators for the step.
promote lists parameters that events will change. batch sets the number of lanes, and lane_values gives parameters a different value per lane.
Running¶
engine = Engine(compiled, seed=1)
engine.schedule(Event(at=600, target="L.sp", value=60.0), Event(at=1200, target="plant.K", value=0.8))
engine.initialize("steady")
engine.run(1800)
run = engine.result()
The engine is resumable: run(until) advances the clock to until seconds, and more events can be scheduled between runs.
engine.schedule(Event(at=2400, target="L.mode", value=0.0), Event(at=2400, target="L.man", value=40.0))
engine.run(3000)
run = engine.result() # everything recorded since the start
initialize takes "steady", "none" or {"burn_in": seconds}, once, before the first run. fork() copies an engine, random streams included, so two copies can continue differently from the same point.
Long runs in pieces¶
A plant that runs for months is best collected and saved in pieces:
engine.run(86400)
day = engine.result(clear=True) # the rows and events recorded so far, dropped from the engine
saved = engine.snapshot() # bytes: states, random streams, pending events
engine = Engine.restore(saved) # later, perhaps in another process
engine.run(2 * 86400) # continues exactly where it stopped
The pieces that result(clear=True) hands over join up to exactly one continuous run, and a cleared engine does not grow, however long it runs. A snapshot records the Homeostat and NumPy versions that made it, and restore() refuses one from other versions. Only restore snapshots you made: like any pickle, a snapshot can run arbitrary code.
An Event has a time at in seconds, a target, a value, an action (set, shift, scale, or reset to return an operator to its initial state), a profile (step or ramp over over seconds), a label (planned or fault:<name>), and optionally the lanes it applies to. Events are checked when they are scheduled.
From a scenario to the core¶
homeostat.prepare(scenario) returns the pieces a scenario compiles to: prepared.graph, prepared.compiled, prepared.events and the domain library's prepared.instances. prepared.engine() gives an engine with the scenario's events scheduled, not yet initialized, and prepared.run(engine) runs it for the scenario's duration.