Mutation, devices and serialization¶
This page states what you may change after a model is built, how a model is moved to another device or dtype, and what survives copying and saving. It describes what the code does today. Anything listed as not supported or not moved is not guarded unless the text says it is refused.
The statements below are pinned by tests/test_ownership.py.
What owns what¶
| Object | Kind | Owns |
|---|---|---|
Network |
plain Python object | the graph (net.graph, a networkx.MultiDiGraph), node and edge attributes, cached operator tensors, dtype and device |
Layers (KirchhoffLayer, TransportLayer, AllocatedFlowLayer) |
plain Python objects, not torch.nn.Module |
a reference to the network, plus index tensors, capacities and counts copied from it at construction, and the network's topology version, device, dtype and attribute fingerprint of that time |
| Constitutive laws, node sources | torch.nn.Module |
their coefficients (parameters when learnable=True, otherwise plain tensors or buffers) |
Model |
plain Python object, not a torch.nn.Module |
the layer mapping, the closure list, the reaction list and the coupling options |
| Closures, drives, reactions | arbitrary callables or plain objects | whatever they close over |
| State and drivers | dictionaries of tensors | passed in on every call; the model keeps none |
Mutation after construction¶
Guaranteed: growing the network is refused. A layer records Network.topology_version
when it is built. If a node or an edge has been added since, every public entry point of the
layer, and so Model.step, steady and the other solves, raises RuntimeError ("the network
topology has changed"). Re-adding a node that already exists, to set attributes, does not count
as a change (it is an attribute edit, below). Add every node and edge first, then build the
layers; to change the graph, build the layers and the model again.
Guaranteed: the graph is frozen once a layer is built. Building a layer freezes
net.graph (Network.freeze, the networkx.freeze mechanism): a direct structural change,
net.graph.add_node(...), add_edge, remove_node, remove_edge, clear, raises
networkx.NetworkXError naming Network.add_node. Network.add_node and
Network.add_edge keep working, bump the topology version and so make the layers refuse, as
above. net.frozen says whether it is frozen; a network no layer has been built on is not.
Copies (deepcopy, pickle) of a frozen network are frozen too.
Guaranteed: an attribute edit is detected by Model.check and Model.to. Layers and
laws copy node and edge attributes into tensors when they are built (for example
orifice_law_from_edges reads Cd and area once), so an edit afterwards,
net.graph.nodes[n]["volume"] = ... or net.add_node(n, volume=...) on an existing node,
does not reach them. Each layer records Network.attribute_fingerprint() (a digest of every
node and edge attribute, names and values, tensors by value) when it is built.
Model.check() reports an attributes-changed error for every layer built before the edit,
and Model.to refuses to move such a model. The check is one pass over the attributes, so
it is not made on every step: a model stepped after an attribute edit without a check still
uses the values of construction time. An attribute holding an object without a value-like
repr (a custom class) counts by its type only, so an edit inside it is not seen. Rebuild the
laws, layers and model after an edit.
Guaranteed: law parameters are live. A law is a module that the layer reads on every call.
Changing a parameter in place (with torch.no_grad(): law.C.mul_(2), or an optimiser step)
changes the next step. Learnable parameters are registered on the law, so
law.named_parameters() lists them and law.state_dict() holds them.
Not supported, not guarded:
- Replacing or editing
model.layers,model.closuresormodel.reactionsafter construction.Model.__init__validates names, keys and drivers once; an appended closure runs without the checks against the layers (its declarations are read on first use, which checks its call signature againstintegrates, but itsoutputs,state_keysandspecies_columnsare not checked against the layers). This page, not the closure contract in Extending, is what governs a closure's lifecycle; units live with the keys (noodl.refs.Field, a closure'sinput_specs). - Changing a layer's private tensors (
cols, the law slices) or its list of laws.
Frozen dataclasses are shallow. The application records (Resistor, Circuit,
SewerNetwork, Manhole, ...) are @dataclass(frozen=True). That forbids assigning an
attribute, nothing more: a list, dict or tensor inside one can still be edited, and
SewerNetwork.notes is a dict that applications and users do edit. Circuit converts its
devices to a tuple; do not assume every frozen record does. Treat a built record as a value
and make a new one to change it (dataclasses.replace).
Devices and dtypes¶
Guaranteed: Model.to(device=None, dtype=None) moves the whole model, in place, and
returns it (CoupledModel.to does the same for every model of a union, and layer.to for
one layer with its network). One walk over the model moves:
- the network (
Network.to; its cached operators are cleared and derived again on the new device and dtype); - every layer's cached index, capacity and law-slice tensors, and its Modified Nodal Analysis structure;
- the laws and node sources (parameters move in place, so they stay the same
nn.Parameterobjects an optimiser holds; buffers and plain tensor attributes move too); - drives, reactions and every closure of a class defined in noodl, attribute by attribute (built-in closures need no hook of their own);
- a user closure (a class defined outside noodl) that defines
to(device=None, dtype=None): that method is called. This is the hook for a closure that holds tensors; - the tensors an application builder attaches to the model (
model.junction_idx,model.out_pipe,model.initial_flows, anotesdict...), and those inside dicts, lists, tuples and noodl records reached from it. Dicts and lists are updated in place, so a dict shared between a closure and the model stays one dict.
A part reached twice (a closure shared by two models of a CoupledModel, or twice in one
model) is moved once. If its to hook returns a replacement object rather than self (or
None, meaning it moved in place), every reference receives that same replacement, so shared
parts stay shared.
dtype sets the precision. A floating tensor takes dtype; a complex tensor stays complex at
that precision (float32 <-> complex64, float64 <-> complex128), so a phasor layer on a
float32 network solves in complex64; an integer or boolean tensor (an index, a mask) keeps its
dtype. A stored torch.dtype or torch.device attribute is mapped the same way. Moving twice
to the same place changes nothing, and moving a float64 model to float64 gives bitwise the same
results. Model.to refuses a model whose network changed topology or attributes since its
layers were built.
Guaranteed: Network.to on a built network makes its layers refuse. Network.to moves the
network only. A layer records the network's device and dtype with its topology version and
refuses to run once they differ ("the network was moved ... Move the whole model with
Model.to"). Model.to repairs it.
State and drivers are the caller's: pass them on the new device and dtype. Newton's default
tolerances follow the residual's dtype, but an application that pins its own (water_steady's
1e-11, the building and electrical solves' tight defaults) needs looser ones in float32
(atol=rtol=1e-5 or so).
tests/test_ownership.py pins float32 moves of a building model, a water model and an
electrical model (DC and phasor) against their float64 results, and a CUDA move when a GPU is
present.
Not moved:
- A closure that is a plain function, a lambda or a
functools.partial: the tensors it closes over stay where they are. Write it as a class with atomethod, or build it on the target device and dtype. - An object from outside noodl without a
tomethod, a NumPy array, and the node and edge attributes of the graph (source data, copied into tensors when the layers were built). - Tensors an application creates at run time with a fixed dtype. The transport layer computes its step in float64 whatever its storage dtype and returns the state's dtype, by design. The building application's controls and inputs write their values in the dtype of the driver they write into, so a moved model with controls keeps its precision.
Copying and serialization¶
copy.deepcopy(model)is supported and gives an independent model: its own network, its own layers and laws. The copy's topology guard refers to the copy's network, so growing the original network does not affect the copy.pickleandtorch.saveof a whole model work when every part is picklable. The built-in layers, laws, drives and reactions are. A closure is an arbitrary callable: a module-level function pickles, alambdaor a locally defined function does not.- A copy keeps integrating closures integrating. The model reads each closure's
declarations once into a
ClosureSpecthat holds the closure object, sodeepcopyandpicklecarry the closure and its declarations together and the copy'sstepadvances the closure's state exactly as the original does (tests/test_ownership.py::test_a_copied_model_still_advances_an_integrating_closure). state_dict()exists only on laws and node sources (they are modules). It holds parameters and buffers, nothing else: not the law kind, the layer, the network or any attribute. AModelhas nostate_dict; there is no one-call way to save and restore a calibrated model other than pickling it, or saving each law'sstate_dictand rebuilding the rest from the same builder code.- Attributes attached to a model are plain Python attributes. An application builder sets
some after construction, for example
model.notes(a dict that the sewer hydraulics keeps updating) andmodel.junction_idx. They travel withdeepcopyandpickleof the model, and with it only. They appear in nostate_dictand no file written by noodl. A copy keeps the sharing that existed (anotesdict that is also a closure'snotesstays one dict inside the copy). - Layer metadata (
quantity,unit,flow_unit,node_hints) is read bymodel.refsand in error messages, never by the numerics. It lives on the layer and is lost by any route that rebuilds the layer.