Drawing a model¶
A Modelica model is a graph before it is anything else. The components are the nodes,
and connect() gives the edges.
The source text holds that graph, but a person cannot see it there. After flattening it
is gone: twenty-six scalar equations keep every piece of the information and none of the
shape. modelica.diagram gets the shape back, as a value you can hold.
This page draws the same models three ways, one for each stage of the toolchain.
from pathlib import Path
root = next(p for p in [Path.cwd(), *Path.cwd().parents] if (p / "examples").is_dir())
MODELS = root / "examples" / "models"
LIBRARY = root / "examples" / "library"
1. The schematic¶
diagram.of reads Modelica files and draws the model in them. Give it the libraries as
well as the model, because name resolution needs them.
The picture is an SVG that this package draws itself. It needs no other program, and it names no colour, so it reads correctly on a light page and on a dark one.
from modelica import diagram
diagram.of(MODELS / "RLCCircuit.mo", LIBRARY / "Electrical.mo")
Three connectors meet at the bottom: c.n, source.n and ground.p. They are drawn as
one junction and not as three lines, because that is what the flattener makes of them —
one sum of currents, taken once. A picture that drew three lines would put the sum in a
place where it is not taken.
The graph itself is an ordinary value. Ask it questions.
picture = diagram.of(MODELS / "RLCCircuit.mo", LIBRARY / "Electrical.mo")
for node in picture.nodes:
print(f"{node.id:8} {node.kind:10} {node.type_name:28} {node.detail}")
source component Electrical.ConstantVoltage ('V = 1',)
r component Electrical.Resistor ('R = 1',)
l component Electrical.Inductor ('L = 1',)
c component Electrical.Capacitor ('C = 1',)
ground component Electrical.Ground ()
·3 junction ()
[(edge.source, edge.source_port, edge.target, edge.target_port) for edge in picture.edges]
[('source', 'p', 'r', 'p'),
('r', 'n', 'l', 'p'),
('l', 'n', 'c', 'p'),
('c', 'n', '·3', ''),
('source', 'n', '·3', ''),
('ground', 'p', '·3', '')]
2. A signal has a direction¶
A physical connector carries a potential and a flow, and neither end is the source. A
signal connector carries an input Real or an output Real, so the connection is
directed: it says which end computes the number.
PIControl is a control loop built from those. The arrows follow the causality, and they
point the same way whichever order the connect() was written in.
diagram.of(MODELS / "PIControl.mo", LIBRARY / "Blocks.mo")
The loop is closed: the plant feeds the subtraction, which feeds the controller, which feeds the plant. That is a fact about the model, and here it is a fact you can see.
3. Opening a component¶
depth says how many levels to open. At 1 each component is one box. At 2 a
component that holds components becomes a frame around them.
Machines.Motor holds a winding, a coil, the converter and the rotor. Its three
connectors are wired from inside and from outside at the same time.
diagram.of(
MODELS / "DCMotor.mo",
LIBRARY / "Electrical.mo",
LIBRARY / "Rotational.mo",
LIBRARY / "Machines.mo",
depth=1,
)
diagram.of(
MODELS / "DCMotor.mo",
LIBRARY / "Electrical.mo",
LIBRARY / "Rotational.mo",
LIBRARY / "Machines.mo",
depth=2,
)
The dashed boxes on the frame are the motor's own connectors. The supply reaches p
from outside and the winding reaches it from inside. Those two occurrences are different
ends of the same connector, and the flattener has to keep them apart — otherwise the
current at the boundary drops out of the equations and the model goes underdetermined.
4. Equations against variables¶
Flattening removes the components. What is left is scalar equations and scalar variables, and the question that matters becomes: which equation contains which variable?
That is the graph matching runs on. It is bipartite. Parameters are left out, because they are known before the solver starts, so no equation has to be spent on one.
from modelica.lang import parse
from modelica.sim import causalize, flatten
sources = [parse(p.read_text()) for p in (LIBRARY / "Electrical.mo", MODELS / "RCCircuit.mo")]
flat = flatten(sources, "RCCircuit")
diagram.incidence(flat)
The darker box is a state. Every other unknown is determined by the equations around it.
5. What to solve, and in what order¶
Causalization matches each equation to the variable it computes, then sorts the result into blocks. A block that reads what another block computes must run after it. The result is a directed acyclic graph, because a cycle would mean the two blocks were one block.
system = causalize(flat)
diagram.blocks(system)
A block drawn with a heavier fill is not a plain assignment. It has to go to a numerical
solver, and its size says how much that costs. RCCircuit has none: it sorts completely,
so the whole model is a sequence of assignments.
AtwoodPendulum does not sort completely. Five coupled degrees of freedom leave a 4×4
block, and the picture says exactly which four unknowns are stuck together.
atwood = causalize(flatten(parse((MODELS / "AtwoodPendulum.mo").read_text()), "AtwoodPendulum"))
diagram.blocks(atwood)
6. Writing it out¶
A graph writes itself three ways. SVG is an image and needs nothing else. Graphviz source
is for when you have dot and a large model. Mermaid source is text, so a Markdown page
can hold the diagram beside the words that explain it.
print(picture.to_mermaid())
graph LR nsource["source<br/>Electrical.ConstantVoltage"] nr["r<br/>Electrical.Resistor"] nl["l<br/>Electrical.Inductor"] nc["c<br/>Electrical.Capacitor"] nground["ground<br/>Electrical.Ground"] n_3(()) nsource ---|p p| nr nr ---|n p| nl nl ---|n p| nc nc ---|n| n_3 nsource ---|n| n_3 nground ---|p| n_3
print(picture.to_dot()[:420], "...")
digraph "RLCCircuit" {
// layout: neato
graph [rankdir=TB, fontname="monospace", label="A series RLC circuit stepped with 1 V -- damped oscillation, two states",
labelloc=b, fontsize=10, splines=true];
node [shape=box, style=rounded, fontname="monospace", fontsize=10];
edge [fontname="monospace", fontsize=9];
"source" [label="source\nElectrical.ConstantVoltage\nV = 1", shape=box, style="rounded"] ...
From a shell, the same three:
python -m modelica.diagram examples/models/RLCCircuit.mo examples/library/Electrical.mo > rlc.svg
python -m modelica.diagram examples/models/RLCCircuit.mo examples/library/Electrical.mo \
--view blocks --format dot | dot -Tpng > blocks.png
What holds the picture honest¶
The schematic resolves names with the flattener's own code, and not with a second copy of
it. A second implementation of extends would drift from the first, and then the picture
would show a model that the toolchain does not simulate.
A test in tests/test_diagram.py states that directly: no box may name a component that
the flat model does not have. It runs over every model in the corpus.