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.