Streaming between recipes in Python¶
How one recipe feeds another: the ports, the wire, who decides when the
consumer runs, and what a DataBuffer between them changes.
This is the Python side of the mechanism described in
src/Core/uprops/docs/streaming.md, which is the C++ one. You do
not need that document to read this one, but
everything here rests on it, and a question this page does not answer is very
likely answered there.
Two scenes ship with the library, built by the examples of the same names, and every listing below is taken from them:
| Scene | What it is |
|---|---|
assets/examples/recipes/01_recipes_pull.gcx |
producer → consumer, read on demand |
assets/examples/recipes/02_recipes_push.gcx |
producer → DataBuffer → consumer, live |
Both are also two clicks away inside gcompose: Recipes panel → + Add → Connected Recipes (pull) and (push), which write the same two recipes into the scene you have open, wired and ready to run.
1. What a stream is here¶
A stream is a sequence of scene objects, read one at a time, through a port that says so. That is all it is. Two quite different things wear it:
- a collection — everything is already there, reading it twice reads the same twice. A recipe that returns a list of boxes produces one of these.
- a queue of what is in flight — items arrive over time, and reading takes
them. A
DataBufferrelaying a producer is one of these; so is aMuonScatterSourcefilling from a running simulation.
The port is the same in both cases and so is the wire. What differs is what reading means, and that difference is the single thing to keep in mind on this page: reading a collection is a replay, reading a queue is a consumption.
A stream is not a thread, and does not imply one. Nothing here starts a thread
unless you ask for one (StreamMode = "Async", or a decorator's
mode="async").
2. Declaring the ports¶
A recipe declares its ports at the top of its own source, as ordinary Python that runs when the recipe is loaded:
from uLib.recipe_agent import StreamInputPort, StreamOutputPort
In = StreamInputPort("In", description="what this recipe reads")
Out = StreamOutputPort("Out", description="what this recipe makes")
Each line puts a port on the node, in the node graph and in the Properties
panel, coloured as a stream port. The names on the left (In, Out) are the
names your run(ctx) body uses; the strings are the labels shown in the UI.
In the panel these two lines are + Add → Stream Input and Stream Output, which write them for you.
When the recipe runs, every declared name is replaced by the value its port
actually holds — so In inside run() is not the StreamInputPort object
you declared, it is what is on the wire.
3. Reading a stream¶
An input port arrives in run() as a Stream, or as None when nothing is
wired to it. Always check:
There are two ways to read it, and choosing correctly is the whole of section 1 applied:
for item in stream — replay¶
Rewinds the producer to its start and walks all of it. Right for a collection.
Wrong for a queue: the rewind is ResetStream(), and on a DataBuffer
that empties the queue before reading. A live queue has nothing behind its
cursor to go back to, so this reads what happens to be there after the clear —
often nothing.
stream.drain() — live¶
for item in In.drain():
...
batch = list(In.drain()) # same thing, as a list
batch = In.take(100) # at most 100
Takes what has arrived now, one item at a time, and leaves the cursor where
it stopped. When the producer has nothing more at this moment, drain()
returns — which is not the end of the stream, only the end of this batch. A
consumer meant to run again returns here and is called again.
Items drained are gone from the queue. That is the same rule as C++: reading a queue consumes it.
Looking without reading¶
len(In) # how many are available (queued, or held by a collection)
In[3] # the item at that index, without consuming anything
Rewinding on purpose¶
Called for you by for item in stream. Rarely what you want explicitly.
4. Writing a stream¶
Assign anything iterable to a declared stream output port and it becomes that port's stream:
Out = StreamOutputPort("Out")
def make_items(ctx, count):
for index in range(int(count)):
yield ctx.create("ContainerBox", "item_%d" % index)
def run(ctx):
global Out # `global`, or the assignment is local
Out = make_items(ctx, Count)
A list, a tuple, a generator, or a Stream you were given — all work. Three
things are worth knowing:
globalis required. Without itOut = ...binds a local name and the port keeps whatever it had.- The collection happens when
run()returns, not while it yields. The generator above is walked once, at the end of the run, and what it yielded becomes the port's stream. A recipe therefore cannot hand over its first item before its last — which is exactly why a live chain puts aDataBufferdownstream to pace what the recipe made (section 7). - At most 50 000 items are taken from one assignment; the rest is dropped. A run that means to produce more than that wants a producer object, not a recipe.
You may also assign an existing scene object that is itself a stream producer
(a DataBuffer, a MuonScatterSource, a collection), in which case the port
simply points at it.
5. Making the connection¶
A wire from one recipe's output port to another's input port is what makes the two a chain. Three ways to make it:
In the node graph — drag from Out to In. What you get is a
connection, described below.
From a script —
ctx.connect(producer, "Out", consumer, "In") # wire it
ctx.disconnect(consumer, "In") # remove whatever drives it
In C++ — ReferencePropertyBase::ConnectFrom, which the two above end in;
uLib::BuildRecipeStreamChain builds either of the shipped chains in one call.
Connecting, and the one thing you cannot assign¶
consumer.In = producer.Out # ValueError: a Stream is not assignable
ctx.connect(producer, "Out", consumer, "In") # this is how two ports meet
A port's value read from Python is a Stream wrapper, and a wrapper cannot be
written into another port — attempting it raises. ctx.connect is how one
port is attached to another, and the node graph's edge is the same thing.
Assignment is for an object, and is right when what the consumer should read is a thing in the scene rather than another recipe's port:
What the connection adds over pointing at an object:
- it takes what the driving port holds now, so a wire made after the producer has run means something immediately;
- it keeps following that port — including when a later run puts a different object there, which an assignment made once would not see;
- it is what the node graph draws, and what a saved scene restores.
One thing that is easy to over-read: a recipe keeps one producer object per stream output port and refills it on every run, so a consumer pointed straight at that object also sees each new run's items. The connection is still what you want between two recipes — it is the wire the scene stores and the graph shows — but if you are debugging a chain, the items arriving is not by itself proof that the wire is there.
6. Who runs the consumer¶
Wiring says where the items go. It does not say when anything runs. Two answers:
Explicitly¶
or the Run button, or obj.run(). This is the pull chain: the consumer
reads the whole stream because you asked it to.
By announcement — AutoRun¶
A recipe follows its input ports (Recipe::FollowsInputPorts), so anything
announcing itself on a wired input port runs it. Two things announce:
- the producer's port being written at the end of a run (so running a producer runs an AutoRun consumer straight after it), and
- a
DataBufferreaching itsNotifyEverythreshold (section 7).
This is the push chain. Runs are coalesced and posted to the application
thread: several announcements in quick succession become one run on the latest
state, rather than a queue of runs (see
docs/code/core/autorun.md).
7. Putting a DataBuffer in between¶
Data/DataBuffer in the Add menu. Between producer and consumer it does three
things worth having: it decouples them onto different threads, it bounds how
far ahead the producer may get, and it is what wakes the consumer.
Wire the producer's output into the buffer's Input, and point the consumer's
stream input at the buffer object itself — a DataBuffer is a stream
producer, so there is no output port to wire from:
Its properties, all in the Stream group:
| Property | What it does |
|---|---|
StreamMode |
"Inline" — pass straight through, no queue, no thread. "Auto" — the same until someone pushes into it. "Async" — a worker thread takes from the producer into the queue; this is the live one. |
Capacity |
How many items may wait. The bound on memory, and the backpressure: a full buffer holds the producer up. |
OverflowPolicy |
What a push into a full buffer does: "Block" (wait for room, the default), "DropOldest", "DropNewest". |
NotifyEvery |
Announce once this many items are queued; 0 never announces. The rate at which an AutoRun consumer runs. |
StreamQueueSize |
Read-only: how many are waiting right now. |
NotifyEvery is the dial worth understanding. It announces once per
crossing of the threshold, not once per item: the queue must fall back below
it before it announces again. So it sets the pace, not an exact batch size —
a consumer draining while the relay keeps filling takes whatever has arrived,
which is usually more than the threshold. The shipped push chain relays 400
items through a buffer holding 32, announcing every 8, and the consumer runs
about 26 times, averaging 15 items a run.
Set it to 1 and the consumer runs per item, which is correct and mostly overhead. Set it larger than the whole run and it never runs at all.
8. A live chain, end to end¶
This is 02_recipes_push.gcx, which the + Add → Connected Recipes (push)
menu entry also writes. Producer:
from uLib.recipe_agent import StreamOutputPort, Property
Out = StreamOutputPort("Out", description="items for the buffer to pace")
Count = Property("Count", default=64, type="int", min=1, max=100000)
def make_items(ctx, count):
for index in range(int(count)):
yield ctx.create("ContainerBox", "item_%d" % index)
def run(ctx):
global Out
Out = make_items(ctx, Count)
Consumer:
from uLib.recipe_agent import StreamInputPort, Property
In = StreamInputPort("In", description="the buffer feeding this recipe")
_state = {"runs": 0, "total": 0, "last": 0}
def get_runs(): return _state["runs"]
def get_total(): return _state["total"]
def get_last(): return _state["last"]
Runs = Property("Runs", getter=get_runs, type="int")
Total = Property("Total", getter=get_total, type="int")
LastBatch = Property("LastBatch", getter=get_last, type="int")
def run(ctx):
if In is None:
return
batch = list(In.drain())
_state["runs"] += 1
_state["last"] = len(batch)
_state["total"] += len(batch)
print("run %d: %d new, %d in all"
% (_state["runs"], len(batch), _state["total"]))
and the scene between them:
buffer.StreamMode = "Async"
buffer.Capacity = 256
buffer.NotifyEvery = 8
consumer.AutoRun = True
ctx.connect(producer, "Out", buffer, "Input")
consumer.In = buffer
Run the producer — that is the only thing anybody runs — and the consumer's
Runs, Total and LastBatch climb while it goes. Each run took only what
had arrived; no item is counted twice; nothing ever holds more than Capacity
items.
Where you run it changes what you see. In the window an AutoRun run is
posted to the event loop, so the consumer runs between other things and its
counters climb while the buffer is still relaying — the live update. In a
batch session (gcompose --batch --recipe ...) there is no event loop to post
to, so the announcements are delivered inline and the whole chain has already
finished by the time the producer's run returns. The pacing is the same and
the counters say so afterwards; there is simply nothing to watch.
Four things have to be true at once for that, and removing any one of them turns it back into a pull chain that happens to have an extra node in it:
StreamMode = "Async"— otherwise the buffer relays only when asked, and nothing arrives over time to announce.NotifyEvery > 0— otherwise nothing announces.AutoRunon the consumer — otherwise the announcement reaches a recipe that does nothing with it.In.drain()in the consumer — otherwise each run rewinds the buffer, which empties it.
9. Results the node can show¶
A plain Property assigned inside run() changes only the script's
variable. It is not written back to the node: the write-back at the end of a
run covers output ports, not properties.
Seen = Property("Seen", default=0, type="int")
def run(ctx):
global Seen
Seen = 42 # the node still shows 0
Keep the value in the script and declare a computed property whose getter returns it — the getter is called every time anything reads the port, so the panel, the node and any wire see the current value:
_state = {"seen": 0}
def get_seen():
return _state["seen"]
Seen = Property("Seen", getter=get_seen, type="int")
def run(ctx):
_state["seen"] = 42 # the node shows 42
The recipe's namespace outlives the run — the source is re-executed only when
it changes — which is what lets the getter still answer afterwards, and what
lets _state accumulate across runs, as the live consumer above does.
+ Add → Computed Property writes this shape for you.
10. The streaming decorators¶
uLib.recipe_agent offers three decorators for the shape of a stream stage.
They are conveniences over plain generators, not a separate mechanism:
from uLib.recipe_agent import stream_producer, stream_filter, stream_consumer
@stream_producer
def make(ctx, n):
for i in range(n):
yield ctx.create("ContainerBox", "item_%d" % i)
@stream_filter
def big_enough(item): # bool -> keep or drop
return item.Size[0] > 1.0
@stream_filter
def doubled(item): # anything else -> the transformed item
item.Size = [2 * c for c in item.Size]
return item
@stream_consumer
def count_it(item):
_state["seen"] += 1
def run(ctx):
global Out
Out = big_enough(make(ctx, int(Count)))
A @stream_filter function is used as a predicate when it returns a bool, as a
transformation when it returns anything else, and as a stage when it is itself
a generator over the incoming stream.
Each takes mode=:
mode="inline"(the default) — runs on the caller's thread, no queue, no thread overhead.mode="async"— runs the body on a worker thread feeding a boundedqueue.Queue(capacity=, default 1024). This is Python-side decoupling, entirely separate from aDataBuffer, and the two are rarely both needed.
@stream_filter(numba=True) compiles the predicate with @njit(fastmath=True)
when numba is installed, and is a plain call when it is not. See
docs/NUMBA_RECIPES.md.
+ Add writes any of the three, and StreamIO writes an input port, an output port and a numba filter joining them.
11. Threads, and what is safe¶
| What | Which thread |
|---|---|
Run button / ctx.run_recipe |
the application (GUI) thread |
Play / obj.start() |
a worker thread |
An AutoRun run |
posted to the application thread, coalesced |
DataBuffer in "Async" mode |
its own worker thread, for the relay only |
@stream_*(mode="async") |
a Python worker thread |
Two rules follow, and both have cost real crashes:
- Scene objects belong to the application thread. Creating, deleting or re-pointing objects from a worker is not safe; read what you need there and do the editing on the application thread. An AutoRun consumer is already on the right thread, which is one more reason to prefer it to a thread of your own.
- A
Geant::Scenebelongs to the thread that built it, and refuses to run from another — it says so in itsStreamProblemproperty rather than crashing. A reconstruction driving a scene's stream from a worker has its work posted back automatically; there is nothing to do but not fight it.
12. Reading a live C++ stream¶
Nothing above is specific to recipes. Any object that produces a stream reads the same way, including the ones a simulation fills:
poca = ctx["poca"] # its Stream port is wired to the source
for muon in poca.Stream.drain(): # what has been measured so far
...
# or reach an object that is a producer directly, with no port in between:
source = ctx["muon_stream"] # a MuonScatterSource
for muon in Stream(source).drain():
...
Stream is in a recipe's namespace already; the wrapper is only needed when
you have the producing object rather than a port pointing at it.
MuonScatterSource has the same dial as the buffer — NotifyEvery, plus
Capacity, BatchSize, WaitTimeout — and reports Queued, Pushed,
Dropped and Driven for the panel. Dropped above zero means the consumer
is slower than the producer and the queue was full;
19_streaming_poca and 20_driven_stream are the
worked examples.
13. Things that catch people¶
| Symptom | Cause |
|---|---|
| The consumer reads nothing | Nothing wired (In is None), or the producer has not run yet. |
| The consumer reads the same items again every run | It uses for item in In where it wants In.drain(). |
| The buffer empties when the consumer runs | The same: iterating rewinds, and a rewind on a queue is a clear. |
| The consumer never runs by itself | AutoRun off, or NotifyEvery is 0, or StreamMode is not "Async". |
| The consumer runs once per item | NotifyEvery is 1. |
| The node shows a stale value | A plain Property was assigned in run(); use a computed one (section 9). |
| The port empties after the producer runs again | The two were assigned, not connected (section 5). |
| Items go missing under load | Capacity too small with OverflowPolicy dropping; "Block" instead, or a bigger capacity. |
| A generator assigned to an output yields nothing | It was consumed already — a generator can only be walked once. |
14. DataSource, DataSink, and DataBuffer are different tools¶
Their names are similar but they move different things:
| Type | Moves | Where to use it |
|---|---|---|
Core.DataSource |
one byte payload from memory or a file | standalone Python, or as an input asset for an image/mesh |
Core.DataSink |
one byte payload to memory or a file | standalone Python, or as an output asset |
DataBuffer |
scene objects through a bounded live queue, and optionally an encoded scene payload | a gcompose scene/Recipe pipeline |
A source and sink have direct pybind APIs for bytes:
from uLib import Core
source = Core.DataSource("calibration")
source.set_data(b'{"gain": 1.25}')
sink = Core.DataSink("copy")
sink.write_source(source)
assert sink.get_data() == b'{"gain": 1.25}'
This does not make each byte a stream item. A stream item is a scene
Object, such as a reconstructed muon or a box made by a Recipe.
To connect a byte source to a domain object, give that object's source port a
DataSource; VoxImage and
TessellatedMesh expose SetSource(),
ReadFromDataSource(), SetSink(), and
WriteToDataSink() in the direct Math binding.
DataBuffer is a registered scene class, not a standalone
Core.DataBuffer class. Configure it through the gcompose
ctx facade:
# gcompose console or Recipe
buffer = ctx.create("DataBuffer", "muon_queue")
buffer.StreamMode = "Async"
buffer.Capacity = 256
buffer.OverflowPolicy = "Block"
buffer.NotifyEvery = 16
ctx.connect(producer, "Out", buffer, "Input")
consumer.In = buffer
consumer.AutoRun = True
The consumer then calls In.drain(). It receives scene objects
from the buffer queue; it does not read the bytes held by a
DataSource. See Core, contexts and data I/O for the
file/embedded-data lifecycle.
15. Reference¶
Declarations (uLib.recipe_agent): StreamInputPort(name, description=),
StreamOutputPort(name, description=), Property(name, default=, type=, min=,
max=, step=, getter=, setter=, description=), Action(name, handler=).
On a Stream: for item in s (replay), s.drain(limit=None) (live),
s.take(n), s.reset(), len(s), s[i], s.producer.
On ctx: ctx.connect(source, "OutPort", target, "InPort"),
ctx.disconnect(target, "InPort"), ctx.run_recipe(recipe),
ctx.set_recipe_source(recipe, source), ctx.recipe_ports(recipe),
ctx.create(class_name, name), ctx.find(name), ctx["name"].
Decorators: @stream_producer(mode=, capacity=),
@stream_filter(mode=, numba=, capacity=), @stream_consumer(mode=).
C++: uLib::BuildRecipeStreamChain(context, kind) builds
either chain; uLib::StreamInput is how a C++ consumer reads one
of these ports; the implementation reference is
src/Core/uprops/docs/streaming.md.