Quickstart#
Installation#
Requires a C++20 compiler, Python ≥ 3.10, NumPy, and Cython ≥ 3.0. The
package version is single-sourced from the repository’s
include/version.h.
cd python
pip install .
python -m pytest # includes enum-parity checks against the C++ headers
Drive a compiled C++ component#
import numpy as np
from hydrocouple.loader import load
component, info, handle = load("./libMyModel.so")
component.initialize()
component.validate()
component.prepare()
output = component.outputs[0]
values = np.empty(output.shape, dtype=np.float64)
while component.status.name not in ("Done", "Failed"):
component.update() # GIL released while C++ runs
ok, msg = output.get_values_into(
values, (0,) * len(output.shape), output.shape)
assert ok, msg
component.finish()
Selections are HDF5-style hyperslabs (start/count per dimension)
and destinations may be non-contiguous views. Dimension orderings are
canonical per data-item type — time is always dimension 0; see each ABC’s
docstring in the API Reference.
On failure, drain the diagnostic queue — the normative failure channel of the standard:
for entry in component.errors():
print(entry.severity.name, entry.source, entry.message)
Implement a component in Python#
Subclass the ABCs, then hand the component to C++ through the bridge:
from hydrocouple.core import ComponentStatus, IModelComponent
from _hydrocouple._core import PyComponentBridge
class MyModel(IModelComponent):
... # lifecycle, arguments, inputs/outputs,
# capabilities(), errors()
bridge = PyComponentBridge(MyModel())
# The bridge holds a C++ IModelComponent* that delegates every virtual
# call (including capabilities() and errors()) back into Python.
Runnable examples live in python/examples/:
sine_wave_component.py (a pure-Python time-marching component whose
lifecycle is checked against the transition table),
drive_cpp_component.py (loading and running a compiled component), and
coupled_python_models.py (two Python components coupled to each other,
covered next).
Couple two Python models together#
Nothing about coupling requires C++. Two components that subclass the ABCs
can exchange values through the standard’s own interfaces, in Python alone
— no compiled component, no SDK, no bridge.
examples/coupled_python_models.py runs a rainfall-runoff model
(producing runoff depth in mm/hr over three catchments) into a channel
routing model (consuming lateral inflow in m³/s over three reaches),
and shows the same exchange done two ways.
The direct way is to copy values in the driver loop:
depth = helpers.get_values_or_raise(output, (0,), output.shape)
flow = depth * areas_m2 / (1000.0 * 3600.0) # mm/hr -> m3/s
helpers.set_values_or_raise(input_item, flow, (0,), input_item.shape)
That is correct, and it is where most couplings start. But the conversion is stranded in the loop: nothing else can reuse it, and no interface records that a conversion happened — so a reader of either model cannot tell that the units disagree.
Moving it into the wiring changes that. An IInput
decides for itself whether a provider is usable, and here the raw output is
refused:
ok, message = input_item.can_consume(output)
# False: "runoff delivers mm/hr, lateral-inflow needs m3/s"
An IAdaptedOutput reconciles them. Because an
adapted output is an output, it can serve as a provider like any other,
and neither component knows it is there:
adapter = AreaWeightedFlowAdapter(output, areas_m2) # IAdaptedOutput
adapter.initialize()
assert input_item.can_consume(adapter)[0]
input_item.set_provider(adapter)
adapter.add_consumer(input_item)
With the link established, only the downstream model is driven. Routing
pulls what it needs through the adapter, which pulls the rainfall-runoff
model forward — the standard’s request-reply mechanism, and the reason
update_values() takes the querying input as its argument:
while routing.status != ComponentStatus.Done:
routing.update() # pulls upstream; nothing calls runoff.update()
Inside routing.update() the consumer side is three calls — and the
component parks in WaitingForData while the request is outstanding,
which is what that status is for:
provider = self._inflow.provider
provider.update_values(self._inflow) # request
values = helpers.get_values_or_raise(provider, (0,), provider.shape)
helpers.set_values_or_raise(self._inflow, values, (0,), provider.shape)
The example asserts that both couplings produce identical discharge at every step and reach, so the adapter is checked against the arithmetic it replaced rather than merely asserted to work.
Two details worth copying into real components. A provider that has reached
Done must not be advanced again — there is no Done → Updating edge
in the transition table — so it keeps serving the values it holds once its
horizon runs out. And a discoverable adapter would normally be produced by
an IAdaptedOutputFactory and expose its
configuration as IArgument, rather than taking
it through the constructor as this one does.
Helpers#
hydrocouple.helpers mirrors the C++ hydrocouplehelpers.h — the
single sanctioned exception to the standard’s no-implementation rule:
the DataKind ⇄ numpy.dtype maps,
is_valid_component_status_transition()
(the lifecycle state machine), typed scalar/slab conveniences with
raising variants, and Julian-day conversions matching the IDateTime
convention.