Add more exmaples and fix CI
🧪 Test / test (push) Successful in 5m59s

This commit is contained in:
2026-05-08 18:28:12 +02:00
parent 127ffb3849
commit 3c683c821d
8 changed files with 526 additions and 40 deletions
+87 -5
View File
@@ -304,16 +304,17 @@ private:
};
```
**Factory syntax — `in<>` / `out<>` tag structs:**
**Factory syntax — `in<>` / `latch<>` / `out<>` tag structs:**
A flat name pack `make_node<f, "a", "b", "c">` is ambiguous (where do inputs end?).
Option chosen: `in<...>` and `out<...>` tag types that wrap the name packs unambiguously.
Both are optional; omitting either means those ports are index-only.
Option chosen: `in<...>`, `latch<...>`, and `out<...>` tag types that wrap the name packs unambiguously.
All are optional; omitting either means those ports are index-only.
```cpp
// Tag types (trivial, no data):
template<fixed_string... Names> struct in {};
template<fixed_string... Names> struct out {};
template<fixed_string... Names> struct in {};
template<fixed_string... Names> struct latch {};
template<fixed_string... Names> struct out {};
// Factory:
// No names
@@ -324,6 +325,9 @@ auto node = make_node<my_func, in<"img","sigma">>(10);
// Both input and output names
auto node = make_node<my_func, in<"img","sigma">, out<"blurred","mask">>(10);
// Mixed synchronous and latched inputs
auto node = make_node<my_func, in<"error">, latch<"setpoint">, out<"output">>(10);
```
**Wrong name count is a compile error.** The `Node` class `static_assert`s that
@@ -342,6 +346,83 @@ Multi-output functions must return `std::tuple<...>`. Single return accepted as-
---
## Component 4a — Latched Input Ports
### Motivation
Control and robotics applications naturally have two kinds of inputs at different update rates:
- **Synchronous inputs** (`in<>`) — the node must have fresh data on every fire. Typical for sensor readings that drive the computation (e.g. encoder RPM).
- **Latched inputs** (`latch<>`) — the node uses the most recently received value, and does not block if no new value has arrived. Typical for setpoints or parameters that change infrequently relative to the control loop (e.g. bearing from a CV pipeline, PID gains).
Without latched ports, a node must block on all inputs simultaneously. This forces the control loop to run at the rate of the slowest input — unacceptable when a 1kHz encoder loop must wait for a 30Hz vision update.
### Semantics
A `latch<>` port:
1. **Does not block** if its channel is empty — it reuses the last successfully popped value.
2. **Does block on first fire** — there is no meaningful "default" value, so the node waits until at least one value has arrived on each latched port before firing for the first time.
3. **Consumes the value** when one is available (standard `pop()`), then holds it until the next value arrives.
The node fires whenever all `in<>` ports have data, using the last known value for each `latch<>` port.
### Implementation in `run_loop`
`run_loop` maintains a `std::tuple` of cached values, one slot per latched port. On each iteration:
```cpp
// Synchronous ports — blocking pop (existing behaviour)
auto sync_args = std::make_tuple(input<0>().pop(), input<1>().pop(), ...);
// Latched ports — non-blocking try_pop; keep cached value on miss
try_pop(latch_cache_<I>, latch_channel_<I>); // updates cache if data available
// Call wrapped function with merged argument tuple
auto result = std::apply(Func, merge(sync_args, latch_cache_));
```
Latched channels are otherwise identical to synchronous channels: bounded FIFO, `shared_ptr` storage policy, same shutdown behaviour.
### Example — PID with live setpoint
```cpp
// bearing arrives at ~30 Hz from CV; rpm arrives at ~1 kHz from encoder
double pid_compute(double rpm, double bearing) { ... }
auto pid = make_node<pid_compute,
in<"rpm">, // synchronous — blocks until fresh encoder tick
latch<"bearing">, // latched — uses last known bearing from CV
out<"pwm">
>(8);
Network net;
net.add("tacho", tacho_node)
.add("tracker", tracker_node)
.add("pid", pid)
.connect("tacho", tacho_node.output<"rpm">(), "pid", pid.input<"rpm">())
.connect("tracker", tracker_node.output<"bearing">(),"pid", pid.input<"bearing">())
.build();
```
The PID node fires at encoder rate. If no new bearing has arrived since the last tick, it reuses the previous one — correct behaviour for a control loop.
### Port Ordering Contract
`in<>` and `latch<>` ports together must cover all function parameters in declaration order. The `static_assert` on name count is extended to cover both tags jointly:
```cpp
static_assert(
sizeof...(InNames) + sizeof...(LatchNames) == input_count ||
(sizeof...(InNames) == 0 && sizeof...(LatchNames) == 0),
"make_node: in<> and latch<> names together must match function arity, or provide none"
);
```
The function parameter at position `i` is synchronous if `i` is in the `in<>` pack, latched if in the `latch<>` pack. Mixed ordering is allowed — the tag packs define which positions are latched, not a contiguous suffix.
---
## Component 5 — `network.hpp`: Graph Builder + Orchestrator
`Network` is **non-owning** — nodes are declared by the user and must outlive the network.
@@ -997,3 +1078,4 @@ All major design questions are now closed:
| Sub-networks | `Network` implements `INode`; `expose_input`/`expose_output` define boundary ports |
| `make_py_network` | Pure C++ template; nanobind module recompilation is the registration step |
| GIL strategy | Acquire per Python callback; release while blocking on channel ops |
| Mixed-rate inputs | `latch<>` tag for ports that reuse last-seen value; blocks only on first fire; node fires at rate of `in<>` ports |