351 lines
10 KiB
Python
351 lines
10 KiB
Python
"""The wiring fundamentals: name binding, fan-in, namespaces, validation."""
|
|
|
|
from fluksio.flow.controller import _declared_inputs
|
|
from fluksio.flow.events import EventBus
|
|
from fluksio.flow.messages import DType, MessageSpec
|
|
from fluksio.flow.nodes import Node
|
|
from fluksio.flow.pipeline import Pipeline, ValidationIssue
|
|
from fluksio.flow.schemas import FlowDef, FlowInput
|
|
|
|
|
|
def spec(name: str, dtype: DType = DType.FLOAT, port: str = "") -> MessageSpec:
|
|
return MessageSpec(name=name, dtype=dtype, port=port)
|
|
|
|
|
|
def make_node(node_id: str, flow: str, f, requires=(), provides=()) -> Node:
|
|
node = Node(f=f, requires=list(requires), provides=list(provides), name=node_id)
|
|
node.assign_flow(flow, node_id)
|
|
return node
|
|
|
|
|
|
def test_bare_names_are_scoped_to_their_flow():
|
|
source = make_node(
|
|
"source", "heating", lambda params: {"temp": 20.0}, provides=[spec("temp")]
|
|
)
|
|
assert "heating.temp" in source.provides
|
|
|
|
|
|
def test_consumer_receives_from_any_producer():
|
|
# Two producers of one message: each publication reaches the consumer.
|
|
seen = []
|
|
|
|
def emit_a(params):
|
|
return {"temp": 1.0}
|
|
|
|
def emit_b(params):
|
|
return {"temp": 2.0}
|
|
|
|
def consume(temp, params):
|
|
seen.append(temp)
|
|
return None
|
|
|
|
a = make_node("a", "heating", emit_a, provides=[spec("temp")])
|
|
b = make_node("b", "heating", emit_b, provides=[spec("temp")])
|
|
c = make_node("c", "heating", consume, requires=[spec("temp")])
|
|
|
|
pipeline = Pipeline(nodes=[a, b, c])
|
|
|
|
assert pipeline.produces["heating.temp"] == [a, b]
|
|
assert pipeline.dependencies[c] == frozenset({a, b})
|
|
|
|
a.inject()
|
|
b.inject()
|
|
|
|
assert seen == [1.0, 2.0]
|
|
# Latest value wins.
|
|
assert pipeline.state["heating.temp"] == 2.0
|
|
|
|
|
|
def test_flows_connect_through_qualified_names():
|
|
def emit(params):
|
|
return {"power": 500.0}
|
|
|
|
def consume(power, params):
|
|
return {"used": power}
|
|
|
|
producer = make_node("meter", "solar", emit, provides=[spec("power")])
|
|
# A bare name would be heating.power; the dotted one crosses the flow.
|
|
consumer = make_node(
|
|
"load",
|
|
"heating",
|
|
consume,
|
|
requires=[spec("solar.power")],
|
|
provides=[spec("used")],
|
|
)
|
|
|
|
pipeline = Pipeline(nodes=[producer, consumer])
|
|
assert pipeline.dependencies[consumer] == frozenset({producer})
|
|
|
|
producer.inject()
|
|
assert pipeline.state["heating.used"] == 500.0
|
|
|
|
|
|
def test_ports_keep_function_arguments_local():
|
|
def convert(celsius, params):
|
|
return {"fahrenheit": celsius * 9 / 5 + 32}
|
|
|
|
node = make_node(
|
|
"convert",
|
|
"heating",
|
|
convert,
|
|
requires=[spec("solar.celsius", port="celsius")],
|
|
provides=[spec("fahrenheit")],
|
|
)
|
|
Pipeline(nodes=[node])
|
|
|
|
assert node.execute({"solar.celsius": 100.0}) == {"heating.fahrenheit": 212.0}
|
|
|
|
|
|
def test_validate_reports_cycles_and_dangling_inputs():
|
|
a = make_node(
|
|
"a", "f", lambda b, params: {"a": b}, requires=[spec("b")], provides=[spec("a")]
|
|
)
|
|
b = make_node(
|
|
"b", "f", lambda a, params: {"b": a}, requires=[spec("a")], provides=[spec("b")]
|
|
)
|
|
lonely = make_node(
|
|
"lonely", "f", lambda missing, params: None, requires=[spec("missing")]
|
|
)
|
|
|
|
issues = Pipeline(nodes=[a, b, lonely]).validate()
|
|
codes = {issue.code for issue in issues}
|
|
|
|
assert "cycle" in codes
|
|
assert "unconnected_input" in codes
|
|
dangling = next(i for i in issues if i.code == "unconnected_input")
|
|
assert dangling.message_name == "f.missing"
|
|
assert dangling.node == "f.lonely"
|
|
|
|
|
|
def test_declared_flow_inputs_are_not_dangling():
|
|
node = make_node(
|
|
"n", "f", lambda setpoint, params: None, requires=[spec("setpoint")]
|
|
)
|
|
issues = Pipeline(nodes=[node]).validate({"f.setpoint": True})
|
|
assert issues == []
|
|
|
|
|
|
def test_a_flow_input_without_a_starting_value_is_reported():
|
|
node = make_node(
|
|
"n", "f", lambda setpoint, params: None, requires=[spec("setpoint")]
|
|
)
|
|
issues = Pipeline(nodes=[node]).validate({"f.setpoint": False})
|
|
assert [issue.code for issue in issues] == ["missing_initial_value"]
|
|
|
|
|
|
def test_a_batch_flows_input_is_a_run_parameter_not_a_missing_value():
|
|
"""A flow that only runs when asked gets its inputs from the run.
|
|
|
|
Calling that a message nothing ever sets had the health summary count a
|
|
training flow as one that cannot run while the Runs screen showed it
|
|
running. A live flow, which nothing is going to start on its own, still
|
|
reports it.
|
|
"""
|
|
node = make_node(
|
|
"n", "f", lambda setpoint, params: None, requires=[spec("setpoint")]
|
|
)
|
|
batch = FlowDef(name="f", mode="batch", inputs=[FlowInput(spec=spec("setpoint"))])
|
|
live = batch.model_copy(update={"mode": "live"})
|
|
|
|
assert Pipeline(nodes=[node]).validate(_declared_inputs(batch)[0]) == []
|
|
issues = Pipeline(nodes=[node]).validate(_declared_inputs(live)[0])
|
|
assert [issue.code for issue in issues] == ["missing_initial_value"]
|
|
|
|
|
|
def test_a_failing_node_does_not_stop_its_siblings():
|
|
ran = []
|
|
|
|
def boom(params):
|
|
raise RuntimeError("nope")
|
|
|
|
def fine(params):
|
|
ran.append("fine")
|
|
return {"ok": 1.0}
|
|
|
|
bad = make_node("bad", "f", boom, provides=[spec("bad_out")])
|
|
good = make_node("good", "f", fine, provides=[spec("ok")])
|
|
|
|
pipeline = Pipeline(nodes=[bad, good])
|
|
pipeline.run()
|
|
|
|
assert ran == ["fine"]
|
|
assert pipeline.state["f.ok"] == 1.0
|
|
|
|
|
|
def test_a_node_returning_something_other_than_a_dict_says_what_is_wrong():
|
|
"""Outputs are keyed by port, so a bare value cannot be one of them."""
|
|
events = []
|
|
|
|
def wrong(params):
|
|
return 42.0
|
|
|
|
bus = EventBus()
|
|
bus.publish = events.append # type: ignore[method-assign]
|
|
node = make_node("n", "f", wrong, provides=[spec("out")])
|
|
Pipeline(nodes=[node], events=bus).run()
|
|
|
|
(error,) = [e for e in events if e["type"] == "node_error"]
|
|
assert "NodeOutputError" in error["error"]
|
|
assert "returned float" in error["error"]
|
|
|
|
|
|
def test_values_carry_timestamps():
|
|
node = make_node("n", "f", lambda params: {"out": 1.0}, provides=[spec("out")])
|
|
pipeline = Pipeline(nodes=[node])
|
|
node.inject()
|
|
|
|
values = pipeline.values("f")
|
|
assert values["f.out"]["value"] == 1.0
|
|
assert values["f.out"]["ts"] > 0
|
|
|
|
|
|
def test_only_advisory_codes_are_flagged_advisory():
|
|
"""The UI reads this to keep an advisory out of the fault tone."""
|
|
hook = ValidationIssue(code="unauthenticated_hook", message="open")
|
|
cycle = ValidationIssue(code="cycle", message="loop")
|
|
|
|
assert hook.advisory is True
|
|
assert cycle.advisory is False
|
|
|
|
|
|
def test_a_producer_that_cannot_run_does_not_strand_its_consumer():
|
|
"""One wave, two producers of one consumer, and one of them never ready.
|
|
|
|
The ready producer must reach its consumer even when a sibling is
|
|
waiting for an input. The consumer can use that sibling's stored value.
|
|
"""
|
|
ran = []
|
|
|
|
power = make_node(
|
|
"cerbo", "power", lambda params: {"batt_v": 50.3}, provides=[spec("batt_v")]
|
|
)
|
|
|
|
def arbitrate(batt_v, params):
|
|
ran.append("boiler")
|
|
return {"boiler_on": 1.0}
|
|
|
|
boiler = make_node(
|
|
"arbiter",
|
|
"boiler",
|
|
arbitrate,
|
|
requires=[spec("power.batt_v")],
|
|
provides=[spec("boiler_on")],
|
|
)
|
|
|
|
# Woken by the same wave; `pump_for` has never been published.
|
|
pump = make_node(
|
|
"pump_run",
|
|
"plugs",
|
|
lambda batt_v, pump_for, params: {"pump": 1.0},
|
|
requires=[spec("power.batt_v"), spec("plugs.pump_for")],
|
|
provides=[spec("pump")],
|
|
)
|
|
|
|
def encode(boiler_on, pump, params):
|
|
ran.append("encoder")
|
|
return {"dmx_boiler": boiler_on}
|
|
|
|
encoder = make_node(
|
|
"switches",
|
|
"dmx",
|
|
encode,
|
|
requires=[spec("boiler.boiler_on"), spec("plugs.pump")],
|
|
provides=[spec("dmx_boiler")],
|
|
)
|
|
|
|
pipeline = Pipeline(nodes=[power, boiler, pump, encoder])
|
|
pipeline.state["plugs.pump"] = 0.0
|
|
|
|
power.inject()
|
|
|
|
assert ran == ["boiler", "encoder"]
|
|
assert pipeline.state["dmx.dmx_boiler"] == 1.0
|
|
|
|
|
|
def test_a_quiet_producer_does_not_veto_a_noisy_sibling():
|
|
"""Two report-by-exception producers, one consumer, and only one with news.
|
|
|
|
A producer that publishes nothing must not block a shared consumer
|
|
from processing its sibling's new value.
|
|
"""
|
|
ran = []
|
|
|
|
power = make_node(
|
|
"cerbo", "power", lambda params: {"batt_v": 50.3}, provides=[spec("batt_v")]
|
|
)
|
|
water = make_node(
|
|
"water_changed",
|
|
"boiler",
|
|
lambda batt_v, params: {"water_boiler": 1.0},
|
|
requires=[spec("power.batt_v")],
|
|
provides=[spec("water_boiler")],
|
|
)
|
|
# Unchanged, so its rbe publishes nothing this wave.
|
|
kitchen = make_node(
|
|
"kitchen_changed",
|
|
"boiler",
|
|
lambda batt_v, params: None,
|
|
requires=[spec("power.batt_v")],
|
|
provides=[spec("kitchen_boiler")],
|
|
)
|
|
|
|
def encode(water_boiler, kitchen_boiler, params):
|
|
ran.append("switches")
|
|
return {"dmx_water_boiler": 255.0 if water_boiler else 0.0}
|
|
|
|
encoder = make_node(
|
|
"switches",
|
|
"dmx",
|
|
encode,
|
|
requires=[spec("boiler.water_boiler"), spec("boiler.kitchen_boiler")],
|
|
provides=[spec("dmx_water_boiler")],
|
|
)
|
|
|
|
pipeline = Pipeline(nodes=[power, water, kitchen, encoder])
|
|
pipeline.state["boiler.water_boiler"] = 0.0
|
|
pipeline.state["boiler.kitchen_boiler"] = 0.0
|
|
|
|
power.inject()
|
|
|
|
assert ran == ["switches"]
|
|
assert pipeline.state["dmx.dmx_water_boiler"] == 255.0
|
|
|
|
|
|
def test_a_consumer_of_only_quiet_producers_still_does_not_run():
|
|
"""The other half of it: freeing a consumer is not the same as running it.
|
|
|
|
Nothing it reads was refreshed, so it must stay put — otherwise every wave
|
|
would re-run the whole graph on values it has already seen.
|
|
"""
|
|
ran = []
|
|
|
|
power = make_node(
|
|
"cerbo", "power", lambda params: {"batt_v": 50.3}, provides=[spec("batt_v")]
|
|
)
|
|
quiet = make_node(
|
|
"changed",
|
|
"boiler",
|
|
lambda batt_v, params: None,
|
|
requires=[spec("power.batt_v")],
|
|
provides=[spec("water_boiler")],
|
|
)
|
|
|
|
def encode(water_boiler, params):
|
|
ran.append("switches")
|
|
return {"dmx_water_boiler": 255.0}
|
|
|
|
encoder = make_node(
|
|
"switches",
|
|
"dmx",
|
|
encode,
|
|
requires=[spec("boiler.water_boiler")],
|
|
provides=[spec("dmx_water_boiler")],
|
|
)
|
|
|
|
pipeline = Pipeline(nodes=[power, quiet, encoder])
|
|
pipeline.state["boiler.water_boiler"] = 0.0
|
|
|
|
power.inject()
|
|
|
|
assert ran == []
|